Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/heap-mark-incremental.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

Mounting N rows in one flush was O(N²) when each row created a user effect whose source was written during row creation (the `ref` effect pattern). An unmarked node entering an already-marked pure heap invalidated the `markHeap` memo, so every later mid-tick memo pull re-walked the whole heap. The insertion now marks the incoming node in place instead; the mount is linear (8000 rows with a ref effect each: 231 ms → 12 ms).
17 changes: 11 additions & 6 deletions packages/signals/src/core/heap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,17 @@ export function insertIntoHeap(n: Computed<any>, heap: Heap) {
n._flags = (flags & ~(REACTIVE_CHECK | REACTIVE_DIRTY)) | REACTIVE_DIRTY | REACTIVE_IN_HEAP;
} else {
n._flags = flags | REACTIVE_IN_HEAP;
// An unmarked node entering a marked heap invalidates the markHeap memo:
// `_marked` is only reset by runHeap, so a write between two mid-tick
// pulls (read-time markHeap + updateIfNecessary) would otherwise leave
// this node unmarked and every downstream pull stale until the next
// flush (#2922: the second `latest()` returned the first write's value).
if (heap._marked && !(flags & REACTIVE_DIRTY)) heap._marked = false;
// An unmarked node entering an already-marked heap is marked on the
// spot, keeping the markHeap memo valid. `_marked` is only reset by
// runHeap, so a write between two mid-tick pulls (read-time markHeap +
// updateIfNecessary) would otherwise leave this node unmarked and every
// downstream pull stale until the next flush (#2922: the second
// `latest()` returned the first write's value). Invalidating the memo
// instead re-walked the WHOLE heap on the next pull — with N effects
// parked in the heap for a synchronous mount (each row writing a ref
// signal its effect subscribes to), mounting N rows was O(N²) (#3350).
// markNode's own guard skips an already-DIRTY node.
if (heap._marked) markNode(n);
}
if (!(flags & REACTIVE_IN_HEAP_HEIGHT)) actualInsertIntoHeap(n, heap);
}
Expand Down
123 changes: 123 additions & 0 deletions packages/signals/tests/heap-mark-incremental.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* #3350: mounting N rows in one flush was O(N²).
*
* Writes schedule subscribers by heap insertion alone; DIRTY/CHECK marks are
* propagated lazily by markHeap, memoized on `heap._marked` until runHeap
* resets it. An unmarked node entering an already-marked heap used to
* invalidate that memo, so the next mid-tick pull re-walked the entire heap.
* A synchronous mount creating a user effect per row (each parked in the pure
* heap until the effect phase) hit that on every row's first memo read.
* Insertions now mark the incoming node in place instead.
*/
import {
createEffect,
createMemo,
createRenderEffect,
createRoot,
createSignal,
flush,
mapArray
} from "../src/index.js";

function mount(N: number) {
let ms = 0;
createRoot(dispose => {
const [zoom] = createSignal(1);
const viewport = createMemo(() => 600 / zoom());
const [items, setItems] = createSignal<number[]>([], { ownedWrite: true });
const rows = createMemo(
mapArray(items, i => {
const visible = createMemo(() => i * 10 < viewport());
createRenderEffect(
() => visible(),
() => {},
{ sync: true } as any
);
const [el, setEl] = createSignal<object | null>(null, { ownedWrite: true });
createEffect(
() => el(),
() => {}
);
setEl({ i });
return visible;
})
);
createRenderEffect(
() => rows().length,
() => {}
);
flush();
const start = performance.now();
setItems(Array.from({ length: N }, (_, i) => i));
flush();
ms = performance.now() - start;
dispose();
});
return ms;
}

describe("heap marking stays incremental across mid-tick pulls", () => {
it("mounting rows with a per-row user effect is linear in N (#3350)", () => {
// Relative tripwire: absolute wall-clock bounds do not survive the
// coverage-instrumented CI job (12× slower than a local run). Compare 8×
// the rows within one process instead — linear scaling lands near 8×,
// the quadratic regime near 64×. Best-of-k tames JIT/GC noise at the
// small end. Measured locally: ~10× fixed (3 → 30 ms), ~50× on next
// (17 → 850 ms).
const best = (N: number, k: number) => {
let ms = Infinity;
for (let i = 0; i < k; i++) ms = Math.min(ms, mount(N));
return ms;
};
best(1000, 2); // warm
const small = best(1000, 3);
const large = best(8000, 2);
expect(large / small).toBeLessThan(24);
});

it("a write landing between two mid-tick pulls is visible through a memo chain in the same pass", () => {
// The first sync render effect's read marks the heap; the row's write
// then inserts an UNMARKED subscriber (doubled) into the marked heap.
// Its downstream memo (label) must be pulled fresh by the next sync
// reader in the same flush — the eager mark has to propagate CHECK past
// the inserted node, not just flag the node itself.
const seen: string[] = [];
createRoot(() => {
const [base] = createSignal(1);
const gate = createMemo(() => base() + 1);
const [n, setN] = createSignal(0, { ownedWrite: true });
const doubled = createMemo(() => n() * 2);
const label = createMemo(() => `n=${doubled()}`);
const [items, setItems] = createSignal<number[]>([], { ownedWrite: true });
const rows = createMemo(
mapArray(items, i => {
createRenderEffect(
() => gate(),
() => {},
{ sync: true } as any
);
setN(i + 10);
createRenderEffect(
() => label(),
v => {
seen.push(v);
},
{ sync: true } as any
);
return i;
})
);
createRenderEffect(
() => rows().length,
() => {}
);
flush();
setItems([1, 2, 3]);
flush();
});
// Each row's first run sees its own write; earlier rows' effects then
// settle on the final value when the label memo lands in the heap pass.
expect(seen.slice(0, 3)).toEqual(["n=22", "n=24", "n=26"]);
expect(seen.slice(3)).toEqual(["n=26", "n=26"]);
});
});
63 changes: 63 additions & 0 deletions packages/signals/tests/mount-rows.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Synchronous mount scaling (#3350 repro shape): a mapArray of N rows, each
// creating a memo read by a sync render effect plus a user effect over a
// per-row ref signal. Every user effect created during the mount parks in
// the pure heap until the effect phase; each row's first mid-tick memo pull
// then ran markHeap. Before the fix an unmarked insertion invalidated the
// markHeap memo, so every pull re-walked the whole heap — O(N²) (4× per 2×
// N). Now the insertion marks the node in place and the mount is linear.
import { bench, describe } from "vitest";
import {
createEffect,
createMemo,
createRenderEffect,
createRoot,
createSignal,
flush,
mapArray
} from "../src/index.js";

function mount(N: number, withEffect: boolean) {
createRoot(dispose => {
const [zoom] = createSignal(1);
const viewport = createMemo(() => 600 / zoom());
const [items, setItems] = createSignal<number[]>([], { ownedWrite: true });
const rows = createMemo(
mapArray(items, i => {
const visible = createMemo(() => i * 10 < viewport());
createRenderEffect(
() => visible(),
() => {},
{ sync: true } as any
);
if (withEffect) {
const [el, setEl] = createSignal<object | null>(null, { ownedWrite: true });
createEffect(
() => el(),
() => {}
);
setEl({ i });
}
return visible;
})
);
createRenderEffect(
() => rows().length,
() => {}
);
flush();
setItems(Array.from({ length: N }, (_, i) => i));
flush();
dispose();
});
}

for (const N of [1000, 4000]) {
describe(`mount ${N} rows`, () => {
bench("memo + sync render effect + user effect over a ref signal (#3350)", () => {
mount(N, true);
});
bench("memo + sync render effect only (reference)", () => {
mount(N, false);
});
});
}
14 changes: 12 additions & 2 deletions scripts/size/.size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,13 @@ module.exports = [
// measured at 10.82. Core scheduler cost; see the core-floor note.
// Effect ownership on finalize re-entry (#3319, 2026-09-09): 10.85 KB -> 10.92 KB,
// measured at 10.883. Core scheduler cost; see the core-floor note.
limit: "10.92 KB",
// Incremental heap marking (#3350, 2026-09-10): 10.92 -> 10.96 KB,
// measured at 10.924 against next's 10.895. A one-call swap in
// insertIntoHeap (`heap._marked = false` -> `markNode(n)`, dropping the
// DIRTY test markNode already performs); createStore and isPending
// scenarios both shrank on the same build, so the +29 B here is brotli
// layout drift, not retained code.
limit: "10.96 KB",
modifyEsbuildConfig
},
{
Expand Down Expand Up @@ -866,7 +872,11 @@ module.exports = [
// in favour of one rule for every router — wrap the write whose landing
// is the destination showing, pass `at` — with the loader wait itself
// being router work (see 08-dev-diagnostics.md, Navigations).
limit: "26.65 KB",
// Incremental heap marking (#3350, 2026-09-10): 26.65 -> 26.68 KB,
// measured at 26.652 against next's 26.598. The insertIntoHeap change is
// a one-call swap that dropped a flag test; the CSR observe scenario on
// the same artifacts did not move, so this is brotli layout drift.
limit: "26.68 KB",
modifyEsbuildConfig: observeEsbuildConfig
},
{
Expand Down
Loading