diff --git a/.changeset/lane-held-settle-no-join.md b/.changeset/lane-held-settle-no-join.md new file mode 100644 index 0000000000..8a7a417108 --- /dev/null +++ b/.changeset/lane-held-settle-no-join.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A landing that settles a reader held by an optimistic lane no longer makes the flush join that lane as its transaction: rows a list guess mounted over a pending refresh render instead of staying empty. diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index bf8b254895..371f741b83 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -369,7 +369,8 @@ export function settlePendingSource(el: Computed, source: Computed = e // L2: a landing resumes the transaction waiting on it (maintainer: "it // resumes the sync frame") — a held reader's re-pass over the landed // value is that transaction's work, and the flush that carries it - // parks into it. + // parks into it. A lane's held reader is the lane's (D-37): the flush + // never takes a lane as its transaction (#4440). if (node._config & CONFIG_HELD) joinFuture(txOf(node)); if (node._x?._blocked) { enqueueSub(node); diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 5d42a3f073..ba086201b2 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -395,6 +395,8 @@ export function resolveTx(t: Transaction): Transaction { * went pending with nothing held). Joining a second one merges it into the * flush's: the work that touched both entangles them (A15). */ export function joinFuture(t: Transaction | null): void { + // A lane is never the flush's transaction (D-37, #4440). + if (laneStageHook && t?._lane) return; const f = flushTransaction; if (f === null) { flushTransaction = t === null ? newTransaction(false) : resolveTx(t); diff --git a/packages/signals/tests/optimistic-lane-held-landing-4440.test.ts b/packages/signals/tests/optimistic-lane-held-landing-4440.test.ts new file mode 100644 index 0000000000..5e4a291774 --- /dev/null +++ b/packages/signals/tests/optimistic-lane-held-landing-4440.test.ts @@ -0,0 +1,136 @@ +import { expect, it } from "vitest"; +import { + action, + createEffect, + createLoadingBoundary, + createOptimisticStore, + createRenderEffect, + createRoot, + createSignal, + flush, + mapArray, + refresh, + untrack +} from "../src/index.js"; + +// #4440: a per-key guess whose action landed, its `refresh` in the air; a +// second action replaces the list. The refresh's landing settles readers a +// guess's lane holds: the flush must not join that lane as its transaction +// (the lane dissolves in the same flush), or the rows born in it are held by +// a dead lane and never render. +const tick = async () => { + for (let i = 0; i < 4; i++) { + await new Promise(r => setTimeout(r, 0)); + flush(); + } +}; + +for (const withCount of [false, true]) + for (const withAssign of [false, true]) + it(`a new row renders after a list guess joins a pending refresh (count=${withCount}, assign=${withAssign})`, async () => { + const asks: { key: string; res: (v: any) => void }[] = []; + const ask = (key: string) => new Promise(res => asks.push({ key, res })); + const land = async (key: string, v: any) => { + for (const a of asks.filter(a => a.key === key)) { + asks.splice(asks.indexOf(a), 1); + a.res(v); + } + await tick(); + }; + let assignTo!: (id: number) => any; + let closeOne!: (id: number) => any; + let view: () => string = () => ""; + createRoot(() => { + const [issues, set] = createOptimisticStore( + async (d: any[]) => { + const r = await ask("fetch"); + d.length = r.length; + r.forEach((x: any, i: number) => { + d[i] = x; + }); + }, + [] as any[], + { key: "id" } + ); + assignTo = action(function* (id: number) { + const i = untrack(() => issues.findIndex((t: any) => t.id === id)); + set((d: any) => { + d[i].assignee = "guest"; + }); + yield ask(`assign:${id}`); + refresh(issues); + }); + closeOne = action(function* (id: number) { + const next = untrack(() => issues.filter((t: any) => t.id !== id)); + set((d: any) => { + d.length = next.length; + next.forEach((x: any, i: number) => { + d[i] = x; + }); + }); + yield ask(`close:${id}`); + refresh(issues); + }); + const [count, setCount] = createSignal(-1); + if (withCount) + createEffect( + () => issues.length, + (n: number) => { + setCount(n); + } + ); + const b = createLoadingBoundary( + () => + mapArray( + () => issues, + (it: any) => { + const s = { id: "", a: "" }; + createRenderEffect( + () => it.id, + v => { + s.id = v; + } + ); + createRenderEffect( + () => it.assignee, + v => { + s.a = v; + } + ); + return s; + } + ), + () => "loading" + ); + createRenderEffect( + () => { + const v: any = b(); + return typeof v === "function" ? v() : v; + }, + (v: any) => { + view = () => + typeof v === "string" ? v : v.map((s: any) => `[#${s.id} ${s.a}]`).join(""); + } + ); + createRenderEffect(count, () => {}); + }); + await tick(); + await land("fetch", [ + { id: 1, assignee: "ann" }, + { id: 3, assignee: "bob" }, + { id: 4, assignee: "dana" } + ]); + if (withAssign) { + assignTo(4); + await tick(); + await land("assign:4", {}); + } + closeOne(3); + await tick(); + await land("close:3", {}); + await land("fetch", [ + { id: 1, assignee: "ann" }, + { id: 4, assignee: withAssign ? "guest" : "dana" } + ]); + expect(view()).toBe(`[#1 ann][#4 ${withAssign ? "guest" : "dana"}]`); + }); diff --git a/packages/web/test/optimistic-guess-joins-refresh-4440.spec.tsx b/packages/web/test/optimistic-guess-joins-refresh-4440.spec.tsx new file mode 100644 index 0000000000..66a29414d9 --- /dev/null +++ b/packages/web/test/optimistic-guess-joins-refresh-4440.spec.tsx @@ -0,0 +1,121 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +// #4440: a per-key guess whose action landed and whose `refresh` is still in +// the air; a second action replaces the list. With a `Loading` around the +// list and an effect reading the list's length, row #1 rendered empty. +import { expect, test } from "vitest"; +import { + Loading, + For, + action, + createOptimisticStore, + createSignal, + createEffect, + flush, + refresh, + untrack +} from "solid-js"; +import { render } from "../src/index.js"; + +const tick = async () => { + for (let i = 0; i < 4; i++) { + await new Promise(r => setTimeout(r, 0)); + flush(); + } +}; + +for (const withCount of [false, true]) + for (const withAssign of [false, true]) + test(`list guess over a pending refresh (count effect=${withCount}, assign first=${withAssign})`, async () => { + const asks: { key: string; res: (v: any) => void }[] = []; + const ask = (key: string) => new Promise(res => asks.push({ key, res })); + const land = async (key: string, v: any) => { + for (const a of asks.filter(a => a.key === key)) { + asks.splice(asks.indexOf(a), 1); + a.res(v); + } + await tick(); + }; + const container = document.body.appendChild(document.createElement("div")); + let assignTo!: (id: number) => any; + let closeOne!: (id: number) => any; + const dispose = render(() => { + const [issues, set] = createOptimisticStore( + async (d: any[]) => { + const r = await ask("fetch"); + d.length = r.length; + r.forEach((x: any, i: number) => { + d[i] = x; + }); + }, + [] as any[], + { key: "id" } + ); + assignTo = action(function* (id: number) { + const i = untrack(() => issues.findIndex((t: any) => t.id === id)); + set((d: any) => { + d[i].assignee = "guest"; + }); + yield ask(`assign:${id}`); + refresh(issues); + }); + closeOne = action(function* (id: number) { + const next = untrack(() => issues.filter((t: any) => t.id !== id)); + set((d: any) => { + d.length = next.length; + next.forEach((x: any, i: number) => { + d[i] = x; + }); + }); + yield ask(`close:${id}`); + refresh(issues); + }); + const [count, setCount] = createSignal(-1); + if (withCount) + createEffect( + () => issues.length, + (n: number) => { + setCount(n); + } + ); + return ( + <> + ({count()}) + loading

}> + + {(it: any) => ( + + [#{it.id} {it.assignee}] + + )} + +
+ + ); + }, container); + await tick(); + await land("fetch", [ + { id: 1, assignee: "ann" }, + { id: 3, assignee: "bob" }, + { id: 4, assignee: "dana" } + ]); + if (withAssign) { + assignTo(4); + await tick(); + await land("assign:4", {}); + } + closeOne(3); + await tick(); + await land("close:3", {}); + await land("fetch", [ + { id: 1, assignee: "ann" }, + { id: 4, assignee: withAssign ? "guest" : "dana" } + ]); + expect(container.textContent).toBe( + `(${withCount ? 2 : -1})[#1 ann][#4 ${withAssign ? "guest" : "dana"}]` + ); + dispose(); + container.remove(); + }); diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 7bcbb47f12..9be61e7fc5 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -1446,9 +1446,10 @@ module.exports = [ // A staged error reaches the memos and derived stores that derive from it (#4456, 2026-10-30, re-measured over next @ b773146): 11.12 KB -> 11.17 KB, recorded minified 30,641 -> 30,771 B (+130 B), measured locally (linux) at 11,153 B brotli. The settle walk marks a derivation of a staged error with it, and a derived store's family hears it (D-41), with the verdict engine alone. Correctness; Size-Exception in #4456. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A verdict reader born into a hold joins it at its re-pass (#4471, #4467, 2026-10-30, re-measured over next @ 749b0d7): cap unchanged at 11.17 KB, recorded minified 30,771 -> 30,800 B (+29 B), measured locally (linux) at 11,166 B brotli. Correctness (D-25, D-13); Size-Exception in #4471 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A derived store with no live leaf skips its flight's observe (#4479, 2026-10-30, re-measured over next @ 663ecb0): 11.17 KB -> 11.19 KB, recorded minified 30,800 -> 30,822 B (+22 B), measured locally (linux) at 11,177 B brotli. Correctness (F-3, D-11); Size-Exception in #4479 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + // A lane's held reader never makes the flush join the lane (D-37, #4440, 2026-10-30, over next @ d0fc2851): cap unchanged at 11.19 KB, recorded minified 30,822 -> 30,840 B (+18 B), measured locally (linux) at 11,185 B brotli. joinFuture returns on a lane's transaction, gated on the lane engine (bundles without lanes pay 0 B). Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. limit: "11.19 KB", // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 28,693 -> 29,086 B (+343 B minified over next), measured locally (linux) at 10,565 B brotli (+135 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 30822, + capMinified: 30840, alias }, { @@ -2741,11 +2742,12 @@ module.exports = [ // A held memo's parked reader re-derives at the seam (#4439, 2026-10-30, over next @ b249ad96): 32.42 KB -> 32.45 KB, recorded minified 98,607 -> 98,689 B (+82 B), measured locally (linux) at 32,433 B brotli. `laneSeams` also re-derives a reader whose pass read a held node, so `isPending` of a memo parked behind a held one turns true. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // Batch re-record (2026-10-30, over next @ 563b6b0): cap unchanged, recorded minified 98,689 -> 98,387 B (-302 B), measured locally (linux) at 32,372 B brotli. Growth is the 2026-10-30 merges (#4454, #4457, #4461). Recorded minified = measured. // D-28's verdict-seat reset compares holds, not lane objects: a seated leaf reading a merged transaction's verdict lane stays the hold's verdict reader (`laneRead`) (#4427, #4455, 2026-10-30, over next @ cddaa76): 32.45 KB -> 32.48 KB, recorded minified 98,387 -> 98,432 B (+45 B), measured locally (linux) at 32,465 B brotli. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + // A lane's held reader never makes the flush join the lane (D-37, #4440, 2026-10-30, over next @ d0fc2851): cap unchanged at 32.48 KB, recorded minified 98,432 -> 98,463 B (+31 B), measured locally (linux) at 32,466 B brotli. joinFuture returns on a lane's transaction, gated on the lane engine (bundles without lanes pay 0 B). Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. limit: "32.48 KB", // An Errored heals when the last reader that caught its error is disposed (#4373, #4384, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 96,989 -> 97,047 B (+58 B minified over next's record), measured locally (linux) at 31,811 B brotli. Recorded minified = measured; CI re-bases. // Boundary readers keyed by the source they wait on (#4373), with enterLane's tail folded into one else-chain as an offset (-29 B on lane pages) (#4384, 2026-10-28, re-recorded over next @ 1b32ff2e): recorded minified 97,024 -> 97,045 B (+21 B minified over next's record), measured locally (linux) at 31,798 B brotli. Recorded minified = measured; CI re-bases. // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 97,045 -> 97,426 B (+240 B minified over next), measured locally (linux) at 31,976 B brotli (+109 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 98432, + capMinified: 98463, alias }, { @@ -5939,11 +5941,12 @@ module.exports = [ // A staged error reaches the memos and derived stores that derive from it (#4456, 2026-10-30, re-measured over next @ b773146): 42.75 KB -> 42.79 KB, recorded minified 125,540 -> 125,655 B (+115 B), measured locally (linux) at 42,777 B brotli. The settle walk marks a derivation of a staged error with it, and a derived store's family hears it (D-41), with the verdict engine alone. Correctness; Size-Exception in #4456. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A verdict reader born into a hold joins it at its re-pass (#4471, #4467, 2026-10-30, re-measured over next @ 749b0d7): 42.79 KB -> 42.83 KB, recorded minified 125,655 -> 125,684 B (+29 B), measured locally (linux) at 42,817 B brotli. Correctness (D-25, D-13); Size-Exception in #4471 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A derived store with no live leaf skips its flight's observe (#4479, 2026-10-30, re-measured over next @ 663ecb0): cap unchanged at 42.83 KB, recorded minified 125,684 -> 125,706 B (+22 B), measured locally (linux) at 42,822 B brotli. Correctness (F-3, D-11); Size-Exception in #4479 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "42.83 KB", + // A lane's held reader never makes the flush join the lane (D-37, #4440, 2026-10-30, over next @ d0fc2851): 42.83 KB -> 42.87 KB, recorded minified 125,706 -> 125,724 B (+18 B), measured locally (linux) at 42,860 B brotli. joinFuture returns on a lane's transaction, gated on the lane engine (bundles without lanes pay 0 B). Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "42.87 KB", // An Errored heals when the last reader that caught its error is disposed (#4373, #4384, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 123,191 -> 123,249 B (+58 B minified over next's record), measured locally (linux) at 41,899 B brotli. Recorded minified = measured; CI re-bases. // Boundary readers keyed by the source they wait on (#4373), with enterLane's tail folded into one else-chain as an offset (-29 B on lane pages) (#4384, 2026-10-28, re-recorded over next @ 1b32ff2e): recorded minified 123,203 -> 123,224 B (+21 B minified over next's record), measured locally (linux) at 41,966 B brotli. Recorded minified = measured; CI re-bases. // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 123,224 -> 123,733 B (+403 B minified over next), measured locally (linux) at 42,119 B brotli (+169 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 125706, + capMinified: 125724, alias: pageAlias }, { @@ -6348,11 +6351,12 @@ module.exports = [ // A staged error reaches the memos and derived stores that derive from it (#4456, 2026-10-30, re-measured over next @ b773146): 48.44 KB -> 48.51 KB, recorded minified 142,780 -> 142,918 B (+138 B), measured locally (linux) at 48,498 B brotli. The settle walk marks a derivation of a staged error with it, and a derived store's family hears it (D-41), with the verdict engine alone. Correctness; Size-Exception in #4456. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A verdict reader born into a hold joins it at its re-pass (#4471, #4467, 2026-10-30, re-measured over next @ 749b0d7): cap unchanged at 48.51 KB, recorded minified 142,918 -> 142,947 B (+29 B), measured locally (linux) at 48,462 B brotli. Correctness (D-25, D-13); Size-Exception in #4471 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A derived store with no live leaf skips its flight's observe (#4479, 2026-10-30, re-measured over next @ 663ecb0): cap unchanged at 48.51 KB, recorded minified 142,947 -> 142,969 B (+22 B), measured locally (linux) at 48,496 B brotli. Correctness (F-3, D-11); Size-Exception in #4479 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + // A lane's held reader never makes the flush join the lane (D-37, #4440, 2026-10-30, over next @ d0fc2851): cap unchanged at 48.51 KB, recorded minified 142,969 -> 142,987 B (+18 B), measured locally (linux) at 48,491 B brotli. joinFuture returns on a lane's transaction, gated on the lane engine (bundles without lanes pay 0 B). Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. limit: "48.51 KB", // An Errored heals when the last reader that caught its error is disposed (#4373, #4384, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 140,451 -> 140,509 B (+58 B minified over next's record), measured locally (linux) at 47,516 B brotli. Recorded minified = measured; CI re-bases. // Boundary readers keyed by the source they wait on (#4373), with enterLane's tail folded into one else-chain as an offset (-29 B on lane pages) (#4384, 2026-10-28, re-recorded over next @ 1b32ff2e): recorded minified 140,463 -> 140,485 B (+22 B minified over next's record), measured locally (linux) at 47,547 B brotli. Recorded minified = measured; CI re-bases. // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 140,485 -> 140,983 B (+405 B minified over next), measured locally (linux) at 47,722 B brotli (+162 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 142969, + capMinified: 142987, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true }