Skip to content

svelte-table: nested mergeObjects in setOptions produces quadratic slowdown on rapid state changes #6235

@AndersRobstad

Description

@AndersRobstad

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

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.

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 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).

Package versions

  • @tanstack/svelte-table 9.0.0-alpha.32
  • @tanstack/table-core 9.0.0-alpha.32
  • svelte 5.55.4

Root cause

@tanstack/svelte-table/dist/createTable.svelte.js:

$effect.pre(() => {
  void mergedOptions.state;
  void mergedOptions.data;
  untrack(() => {
    table.setOptions((prev) => {
      return mergeObjects(prev, mergedOptions); // wraps prev into a new getter-chain layer
    });
  });
});

Each tick:

  1. functionalUpdate(updater, table.options)mergeObjects(prev, mergedOptions) — depth + 1
  2. table_mergeOptions via the adapter-installed default (defaults, next) => mergeObjects(defaults, next) — depth + 1
  3. 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) => {
    const result: Record<PropertyKey, unknown> = {};
    const allKeys = new Set([...Reflect.ownKeys(prev), ...Reflect.ownKeys(next)]);
    for (const key of allKeys) {
      // `undefined` from `next` means "next didn't define this key" (not
      // "next explicitly set undefined"), so we fall through to `prev`.
      const fromNext = (next as Record<PropertyKey, unknown>)[key];
      result[key as string] = fromNext !== undefined ? fromNext : (prev as Record<PropertyKey, unknown>)[key];
    }
    return result;
  },
  // ...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:

  1. Adapter level: in createTable.svelte.js, write a fresh resolved snapshot instead of wrapping prev.
  2. 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)

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

  • 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions