diff --git a/.changeset/settle-fast-path.md b/.changeset/settle-fast-path.md new file mode 100644 index 000000000..80e372346 --- /dev/null +++ b/.changeset/settle-fast-path.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +The seam (`settle`) no longer pays the effect-queue merge on a plain flush — nothing parked, no lane reveal, no landing: this flush's runs are the queue as they stand instead of a `concat` copy, a parked flush stashes its runs in place, and the per-flush list resets run only when a list is non-empty. About 100 ns less fixed cost per flush (−11% on a 20k-key store commit, −45% on a one-signal flush), with the effect order of every other seam unchanged. diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 5a92ab804..215447e49 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -786,8 +786,7 @@ export class GlobalQueue implements IQueue { ) recompute(zombies[i]); // The unchanged passes' tails stay linked (A30, #3469): their inputs - // are held, and the committed frame still derives from them. - heldTrims.length = 0; + // are held, and the committed frame still derives from them — no trim. } else { commitPendingNodes(); // The flush committed: an unchanged pass's stale tail goes now. Not @@ -799,8 +798,12 @@ export class GlobalQueue implements IQueue { const t = heldTrims[i]; if (t._x?._error == null && !(t as any)._modified) trimStaleDeps(t); } - heldTrims.length = 0; } + // The list is the flush's either way. Reset only when there is something + // to reset (`stagedReaders` below likewise): `length = 0` is a runtime + // call even on an empty array, and the plain flush — no unchanged pass, + // no lane work — has nothing in either. + if (heldTrims.length) heldTrims.length = 0; // Lanes: a blocked one parks its frame (its own flight is up); an // unblocked one reveals this round's work. Then land every transaction // no frame is waiting on — its lanes end with it. Backwards: a landing @@ -813,7 +816,7 @@ export class GlobalQueue implements IQueue { // writes if it parked: the lane seam re-derives them on the committed // world (a lane's runs wait this round). GlobalQueue._laneSeams?.(t !== null ? stagedReaders : null); - stagedReaders.length = 0; + if (stagedReaders.length) stagedReaders.length = 0; this._queues = [[], []]; for (let k = transactions.length - 1; k >= 0; k--) { const u = transactions[k]; @@ -848,8 +851,15 @@ export class GlobalQueue implements IQueue { holding = true; } for (let i = 0; i < 2; i++) { - if (parked) u!._queues[i] = u!._queues[i].concat(own[i]); - this._queues[i] = lanes[i].concat(parked ? [] : own[i], this._queues[i]); + if (parked) append(u!._queues[i], own[i]); + // The plain flush — no reveal, no landing, not parked — has nothing to + // order around its own runs: they are the queue as they stand (the + // same array, not a copy; `run` takes it whole). Every other seam + // builds the ordered queue above. + this._queues[i] = + parked || lanes[i].length || this._queues[i].length + ? lanes[i].concat(parked ? [] : own[i], this._queues[i]) + : own[i]; } if (deferredZombies.length !== 0) { // Survivors are the displayed frame of a held pass: they rerun for the @@ -864,8 +874,8 @@ export class GlobalQueue implements IQueue { GlobalQueue._boundarySeam?.(); } run(type: number) { - if (this._queues[type - 1].length) { - const effects = this._queues[type - 1]; + const effects = this._queues[type - 1]; + if (effects.length) { this._queues[type - 1] = []; runQueue(effects, type); } diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 4fab2f1a5..9fe0f5ba7 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -1,7 +1,7 @@ { "signals: core floor (createSignal/Memo/Effect/Root/flush)": "7.33 KB", - "app: render + one signal (the simple-app floor)": "9.81 KB", - "app: hydrating (no stores) with Show/For/Loading/Errored/lazy": "17.64 KB", + "app: render + one signal (the simple-app floor)": "9.83 KB", + "app: hydrating (no stores) with Show/For/Loading/Errored/lazy": "17.66 KB", "page: base server components (hydrating + dynamic + frames + sf reference)": "44.05 KB", "page: live server components (base + live/GET + action + isPending/latest)": "47.67 KB", "server: floor (getRequestEvent + isServer)": "1.34 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index a3c12f639..c9aae16b5 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -1266,6 +1266,19 @@ module.exports = [ // -3 B minified). The same parking-gate change as the `+ isPending/latest` // note; brotli layout turns the -3 B minified into a saving here. Not // lowered (frozen caps only fall on a deliberate ratchet). + // Size-Exception (settle fast path, 2026-10-04): 9.81 -> 9.83 KB, measured + // at 9,812 B against `next` @ 924d909d9's 9,801 (+11 B; 2 B over the cap; + // +5 B minified). The seam's effect-queue merge is skipped on the plain + // flush — nothing parked, no lane reveal, no landing — and the per-flush + // list resets run only on a non-empty list: ~100 ns less fixed cost per + // flush (one signal + one render effect + flush: 240 -> 133 ns; a 20k-key + // store commit: 844 -> 748 ns). Brotli layout: the same +5 B minified + // measures -16 B on `+ createStore`, 0 B on the CSR app, +28 B on + // `+ every store primitive family` and +17/+21 B on the two pages (both + // within their caps); 44 equivalent encodings measured, none under every + // cap at once, this one the smallest minified. Accepted by the + // maintainer (2026-10-04). The cap is frozen again at 9.83 KB + // (measured + 10 B). limit: floorCaps["app: render + one signal (the simple-app floor)"], alias }, @@ -1573,6 +1586,12 @@ module.exports = [ // above is gone (no heap refusal, no `_manualWriteTime`, no // `deleteFromHeap` in the setter). Reverts the #3740 raise. The cap is // frozen again at 19.69 KB. + // Size-Exception (settle fast path, 2026-10-04): 17.64 -> 17.66 KB, + // measured at 17,650 B against `next` @ 924d909d9's 17,632 (+18 B; 10 B + // over the cap; +4 B minified). The same seam change as the simple-app + // floor note; brotli layout over the larger bundle. Accepted by the + // maintainer (2026-10-04). The cap is frozen again at 17.66 KB + // (measured + 10 B). limit: floorCaps["app: hydrating (no stores) with Show/For/Loading/Errored/lazy"], alias }, @@ -1975,7 +1994,14 @@ module.exports = [ // Lowered (#3774, 2026-10-04): 30.82 -> 28.78 KB, measured at 28.77 KB by CI // at 22c3d3e14 — the hold model (L2): the signals core rebuilt from its // rulings. Lowering is the ratchet; raise only with a Size-Exception. - limit: "28.78 KB", + // Size-Exception (settle fast path, 2026-10-04): 28.78 -> 28.80 KB, + // measured at 28,785 B against `next` @ 924d909d9's 28,757 (+28 B; 5 B + // over the cap; +5 B minified, all of it the signals core). The same + // seam change as the simple-app floor note; brotli layout over this + // bundle (before #3791 the same +5 B measured +2 B here). Accepted by + // the maintainer (2026-10-04). The cap is frozen again at 28.80 KB + // (measured + 10 B). + limit: "28.80 KB", alias }, {