Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/alpine-table/src/reactivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions packages/angular-table/src/reactivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ export interface TableAtomOptions<T> extends AtomOptions<T> {
*/
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
59 changes: 49 additions & 10 deletions packages/table-core/src/core/table/constructTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export function constructTable<
const table = {
_cellInstanceInitFns: [],
_columnInstanceInitFns: [],
_epoch: 0,
_features: { ...coreFeatures, ...features },
_headerGroupInstanceInitFns: [],
_headerInstanceInitFns: [],
Expand All @@ -73,6 +74,30 @@ export function constructTable<
baseAtoms: {},
} as unknown as Table_Internal<TFeatures, TData>

// 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<TAtom extends { set: (updaterOrValue: any) => 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<TableFeature> = Object.values(table._features)

const defaultOptions = featuresList.reduce((obj, feature) => {
Expand All @@ -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<any>
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
Expand All @@ -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<any>
if (typeof atom.set === 'function') {
bumpEpochOnSet(atom)
}
}
}

if (_reactivity.createOptionsStore) {
// @ts-ignore - direct set
table.optionsStore = _reactivity.createWritableAtom<
TableOptions<TFeatures, TData>
>(mergedOptions, { debugName: 'table/optionsStore' })
table.optionsStore = bumpEpochOnSet(
_reactivity.createWritableAtom<TableOptions<TFeatures, TData>>(
mergedOptions,
{ debugName: 'table/optionsStore' },
),
)
Object.defineProperty(table, 'options', {
configurable: true,
enumerable: true,
Expand All @@ -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(
() => {
Expand Down
8 changes: 8 additions & 0 deletions packages/table-core/src/core/table/coreTablesFeature.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,14 @@ export interface Table_CoreProperties<
* constructor so the engine stores their fields in-object.
*/
_cellConstructor?: new (...args: Array<any>) => 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
*/
Expand Down
5 changes: 5 additions & 0 deletions packages/table-core/src/core/table/coreTablesFeature.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions packages/table-core/src/store-reactivity-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
35 changes: 35 additions & 0 deletions packages/table-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,12 @@ export function flattenBy<TNode>(
}

interface MemoOptions<TDeps extends ReadonlyArray<any>, 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<TDeps>) => TResult
memoDeps?: (depArgs?: TDepArgs) => [...TDeps] | undefined
onAfterCompare?: (depsChanged: boolean) => void
Expand All @@ -330,6 +336,7 @@ interface MemoOptions<TDeps extends ReadonlyArray<any>, TDepArgs, TResult> {
* The memo recomputes only when its dependency tuple changes and can emit debug timing information.
*/
export const memo = <TDeps extends ReadonlyArray<any>, TDepArgs, TResult>({
epochSource,
fn,
memoDeps,
onAfterCompare,
Expand All @@ -341,8 +348,28 @@ export const memo = <TDeps extends ReadonlyArray<any>, TDepArgs, TResult>({
) => TResult) => {
let deps: Array<any> | 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
Expand All @@ -356,6 +383,11 @@ export const memo = <TDeps extends ReadonlyArray<any>, TDepArgs, TResult>({
}
onAfterCompare?.(depsChanged)

if (epochSource) {
lastValidatedEpoch = epoch
lastDepArgs = depArgs
}

if (!depsChanged) {
return result!
}
Expand Down Expand Up @@ -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,
})
}

Expand Down
6 changes: 6 additions & 0 deletions packages/table-core/tests/unit/core/rows/constructRow.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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])
Expand Down
3 changes: 3 additions & 0 deletions packages/vue-table/src/reactivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading