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/lane-held-settle-no-join.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion packages/signals/src/core/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,8 @@ export function settlePendingSource(el: Computed<any>, source: Computed<any> = 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);
Expand Down
2 changes: 2 additions & 0 deletions packages/signals/src/core/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
136 changes: 136 additions & 0 deletions packages/signals/tests/optimistic-lane-held-landing-4440.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>(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"}]`);
});
121 changes: 121 additions & 0 deletions packages/web/test/optimistic-guess-joins-refresh-4440.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<any>(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 (
<>
<b>({count()})</b>
<Loading fallback={<p>loading</p>}>
<For each={issues}>
{(it: any) => (
<i>
[#{it.id} {it.assignee}]
</i>
)}
</For>
</Loading>
</>
);
}, 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();
});
Loading
Loading