Skip to content

feat(builder): honour a block's lock in every command, not only in drag - #1016

Merged
mobeenabdullah merged 1 commit into
mainfrom
feat/block-locking
Aug 18, 2026
Merged

mobeenabdullah merged 1 commit into
mainfrom
feat/block-locking

Conversation

@mobeenabdullah

Copy link
Copy Markdown
Collaborator

A lock the editor showed and did not keep. The engine defines locked as "the editor command layer must not let the author move or delete this node". Only the canvas drag read it. #1013 shipped a Locked badge in the layers panel yesterday — so the editor advertised a lock while alt+Arrow moved the block and Delete removed it.

That inconsistency is mine, from #1013, and it is worse than not showing the badge. This is the fix.

Move and delete do not ask the same question

Moving checks the node alone. A container holding a locked child leaves that child in the same slot, at the same index, with the same neighbours — nothing the lock protects has changed. Refusing would let one locked caption freeze the whole section around it, which an author experiences as the editor breaking rather than as a lock working.

Deleting checks the subtree. Removing a container destroys what is inside it. An author who locked a caption and then deleted its section would lose the thing they locked — through an action aimed at something else, with the descendant count as the only clue. The refusal names the outermost locked node, because "this section holds a locked block" is followed by going to find it, and the deepest match is the least likely to be the one they meant.

One predicate, because the command layer is three places and three inline reads of node.locked agree right up until one of them gains that distinction. The canvas drag now asks it too, rather than reading the flag itself.

The check stays out of applyOp, deliberately

The engine is explicit: "system transforms (migrations, overlays, restore) still operate on locked nodes, and the pure tree primitives do not read it." Enforcing there would read correctly and be wrong — an author's editing lock would start refusing the system's own writes.

Refusals are announced; the existing silent ones stay silent

That is the existing reasoning applied, not overturned. The move handler already says refusals stay quiet because "'this block is already first' on every press at the end of a list is noise an author cannot act on, and the block not moving is itself the answer." A lock is the opposite on every clause: nothing on the page explains it, the remedy is one the author can act on, and a keyboard user has no badge to look at.

Structural refusals silent, policy refusals announced.

Verified end to end, with a genuinely locked block

Nothing in the UI sets locked yet, so the document was seeded through the API the way an import or an agent would.

layer rows:  ["Locked headingLocked", "Free text"]     ← badge renders
alt+Down  →  order unchanged | "Heading is locked. Unlock it to move it."
Delete    →  order unchanged | "Heading is locked. Unlock it to delete it."

container case, reached via the breadcrumb (the canvas click selects the deepest
block, which is exactly why the breadcrumb exists):
Delete    →  order unchanged
             "This block contains Heading, which is locked. Unlock it to delete."
pageerrors: []

Unlocked editing re-verified in the same browser and unaffected: click selects, alt+Arrow reorders and announces "Block moved", Delete removes and announces the undo path.

Tests

620 in @nextlyhq/builder (was 615), 61 in plugin-page-builder, 30/30 tasks. Four stub-verifications, including both directions of the asymmetry — making delete node-only fails 4 tests, making move subtree-wide fails the two that assert a container may still move.

Not in this PR

No lock toggle. Nothing can set locked from the UI yet, and the natural home is a question worth its own decision: a button inside a role="treeitem" row complicates the APG keyboard model that TreeView owns, and the inspector is currently built around props/PropSchema rather than node-level fields. Enforcement is complete on its own — it makes the badge honest for every document an import, an agent or the API can already produce — and the toggle is a UI decision rather than a correctness one.

One unexplained observation, recorded rather than smoothed over

In one probe run, alt+Down on a container selected via the breadcrumb neither moved it nor announced anything. A focused probe afterwards could not reproduce it: keyboard moves fire correctly after both a canvas click and a crumb click, in both selection paths. I have no mechanism for the single observation and am not claiming one.

The engine defines `locked` as "the editor command layer must not let the
author move or delete this node", and until now only the canvas drag read it.
The layers panel shipped a Locked badge yesterday, so the editor was showing a
lock it did not keep: the same block moved with alt+Arrow and deleted with
Delete.

The rule is asked in one place because the command layer is several, and
because the two commands do NOT ask the same question.

Moving checks the node alone. A container that holds a locked child leaves that
child in the same slot at the same index with the same neighbours, so nothing
the lock protects has changed — and refusing would let one locked caption
freeze the whole section around it.

Deleting checks the subtree. Removing a container destroys what is inside it,
so an author who locked a caption and then deleted its section would lose the
thing they locked, through an action aimed at something else, with the
descendant count as the only clue. The refusal names the outermost locked node,
because "this section holds a locked block" is followed by going to find it.

Refusals are ANNOUNCED, where the existing structural ones stay silent. That
distinction is the existing reasoning applied rather than overturned: "this
block is already first" is noise an author cannot act on and the block not
moving is itself the answer, while nothing on the page explains a lock, the
remedy is one the author can act on, and a keyboard user has no badge to see.

The check stays out of `applyOp`. The engine is explicit that the pure tree
primitives do not read the flag and that system transforms — migrations,
overlays, restore — still operate on locked nodes, so enforcing it there would
make an author's editing lock start refusing the system's own writes.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex please review this PR

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@mobeenabdullah, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 44 minutes

Limit details: You’ve used all 1 included review currently available under your plan.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 98b96d0c-e824-4b8e-942b-d2c2fb244320

📥 Commits

Reviewing files that changed from the base of the PR and between 12d444c and 3782024.

⛔ Files ignored due to path filters (1)
  • .changeset/locked-blocks-are-actually-locked.md is excluded by !.changeset/**
📒 Files selected for processing (6)
  • packages/builder/src/canvas-drag.tsx
  • packages/builder/src/index.ts
  • packages/builder/src/keyboard-actions.test.tsx
  • packages/builder/src/keyboard-actions.tsx
  • packages/builder/src/locking.test.ts
  • packages/builder/src/locking.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@mobeenabdullah
mobeenabdullah merged commit 15cad99 into main Aug 18, 2026
13 checks passed
@greptile-apps

greptile-apps Bot commented Aug 18, 2026

Copy link
Copy Markdown

Greptile Summary

The PR centralizes lock-policy queries and applies them to canvas drag and keyboard move/delete commands, including subtree-aware deletion and accessible refusal announcements.

  • Adds reusable move and delete lock predicates and exports them from the builder package.
  • Applies lock checks to drag and keyboard command paths.
  • Adds focused unit and component coverage plus the required lockstep patch changeset.

Confidence Score: 4/5

The container-move contract must be reconciled with applyOp before merging, because unlocked containers with locked descendants still cannot be moved.

The command layer intentionally allows an unlocked container to move, but the existing operation layer rejects the same move after finding its locked descendant, leaving keyboard and drag behavior inconsistent with the feature’s stated contract.

Files Needing Attention: packages/builder/src/locking.ts, packages/builder/src/ops.ts, packages/builder/src/keyboard-actions.test.tsx

Important Files Changed

Filename Overview
packages/builder/src/locking.ts Adds centralized node-only move and subtree-aware delete predicates, but the move semantics conflict with applyOp’s existing subtree-wide enforcement.
packages/builder/src/keyboard-actions.tsx Announces lock refusals for keyboard move/delete, although the advertised container-move path is still rejected downstream.
packages/builder/src/canvas-drag.tsx Replaces the inline drag lock read with the shared move predicate, inheriting the downstream semantic mismatch.
packages/builder/src/index.ts Safely exports the new plain locking helpers from the existing server-safe root entry.
packages/builder/src/locking.test.ts Tests the intended move/delete asymmetry at the predicate level but does not cover integration with applyOp.
packages/builder/src/keyboard-actions.test.tsx Uses an apply spy that accepts container moves, so it misses the real store’s subtree-wide rejection.
.changeset/locked-blocks-are-actually-locked.md Provides the repository-required all-package patch changeset and describes the intended lock behavior.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Move unlocked container with locked child] --> B[lockBlockingMove checks container only]
  B -->|Allowed| C[Keyboard or drag calls editor.apply]
  C --> D[applyOp lockedWithin scans subtree]
  D -->|Locked child found| E[Move rejected]
  E --> F[Keyboard silently does not move / drag cannot commit]
Loading

Fix all with Greploop Fix All in Claude Code

Prompt To Fix All With AI
### Issue 1
packages/builder/src/locking.ts:57-59
**Subtree lock still blocks moves**

When an unlocked container has a locked descendant, `lockBlockingMove` permits the move, but the existing subtree-wide check in `applyOp` rejects it, causing keyboard movement to become a silent no-op and preventing drag from committing the move.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "feat(builder): honour a block's lock in ..." | Re-trigger Greptile

Comment on lines +57 to +59
const node = findNode(document.nodes, id);
if (node === undefined) return undefined;
return isLocked(node) ? node : undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Subtree lock still blocks moves

When an unlocked container has a locked descendant, lockBlockingMove permits the move, but the existing subtree-wide check in applyOp rejects it, causing keyboard movement to become a silent no-op and preventing drag from committing the move.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/builder/src/locking.ts
Line: 57-59

Comment:
**Subtree lock still blocks moves**

When an unlocked container has a locked descendant, `lockBlockingMove` permits the move, but the existing subtree-wide check in `applyOp` rejects it, causing keyboard movement to become a silent no-op and preventing drag from committing the move.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

@github-actions github-actions Bot added the type: docs Documentation only label Aug 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Fallow audit report

Found 40 findings.

Details
Severity Rule Location Description
major fallow/unused-dependency apps/playground/package.json:24 Package '@nextlyhq/adapter-drizzle' is in dependencies but never imported; imported in other workspaces: packages/adapter-mysql, packages/adapter-postgres, packages/adapter-sqlite, packages/nextly
major fallow/unused-dependency apps/playground/package.json:25 Package '@nextlyhq/adapter-mysql' is in dependencies but never imported; imported in other workspaces: packages/nextly
major fallow/unused-dependency apps/playground/package.json:26 Package '@nextlyhq/adapter-postgres' is in dependencies but never imported; imported in other workspaces: packages/nextly
major fallow/unused-dependency apps/playground/package.json:27 Package '@nextlyhq/adapter-sqlite' is in dependencies but never imported; imported in other workspaces: packages/nextly
major fallow/unused-dependency apps/playground/package.json:42 Package 'sharp' is in dependencies but never imported; imported in other workspaces: packages/nextly
minor fallow/unused-dev-dependency apps/playground/package.json:45 Package '@eslint/eslintrc' is in devDependencies but never imported
minor fallow/unused-dev-dependency apps/playground/package.json:54 Package 'better-sqlite3' is in devDependencies but never imported; imported in other workspaces: packages/adapter-sqlite, packages/nextly
minor fallow/unused-dev-dependency apps/playground/package.json:55 Package 'culori' is in devDependencies but never imported; imported in other workspaces: packages/ui
minor fallow/unused-dev-dependency e2e/package.json:17 Package '@nextlyhq/adapter-drizzle' is in devDependencies but never imported; imported in other workspaces: packages/adapter-mysql, packages/adapter-postgres, packages/adapter-sqlite, packages/nextly
minor fallow/unused-dev-dependency e2e/package.json:18 Package '@nextlyhq/adapter-sqlite' is in devDependencies but never imported; imported in other workspaces: packages/nextly
minor fallow/unused-dev-dependency e2e/package.json:22 Package '@nextlyhq/plugin-form-builder' is in devDependencies but never imported; imported in other workspaces: apps/playground
minor fallow/unused-dev-dependency e2e/package.json:23 Package '@nextlyhq/plugin-page-builder' is in devDependencies but never imported; imported in other workspaces: apps/playground
minor fallow/dev-dependency-in-production package.json:92 devDependency '@manypkg/get-packages' is imported by production code at runtime (consider moving to dependencies)
minor fallow/dev-dependency-in-production package.json:104 devDependency 'micromatch' is imported by production code at runtime (consider moving to dependencies)
minor fallow/unused-dev-dependency package.json:106 Package 'publint' is in devDependencies but never imported
major fallow/unused-dependency packages/admin/package.json:110 Package '@lexical/history' is in dependencies but never imported
major fallow/unused-dependency packages/admin/package.json:119 Package '@radix-ui/react-accordion' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:120 Package '@radix-ui/react-alert-dialog' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:121 Package '@radix-ui/react-avatar' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:122 Package '@radix-ui/react-checkbox' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:123 Package '@radix-ui/react-collapsible' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:124 Package '@radix-ui/react-dialog' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:125 Package '@radix-ui/react-dropdown-menu' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:127 Package '@radix-ui/react-popover' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:128 Package '@radix-ui/react-radio-group' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:129 Package '@radix-ui/react-select' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:130 Package '@radix-ui/react-separator' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:132 Package '@radix-ui/react-switch' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:133 Package '@radix-ui/react-tabs' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:134 Package '@radix-ui/react-tooltip' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:140 Package 'cmdk' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:149 Package 'tailwind-variants' is in dependencies but never imported
major fallow/unused-dependency packages/admin/package.json:150 Package 'tailwindcss-animate' is in dependencies but never imported
minor fallow/unused-dev-dependency packages/builder/package.json:68 Package '@nextlyhq/plugin-sdk' is in devDependencies but never imported; imported in other workspaces: apps/playground, packages/plugin-form-builder, packages/plugin-page-builder, packages/plugin-sdk, packages/plugin-seo
major fallow/unused-dependency packages/plugin-page-builder/package.json:51 Package '@dnd-kit/dom' is in dependencies but never imported
major fallow/unused-dependency packages/plugin-page-builder/package.json:52 Package '@dnd-kit/react' is in dependencies but never imported
major fallow/unused-dependency packages/plugin-page-builder/package.json:56 Package 'isomorphic-dompurify' is in dependencies but never imported; imported in other workspaces: packages/nextly
minor fallow/unused-dev-dependency packages/plugin-page-builder/package.json:63 Package '@tanstack/react-query' is in devDependencies but never imported; imported in other workspaces: packages/admin, packages/plugin-form-builder
minor fallow/unused-dev-dependency packages/plugin-page-builder/package.json:72 Package '@dnd-kit/abstract' is in devDependencies but never imported
major fallow/unused-dependency packages/telemetry/package.json:32 Package 'env-paths' is in dependencies but never imported

Generated by fallow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: docs Documentation only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant