Skip to content

refactor: factor SelectionStateProvider shape into createSliceStore#2759

Draft
christianhg wants to merge 3 commits into
mainfrom
refactor/slice-store-factor
Draft

refactor: factor SelectionStateProvider shape into createSliceStore#2759
christianhg wants to merge 3 commits into
mainfrom
refactor/slice-store-factor

Conversation

@christianhg

Copy link
Copy Markdown
Member

SelectionStateProvider ships a self-contained pattern that's worth reusing: subscribe once to an actor, recompute a state struct per change (microtask-coalesced), expose it through a useSyncExternalStore-compatible store so consumers attach per-slice and re-render only when their slice flips.

This PR extracts the framework code into createSliceStore<TState> and rewrites SelectionStateProvider on top of it. Static parts (the context, the default store, the equality function) live on the factory return; dynamic parts (the actor, the compute closure) are Provider props so callers can thread engine refs without forcing a context tear-down on engine change.

The factor is structural only:

  • Same actor subscription (editor firehose).
  • Same mount-time reconcile between the lazy seed and the post-commit recompute.
  • Same equality short-circuit on notify.
  • Same exposed hooks (useIsFocusedContainer / useIsSelectedContainer / useIsFocusedLeaf / useIsSelectedLeaf) with identical behavior.

selection-state-context.tsx shrinks from 255 to 174 lines, keeping only the domain bits: SelectionState, defaultSelectionState, selectionStatesEqual, the compute closure that reads editorEngine.snapshot, and the four slice hooks.

createSliceStore is internal-only — not exported from index.ts. Future slice stores for engine signals (drop target, list index, focused/selected migration off render-props) consume it.

No consumer-observable change. render-count-regression's "Typing into one sibling re-renders constant work, not O(N) siblings" still passes — the per-slice fan-out cost shape is what the factor is preserving.

@changeset-bot

changeset-bot Bot commented Jun 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: de6497e

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

This PR includes changesets to release 12 packages
Name Type
@portabletext/editor Minor
@portabletext/plugin-character-pair-decorator Patch
@portabletext/plugin-emoji-picker Patch
@portabletext/plugin-input-rule 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 Jun 8, 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 Jun 8, 2026 11:38am
portable-text-example-basic Ready Ready Preview, Comment Jun 8, 2026 11:38am
portable-text-playground Error Error Jun 8, 2026 11:38am

Request Review

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @portabletext/editor

Compared against main (fe62016a)

@portabletext/editor

Metric Value vs main (fe62016)
Internal (raw) 781.7 KB +3.9 KB, +0.5%
Internal (gzip) 148.8 KB +1.0 KB, +0.7%
Bundled (raw) 1.39 MB +3.9 KB, +0.3%
Bundled (gzip) 310.7 KB +1.0 KB, +0.3%
Import time 96ms +1ms, +1.1%

@portabletext/editor/behaviors

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

@portabletext/editor/plugins

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

@portabletext/editor/selectors

Metric Value vs main (fe62016)
Internal (raw) 79.4 KB -
Internal (gzip) 14.5 KB -
Bundled (raw) 74.9 KB -
Bundled (gzip) 13.4 KB -
Import time 8ms +0ms, +0.9%

@portabletext/editor/traversal

Metric Value vs main (fe62016)
Internal (raw) 25.5 KB -
Internal (gzip) 5.0 KB -
Bundled (raw) 25.5 KB -
Bundled (gzip) 5.0 KB -
Import time 6ms -0ms, -0.6%

@portabletext/editor/utils

Metric Value vs main (fe62016)
Internal (raw) 28.8 KB -
Internal (gzip) 6.0 KB -
Bundled (raw) 26.7 KB -
Bundled (gzip) 5.7 KB -
Import time 6ms -0ms, -2.2%

🗺️ . · ./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 (fe62016a)

Metric Value vs main (fe62016)
Internal (raw) 53.0 KB -
Internal (gzip) 9.6 KB -
Bundled (raw) 347.6 KB -
Bundled (gzip) 96.0 KB -
Import time 39ms -1ms, -2.7%

🗺️ 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.

Extracts the actor-subscription + microtask-coalesced recompute +
per-slice notify pattern out of selection-state-context.tsx into a
reusable createSliceStore primitive.

The factor is structural only: same actor (editor firehose), same
mount-time reconcile, same equality short-circuit, same exposed
hooks. No consumer-observable change. selection-state-context.tsx
shrinks from 255 to 174 lines as it loses the framework code and
keeps only the domain bits (SelectionState shape, defaultSelectionState,
selectionStatesEqual, compute closure, the four useIs* slice hooks).

createSliceStore is internal-only; not exported from index.ts. Future
slice stores for engine signals (drop-target, list-index, etc.) will
consume it.
…d*` and `useIsSelected*` hooks

The render callbacks of `defineContainer`, `defineTextBlock`,
`defineBlockObject`, `defineInlineObject`, and `defineSpan` no
longer receive `focused` and `selected`. Read them from inside the
render body via the new `useIsFocused{Container,Leaf}` and
`useIsSelected{Container,Leaf}` hooks. Each hook subscribes to a
single slice and only triggers a re-render when its own value flips,
so renders that don't read `focused` or `selected` no longer
re-render when the caret moves between unrelated nodes.

The legacy `renderBlock`, `renderChild`, `renderDecorator`, and
`renderAnnotation` callbacks on `<PortableTextEditable>` keep their
existing `focused` and `selected` props.
Adds an `@alpha` primitive that lets plugins define a slice of state
derived from the editor snapshot. The slice is computed once per
snapshot change and shared across all consumers — components reading
the slice only re-render when their selected value flips.

The primitive is intentionally minimal: a plugin author writes a
`compute(snapshot)` function and an `equal(a, b)` selector comparator,
and gets back a `Plugin` to mount and a `useSlice(select)` hook to
read. The engine owns the slice host and registry; plugins don't need
to know how subscription, coalescing, or fan-out work.
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