Skip to content

feat: add editor.registerRangeDecorations and RangeDecorationsPlugin - #3193

Closed
christianhg wants to merge 1 commit into
mainfrom
feat/register-range-decorations
Closed

christianhg wants to merge 1 commit into
mainfrom
feat/register-range-decorations

Conversation

@christianhg

@christianhg christianhg commented Aug 27, 2026 •

Copy link
Copy Markdown
Member

What

Range decorations have exactly one entry point: the rangeDecorations prop on PortableTextEditable. A plugin cannot draw a decoration at all, so everything that wants one (SDK presence, SDK comments, anything downstream) has to own or wrap the editable and splice its decorations into the caller's array. This PR gives the editor a multi-source decoration channel:

import {RangeDecorationsPlugin} from '@portabletext/editor/plugins'

<RangeDecorationsPlugin
  rangeDecorations={matches.map((match) => ({
    id: match.id,
    range: match.range,
    component: (props) => <mark>{props.children}</mark>,
  }))}
/>

The raw API is editor.registerRangeDecorations({rangeDecorations, on}), returning {update, unregister}; the plugin component is sugar over it, mirroring BehaviorPlugin over registerBehavior, including its stability contract for rangeDecorations (on may be inline: the plugin wraps it in useEffectEvent and always calls the latest handler). Each registration is an independent layer: update replaces that layer's set, reconciled by id, and the layer keeps its stacking position across updates. Rendered order is defined: prop decorations first (outermost), then registrations in the order they were made; within a layer, array order. A pinning test mounts the plugin before the editable in JSX to prove order does not depend on effect timing.

Registered decorations have their own @beta config type, RegistrableRangeDecoration ({id, range, component}), rather than reusing the legacy RangeDecoration: the new surface outlives the prop, so it gets the right names (range, not a second meaning of "selection"; no untyped payload, data closes over the component), and the legacy @public type stays untouched apart from a compatible render-props widening. Movement is reported per layer, not per decoration: the registration's on handler receives RangeDecorationEvent ({type: 'moved', rangeDecoration, newRange, origin}, a union with room to grow), matching how every known consumer already works (one handler switching on identity) and keeping handler identity out of reconciliation entirely.

update reconciles by id: an unchanged range keeps the decoration's live position while adopting a new component reference (the case the prop's equality guard structurally cannot handle, pinned red under those semantics), a removed id unregisters, and a decoration destroyed by an edit stays dead through redundant updates, reviving on a deliberate re-anchor: a changed range, or omitting the id for one update and re-adding it (pinned red against both naive variants: resurrection and event re-fire, plus the same-range revive). Decoration components also receive isFirst/isLast, true for the fragments containing the decoration's start and end, so one-time chrome (a caret, a badge) renders once even when the range fragments across mark boundaries, blocks, or overlapping decorations; each case has a DOM-structure test.

Design notes

Moving the store to the provider is what makes multiple sources possible, and it fixes a live bug on the way: each PortableTextEditable used to spawn its own decorations actor writing the shared engine state, so two editables under one EditorProvider cleared each other's decorations on every update. One actor per editor ends that (pinned red on the old wiring). Editable unmount now removes its decorations instead of leaving them stale; equal prop resupplies stay free (the decorate function identity is stable across them, pinned at the machine level).

Duplicate ids within one registration throw synchronously in the consumer's call stack; across registrations they are scoped and legal. Consumers holding RangeDecoration[] migrate with one map ({selection, payload, ...rest} to {...rest, id, range: selection} plus one on handler replacing the per-decoration onMoved closures); no conversion helper is exported, since it could not decide where id comes from.

Not covered: priority between layers (registration order is the contract; a priority field is additive later), attribute-only and widget decorations (additive union members later), and coalescing adjacent same-decoration fragments (spiked separately).

@vercel

vercel Bot commented Aug 27, 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 Aug 27, 2026 3:17pm
portable-text-example-basic Ready Ready Preview Aug 27, 2026 3:17pm
portable-text-playground Ready Ready Preview Aug 27, 2026 3:17pm

Request Review

@github-actions

github-actions Bot commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

Bundle Stats

✅ No significant changes.

All scenario measurements (7)

🗺️ @portabletext/editor / @portabletext/editor · @portabletext/editor / @portabletext/editor/behaviors · @portabletext/editor / @portabletext/editor/plugins · @portabletext/editor / @portabletext/editor/selectors · @portabletext/editor / @portabletext/editor/traversal · @portabletext/editor / @portabletext/editor/utils · @portabletext/markdown / @portabletext/markdown · Artifacts

Scenario Kind Bundle (raw / gzip) Gzip change Import time Import change
⚪ @portabletext/editor / @portabletext/editor export 1.10 MB / 255.7 KB +2.5 KB, +1.0% 63 ms +1 ms, +1.5%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 1.4 KB None 2 ms +0 ms, +1.6%
⚪ @portabletext/editor / @portabletext/editor/plugins export 7.5 KB / 2.5 KB +747 B, +40.9% 7 ms +0 ms, +0.8%
⚪ @portabletext/editor / @portabletext/editor/selectors export 93.4 KB / 21.2 KB -13 B, -0.1% 7 ms +0 ms, +1.8%
⚪ @portabletext/editor / @portabletext/editor/traversal export 41.4 KB / 10.7 KB -11 B, -0.1% 5 ms -0 ms, -0.6%
⚪ @portabletext/editor / @portabletext/editor/utils export 32.8 KB / 8.6 KB None 5 ms -0 ms, -0.4%
⚪ @portabletext/markdown / @portabletext/markdown export 272.2 KB / 79.6 KB None 35 ms +1 ms, +4.4%

Significant means at least 1.0 KB and 1% gzip, or at least 5 ms and 10% import time.

@changeset-bot

changeset-bot Bot commented Aug 27, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 04cd25a

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 Minor
@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

@christianhg
christianhg force-pushed the feat/register-range-decorations branch from f423f42 to 4945dcb Compare August 27, 2026 10:28
@christianhg
christianhg force-pushed the feat/register-range-decorations branch from 4945dcb to 9b3e262 Compare August 27, 2026 12:11
@christianhg
christianhg force-pushed the feat/register-range-decorations branch from 9b3e262 to fb73870 Compare August 27, 2026 12:22
Range decorations had one entry: the `rangeDecorations` prop on
`PortableTextEditable`, so a plugin could not contribute decorations
at all, and each `PortableTextEditable` spawned its own
`rangeDecorationsMachine` writing shared `editorEngine.decoratedRanges`,
so two editables under one provider cleared each other's decorations
on every update and operation.

The machine now lives once per editor: spawned in
`createInternalEditor`, started with the provider, handed to editables
through an internal context. Context holds sources (layers), each
`{sourceKey, kind, rangeDecorations, decoratedRanges, on}`; `decorate`
reads the flattened result with prop sources sorted before registered
sources (prop decorations wrap outermost regardless of JSX effect
order). The legacy prop becomes one source per editable (keyed by
`useId`), keeping the payload-equality guard scoped to the prop path;
an equal resupply skips the `decorate.fn` reassignment, so the
decorate pass stays free for that case. Editable unmount removes its
source instead of leaving stale ranges behind.

Registered configs are their own `@beta` type,
`RegistrableRangeDecoration` ({id, range, component}), so the legacy
`RangeDecoration` stays untouched (`payload`, `onMoved`, and the
`selection` vocabulary stay on the prop path only). Movement flows
through a registration-level `on` handler receiving the
`RangeDecorationEvent` union ({type: 'moved', rangeDecoration,
newRange, origin}), fixed at registration so handler identity never
participates in reconciliation. `registerRangeDecorations` validates
unique `id`s synchronously and returns `{update, unregister}`.
Registered sources reconcile by `id`: an unchanged range keeps the
live (possibly moved) position while adopting a new `component`
reference; a removed `id` unregisters; a decoration killed by an edit
is tombstoned under its live-at-death range, and a subsequent
`update` revives it when the incoming range differs from both
the previous config and the tombstone (a redundant resupply or a
moved-event echo stays dead and emits nothing); omitting the `id`
from an `update` clears its tombstone, so drop-and-re-add is the
explicit same-range revive lever.
`RangeDecorationsPlugin` assumes a stable `rangeDecorations`
reference, mirroring `BehaviorPlugin`: it registers an empty layer,
pushes data through `update`, and wraps `on` in `useEffectEvent`, so
an inline handler never re-registers the layer and events always
reach the latest one.

Fragment-aware rendering rides on the same machinery:
`splitDecorationsByChild` stamps each per-child clip with whether it
holds the decoration's true document-wide edge, `getTextDecorations`
resolves per-leaf `isFirst`/`isLast` in a finalization pass (unique
per decoration), and `RenderLeaf` passes both to the component via
`RangeDecorationRenderProps`. The default `Object.assign` merge path
does not receive the bookkeeping fields.

Pinned by browser suites (including the machine scenarios, which the
vitest config runs in the browser project) plus unit and type tests:
registration rendering and cross-source nesting order (red on
arrival-order flattening), `id` reconciliation including the new-
component case (red under payload equality) and the moved-then-killed
resurrection case (red on the tombstone-only comparison), per-layer
`on` event routing across two registrations, fragment edges across
mark boundaries, blocks, and overlaps, and two editables under one
provider (red on per-editable actors).
@christianhg

Copy link
Copy Markdown
Member Author

Superseded by #3203, which implements the final v2 surface (layer handle with current, batched moved/content-changed/lost events, defineRangeDecoration, useRangeDecorations, non-null range, singular RangeDecorationPlugin). The branch stays for reference.

This branch was successfully deployed

3 active deployments
Preview – portable-text-editor-documentation — 04cd25af Deployed Aug 27, 2026 by vercel[bot]
Preview – portable-text-playground — 04cd25af Deployed Aug 27, 2026 by vercel[bot]
Preview – portable-text-example-basic — 04cd25af Deployed Aug 27, 2026 by vercel[bot]
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