You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
mergeObjects has had this shape for a long time, but the current @tanstack/svelte-table adapter makes the consequences prominent: its $effect.pre calls setOptions((prev) => mergeObjects(prev, mergedOptions)) on every reactive tick. Because mergeObjects defines getters whose closures reference the previous result, table.options after N ticks is an N-deep chain. Reads of any key that lives only at the bottom of the chain — e.g. feature defaults installed by getDefaultTableOptions — walk the entire chain, making each read O(N). Row-model pipelines read those defaults many times per row per tick, producing O(N²) aggregate work and user-visible freezes while typing into a global filter input.
We hit this after bumping from 9.0.0-alpha.10 to 9.0.0-alpha.32. The mergeObjects implementation is identical between the two, but the newer adapter wires up the effect so that every reactive state change fires setOptions, letting the chain grow much faster in practice.
Scope: only measured on @tanstack/svelte-table. The table_mergeOptions default in @tanstack/table-core ((defaults, next) => mergeObjects(defaults, next)) is the structural source of the chain growth, so other adapters (React, Vue, Solid, Angular) may be affected to the extent their integrations also re-call setOptions on each render/reactive update — not verified.
Type 30–50 characters into the filter input and watch default-only ms (10k reads of onExpandedChange, installed by rowExpandingFeature.getDefaultTableOptions) climb. user-key ms (enableRowSelection, user-provided) stays flat.
Measured on 500 rows, 7 features active:
keystrokes
chain depth
default-only read (20k)
user-key read (20k)
0
2
~2 ms
~0.2 ms
50
103
~94 ms
~0.2 ms
100
203
~275 ms
~1.5 ms
Expected: typing into a global filter stays responsive indefinitely; table.options reads are O(1) regardless of how many times the effect has run.
Actual:table.options is an N-deep mergeObjects chain after N reactive ticks. Feature-default-key reads scale linearly with N. A Chrome performance profile on a production app we maintain showed the mergeObjects getter dominating CPU, with per-keystroke latency growing 15 → 183 → 374 → 899 → 1854 → 3486 → 7043 → 13923 ms across eight consecutive keystrokes.
Environment
Reproduced in StackBlitz (WebContainer, Chrome) and locally (macOS 15.3, Chrome 131, Node 22).
$effect.pre(()=>{voidmergedOptions.state;voidmergedOptions.data;untrack(()=>{table.setOptions((prev)=>{returnmergeObjects(prev,mergedOptions);// wraps prev into a new getter-chain layer});});});
table_mergeOptions via the adapter-installed default (defaults, next) => mergeObjects(defaults, next) — depth + 1
Stored in optionsStore; table.options returns this chain
User-provided options (data, columns, state, enableX) are in the mergedOptions closure — found on the first source check, O(1) reads. Feature-default options (onExpandedChange, onColumnVisibilityChange, onRowSelectionChange, onSortingChange, onColumnFiltersChange, getColumnCanGlobalFilter, isMultiSortEvent) live only in the flat initial store state {...defaultOptions, ...tableOptions} at the bottom — every read walks the full chain, O(N).
Why this was latent
Hot code paths that read user options aren't affected. You only feel it when the row-model pipeline (filter, sort, expand) reads a feature-default option many times per row, and the chain is allowed to grow across many reactive ticks in quick succession (e.g. rapid keystrokes in a filter input). Older adapter versions triggered setOptions less aggressively, so the chain didn't grow fast enough for this to be visible in practice.
Workaround
Override mergeOptions at the user level to resolve each key once and store plain data descriptors. Live implementation in the repro: src/App.svelte#L52-L73.
createTable({mergeOptions: (prev,next)=>{constresult: Record<PropertyKey,unknown>={};constallKeys=newSet([...Reflect.ownKeys(prev), ...Reflect.ownKeys(next)]);for(constkeyofallKeys){// `undefined` from `next` means "next didn't define this key" (not// "next explicitly set undefined"), so we fall through to `prev`.constfromNext=(nextasRecord<PropertyKey,unknown>)[key];result[keyasstring]=fromNext!==undefined ? fromNext : (prevasRecord<PropertyKey,unknown>)[key];}returnresult;},// ...rest of options});
Reactivity is preserved because the adapter's $effect.pre fires setOptions on every state/data change, so table.options is repopulated with fresh snapshots before any consumer reads it within the tick.
A descriptor-copy merger that preserves getters (Object.defineProperty(result, key, { ...desc, configurable: true })) is not sufficient — the chain of getter closures still grows, just with half the constant. The repro's "Use flat mergeOptions override (fix)" checkbox toggles this true-flat implementation on, demonstrating that the same column's read time drops from ~275 ms to ~0 ms at 100 keystrokes.
Suggested upstream fix
Either:
Adapter level: in createTable.svelte.js, write a fresh resolved snapshot instead of wrapping prev.
Core level: change the default mergeOptions in table_mergeOptions so it resolves and flattens instead of nesting mergeObjects.
Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
Do you intend to try to help solve this bug with your own PR?
No, because I do not know how
Terms & Code of Conduct
I agree to follow this project's Code of Conduct
I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.
TanStack Table version
9.0.0-alpha.32
Framework/Library version
Svelte 5.55.4
Describe the bug and the steps to reproduce it
Summary
mergeObjectshas had this shape for a long time, but the current@tanstack/svelte-tableadapter makes the consequences prominent: its$effect.precallssetOptions((prev) => mergeObjects(prev, mergedOptions))on every reactive tick. BecausemergeObjectsdefines getters whose closures reference the previous result,table.optionsafter N ticks is an N-deep chain. Reads of any key that lives only at the bottom of the chain — e.g. feature defaults installed bygetDefaultTableOptions— walk the entire chain, making each read O(N). Row-model pipelines read those defaults many times per row per tick, producing O(N²) aggregate work and user-visible freezes while typing into a global filter input.We hit this after bumping from
9.0.0-alpha.10to9.0.0-alpha.32. ThemergeObjectsimplementation is identical between the two, but the newer adapter wires up the effect so that every reactive state change firessetOptions, letting the chain grow much faster in practice.Scope: only measured on
@tanstack/svelte-table. Thetable_mergeOptionsdefault in@tanstack/table-core((defaults, next) => mergeObjects(defaults, next)) is the structural source of the chain growth, so other adapters (React, Vue, Solid, Angular) may be affected to the extent their integrations also re-callsetOptionson each render/reactive update — not verified.Reproduction
https://github.com/AndersRobstad/tanstack-merge-repro (open in StackBlitz)
Type 30–50 characters into the filter input and watch default-only ms (10k reads of
onExpandedChange, installed byrowExpandingFeature.getDefaultTableOptions) climb. user-key ms (enableRowSelection, user-provided) stays flat.Measured on 500 rows, 7 features active:
Expected: typing into a global filter stays responsive indefinitely;
table.optionsreads are O(1) regardless of how many times the effect has run.Actual:
table.optionsis an N-deepmergeObjectschain after N reactive ticks. Feature-default-key reads scale linearly with N. A Chrome performance profile on a production app we maintain showed themergeObjectsgetter dominating CPU, with per-keystroke latency growing 15 → 183 → 374 → 899 → 1854 → 3486 → 7043 → 13923 ms across eight consecutive keystrokes.Environment
Package versions
@tanstack/svelte-table9.0.0-alpha.32@tanstack/table-core9.0.0-alpha.32svelte5.55.4Root cause
@tanstack/svelte-table/dist/createTable.svelte.js:Each tick:
functionalUpdate(updater, table.options)→mergeObjects(prev, mergedOptions)— depth + 1table_mergeOptionsvia the adapter-installed default(defaults, next) => mergeObjects(defaults, next)— depth + 1optionsStore;table.optionsreturns this chainUser-provided options (
data,columns,state,enableX) are in themergedOptionsclosure — found on the first source check, O(1) reads. Feature-default options (onExpandedChange,onColumnVisibilityChange,onRowSelectionChange,onSortingChange,onColumnFiltersChange,getColumnCanGlobalFilter,isMultiSortEvent) live only in the flat initial store state{...defaultOptions, ...tableOptions}at the bottom — every read walks the full chain, O(N).Why this was latent
Hot code paths that read user options aren't affected. You only feel it when the row-model pipeline (filter, sort, expand) reads a feature-default option many times per row, and the chain is allowed to grow across many reactive ticks in quick succession (e.g. rapid keystrokes in a filter input). Older adapter versions triggered
setOptionsless aggressively, so the chain didn't grow fast enough for this to be visible in practice.Workaround
Override
mergeOptionsat the user level to resolve each key once and store plain data descriptors. Live implementation in the repro: src/App.svelte#L52-L73.Reactivity is preserved because the adapter's
$effect.prefiressetOptionson every state/data change, sotable.optionsis repopulated with fresh snapshots before any consumer reads it within the tick.A descriptor-copy merger that preserves getters (
Object.defineProperty(result, key, { ...desc, configurable: true })) is not sufficient — the chain of getter closures still grows, just with half the constant. The repro's "Use flatmergeOptionsoverride (fix)" checkbox toggles this true-flat implementation on, demonstrating that the same column's read time drops from ~275 ms to ~0 ms at 100 keystrokes.Suggested upstream fix
Either:
createTable.svelte.js, write a fresh resolved snapshot instead of wrappingprev.mergeOptionsintable_mergeOptionsso it resolves and flattens instead of nestingmergeObjects.Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
https://stackblitz.com/~/github.com/AndersRobstad/tanstack-merge-repro
Screenshots or Videos (Optional)
Skjermopptak.2026-04-21.kl.22.25.13.mov
Do you intend to try to help solve this bug with your own PR?
No, because I do not know how
Terms & Code of Conduct