Skip to content

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

Open
KevinVandy wants to merge 1 commit into
fix/react-sync-state-adapterfrom
fix/react-sync-state-simplified
Open

refactor: move render-phase sync strategy into core reactivity bindings#6459
KevinVandy wants to merge 1 commit into
fix/react-sync-state-adapterfrom
fix/react-sync-state-simplified

Conversation

@KevinVandy

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. constructTable warns in dev when deferExternalStateSync is set with plain options but no commit hook (the one combination that silently strands subscribers).

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.

Not in this PR (follow-ups)

  • Preact adoption of renderPhaseReactivity (it has the identical sync-on-render code today, silently paying the double render+commit)
  • Lit audit (controller.table() writes its options store during render(); hostUpdated() is the natural commit hook)
  • 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: 7a60c1f7-f6ff-4746-a457-4fc7c1b764e6

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 e4b3f56

Command Status Duration Result
nx run-many --targets=build --exclude=examples/** ❌ Failed 43s View ↗
nx affected --targets=test:eslint,test:sherif,t... ✅ Succeeded 7m 16s View ↗

☁️ Nx Cloud last updated this comment at 2026-07-28 03:43:50 UTC

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