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
9 changes: 9 additions & 0 deletions .changeset/frames-refetch-lands-at-commit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@solidjs/web": patch
---

Server-component content for a call a boundary is showing now lands with the transition that read it, instead of morphing in when the response arrives. A refetch, or a single-flight region, is staged: the call resolves to a binding naming the staged version, and the mount commits it when that binding reaches it. The slot args go first, under the transition, so a fill deriving optimistic intent over a server arg never reads the old arg. The markup lands at the commit. Single-flight regions show when the integration's cache takes the mutation's slice, so any cache that subscribes to flight data drives it, not just Solid Router.

An address switch's new content lands at the same commit: the frame re-binds in the run of the effect that follows the address, while the switch gate re-arms in its pass and settles on the new address's first content through a frameless registration on the host — so a second switch mid-flight still binds to the live call, and the morph no longer lands beside siblings the transaction still holds.

Behaviour change: a response for a showing call that no reader mounts is never shown. Previously it morphed every mount of the address on arrival.
109 changes: 109 additions & 0 deletions packages/signals/tests/compute-write-joins-transition.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// A write made from a computation's compute half while it runs under a held
// transition joins that transition: it is held with everything else the
// transition holds, and computations under the transition read it ahead of
// the settle that ends the action's optimistic overrides.
//
// Consumers rely on this: a server-component mount commits a refetched
// region in two phases. When its address accessor delivers the new content
// token, the mount's compute half pushes the region's slot args into the
// live fills — under the transition, so a fill deriving `intent ?? arg`
// reads the new arg while the intent is still live — and its effect half
// morphs the markup at the commit.
import {
action,
createMemo,
createOptimistic,
createRenderEffect,
createRoot,
createSignal,
flush
} from "../src/index.js";

afterEach(() => flush());

const tick = () => new Promise(r => setTimeout(r));

function setup() {
let release!: () => void;
let releaseOther!: () => void;
const trace: string[] = [];
const shown: string[] = [];
const [x, setX] = createSignal(0);
const [token, setToken] = createSignal("t0", { ownedWrite: true });
const [arg, setArg] = createSignal(false, { ownedWrite: true });
const [intent, setIntent] = createOptimistic<boolean | undefined>(undefined);
createRoot(() => {
const source = createMemo(() => {
if (x() === 0) return "t0";
const p = new Promise<string>(r => (release = () => r("t1")));
// The delivery: registered before the memo's own handler, so it runs
// just ahead of the landing (as `dynamic` delivers an address).
p.then(v => setToken(v));
return p;
});
// The mount: its compute half pushes the args a new token carries.
createRenderEffect(
() => {
const t = token();
if (t === "t1") setArg(true);
return t;
},
t => void shown.push(`token=${t}`)
);
// The fill: derives the intent over the server's arg.
createMemo(() => trace.push(`${arg()}/${intent() ?? arg()}`));
createRenderEffect(arg, v => void shown.push(`arg=${v}`));
createRenderEffect(source, () => {});
// A second read the same transition waits on (the `other` flag only).
const other = createMemo(() =>
x() === 2 ? new Promise<string>(r => (releaseOther = () => r("o1"))) : "o0"
);
createRenderEffect(other, () => {});
});
flush();
shown.length = 0;
const write = action(function* (n: number) {
setIntent(true);
setX(n);
});
return {
trace,
shown,
write: (n = 1) => write(n),
release: () => release(),
releaseOther: () => releaseOther()
};
}

describe("a compute-half write under a held transition joins it", () => {
it("is held while the transition still waits, and commits with it", async () => {
const t = setup();
t.write(2);
await tick();

t.release();
await tick();
// The token landed and the compute half wrote the arg; the transition
// still waits on `other`, so neither the token nor the arg is shown.
expect(t.shown).toEqual([]);

t.releaseOther();
await tick();
expect(t.shown).toEqual(["token=t1", "arg=true"]);
});

it("the fill never reads the server's old arg once the intent was written", async () => {
const t = setup();
expect(t.trace).toEqual(["false/false"]);

t.write();
await tick();
expect(t.trace).toEqual(["false/false", "false/true"]);

t.release();
await tick();
expect(t.shown).toEqual(["token=t1", "arg=true"]);
expect(t.trace.slice(2).filter(entry => entry.endsWith("/false"))).toEqual([]);
expect(t.trace.at(-1)).toBe("true/true");
});
});
86 changes: 86 additions & 0 deletions packages/signals/tests/settle-folds-queued-writes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// When an async node lands, core re-enters the transition waiting on it.
// Writes still queued at that point — made earlier in the same synchronous
// run, before the next flush — join that transition and commit with it.
//
// Consumers rely on this: `dynamic()` delivers a server component's new
// address to its mounted instance from the resolution callback, just before
// the landing, so the instance's rebind (and the morph it drives) waits for
// the transition's commit like everything else the transition holds.
//
// A write with nothing landing behind it is a plain write and applies now.
import {
action,
createMemo,
createRenderEffect,
createRoot,
createSignal,
flush
} from "../src/index.js";

afterEach(() => flush());

const tick = () => new Promise(r => setTimeout(r));

/** A transition held by two async reads, `a` and `b`; `s` is unrelated. */
function setup(beforeLanding?: () => void) {
let releaseA!: () => void;
let releaseB!: () => void;
const log: string[] = [];
const [x, setX] = createSignal(0);
const [s, setS] = createSignal("s0");
createRoot(() => {
const a = createMemo(() => {
if (x() === 0) return "a0";
const p = new Promise<string>(r => (releaseA = () => r("a1")));
// Registered before the memo's own handler, so it runs first.
if (beforeLanding) p.then(beforeLanding);
return p;
});
const b = createMemo(() =>
x() === 0 ? "b0" : new Promise<string>(r => (releaseB = () => r("b1")))
);
createRenderEffect(a, v => void log.push(`a=${v}`));
createRenderEffect(b, v => void log.push(`b=${v}`));
createRenderEffect(s, v => void log.push(`s=${v}`));
});
flush();
log.length = 0;
action(function* () {
setX(1);
})();
return { log, setS, releaseA: () => releaseA(), releaseB: () => releaseB() };
}

describe("a landing folds the writes queued ahead of it into its transition", () => {
it("a write queued just before a landing commits with the transition", async () => {
let setS!: (v: string) => void;
const t = setup(() => setS("s1"));
setS = t.setS;
await tick();

t.releaseA();
await tick();
// `a` landed and its transition still waits on `b`: so does the write.
expect(t.log).toEqual([]);

t.releaseB();
await tick();
expect(t.log).toContain("s=s1");
expect(t.log).toContain("a=a1");
expect(t.log).toContain("b=b1");
});

it("a write with no landing behind it applies at once", async () => {
const t = setup();
await tick();

t.setS("s1");
await tick();
expect(t.log).toEqual(["s=s1"]);

t.releaseA();
t.releaseB();
await tick();
expect(t.log).toEqual(["s=s1", "b=b1", "a=a1"]);
});
});
Loading
Loading