Skip to content

refactor: move render-phase sync strategy into core reactivity bindings - #6459

Merged
KevinVandy merged 10 commits into
fix/react-sync-state-adapterfrom
fix/react-sync-state-simplified
Jul 28, 2026
Merged

refactor: move render-phase sync strategy into core reactivity bindings#6459
KevinVandy merged 10 commits into
fix/react-sync-state-adapterfrom
fix/react-sync-state-simplified

Conversation

@KevinVandy

@KevinVandy KevinVandy commented Jul 28, 2026

Copy link
Copy Markdown
Member

Builds directly on this branch's deferred controlled-state publication (great work — the deferred publish, read-through atoms, live facade, and captured-state semantics are all load-bearing and verified). This PR keeps all of that and consolidates it behind a consistent adapter interface, while simplifying the two pieces that turned out to be replaceable with less machinery. All 11 useTable tests from this branch pass unchanged.

What changed

1. Strategy flags on TableReactivityBindings (instead of a per-call option)

deferExternalStateSync?: boolean and commit?: () => void join createOptionsStore/wrapExternalAtoms. They describe two independent axes we confirmed across all 8 adapters:

  • Read side (createOptionsStore): can the dependency graph see options changes? Signal adapters: yes. React/Preact: no — hence the live facade.
  • Write side (deferExternalStateSync): is setOptions a notification-safe moment? Signal adapters sync inside effects/watchers (eager stays correct — unchanged). React/Preact sync during render (defer).

table_setOptions consults the flag, so an adapter declares its strategy once instead of threading { syncExternalState: false } through call sites. The coherence rule (deferExternalStateSync with plain options requires commit) is documented on the bindings types.

2. Core owns the commit bump

table_syncExternalStateToBaseAtoms(table, capturedState | null, compare?) replaces the arguments.length overloads with an explicit sentinel (null = captured "no controlled state", omitted = read current options), and now calls _reactivity.commit?.() inside its own batch — including when nothing is published. The ownership-release invariant ("bump even with no atom write") was the easiest thing for a future adapter to forget, so it lives in core now. The adapter's layout effect is a single call.

3. renderPhaseReactivity preset in @tanstack/table-core/reactivity

The live readonly-atom facade + commit atom move from react-table/src/reactivity.ts into a core preset. Store primitives (createAtom, batch) are injected by the adapter so every atom lives in the adapter's store instance alongside user external atoms (dependency tracking is module-global — the preset must not use table-core's own store copy). The React reactivity file is now ~15 lines, and the Preact port becomes a mechanical follow-up.

4. createCommitFilteredSource replaces useTableSelector

The root-notification filter is real and necessary — without it the owner re-renders once per controlled update just to find nothing changed. But because the facade guarantees referentially stable snapshots, a reference check on the last snapshot the hook read achieves the same filtering as the committed-selection bookkeeping, without the selector refs and without the implicit requirement that useTableSelector's layout effect run before the publish effect. useTable now uses plain useSelector over the filtered source.

Verification

  • react-table: 11/11 tests from this branch, unchanged + types + lint
  • table-core: 1017/1017 (incl. 6 new tests for the flag, sentinel, commit-on-empty-publish, dev warning, and filter) + types + lint + size-limit (19.53 kB / 25 kB)
  • E2E: basic-external-state 3/3, basic-external-atoms 3/3 (Chromium)
  • Live check in basic-external-state (StrictMode + React Compiler + devtools): one render pass and one commit per controlled update, zero render-phase warnings, correct behavior through pagination / sorting (with auto page reset) / page-size / 1M-row stress. Baseline beta pays two render passes per controlled update (the warning marks a discarded pass), so this lands at half the render work.

Update: Preact adapter included

Preact had the identical sync-on-render code and silently paid the double render (its store shim's forceUpdate during render defers an extra pass — no warning, same waste). Since the preset made it mechanical, it's now in this PR:

  • preactReactivity collapses onto renderPhaseReactivity({ createAtom, batch }) from @tanstack/preact-store (which pins the same @tanstack/store@0.11.0 instance as table-core)
  • useTable mirrors the React adapter: flag-driven setOptions, captured-state publish in an isomorphic layout effect, createCommitFilteredSource root subscription
  • New unit tests: one render pass per controlled update with controlled/selected/atom/store/row-model agreement in every pass, exactly one post-commit store notification for external subscribers, uncontrolled updates unaffected (4/4)
  • New e2e spec for controlled pagination in the preact basic-external-state example (2/2)

Remaining follow-up: Ember read-through reconciliation. Signal-family adapters (Solid/Vue/Svelte/Angular/Alpine) intentionally stay eager — verified unaffected by the core changes.

Update: Lit adapter migrated (fixes the basic-subscribe e2e regression)

The basic-subscribe e2e failure after this branch's shallow store compare exposed two stacked Lit issues:

  1. The subscribe directive pinned islands to stale renders. It returned noChange for host-driven re-renders with an unchanged source + selector, so islands only updated from store notifications — which previously fired for ANY setOptions call (the aggregate snapshot was rebuilt without a comparator). The correct shallow compare stopped notifying for options-only changes, so Regenerate Data left islands showing stale row models. The directive now re-renders islands on every host update (adopting the latest template closure — also fixing a latent stale-closure bug) while subscriptions still drive updates between host renders.
  2. Lit was the off-diagonal reactivity case flagged in the axes rationale: a reactive options store written during render(), costing a second update cycle per interaction. litReactivity now uses renderPhaseReactivity with @tanstack/lit-store primitives; TableController publishes captured controlled state from hostUpdated() and reads its root subscription through createCommitFilteredSource. The optionsStore subscription is gone — options changes flow through host renders (lit reactive properties), the pattern 30 of 31 lit examples already use. basic-subscribe was the lone outlier (table constructed once + imperative setOptions in updated()) and now re-syncs options per render pass like the rest.

Measured on basic-subscribe: Regenerate Data works with one host update (previously two, with stale islands), zero idle update churn, and row-selection clicks still update only their island with zero host updates. Lit unit suite updated to the new contract incl. a hostUpdated publication test (6/6); all 34 lit example e2e suites pass, including the previously failing one.

Note: the dev-only coherence warning for deferExternalStateSync-without-commit bindings was dropped during review; the rule remains documented on the TableReactivityBindings types.

Not in this PR (follow-ups)

  • Ember: reconcile its local atoms > options.state > baseAtom read-through in use-table.ts with core's (core's hasOwn check also distinguishes explicitly-undefined controlled slices)

🤖 Generated with Claude Code

Builds on the deferred controlled-state publication from this branch and
consolidates it behind a consistent adapter interface:

- TableReactivityBindings gains two optional properties describing the two
  strategy axes: deferExternalStateSync (write-side: is setOptions a
  notification-safe moment?) and commit (invalidation hook for readonly atoms
  whose compute reads non-reactive plain options). constructTable warns in dev
  when defer is set with plain options but no commit hook.
- table_setOptions consults the bindings flag instead of a per-call
  { syncExternalState } option, so an adapter's strategy is declared once.
- table_syncExternalStateToBaseAtoms replaces the arguments.length overloads
  with an explicit `capturedState | null` sentinel, and now owns the commit
  bump: it runs inside the write batch and fires even when nothing is
  published, so ownership releases still invalidate subscribers.
- New renderPhaseReactivity preset in @tanstack/table-core/reactivity hosts
  the live readonly-atom facade + commit atom (moved from the React adapter).
  Store primitives are injected by the adapter so all atoms share one store
  instance with user external atoms. Preact can adopt the same preset later.
- New createCommitFilteredSource replaces useTableSelector: because facade
  snapshots are referentially stable, a reference check on the last snapshot
  read is enough to skip the root hook's redundant post-commit notification.
  This drops the committed-selection refs and the implicit requirement that
  the selector's layout effect run before the publish effect.
- useTable: plain useSelector over the filtered source; the publish layout
  effect is a single table_syncExternalStateToBaseAtoms call.

All 11 react-table tests from this branch pass unchanged. table-core: 1017
tests, types, lint, size-limit green. Both example e2e smoke suites pass.
Verified in basic-external-state (StrictMode + React Compiler + devtools):
one render pass and one commit per controlled update, no render-phase
warning, correct behavior through pagination/sorting/page-size/1M-row stress.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 31c3b68d-c91e-485f-b095-6d93bd4ed52f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/react-sync-state-simplified

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@nx-cloud

nx-cloud Bot commented Jul 28, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit f79077e

Command Status Duration Result
nx affected --targets=test:eslint,test:sherif,t... ✅ Succeeded 6m 51s View ↗
nx run-many --targets=build --exclude=examples/** ✅ Succeeded 52s View ↗

☁️ Nx Cloud last updated this comment at 2026-07-28 16:12:04 UTC

Preact had the identical sync-options-during-render code as React and paid
the same cost silently: the mid-render store notification scheduled an extra
deferred render per controlled update (Preact never warns about it).

Same shape as the React adapter: preactReactivity collapses onto
renderPhaseReactivity with @tanstack/preact-store primitives, useTable defers
publication to an isomorphic layout effect with the captured controlled
state, and the root useSelector reads through createCommitFilteredSource.

New unit tests assert one render pass per controlled update with consistent
controlled/selected/atom/store/row-model reads, exactly one post-commit store
notification for external subscribers, and uncontrolled updates still
re-rendering. New e2e spec exercises controlled pagination in the
basic-external-state preact example. Types, lint, and build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pkg-pr-new

pkg-pr-new Bot commented Jul 28, 2026

Copy link
Copy Markdown
More templates

@tanstack/alpine-table

npm i https://pkg.pr.new/TanStack/table/@tanstack/alpine-table@6459

@tanstack/angular-table

npm i https://pkg.pr.new/TanStack/table/@tanstack/angular-table@6459

@tanstack/angular-table-devtools

npm i https://pkg.pr.new/TanStack/table/@tanstack/angular-table-devtools@6459

@tanstack/ember-table

npm i https://pkg.pr.new/TanStack/table/@tanstack/ember-table@6459

@tanstack/lit-table

npm i https://pkg.pr.new/TanStack/table/@tanstack/lit-table@6459

@tanstack/match-sorter-utils

npm i https://pkg.pr.new/TanStack/table/@tanstack/match-sorter-utils@6459

@tanstack/preact-table

npm i https://pkg.pr.new/TanStack/table/@tanstack/preact-table@6459

@tanstack/preact-table-devtools

npm i https://pkg.pr.new/TanStack/table/@tanstack/preact-table-devtools@6459

@tanstack/react-table

npm i https://pkg.pr.new/TanStack/table/@tanstack/react-table@6459

@tanstack/react-table-devtools

npm i https://pkg.pr.new/TanStack/table/@tanstack/react-table-devtools@6459

@tanstack/solid-table

npm i https://pkg.pr.new/TanStack/table/@tanstack/solid-table@6459

@tanstack/solid-table-devtools

npm i https://pkg.pr.new/TanStack/table/@tanstack/solid-table-devtools@6459

@tanstack/svelte-table

npm i https://pkg.pr.new/TanStack/table/@tanstack/svelte-table@6459

@tanstack/table-core

npm i https://pkg.pr.new/TanStack/table/@tanstack/table-core@6459

@tanstack/table-devtools

npm i https://pkg.pr.new/TanStack/table/@tanstack/table-devtools@6459

@tanstack/vue-table

npm i https://pkg.pr.new/TanStack/table/@tanstack/vue-table@6459

@tanstack/vue-table-devtools

npm i https://pkg.pr.new/TanStack/table/@tanstack/vue-table-devtools@6459

commit: f79077e

KevinVandy and others added 5 commits July 28, 2026 07:53
…be islands from stale renders

The lit basic-subscribe e2e regression had two stacked causes:

1. The subscribe directive returned noChange for host-driven re-renders with
   an unchanged source + selector, so islands only ever updated from store
   notifications. They previously received one for ANY setOptions call
   because the aggregate state snapshot was rebuilt without a comparator;
   the (correct) shallow compare on table.store stopped notifying for
   options-only changes, pinning islands to stale row models after
   Regenerate Data. The directive now re-renders islands on every host
   update and adopts the latest template closure, while subscriptions still
   drive updates between host renders.

2. Lit was the off-diagonal reactivity case: a reactive options store
   written during render(), costing a second update cycle per interaction
   (requestUpdate mid-update from the optionsStore subscription).
   litReactivity now uses the renderPhaseReactivity preset with
   @tanstack/lit-store primitives: plain options synced during render,
   captured controlled state published from hostUpdated(), and the
   controller's root subscription reads through createCommitFilteredSource.
   The optionsStore subscription is gone; options changes flow through host
   renders (lit reactive properties), which 30 of 31 lit examples already
   use. basic-subscribe was the lone outlier constructing the table once
   and pushing data via imperative setOptions in updated() — it now
   re-syncs options per render pass like the rest.

Measured on basic-subscribe: Regenerate Data works again with one host
update (previously two, with stale islands), zero idle update churn, and a
row-selection click still updates only its island with zero host updates.

Unit tests: directive/controller suites updated to the new contract plus a
hostUpdated publication test (6/6). All 34 lit example e2e suites pass,
including the previously failing basic-subscribe. Types, lint, build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…warning

The constructTable dev warning for deferExternalStateSync-without-commit was
removed in the previous commit; align the test suite (1010 tests green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@KevinVandy
KevinVandy requested a review from riccardoperra July 28, 2026 14:45
…s loop

The consolidated loop interleaved constructTableAPIs with
initTableInstanceData per feature (and ran APIs first within each feature),
breaking the lifecycle contract from #6446 that constructTable.test.ts
encodes: every feature's table instance data initializes before ANY table
APIs are constructed, so API constructors can read instance data owned by
other features and init hooks observe a pre-API table.

Keep the consolidation of the init-fn collection with instance-data init
(one phase-1 loop writing the precomputed arrays directly), and restore
constructTableAPIs as its own second pass.

table-core: 1097/1097. React/preact/lit adapter suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@nx-cloud nx-cloud Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

At least one additional CI pipeline execution has run since the conclusion below was written and it may no longer be applicable.

Nx Cloud is proposing a fix for your failed CI:

We updated the constructTable test to align with the intentional single-pass loop introduced by this PR, which interleaves each feature's initTableInstanceData and constructTableAPIs calls rather than running all inits before all APIs. The failing assertion (expect(table.reset).toBeUndefined()) reflected the old two-pass guarantee that no longer holds, and the expected calls order was updated from all-init-then-all-api to per-feature init+api pairs. This brings the test contract in sync with the new loop semantics without changing any production behavior.

Warning

We could not verify this fix.

Suggested Fix changes
diff --git a/packages/table-core/tests/unit/core/table/constructTable.test.ts b/packages/table-core/tests/unit/core/table/constructTable.test.ts
index adecafbfe..331818cb0 100644
--- a/packages/table-core/tests/unit/core/table/constructTable.test.ts
+++ b/packages/table-core/tests/unit/core/table/constructTable.test.ts
@@ -35,7 +35,7 @@ function getterOnlyMerge(...sources: Array<any>) {
 }
 
 describe('constructTable', () => {
-  it('initializes feature-owned table data before constructing any table APIs', () => {
+  it('initializes feature-owned table data before constructing its own APIs', () => {
     const calls: Array<string> = []
     const initA = vi.fn((table: any) => {
       calls.push('init-a')
@@ -43,7 +43,6 @@ describe('constructTable', () => {
       expect(table.baseAtoms.lifecycle.get()).toBe('initial')
       expect(table.atoms.lifecycle.get()).toBe('initial')
       expect(table.store.state.lifecycle).toBe('initial')
-      expect(table.reset).toBeUndefined()
       table.firstInitialized = true
     })
     const initB = vi.fn((table: any) => {
@@ -54,7 +53,6 @@ describe('constructTable', () => {
     const apiA = vi.fn((table: any) => {
       calls.push('api-a')
       expect(table.firstInitialized).toBe(true)
-      expect(table.secondInitialized).toBe(true)
     })
     const apiB = vi.fn((table: any) => {
       calls.push('api-b')
@@ -94,8 +92,8 @@ describe('constructTable', () => {
 
     expect(calls).toEqual([
       'init-a',
-      'init-b',
       'api-a',
+      'init-b',
       'api-b',
       'api-without-init',
     ])

Apply fix via Nx Cloud  Reject fix via Nx Cloud


Or Apply changes locally with:

npx nx-cloud apply-locally KfyD-vDrX

Apply fix locally with your editor ↗   View interactive diff ↗



🎓 Learn more about Self-Healing CI on nx.dev

KevinVandy and others added 2 commits July 28, 2026 09:54
Drop the two-phase construction contract (all initTableInstanceData before
any constructTableAPIs). Every in-repo hook is pure property assignment with
lazy API closures, so the phase barrier bought nothing; features are now
processed in one registration-order pass with each feature's instance data
initialized just before its own APIs are constructed.

New contract, encoded in the lifecycle test and documented in the
custom-features guides and TableFeature jsdoc: hooks may rely on data and
APIs of features registered earlier; eager cross-feature reads belong in
lazy API bodies.

table-core 1097/1097; react/preact/lit adapter suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
constructTable always assigns the five _*InstanceInitFns arrays before any
constructor can run, so model them as required and drop the non-null
assertions at the use sites (including the leftover one in buildHeaderGroups
that eslint flagged as unnecessary).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@KevinVandy
KevinVandy merged commit 4a5fcbe into fix/react-sync-state-adapter Jul 28, 2026
9 checks passed
@KevinVandy
KevinVandy deleted the fix/react-sync-state-simplified branch July 28, 2026 16:15
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