docs/plans/2026-05-23-slate-v2-projection-refresh-selection-repair-ralplan.md
Current fix: good regression fix, not the best final architecture.
The current code correctly fixes #5987, but the repair signal is in the wrong
place. Editable.decorate refreshes a projection source and then directly calls
EDITOR_TO_FORCE_RENDER.get(editor)?.(). That proves the root cause, but it
couples a legacy decorate adapter to the editable repair renderer.
Best target: projection refresh owns the invalidation result, and editable owns the render/selection repair bridge. Decoration adapters only refresh their source.
Score: 0.82 current, 0.94 target.
.tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx:1351
creates a legacy Editable.decorate projection source..tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx:1707
refreshes that source on decorate identity changes..tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx:1712
calls EDITOR_TO_FORCE_RENDER directly from the adapter..tmp/slate-v2/packages/slate-react/src/projection-store.ts:531 already has
the right conceptual owner: projectionStore.refresh(...)..tmp/slate-v2/packages/slate-react/src/hooks/use-slate-decoration-source.ts:79
and :125 have the same external refresh pattern for first-class decoration
sources, so the architecture cannot be solved only in Editable.decorate..tmp/slate-v2/playwright/integration/examples/decorations-async.test.ts
proves the browser bug: model selection stayed at offset 41, while DOM
selection stayed at offset 35 before the fix.useSlateDecorationSource
uses the same external refresh shape.Add an internal projection refresh result:
type SlateProjectionRefreshResult = {
changedRuntimeIds: readonly RuntimeId[]
changedSourceId?: string
didChange: boolean
reason: SlateSourceDirtinessContext['reason']
requiresDOMSelectionExport: boolean
}
SlateProjectionStore.refresh() should either return this result or publish it
through a narrow internal subscription.
Editable owns one bridge:
useProjectionDOMRepairBridge({
editor,
projectionStore,
requestEditableRepair,
})
The bridge reacts only when refresh() reports rendered text/projection change.
It schedules a typed repair:
requestEditableRepair({
reason: 'projection-refresh',
selection: 'export-model-to-dom-after-commit',
runtimeIds: changedRuntimeIds,
})
The bridge runs from the editable runtime because only the editable runtime can coordinate React commit timing, DOM repair, IME state, and selection export.
Adapters must not touch EDITOR_TO_FORCE_RENDER.
Allowed:
source.refresh({ forceInvalidate: true, reason: 'external' })
Not allowed:
EDITOR_TO_FORCE_RENDER.get(editor)?.()
That applies to:
Editable.decorateuseSlateDecorationSourceuseSlateRangeDecorationSourceNo new app-facing API is needed.
Keep:
Editable decorate={decorate} as adapter compatibilitydecorationSources / projection sources as the primary v2 pathdeps on useSlateDecorationSourceDo not expose:
forceRenderSlate users should only say what decorations exist. Slate React decides when a projection refresh requires DOM selection export.
Keep the current browser proof:
.tmp/slate-v2/playwright/integration/examples/decorations-async.test.tsAdd these before calling the architecture final:
useSlateDecorationSourcesource.refreshSlateProjectionRefreshResult or internal refresh event in
projection-store.ts.EDITOR_TO_FORCE_RENDER call out of
Editable.decorate.useSlateDecorationSource through the same bridge..tmp/slate-v2 gates:
bun --filter slate-react typecheckPLAYWRIGHT_RETRIES=0 bun playwright playwright/integration/examples/decorations-async.test.ts --project=chromiumbun lint:fix#5987 remains Fixes #5987 because the current regression proof is valid.#4993, #4997,
and #3383; no additional fixed claim until those exact repros pass.Principles:
Rejected alternatives:
#5987, but leaves duplicate repair
pressure in every external projection sourceforceRender from every source hook: worse duplication and worse trace
qualityralph implementation pass, if accepted.
This review should not edit .tmp/slate-v2 implementation code directly.
Status: complete.
Implemented target:
projection-store.ts returns/emits SlateProjectionRefreshResult.decoration-source.ts forwards projection refresh events through composed
sources.projection-repair-bridge.ts is the single editable-owned bridge from
projection refresh to DOM selection repair.runtime-root-engine.ts installs that bridge after the editable repair
runtime exists.Editable.decorate now only refreshes its source; it no longer imports or
calls EDITOR_TO_FORCE_RENDER.useSlateDecorationSource gets the same repair behavior through the shared
projection refresh event.Regression proof:
35
vs expected 41; legacy prop path passed because it still had the local
adapter force-render.41.Verification:
.tmp/slate-v2: bun test ./packages/slate-react/test/projections-and-selection-contract.tsx.tmp/slate-v2: PLAYWRIGHT_RETRIES=0 bun playwright playwright/integration/examples/decorations-async.test.ts --project=chromium.tmp/slate-v2: bun --filter slate-react typecheck.tmp/slate-v2: bun lint:fixDiff review:
no issue: projection refresh result is the right owner for changed runtime
ids and skipped repair.no issue: first-class decoration source and legacy prop path share the same
browser row.fixed during review: removed an unused bridge editor parameter.fixed during review: skipped targeted refresh results no longer report the
store source id as changed.Issue accounting:
#5987 remains Fixes #5987.