docs/plans/2026-05-03-slate-v2-virtualization-decoupling-ralplan.md
Keep TanStack Virtual. Refactor the integration until virtualization is a small
private strategy adapter, not a cross-cutting branch inside the normal
EditableTextBlocks body.
The current implementation is a real functional win: TanStack is installed,
useVirtualizedRootPlan exists, DOM coverage boundaries exist, package tests
exist, and the full browser example exists. The remaining problem is
architecture hygiene. Right now virtualization still leaks into public option
types, shell segment placeholders, root-source planning, keyboard naming,
materialization ownership, metrics, and the giant render branch.
Blunt take: if the next patch only moves files around, it is fake decoupling. The real refactor is to make shell, staged, and virtualized rendering share only root sources, DOM coverage policy, materialization plumbing, and metrics contracts. TanStack belongs behind one virtualized adapter.
Closure verdict:
Intent:
Desired outcome:
EditableTextBlocks resolves the requested strategy and delegates the
virtualized branch to a focused virtualized root module.viewport-virtualization boundaries.In scope:
packages/slate-react/src/rendering-strategy/**packages/slate-react/src/components/editable-text-blocks.tsxpackages/slate-react/src/components/editable.tsxpackages/slate-react/src/editable/*rendering*, keyboard, and runtime
strategy policy where virtualized is currently classified as shell.Non-goals:
slots.Boundary work.Decision boundaries:
previewChars from the experimental virtualized option
shape.shell but mean
"DOM-incomplete strategy".renderingStrategy prop name.Unresolved user-decision points:
Current facts from .tmp/slate-v2:
@tanstack/react-virtual is already a slate-react dependency.
Source: .tmp/slate-v2/packages/slate-react/package.json:18-23.RenderingStrategyOptions is owned by create-segment-plan.ts, and the
virtualized object still exposes previewChars.
Source:
.tmp/slate-v2/packages/slate-react/src/rendering-strategy/create-segment-plan.ts:3-25.useVirtualizer, runtime-id item keys,
retained selected/promoted indexes, coalesced missing ranges, scrollToIndex,
and measureElement.
Source:
.tmp/slate-v2/packages/slate-react/src/rendering-strategy/use-virtualized-root-plan.ts:1-6,
:89-179, and :181-293.EditableTextBlocksInner imports virtualized types/hooks/components directly,
stores virtualized state, calls the TanStack plan hook, owns virtualized
materialization, computes virtualized metrics, and renders virtualized rows
inline.
Source:
.tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx:47-54,
:1260-1377, :1436-1478, :1580-1664, and :1714-1761.RenderingStrategySegmentShell receives coverageReason: 'viewport-virtualization'.
Source:
.tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx:1762-1794.RenderingStrategySegmentShell itself accepts
'shell-aggressive' | 'viewport-virtualization', derives selection policy
from that reason, and records state: 'virtualized'.
Source:
.tmp/slate-v2/packages/slate-react/src/rendering-strategy/segment-shell.tsx:58-111.root-selector-sources.ts is generic by name but imports
createSegmentPlan, owns shell config with previewChars, and returns
segmentPlan.
Source:
.tmp/slate-v2/packages/slate-react/src/editable/root-selector-sources.ts:7-14
and :208-272.isShellRenderingStrategy.
Source:
.tmp/slate-v2/packages/slate-react/src/editable/keyboard-input-strategy.ts:55-60,
:152-164, and :209-220..tmp/slate-v2/packages/slate-react/test/rendering-strategy-and-scroll.tsx:171-364
and
.tmp/slate-v2/playwright/integration/examples/rendering-strategy-runtime.test.ts:329-365..tmp/slate-v2/site/examples/ts/rendering-strategy-runtime.tsx:32-37
and :380-417.Research evidence:
docs/research/sources/editor-architecture/tanstack-virtual-and-github-large-surface-virtualization.md.Principles:
Top drivers:
EditableTextBlocks.Viable options:
| Option | Verdict | Reason |
|---|---|---|
| Cosmetic file split only | reject | Leaves materialization, metrics, shell fallback, and naming coupled. |
| Extract virtualized strategy adapter and renderer | choose | Smallest refactor that makes TanStack private and keeps current behavior. |
| Rewrite all rendering strategies into a new engine | reject | Too much blast radius for a decoupling pass. |
Move virtualization into slate-dom | reject | slate-dom should own DOM coverage, not React/TanStack hooks. |
| Expose a public virtualizer plugin | reject | Leaks implementation and makes app authors own editor correctness. |
Chosen option:
Consequences:
Status: complete.
Intent/boundary result:
slate-react rendering strategy ownership.slots.Boundary, no Phase 6 large-doc convergence.Steelman result:
EditableTextBlocksInner owns every branch.High-risk result:
Performance/DX result:
renderingStrategy, estimatedBlockSize,
overscan, threshold.previewChars is cut from virtualized mode because it is a shell-preview
concept. Keeping it would be a polite lie.Regression result:
viewport-virtualization, virtualized has no shell DOM,
default/staged/shell do not mount virtualizer DOM, and virtualized options
reject previewChars.Plan changes from closure:
done.0.94.ralph-execution.Target ownership:
EditableTextBlocks
resolves public renderingStrategy
reads shared root sources
owns placeholder/render props
delegates to one active strategy surface
rendering-strategy/options.ts
public strategy option types and normalization
rendering-strategy/root-sources.ts
root runtime ids, document epoch, selection top-level index
no shell segment planning
rendering-strategy/staged/*
DOM-present root group planning and placeholders
rendering-strategy/shell/*
fixed segment plan and shell preview placeholders only
rendering-strategy/virtualized/*
TanStack hook
virtualized root surface
virtualized missing-range boundaries
virtualized materialization
rendering-strategy/materialization.ts
one materialize-handler owner that delegates to the active strategy
rendering-strategy/metrics.ts
strategy-neutral metrics assembly
Shared contract:
type EditableRenderingPlan =
| { type: 'plain'; mountedTopLevelRuntimeIds: null }
| { type: 'staged'; materializeBoundary(...): boolean; ... }
| { type: 'shell'; materializeBoundary(...): boolean; ... }
| { type: 'virtualized'; materializeBoundary(...): boolean; ... }
Do not over-abstract this into a public plugin system. Keep it private until there are at least two external rendering strategies that need the same extension point.
Keep:
<Editable renderingStrategy={{ type: "virtualized", estimatedBlockSize: 32 }} />
Revise the option ownership:
type RenderingStrategyOptions =
| RenderingStrategyType
| ShellRenderingStrategyOptions
| VirtualizedRenderingStrategyOptions;
type ShellRenderingStrategyOptions = {
type: "shell";
overscan?: number;
previewChars?: number;
segmentSize?: number;
threshold?: number;
};
type VirtualizedRenderingStrategyOptions = {
type: "virtualized";
estimatedBlockSize?: number;
overscan?: number;
threshold?: number;
};
Hard cut:
previewChars is shell-only. Cut it from virtualized options.getScrollElement.measureElement.rangeExtractor.getItemKey.Reason:
previewChars describes shell previews. Virtualized mode renders actual
mounted rows and hidden DOM coverage boundaries, not preview snippets.Virtualized module responsibilities:
useVirtualizer;getItemKey;Non-responsibilities:
EditableDOMRoot event handling;Single-owner materialization rule:
EditableTextBlocks installs at most one DOMCoverage.setMaterializeHandler
per editor surface.materializeBoundary or null.Terminology rule:
shell.isShellRenderingStrategy includes virtualized; target name should
be closer to isDOMIncompleteRenderingStrategy or
usesModelBackedSelectionStrategy.Current good seed:
EditableTextBlocks already uses separate wrappers so the TanStack hook is
only called in the virtualized wrapper.
Source:
.tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx:1845-1860.Target:
EditableTextBlocksVirtualized, a real component
that owns:
useVirtualizedRootPlan;EditableTextBlocksInner free of:
@tanstack/react-virtual;useVirtualizedRootPlan;RenderingStrategyVirtualizedRangeBoundary;Do not duplicate the repeated descendant render prop soup by hand in three
places. Introduce a private render helper or prop bag for
EditableDescendantNode only if it reduces the giant branch.
This refactor improves Plate's migration story because Plate can see virtualized mode as one explicit strategy with metrics and degradation state.
Requirements:
requestedStrategy, effectiveStrategy, cohort, mounted
count, boundary count, and native completeness from metrics.No current-version Plate adapter is required.
No operation semantics change.
Requirements:
No current-version slate-yjs fixture is required for this refactor.
| Proof | Required result |
|---|---|
| Type: virtualized options | previewChars rejected for type: 'virtualized'. |
| Unit: shell boundary | RenderingStrategySegmentShell cannot register viewport-virtualization. |
| Unit: virtualized boundary | viewport missing ranges still register viewport-virtualization. |
| Unit: strategy normalization | shell, staged, and virtualized configs normalize independently. |
| Unit: materialization owner | only one active DOMCoverage materializer is installed. |
| Unit: broad selection | virtualized select-all still becomes model-backed where ranges are unmounted. |
| Unit: metrics | virtualized metrics survive after moving metrics builder out of EditableTextBlocks. |
| Browser: full example | full virtualized example still renders bounded DOM and scrolls to block 1000. |
| Browser: no shell leak | virtualized full example has no [data-slate-rendering-strategy-shell]. |
| Browser: typing | mounted virtualized row typing still goes through the normal editor path. |
| Stress: normal path | non-virtualized modes do not mount virtualizer DOM or viewport boundaries. |
Keep existing proofs green:
packages/slate-react/test/rendering-strategy-and-scroll.tsx:171-364playwright/integration/examples/rendering-strategy-runtime.test.ts:329-365Run targeted first, broad later:
This refactor is allowed to finish without 25k/100k release-hardening only if it does not claim virtualization is release-grade default behavior.
| Lens | Applicability | Findings | Plan delta |
|---|---|---|---|
vercel-react-best-practices | applied | Relevant rules: bundle-conditional, client-event-listeners, rerender-defer-reads, rerender-derived-state, rerender-use-ref-transient-values, rerender-split-combined-hooks, js-set-map-lookups. | Keep TanStack and scroll state out of non-virtualized surfaces; isolate transient scroll/measurement in virtualized module. |
performance-oracle | applied | Current hook uses maps and coalescing, but EditableTextBlocks still computes all strategy metrics inline. | Move metrics to strategy-neutral builder and keep lookup O(1)/range-coalesced. |
performance | applied | GitHub lesson: cheap repeated units first, virtualization for p95+ tail. | Decoupling must not add default-path component, handler, effect, or subscription cost. |
tanstack-virtual | applied | Use count, getScrollElement, estimateSize, overscan, getItemKey, rangeExtractor, measureElement, scrollToIndex; keep these private. | Virtualized adapter owns these. |
react-useeffect | applied | Effects are valid only for DOMCoverage external sync, ResizeObserver, and virtualizer measurement. | Replace competing materialization effects with one active strategy handler. |
tdd | applied | Refactor should be protected through public Editable behavior and browser example, not private implementation snapshots. | Add behavior tests for shell/virtualized separation before moving code. |
build-web-apps:shadcn | skipped | No UI styling or shadcn component surface. | none. |
EditableTextBlocksInner
carries virtualized state and branch cost across strategy orchestration.<1000;1000-4999;5000-9999;10000-24999;25000+.Trigger:
Blast radius:
slate-react public types;Failure scenarios:
Rollback answer:
EditableTextBlocksInner
owns virtualized config, hook, materialization, metrics, and render branch.
That makes every future shell/staged change re-evaluate virtualized behavior.editable-text-blocks.tsx:1260-1761.previewChars type cut for experimental virtualized options.viewport-virtualization from shell segment placeholderssegment-shell.tsx:58-111 and
editable-text-blocks.tsx:1762-1794.coverageReason prop. Weaker because it makes
shell own virtualized policy.previewChars from virtualized optionspreviewChars lies about behavior.previewChars in
create-segment-plan.ts:19-25, but the virtualized branch renders measured
rows and hidden boundaries in editable-text-blocks.tsx:1714-1761.estimatedBlockSize, overscan, and threshold.previewChars.viewport-virtualization reason inside RenderingStrategySegmentShell.isShellRenderingStrategy predicate that returns true for virtualized
without a better name.previewChars;viewport-virtualization;RenderingStrategyType, RenderingStrategyOptions,
ShellRenderingStrategyOptions, and VirtualizedRenderingStrategyOptions out
of create-segment-plan.ts.createSegmentPlan shell-only.previewChars from virtualized options.root-selector-sources.ts.RenderingStrategyRootConfig shell-specific or rename it to
ShellRenderingStrategyConfig.normalize-virtualized-config.ts;use-virtualized-root-plan.ts;virtualized-range-boundary.tsx;editable-virtualized-root.tsx.EditableTextBlocksInner.materializeBoundary delegate.EditableDOMRoot because it owns the actual root element.shell and virtualized unchanged.rendering-strategy-runtime?runtime_mode=virtualized-full&blocks=1000.bun test ./packages/slate-react/test/rendering-strategy-and-scroll.tsxbunx turbo typecheck --filter=./packages/slate-reactbun typecheck:sitebun lint:fixPLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/rendering-strategy-runtime.test.ts --project=chromium --grep "TanStack-backed virtualized"bun checkrg -n "@tanstack/react-virtual|useVirtualizer|Virtualized" .tmp/slate-v2/packages/slate-react/src
Expected after refactor:
rendering-strategy/virtualized/**.EditableTextBlocksInner has no useVirtualizedRootPlan import.segment-shell.tsx has no viewport-virtualization.previewChars.Total score: 0.94.
| Dimension | Score | Evidence |
|---|---|---|
| React 19.2 runtime performance | 0.94 | TanStack is kept in the virtualized adapter; default/staged/shell paths get explicit no-virtualizer guards; transient scroll/measurement state stays out of repeated blocks. |
| Slate-close unopinionated DX | 0.95 | Public prop stays renderingStrategy; public options stay editor-shaped; raw TanStack options are rejected; previewChars becomes shell-only. |
| Plate and slate-yjs migration backbone | 0.91 | Mount state remains local, runtime ids stay internal keys, operations/collab semantics do not change, and Plate reads metrics instead of importing strategy internals. |
| Regression-proof testing strategy | 0.94 | Existing behavior proof stays; Phase 0 adds red decoupling guards before any file movement. |
| Research evidence completeness | 0.93 | Live source, refreshed TanStack/GitHub research, package tests, and browser example all agree on current state and target gap. |
| shadcn-style composability and minimalism | 0.94 | No UI chrome surface; private component split is minimal and avoids public plugin over-abstraction. |
Ready for done: all review passes are complete, no dimension is below 0.85,
and the remaining work is implementation, not planning.
| Pass | Status | Evidence added | Plan delta | Next owner |
|---|---|---|---|---|
| Current-state read and initial score | complete | Live source refs for options, hook, component, shell, root sources, keyboard, tests, example. | New decoupling plan created. | done |
| Intent/boundary and decision brief | complete | Full Ralph Closure Pass. | No user decision needed; implementation may proceed later. | done |
| Performance/DX/regression pressure | complete | Performance Pass and Full Ralph Closure Pass. | Phase 0 red guards locked before refactor. | done |
| Slate maintainer objection ledger | complete | Three accepted objection rows. | Keep extraction, cut virtualized previewChars, remove viewport reason from shell. | done |
| High-risk deliberate closure | complete | Pre-mortem plus rollback answer. | Current working TanStack implementation is the rollback baseline. | done |
| Closure score | complete | Scorecard raised to 0.94. | Completion state may be done. | ralph-execution |
previewChars in virtualized options.viewport-virtualization.No open user questions remain.
previewChars is used by a documented example or package test not found
in this pass, move the cut behind a deprecation note only if the API is no
longer experimental.slate-dom API changes, split that
into a smaller DOM coverage patch instead of hiding the collision.When this ralplan reaches done, report:
previewChars;Completion is done for planning. Execution has not started.
Passed gates:
>=0.92 and no dimension is below 0.85;active goal state no longer has a runnable slate-ralplan pass.Next implementation owner:
ralph-execution, starting at Phase 0 red decoupling guards.