Skip to content

fix: validate insert.block sibling placements against the parent array's schema#2931

Draft
christianhg wants to merge 1 commit into
mainfrom
fix-insert-block-sibling-placement-scope
Draft

fix: validate insert.block sibling placements against the parent array's schema#2931
christianhg wants to merge 1 commit into
mainfrom
fix-insert-block-sibling-placement-scope

Conversation

@christianhg

Copy link
Copy Markdown
Member

While hardening table clicks in #2910 we found that insert.block with {_type: 'block'} addressed at a table row's path and placement: 'before' writes the text block into rows, schema-invalid data that nothing catches downstream. The click behaviors now guard against raising that event, but the operation itself kept writing when pointed there directly.

The operation already validates position-aware: it derives a destination from the operation's at, resolves getPathSubSchema there, and no-ops blocks the schema rejects. The defect is a scope disagreement specific to sibling placements. resolveTarget lands before/after blocks as siblings of the enclosing block at the destination, inside its parent array, while the validation scope derived from the destination path's own sub-schema view. The two only differ when the destination points at a container member: a point at a table row resolves toward the cell content view, which accepts text blocks, while the sibling insertion lands in rows, which does not.

Sibling placements now resolve their validation scope from the enclosing block's parent array. Acceptance is an explicit membership check against the enclosing container's of, and the parse then runs against that array's sub-schema so fields validate in the right scope. The membership check cannot be delegated to the parse: a Schema structurally always carries a text-block type, so a sub-schema alone can never reject {_type: 'block'}, which is the same reason fitBlocksToDestination layers its own accept-list checks over the paste pipeline. Root-level destinations are untouched, the root accepts everything the schema declares, and placement: 'auto' keeps the inner-view derivation since its landing logic (replace, merge, first-child descent) genuinely targets the destination's inside.

tests/insert-block-placement-scope.test.tsx pins four corners: the disallowed sibling no-ops (red before the fix, with the block landing inside rows), an accepted sibling inserts (a row beside a row), a leaf destination where both derivations agree stays unchanged, and a root-level sibling insert beside the container still works, the contract the container escape behaviors rely on. Blast radius checked by running the full editor suite (1705 browser tests on chromium plus 852 unit tests) and the plugin-table suite, all passing unmodified.

…ray's schema

`operation.insert.block` validates position-aware: it derives a
destination from the operation's `at`, resolves the sub-schema there,
and no-ops blocks the schema rejects. But for `placement: 'before' |
'after'` the validation scope and the landing position disagreed: the
scope derived from the destination path's own sub-schema view, while
`resolveTarget` lands the block as a sibling of the enclosing block at
that destination, inside its parent array. The two only differ when
the destination points at a container member: a point at a table row
resolves toward the cell content view (which accepts text blocks),
while the sibling insertion lands in `rows` (which does not), so a
text block addressed at a row passed validation and landed as
schema-invalid data.

Sibling placements now resolve their scope from the enclosing block's
parent array: an explicit membership check against the enclosing
container's `of` decides acceptance, and the parse runs against that
array's sub-schema. The membership check is separate from the parse
because a `Schema` structurally always carries a text-block type, so
a sub-schema alone can never reject `{_type: 'block'}`, the same
reason `fitBlocksToDestination` does its own accept-list checks.
Root-level destinations keep today's behavior: the root accepts
everything the schema declares.

`tests/insert-block-placement-scope.test.tsx` pins the no-op (red
before the fix), an accepted sibling (a row beside a row), the
leaf-destination case where both derivations agree, and the
root-level sibling insert. Full editor suite (1705 chromium + 852
unit) and the plugin-table suite pass unmodified.
@changeset-bot

changeset-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 345107b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
@portabletext/editor Patch
@portabletext/plugin-character-pair-decorator Patch
@portabletext/plugin-dnd Patch
@portabletext/plugin-emoji-picker Patch
@portabletext/plugin-input-rule Patch
@portabletext/plugin-list-index Patch
@portabletext/plugin-markdown-shortcuts Patch
@portabletext/plugin-one-line Patch
@portabletext/plugin-paste-link Patch
@portabletext/plugin-sdk-value Patch
@portabletext/plugin-table Patch
@portabletext/plugin-typeahead-picker Patch
@portabletext/plugin-typography Patch
@portabletext/toolbar Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
portable-text-editor-documentation Ready Ready Preview, Comment Jul 7, 2026 5:33am
portable-text-example-basic Ready Ready Preview, Comment Jul 7, 2026 5:33am
portable-text-playground Ready Ready Preview, Comment Jul 7, 2026 5:33am

Request Review

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @portabletext/editor

Compared against main (e94e6766)

@portabletext/editor

Metric Value vs main (e94e676)
Internal (raw) 801.6 KB +940 B, +0.1%
Internal (gzip) 153.4 KB +217 B, +0.1%
Bundled (raw) 1.41 MB +940 B, +0.1%
Bundled (gzip) 317.6 KB +215 B, +0.1%
Import time 97ms +0ms, +0.4%

@portabletext/editor/behaviors

Metric Value vs main (e94e676)
Internal (raw) 467 B -
Internal (gzip) 207 B -
Bundled (raw) 424 B -
Bundled (gzip) 171 B -
Import time 2ms -0ms, -1.4%

@portabletext/editor/plugins

Metric Value vs main (e94e676)
Internal (raw) 2.7 KB -
Internal (gzip) 894 B -
Bundled (raw) 2.5 KB -
Bundled (gzip) 827 B -
Import time 7ms +0ms, +0.2%

@portabletext/editor/selectors

Metric Value vs main (e94e676)
Internal (raw) 81.2 KB -
Internal (gzip) 14.9 KB -
Bundled (raw) 76.7 KB -
Bundled (gzip) 13.8 KB -
Import time 8ms +0ms, +0.2%

@portabletext/editor/traversal

Metric Value vs main (e94e676)
Internal (raw) 28.0 KB -
Internal (gzip) 5.6 KB -
Bundled (raw) 27.9 KB -
Bundled (gzip) 5.5 KB -
Import time 6ms +0ms, +0.3%

@portabletext/editor/utils

Metric Value vs main (e94e676)
Internal (raw) 29.7 KB -
Internal (gzip) 6.2 KB -
Bundled (raw) 27.1 KB -
Bundled (gzip) 5.8 KB -
Import time 6ms +0ms, +0.1%

🗺️ . · ./behaviors · ./plugins · ./selectors · ./traversal · ./utils · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

📦 Bundle Stats — @portabletext/markdown

Compared against main (e94e6766)

Metric Value vs main (e94e676)
Internal (raw) 53.8 KB -
Internal (gzip) 9.8 KB -
Bundled (raw) 348.9 KB -
Bundled (gzip) 96.3 KB -
Import time 40ms +1ms, +3.8%

🗺️ View treemap · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant