diff --git a/packages/alpine-table/src/reactivity.ts b/packages/alpine-table/src/reactivity.ts index 2e94e93045..ad1e7899c8 100644 --- a/packages/alpine-table/src/reactivity.ts +++ b/packages/alpine-table/src/reactivity.ts @@ -13,6 +13,9 @@ import type { export function alpineReactivity(): TableReactivityBindings { return { createOptionsStore: true, + // All state/options writes flow through the table's patched + // atoms/optionsStore, so the memo epoch fast path is safe here. + supportsWriteEpoch: true, wrapExternalAtoms: false, addSubscription: () => { throw new Error( diff --git a/packages/angular-table/src/reactivity.ts b/packages/angular-table/src/reactivity.ts index 581e07f13e..d5801e1441 100644 --- a/packages/angular-table/src/reactivity.ts +++ b/packages/angular-table/src/reactivity.ts @@ -69,6 +69,9 @@ export function angularReactivity(injector: Injector): TableReactivityBindings { return { createOptionsStore: true, + // All state/options writes flow through the table's patched + // atoms/optionsStore, so the memo epoch fast path is safe here. + supportsWriteEpoch: true, wrapExternalAtoms: true, addSubscription: (subscription) => { subscriptions.add(subscription) diff --git a/packages/table-core/src/core/reactivity/coreReactivityFeature.types.ts b/packages/table-core/src/core/reactivity/coreReactivityFeature.types.ts index 045d0dd90a..968de449a8 100644 --- a/packages/table-core/src/core/reactivity/coreReactivityFeature.types.ts +++ b/packages/table-core/src/core/reactivity/coreReactivityFeature.types.ts @@ -21,6 +21,17 @@ export interface TableAtomOptions extends AtomOptions { */ export interface TableReactivityBindings { createOptionsStore: boolean + /** + * Opt-in to the memoized-API write-epoch fast path. Only set this after + * verifying that every state/options write path in the binding advances + * `table._epoch` before the next memoized read: writes through the + * patched base atoms/optionsStore qualify automatically, but bindings + * that stage state in framework reactivity (live options getters, + * post-render option-sync effects) can let reads observe new state before + * the epoch moves, which would serve stale memo results. Bindings without + * this flag keep the plain per-call dependency check. + */ + supportsWriteEpoch?: boolean wrapExternalAtoms: boolean /** * Invalidates readonly atoms whose compute reads non-reactive inputs (plain diff --git a/packages/table-core/src/core/reactivity/renderPhaseReactivity.ts b/packages/table-core/src/core/reactivity/renderPhaseReactivity.ts index 42b30a20f7..dda8cf55d5 100644 --- a/packages/table-core/src/core/reactivity/renderPhaseReactivity.ts +++ b/packages/table-core/src/core/reactivity/renderPhaseReactivity.ts @@ -65,6 +65,11 @@ export function renderPhaseReactivity( return { createOptionsStore: false, + // Options are plain data re-synchronized through `table_setOptions` + // during render (which bumps the epoch before any read of the new + // render), and all state writes flow through the patched base atoms, so + // the memo epoch fast path is safe for render-phase adapters. + supportsWriteEpoch: true, wrapExternalAtoms: false, addSubscription: () => { throw new Error( diff --git a/packages/table-core/src/core/table/constructTable.ts b/packages/table-core/src/core/table/constructTable.ts index 7a6519d354..f6e6e3aeab 100644 --- a/packages/table-core/src/core/table/constructTable.ts +++ b/packages/table-core/src/core/table/constructTable.ts @@ -62,6 +62,7 @@ export function constructTable< const table = { _cellInstanceInitFns: [], _columnInstanceInitFns: [], + _epoch: 0, _features: { ...coreFeatures, ...features }, _headerGroupInstanceInitFns: [], _headerInstanceInitFns: [], @@ -73,6 +74,30 @@ export function constructTable< baseAtoms: {}, } as unknown as Table_Internal + // Every state/options write surface bumps the table's write epoch + // synchronously, at the exact point the new value is resolved: after the + // user updater runs (updater reads still see the pre-write epoch, so they + // cannot cache post-write results early) and immediately before the store + // assigns and notifies (synchronous listeners see the new value AND the + // new epoch together, so already-validated memos revalidate for them). + // Bumping before or after `set` instead leaves a window where epoch and + // value disagree for code running inside the write. + function bumpEpochOnSet any }>( + atom: TAtom, + ): TAtom { + const originalSet = atom.set.bind(atom) + atom.set = (updaterOrValue: any) => + originalSet((old: any) => { + const next = + typeof updaterOrValue === 'function' + ? updaterOrValue(old) + : updaterOrValue + table._epoch++ + return next + }) + return atom + } + const featuresList: Array = Object.values(table._features) const defaultOptions = featuresList.reduce((obj, feature) => { @@ -84,9 +109,11 @@ export function constructTable< if (_reactivity.wrapExternalAtoms && mergedOptions.atoms) { for (const [atomKey, _atom] of Object.entries(mergedOptions.atoms)) { const atom = _atom as Atom - const wrappedAtom = _reactivity.createWritableAtom(atom.get(), { - debugName: `externalAtom/${atomKey}`, - }) + const wrappedAtom = bumpEpochOnSet( + _reactivity.createWritableAtom(atom.get(), { + debugName: `externalAtom/${atomKey}`, + }), + ) ;(mergedOptions.atoms as any)[atomKey] = wrappedAtom // Two-way syncing between the original atom and the wrapped one. let syncExternal = false @@ -102,13 +129,26 @@ export function constructTable< _reactivity.addSubscription(syncAtomToWrappedSub) _reactivity.addSubscription(syncWrappedToAtomSub) } + } else if (mergedOptions.atoms) { + // Unwrapped external atoms are read directly by the derived state atoms; + // their writes must still advance the epoch. Patch their `set` in place + // (bindings without subscription tracking cannot register a listener). + for (const _atom of Object.values(mergedOptions.atoms)) { + const atom = _atom as Atom + if (typeof atom.set === 'function') { + bumpEpochOnSet(atom) + } + } } if (_reactivity.createOptionsStore) { // @ts-ignore - direct set - table.optionsStore = _reactivity.createWritableAtom< - TableOptions - >(mergedOptions, { debugName: 'table/optionsStore' }) + table.optionsStore = bumpEpochOnSet( + _reactivity.createWritableAtom>( + mergedOptions, + { debugName: 'table/optionsStore' }, + ), + ) Object.defineProperty(table, 'options', { configurable: true, enumerable: true, @@ -134,11 +174,10 @@ export function constructTable< for (let i = 0; i < stateKeys.length; i++) { const key = stateKeys[i]! - table.baseAtoms[key] = _reactivity.createWritableAtom( - table.initialState[key], - { + table.baseAtoms[key] = bumpEpochOnSet( + _reactivity.createWritableAtom(table.initialState[key], { debugName: `table/baseAtoms/${key}`, - }, + }), ) as any ;(table.atoms as any)[key] = _reactivity.createReadonlyAtom( () => { diff --git a/packages/table-core/src/core/table/coreTablesFeature.types.ts b/packages/table-core/src/core/table/coreTablesFeature.types.ts index 8c144bf1cd..7ff8e9ea94 100644 --- a/packages/table-core/src/core/table/coreTablesFeature.types.ts +++ b/packages/table-core/src/core/table/coreTablesFeature.types.ts @@ -170,6 +170,14 @@ export interface Table_CoreProperties< * constructor so the engine stores their fields in-object. */ _cellConstructor?: new (...args: Array) => object + /** + * Monotonic write epoch. Bumped synchronously on every table state, + * options, or external-atom write. Memoized APIs use it as a fast path: + * when the epoch has not moved since a memo last validated, its cached + * result is returned without re-running the dependency check. + * @internal + */ + _epoch: number /** * Prototype cache for Cell objects - shared by all cells in this table */ diff --git a/packages/table-core/src/core/table/coreTablesFeature.utils.ts b/packages/table-core/src/core/table/coreTablesFeature.utils.ts index ddc27b1c14..f2780c05c3 100644 --- a/packages/table-core/src/core/table/coreTablesFeature.utils.ts +++ b/packages/table-core/src/core/table/coreTablesFeature.utils.ts @@ -217,7 +217,12 @@ export function table_setOptions< if (table.optionsStore) { table.optionsStore.set(() => mergedOptions) } else { + // Options are an input to memoized APIs; a plain assignment bypasses the + // epoch-bumping atom writes, so bump here (after the write, matching the + // patched-atom ordering). The optionsStore branch bumps through its + // patched `set`. table.options = mergedOptions + table._epoch++ } if (options?.syncExternalState !== false) { table_publishExternalState(table, mergedOptions.state ?? null) diff --git a/packages/table-core/src/store-reactivity-bindings.ts b/packages/table-core/src/store-reactivity-bindings.ts index e463b87c84..cbb8d8580a 100644 --- a/packages/table-core/src/store-reactivity-bindings.ts +++ b/packages/table-core/src/store-reactivity-bindings.ts @@ -19,6 +19,9 @@ import type { TableReactivityBindings } from './core/reactivity/coreReactivityFe export function storeReactivityBindings(): TableReactivityBindings { return { createOptionsStore: true, + // All writes flow through the table's patched atoms/optionsStore, so the + // memo epoch fast path is safe here. + supportsWriteEpoch: true, wrapExternalAtoms: false, addSubscription: () => { throw new Error( diff --git a/packages/table-core/src/utils.ts b/packages/table-core/src/utils.ts index d42ccacaba..15074e7144 100755 --- a/packages/table-core/src/utils.ts +++ b/packages/table-core/src/utils.ts @@ -316,6 +316,12 @@ export function flattenBy( } interface MemoOptions, TDepArgs, TResult> { + /** + * Write-epoch source (the table). When provided and the epoch has not + * moved since this memo last validated (for the same depArgs identity), + * the cached result is returned without running `memoDeps`. + */ + epochSource?: { _epoch?: number } fn: (...args: NoInfer) => TResult memoDeps?: (depArgs?: TDepArgs) => [...TDeps] | undefined onAfterCompare?: (depsChanged: boolean) => void @@ -330,6 +336,7 @@ interface MemoOptions, TDepArgs, TResult> { * The memo recomputes only when its dependency tuple changes and can emit debug timing information. */ export const memo = , TDepArgs, TResult>({ + epochSource, fn, memoDeps, onAfterCompare, @@ -341,8 +348,28 @@ export const memo = , TDepArgs, TResult>({ ) => TResult) => { let deps: Array | undefined = [] let result: TResult | undefined + let lastValidatedEpoch: number | undefined = -1 + let lastDepArgs: TDepArgs | undefined const memoizedFn = (depArgs?: TDepArgs): TResult => { + // Epoch fast path: no table write since this memo last validated means + // no dependency can have changed, so skip the dependency check entirely + // (it may cascade through other memoized APIs and allocates a deps + // array). The epoch is captured before validating so a bump landing + // during a recompute forces revalidation on the next call. Sources + // without an epoch (mock tables in tests) always validate. + let epoch: number | undefined + if (epochSource) { + epoch = epochSource._epoch + if ( + epoch === lastValidatedEpoch && + depArgs === lastDepArgs && + epoch !== undefined + ) { + return result! + } + } + onBeforeCompare?.() const newDeps = memoDeps?.(depArgs) let depsChanged = !newDeps || newDeps.length !== deps?.length @@ -356,6 +383,11 @@ export const memo = , TDepArgs, TResult>({ } onAfterCompare?.(depsChanged) + if (epochSource) { + lastValidatedEpoch = epoch + lastDepArgs = depArgs + } + if (!depsChanged) { return result! } @@ -541,6 +573,9 @@ export function tableMemo< return memo({ ...memoOptions, ...debugOptions, + // The epoch fast path is opt-in per reactivity binding; see + // TableReactivityBindings.supportsWriteEpoch. + epochSource: table._reactivity.supportsWriteEpoch ? table : undefined, }) } diff --git a/packages/table-core/tests/unit/core/rows/constructRow.test.ts b/packages/table-core/tests/unit/core/rows/constructRow.test.ts index 69bef711aa..cdfe25c29e 100644 --- a/packages/table-core/tests/unit/core/rows/constructRow.test.ts +++ b/packages/table-core/tests/unit/core/rows/constructRow.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { constructTable } from '../../../../src' import { constructRow } from '../../../../src/core/rows/constructRow' +import { table_setOptions } from '../../../../src/static-functions' import { testFeatures } from '../../../fixtures/features' import type { Row } from '../../../../src/types/Row' @@ -86,6 +87,11 @@ describe('constructRow', () => { parent.subRows = [leafB, leafA] + // Memoized APIs revalidate once per table write epoch. Row models only + // mutate subRows inside epoch-advancing writes; a manual structural + // mutation surfaces after the next table write. + table_setOptions(table, (options) => options) + const nextLeafRows = parent.getLeafRows() expect(nextLeafRows).not.toBe(firstLeafRows) expect(nextLeafRows).toEqual([leafB, leafA]) diff --git a/packages/vue-table/src/reactivity.ts b/packages/vue-table/src/reactivity.ts index a79bc2937a..f022c419b6 100644 --- a/packages/vue-table/src/reactivity.ts +++ b/packages/vue-table/src/reactivity.ts @@ -64,6 +64,9 @@ export function vueReactivity(): TableReactivityBindings { return { createOptionsStore: true, + // All state/options writes flow through the table's patched + // atoms/optionsStore, so the memo epoch fast path is safe here. + supportsWriteEpoch: true, wrapExternalAtoms: true, addSubscription: (subscription) => { subscriptions.add(subscription)