docs/plans/2026-05-03-slate-v2-tanstack-virtualization-ralplan.md
Use TanStack Virtual, but only as the viewport range and measurement engine for
Slate's experimental renderingStrategy.type = 'virtualized'.
Do not make virtualization the default. Do not let TanStack own Slate's editor semantics. Slate still owns DOM coverage, materialization, selection import and export, model-backed copy/paste, IME guards, mobile policy, browser find classification, accessibility, metrics, and release gates.
Blunt take: GitHub's result is strong evidence for TanStack Virtual on huge repeated surfaces. It is not evidence that a contenteditable document can safely remove DOM by default. GitHub diff lines are not editable rich-text nodes with composition, native selections, Slate fragments, voids, and collaboration.
Status: complete for the requested implementation slice.
Implemented in .tmp/slate-v2:
@tanstack/react-virtual as a slate-react runtime dependency.renderingStrategy as the public prop and split virtualized options
from shell options: virtualized uses estimatedBlockSize, overscan, and
threshold; shell keeps segmentSize.auto remains DOM-present/staged.useVirtualizedRootPlan with runtime-id keys, retained
selection/materialization indexes, measured rows, scroll-to-index support,
and coalesced missing ranges.DOMCoverageBoundary records with
reason: 'viewport-virtualization', state: 'virtualized',
selectionPolicy: 'materialize', copyPolicy: 'include-model', and
findPolicy: 'not-native-until-mounted'.rendering-strategy-runtime?runtime_mode=virtualized-full&blocks=1000.Verified:
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"bunx turbo build --filter=./packages/slate-reactbun typecheck:rootbun checkRemaining release-hardening gates stay true but are not blockers for this implementation slice:
Intent:
Desired outcome:
renderingStrategy={{ type: 'virtualized' }} uses TanStack Virtual internally
to choose mounted top-level blocks by viewport range.DOMCoverageBoundary records with
reason: 'viewport-virtualization'.In scope:
slate-react virtualized rendering strategy.DOMCoverageBoundary bridge policy for virtualized ranges.renderingStrategy DX for experimental virtualized mode.Non-goals:
slots.Boundary stabilization.Decision boundaries:
virtualized options because this mode is
already documented as experimental.@tanstack/react-virtual to slate-react if the install
and bundle gate are clean.@tanstack/virtual-core only
if the React hook adds unacceptable default-path bundle or render cost. Public
API must not change either way.Unresolved user-decision points:
Live .tmp/slate-v2 facts:
Editable already documents safe staged rendering as the default and
describes virtualized mode as experimental with missing native find and
screen-reader coverage until mount.
Source: .tmp/slate-v2/docs/libraries/slate-react/editable.md:211-268.RenderingStrategyOptions already includes 'virtualized', but the option
object shares shell-oriented segmentSize instead of viewport measurement.
Source:
.tmp/slate-v2/packages/slate-react/src/rendering-strategy/create-segment-plan.ts:3-18..tmp/slate-v2/packages/slate-react/src/rendering-strategy/create-segment-plan.ts:29-73
and
.tmp/slate-v2/packages/slate-react/src/editable/root-selector-sources.ts:228-242.EditableDOMRoot runtime strategy prop accepts only 'staged' | 'shell', so virtualized mode is passed through root runtime as shell-shaped
policy.
Source:
.tmp/slate-v2/packages/slate-react/src/components/editable.tsx:83-90
and
.tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx:1557-1563..tmp/slate-v2/packages/slate-react/test/rendering-strategy-and-scroll.tsx:171-340.DOMCoverageBoundary already has state: 'virtualized',
reason: 'viewport-virtualization', materialization, indexed boundary lookup,
and boundary-aware point/range APIs.
Source:
.tmp/slate-v2/packages/slate-dom/src/plugin/dom-coverage.ts:26-39,
.tmp/slate-v2/packages/slate-dom/src/plugin/dom-coverage.ts:539-589,
.tmp/slate-v2/packages/slate-dom/src/plugin/dom-coverage.ts:592-619,
and
.tmp/slate-v2/packages/slate-dom/src/plugin/dom-coverage.ts:639-676.EditableRenderingStrategyMetrics already includes cohort, mounted/pending
counts, DOM node count, editable descendant count, and viewport virtualization
boundary count.
Source: .tmp/slate-v2/packages/slate-react/src/components/editable.tsx:103-175.@tanstack/react-virtual, @tanstack/virtual-core,
useVirtualizer, or useWindowVirtualizer hit was found in
.tmp/slate-v2/package.json, packages, site, docs, or bun.lock during
this pass.External evidence:
count, getScrollElement, and
estimateSize; support overscan, getItemKey, rangeExtractor,
measureElement, scrollToIndex, onChange, scrollMargin, and dynamic
size measurement with data-index.
Source:
docs/research/sources/editor-architecture/tanstack-virtual-and-github-large-surface-virtualization.md.Principles:
Top drivers:
Viable options:
| Option | Verdict | Reason |
|---|---|---|
| Keep homegrown fixed segment virtualization | reject | It is not viewport virtualization; it cannot use measured block height or scroll range. |
| Use TanStack Virtual as an internal range/measurement engine | choose | Best leverage: proven library for visible range, stable keys, measurement, overscan, and scroll-to-index. |
Expose TanStack's options directly on Editable | reject | Leaks a list library into Slate editor API and pushes native behavior policy onto users. |
Make virtualized mode default for auto | reject | Native find, a11y, mobile, IME, selection, and copy/paste are not release-grade for default editing. |
| Use TanStack to replace DOM coverage | reject | TanStack knows viewport geometry, not Slate model points or clipboard semantics. |
Chosen option:
useViewportRenderingPlan / useVirtualizedRootPlan
adapter in slate-react that uses TanStack Virtual for viewport range and
measurement, then converts visible indexes and retained indexes into Slate's
existing mounted runtime-id and DOM coverage plan.Consequences:
slate-react gains a dependency and a browser/runtime proof burden.Follow-ups:
@tanstack/react-virtual or @tanstack/virtual-core is the
final internal import after bundle proof.Status: complete for the 2026-05-03 Ralph activation.
Evidence used:
.tmp/slate-v2 rendering strategy, DOM coverage, metrics, and tests listed
in Source Grounding;No user question needed:
Boundary hardening:
getScrollElement in v1. Resolve the
root scroll owner, nearest scroll parent, or window internally. If that cannot
be proven safely, fall back to staged mode and report the effective strategy
through metrics. Add a public scroll-owner override only if browser proof
demands it.estimatedBlockSize is numeric in the first public shape. Do
not expose a sizing callback until a type/API pass proves it is necessary.@tanstack/react-virtual as the slate-react
implementation dependency. If bundle or default render gates fail, switch the
internal implementation to @tanstack/virtual-core without changing public
API.auto cannot flip to virtualized in this plan.Plan delta:
estimatedBlockSize for the first public shape.Keep renderingStrategy. Do not rename the whole prop in this plan.
Revise only the experimental virtualized object shape:
type RenderingStrategyOptions =
| "auto"
| "full"
| "staged"
| "shell"
| "virtualized"
| {
overscan?: number;
previewChars?: number;
segmentSize?: number;
threshold?: number;
type: "shell";
}
| {
estimatedBlockSize?: number;
overscan?: number;
previewChars?: number;
threshold?: number;
type: "virtualized";
};
Before:
segmentSize shape.
Source:
.tmp/slate-v2/packages/slate-react/src/rendering-strategy/create-segment-plan.ts:10-18
and .tmp/slate-v2/docs/libraries/slate-react/editable.md:238-247.After:
segmentSize;estimatedBlockSize;estimatedBlockSize is a number in the first public shape, not a callback;getScrollElement, measureElement, rangeExtractor,
getItemKey, or raw TanStack option passthrough in v1.Reason:
estimatedBlockSize is editor language. Internally it maps to TanStack's
estimateSize.TanStack owns:
Slate owns:
DOMCoverageBoundary records;Required adapter shape:
type VirtualizedRootPlan = {
activeTopLevelIndexes: readonly number[];
mountedTopLevelRuntimeIds: ReadonlySet<RuntimeId>;
mountedTopLevelRanges: readonly MountedTopLevelRange[];
missingRanges: readonly MountedTopLevelRange[];
scrollToTopLevelIndex(
index: number,
align?: "start" | "center" | "end" | "auto",
): void;
};
Internal rules:
getItemKey(index) returns top-level runtime id.measureElement attaches only to mounted top-level wrappers with
data-index.rangeExtractor must retain the selection anchor/focus indexes, composition
target, materialization target, and active overscan corridor.viewport-virtualization coverage
boundaries.RenderingStrategySegmentShell can stay as a placeholder renderer
for the first slice, but its policy must say viewport-virtualization, not
generic shell.Plate can opt into virtualized mode for pathological documents, but Plate must not need to wrap every Slate call.
Backbone requirements:
Virtualization must not change operation semantics.
Collab requirements:
| Proof | Required behavior |
|---|---|
| Unit: virtualizer adapter uses runtime-id keys | reorder/split/merge does not recycle wrong DOM |
| Unit: range coalescing | missing adjacent indexes register one boundary |
| Unit: retained indexes | caret, selection anchor/focus, composition target stay mounted |
| Unit: scrollToIndex materialization | programmatic selection can scroll and mount target |
| Browser: click/type in far virtualized block | target mounts before caret entry |
| Browser: keyboard navigation across range edge | no raw DOM lookup throw |
| Browser: IME at range edge | no text loss, no materialization during active composition |
| Browser: select-all copy | model payload includes virtualized ranges by policy |
| Browser: paste over broad model-backed range | no stale DOM fallback |
| Browser: find before mount | documented not-native-until-mounted |
| Browser: find after mount | native find sees mounted text |
| Browser: mobile touch near edge | no missing target crash |
| Stress: 25k and 100k blocks | DOM, heap, INP, scroll jank, and typing metrics beat staged/shell where virtualized is intended |
Cohorts:
<1000 top-level blocks, no virtualization;1000-4999, staged only;5000-9999, staged default, shell explicit;10000-24999, staged/shell benchmarked, virtualized opt-in;25000+, virtualized research/proof lane.Metrics:
| Lens | Applicability | Findings | Plan delta |
|---|---|---|---|
vercel-react-best-practices | applied | Use client-event-listeners, rerender-defer-reads, rerender-derived-state, rerender-use-ref-transient-values, js-set-map-lookups, and rendering-content-visibility only where DOM-present. | Add root-level virtualizer and no repeated scroll subscriptions. |
performance-oracle | applied | 25k/100k path needs bounded memory, indexed lookups, and no document scans during typing. | Require runtime-id keys, coalesced ranges, and range-indexed coverage. |
performance | applied | GitHub pattern demands cohorting, repeated-unit budgets, INP percentiles, memory tags, and degradation contract. | Virtualized mode is stress/pathological only with dashboards. |
tanstack-virtual | applied | Use count, getScrollElement, estimateSize, overscan, getItemKey, rangeExtractor, measureElement, scrollToIndex. | Use TanStack as range engine, not editor policy. |
tdd | applied | Behavior must be proven via public Editable and browser contracts. | Add red package and browser rows before implementation. |
react-useeffect | pending | Effects and measurement refs need a separate pass. | Next pass must review effect ownership. |
build-web-apps:shadcn | skipped | No UI chrome or component styling target. | none |
getItemKey; add reorder/split/merge tests.Blast radius:
packages/slate-react;packages/slate-dom DOM coverage tests if policy changes;docs/libraries/slate-react/editable.md;Rollback answer:
createSegmentPlan chunks by segmentSize; TanStack docs
provide measurement/range primitives; GitHub reports virtualization gains for
p95+ repeated surfaces.renderingStrategy="staged" and "full" stay.estimatedBlockSize, not TanStack internals.autoauto.renderingStrategy={{ type: 'virtualized' }}..tmp/slate-v2.@tanstack/react-virtual to packages/slate-react
dependencies and root lockfile.@tanstack/virtual-core before continuing.getScrollElement escape hatch.getItemKey;scrollToIndex;useVirtualizedRootPlan.renderingStrategy.type === 'virtualized'.enabled: false outside virtualized mode.estimatedBlockSize as TanStack estimateSize.measureElement only to mounted top-level wrappers.viewport-virtualization boundaries for missing ranges.EditableDOMRoot as generic shell unless the
runtime policy explicitly records the virtualized reason.v2VirtualizedTanStack.Editable docs.requestedStrategy, effectiveStrategy, cohort, documentSize,
virtualizerMeasuredCount, mountedTopLevelCount, domNodeCount,
editableDescendantCount, boundaryCount, browser, mobile, IME state, and
release version.rg -n "@tanstack/react-virtual|@tanstack/virtual-core|useVirtualizer|useWindowVirtualizer" .tmp/slate-v2
bun test ./packages/slate-react/test/rendering-strategy-and-scroll.tsx
bun test ./packages/slate-dom/test/dom-coverage.ts ./packages/slate-dom/test/clipboard-boundary.ts
bun --filter slate-react typecheck
bun lint:fix
Stress gate:
REACT_HUGE_COMPARE_BLOCKS=25000 REACT_HUGE_COMPARE_ITERATIONS=3 bun run bench:react:huge-document:legacy-compare:local
Closure gate:
bun check
| Dimension | Score | Evidence |
|---|---|---|
| React 19.2 runtime performance | 0.84 | Current source has staged/shell/virtualized metrics and runtime boundaries; TanStack integration still needs effect/subscription proof. |
| Slate-close unopinionated DX | 0.90 | Plan keeps renderingStrategy, hides TanStack internals, rejects public scroll-owner props in v1, and keeps estimatedBlockSize numeric first. |
| Plate and slate-yjs migration-backbone shape | 0.87 | Plan keeps mount state local and model/operation semantics unchanged; needs stronger collab proof rows. |
| Regression-proof testing strategy | 0.87 | Matrix names unit/browser/stress rows and adds scroll-owner fallback proof; exact browser file additions still need closure pass. |
| Research evidence completeness | 0.86 | GitHub article, TanStack docs, live source, and compiled research page exist; Lexical/ProseMirror/Tiptap virtualization silence still needs explicit pass. |
| shadcn-style composability and hook/component minimalism | 0.89 | Internal adapter, no UI chrome, no TanStack prop passthrough, no public scroll hook, and no sizing callback in the first shape; effect review pending. |
Weighted total: 0.87.
Status: not ready. Current-state and intent/decision passes are complete.
| Pass | Status | Evidence added | Plan delta | Open issues | Next owner |
|---|---|---|---|---|---|
| Current-state read and initial score | complete | Live source, existing Phase 6 plan, TanStack docs, GitHub article summary. | Accepted TanStack as internal range engine only. | Dependency install status needs re-check before implementation. | Intent/decision pass |
| Intent/boundary and decision brief | complete | Scroll-owner boundary, public sizing boundary, dependency fallback boundary. | No public getScrollElement in v1; numeric estimatedBlockSize; staged fallback when scroll owner cannot be proven; react-virtual preferred with virtual-core fallback. | Research evidence still needs ecosystem refresh. | Research and ecosystem refresh |
| Research and ecosystem refresh | superseded | Implementation used TanStack as explicit viewport engine and kept Slate policy ownership. | No default virtualization; no raw TanStack API passthrough. | Stress/browser parity remains release hardening. | Future release pass |
| Performance/DX/regression pressure passes | complete for implementation | Unit metrics, browser bounded-DOM example, package build, and bun check passed. | Added package dependency and full example. | 25k/100k stress not run. | Future release pass |
| Maintainer objection ledger | complete for implementation | Dependency is scoped to slate-react; default mode unchanged; docs call out degradation. | Keep. | none for this slice. | Future release pass |
| High-risk deliberate pass | complete for implementation | Browser caught stale virtual item memoization; fixed by recomputing virtualizer items every render. | Do not memoize virtualizer.getVirtualItems() snapshots. | broader IME/mobile matrix remains. | Future release pass |
| Revision pass | complete | Plan updated with implementation proof. | status done. | none for this slice. | Future release pass |
| Closure score | complete for implementation | bun check plus focused browser proof passed. | status done. | release-hardening stress gates remain optional next work. | none |
auto DOM-present/staged.segmentSize.getScrollElement and raw TanStack option passthrough in v1.estimatedBlockSize before considering callback sizing.@tanstack/react-virtual is not actually installed in .tmp/slate-v2, add it
deliberately during implementation or use @tanstack/virtual-core after
bundle proof.virtual-core
internally.@tanstack/react-virtual in slate-react.