Skip to content

fix: validate inserts against the schema at the path#2611

Merged
christianhg merged 2 commits into
mainfrom
feat/insert-respects-sub-schema
May 6, 2026
Merged

fix: validate inserts against the schema at the path#2611
christianhg merged 2 commits into
mainfrom
feat/insert-respects-sub-schema

Conversation

@christianhg
Copy link
Copy Markdown
Member

@christianhg christianhg commented May 6, 2026

Pasting a stock ticker into a callout where the callout's schema doesn't allow it, the editor today inserts it anyway. The toolbar then opens the inline-object popover, the user edits the symbol, hits save, and the document silently doesn't update. Down the chain child.set throws because the type isn't in the path's schema; the throw gets caught and logged, the user sees nothing.

The cause is a render-vs-insert-vs-set asymmetry. Render is permissive (defineLeaf matches by global _type chain). Insert is permissive (parsers run against the root schema). Set is strict (validates against the schema at the path). Phantom data slips in through insert, then becomes uneditable.

This makes inserts validate against the schema at the path like sets already do. The schema is the contract for what types are allowed where, applied uniformly to all write boundaries:

  • insert.block derives the schema at the destination and parses the block against it. If the block's type isn't declared there, the operation noops.
  • insert.child derives the schema at the focus block and parses the child against it. If neither span nor inline-object parsers find a match, the operation noops.
  • child.set was already path-scoped but threw when the type wasn't in the schema; it now noops to match insert ops.

Render stays permissive: a document authored under a looser schema continues to render in full. Existing data is never modified by this validation; only new data crossing into the editor is checked.

Root-level was a mistake

Validation was previously strict only inside containers and permissive at the root - unknown decorators, annotations, and inline objects at the root were silently accepted, tracked optimistically. That asymmetry was a mistake. The schema is the contract everywhere, applied uniformly to every write boundary regardless of depth.

Mechanism

The editor walks ancestors via getEnclosingContainer and returns the Schema that applies at a given path: a registered container's resolved sub-schema when one is found, root schema otherwise. The same walker (getPathSubSchema) is used by every consumer that asks "what's allowed here?" - operations, normalization, selectors. There is one concept (Schema) and one walker.

The parsers (parseBlock, parseInlineObject, parseSpan, parseChild, parseMarkDefs, parseBlocks) take schema: Schema as a required parameter. Every caller is explicit about which schema it parses against:

  • Operations pass getPathSubSchema(snapshot, path) derived from the operation's target path.
  • Converters, paste handlers, slice/merge utilities, and tests pass context.schema directly because they have no path context - they're parsing arbitrary external data, the document's top schema is what they're parsing against.

Deriving the schema at the destination in insert.block

insert.block validates against the schema where the new block will land. The destination depends on placement and what the caller passed for at:

  • at is a range and placement: 'before' - the new block lands at the start of the range. Use rangeStart(at).path.
  • at is a range and any other placement - the new block lands at or near the end of the range. Use rangeEnd(at).path.
  • at is undefined and the editor has content - fall back to the editor's end. Use editorEnd(editor).path.
  • The editor is empty - there's no path to walk from. Use root schema directly.

The path is fed through getPathSubSchema which walks up to the enclosing container. Since getEnclosingContainer skips text blocks (they're not registered containers), passing a leaf path is fine: the walk goes leaf → text block → container or root, and lands on the container's resolved sub-schema when there is one.

For collapsed selections (the common case) rangeStart and rangeEnd are the same point so the start/end branch doesn't matter. The branch only matters for expanded selections that cross containers.

getSubSchema in @portabletext/schema

The schema package gains getSubSchema(schema, of) - a public function that derives the resolved sub-schema for a container's of declaration. The {type: 'block'} entry (if present) supplies the resolved styles, decorators, annotations, lists, and inlineObjects; non-block of members become the schema's block objects.

This was previously editor-internal but it's pure schema math with no editor concerns. Anything that reads a Sanity schema and asks "what's allowed inside this nested container?" can use it directly.

The editor's getPathSubSchema(snapshot, path) composes this with getEnclosingContainer to answer the path-relative question.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 6, 2026

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 May 6, 2026 10:16am
portable-text-example-basic Ready Ready Preview, Comment May 6, 2026 10:16am
portable-text-playground Ready Ready Preview, Comment May 6, 2026 10:16am

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 6, 2026

🦋 Changeset detected

Latest commit: 38df72b

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

This PR includes changesets to release 16 packages
Name Type
@portabletext/schema Minor
@portabletext/editor Patch
@portabletext/block-tools Patch
@portabletext/html Patch
@portabletext/markdown Patch
@portabletext/plugin-emoji-picker Patch
@portabletext/plugin-input-rule Patch
@portabletext/plugin-sdk-value Patch
@portabletext/plugin-typeahead-picker Patch
@portabletext/plugin-typography Patch
@portabletext/sanity-bridge Patch
@portabletext/plugin-character-pair-decorator Patch
@portabletext/plugin-markdown-shortcuts Patch
@portabletext/plugin-one-line Patch
@portabletext/plugin-paste-link 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

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 6, 2026

📦 Bundle Stats — @portabletext/editor

Compared against main (7d499a03)

@portabletext/editor

Metric Value vs main (7d499a0)
Internal (raw) 720.5 KB -2.5 KB, -0.3%
Internal (gzip) 138.3 KB -404 B, -0.3%
Bundled (raw) 1.32 MB -1016 B, -0.1%
Bundled (gzip) 298.7 KB -172 B, -0.1%
Import time 94ms +1ms, +0.8%

@portabletext/editor/behaviors

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

@portabletext/editor/plugins

Metric Value vs main (7d499a0)
Internal (raw) 3.1 KB -
Internal (gzip) 967 B -
Bundled (raw) 2.9 KB -
Bundled (gzip) 899 B -
Import time 8ms -0ms, -1.5%

@portabletext/editor/selectors

Metric Value vs main (7d499a0)
Internal (raw) 70.1 KB -3.0 KB, -4.1%
Internal (gzip) 12.8 KB -556 B, -4.1%
Bundled (raw) 66.9 KB -1.4 KB, -2.0%
Bundled (gzip) 12.0 KB -207 B, -1.7%
Import time 7ms -0ms, -0.3%

@portabletext/editor/utils

Metric Value vs main (7d499a0)
Internal (raw) 27.5 KB -1.9 KB, -6.4%
Internal (gzip) 5.7 KB -379 B, -6.1%
Bundled (raw) 25.6 KB -435 B, -1.6%
Bundled (gzip) 5.4 KB -56 B, -1.0%
Import time 6ms -0ms, -1.8%

🗺️ . · ./behaviors · ./plugins · ./selectors · ./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.

@christianhg christianhg force-pushed the feat/insert-respects-sub-schema branch from 80a7d2c to fccf02e Compare May 6, 2026 07:54
@christianhg christianhg changed the title feat!: validate inserts against the path's sub-schema feat: validate inserts against the path's sub-schema May 6, 2026
@christianhg christianhg changed the title feat: validate inserts against the path's sub-schema fix: validate inserts against the path's sub-schema May 6, 2026
@christianhg christianhg force-pushed the feat/insert-respects-sub-schema branch from fccf02e to 365e64a Compare May 6, 2026 08:26
@christianhg christianhg force-pushed the feat/insert-respects-sub-schema branch from 365e64a to f67af12 Compare May 6, 2026 08:35
@christianhg christianhg force-pushed the feat/insert-respects-sub-schema branch 3 times, most recently from 9bfc7fd to 3a8e6ad Compare May 6, 2026 08:54
@christianhg christianhg changed the title fix: validate inserts against the path's sub-schema fix: validate inserts against the schema at the path May 6, 2026
@christianhg christianhg force-pushed the feat/insert-respects-sub-schema branch from 3a8e6ad to eeec4a0 Compare May 6, 2026 08:55
…ainer's `of` declaration

Containers declare which types are allowed inside them via the `of` array on a child field. `getSubSchema(schema, of)` returns the resolved `Schema` view that applies inside such a container, so operations and validators that ask "what's allowed at this position?" can treat the result like any other top-level `Schema`.

The `{type: 'block'}` entry (if present) supplies the resolved styles, decorators, annotations, lists, and inlineObjects. Non-block `of` members become the schema's block objects.
The editor enforces the schema as a contract for new data crossing into documents. `insert.block` and `insert.child` now validate against the schema that applies at the target path. Operations introducing types not declared in that schema have no effect.

`child.set` with an unknown type also no longer logs an error; it noops.

Previously, validation was strict only inside containers and permissive at the root - unknown decorators, annotations, and inline objects at the root were silently accepted. That asymmetry was a mistake. The schema is the contract for what's allowed where, applied uniformly to every write boundary regardless of depth.

Existing document data is never modified by this enforcement. A document with content authored under an older or different schema continues to load and render unchanged. Schema validation is for new data crossing into the editor, not for cleaning up data already there.
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