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/shell-grace-task.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

`renderToStream` waits one task before flushing the shell while fragments are still pending, so a read whose retry chain outruns the microtask drain (a server function over in-memory data, a cache answering on `setImmediate`, I/O that already completed) renders inline instead of shipping its `Loading` fallback, a `<template>` and a reveal that shifts the page. Renders with nothing pending flush exactly as before; genuinely pending I/O pays one task of first-byte latency.
16 changes: 16 additions & 0 deletions packages/web/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3260,6 +3260,14 @@ export function renderToStream(code, options = {}) {
let lastBlockingSize = -1;
let lastRegistrySize = -1;
let drainTurn = 0;
// One task of grace once the microtask drain settles, while fragments are
// pending: a read whose retry chain outruns the drain turns (a server
// function over an in-memory catalog, a cache answering on `setImmediate`,
// I/O that already completed) still renders inline instead of shipping its
// fallback, a template and a reveal that shifts the page. 1.x waited a task
// before every shell flush. A render with nothing pending pays nothing;
// genuinely pending I/O pays one task of TTFB.
let graced = false;
const scheduleFlush = (fn, awaited) => {
const attempt = () => {
// Flush batched stubs at the TOP of the drain, not at doShell: a
Expand All @@ -3276,12 +3284,20 @@ export function renderToStream(code, options = {}) {
queue(attempt);
return;
}
if (!graced && registry.size) {
graced = true;
lastRegistrySize = -1;
drainTurn = 0;
deferFlush(attempt);
return;
}
fn();
};
const progressed = awaited !== lastBlockingSize;
lastBlockingSize = awaited;
lastRegistrySize = -1;
drainTurn = 0;
graced = false;
progressed ? queue(attempt) : setTimeout(attempt);
};
let cachedReadable;
Expand Down
4 changes: 3 additions & 1 deletion packages/web/test/server/frame-plain-bound.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,9 @@ describe("the plain-response streaming bound", () => {
}
)
);
await vi.advanceTimersByTimeAsync(1);
// Past the shell, so the first value streams as a fragment (a value
// ready inside the shell's grace task would render inline).
await until(c => c.type === "html");
ch.push("v0");
await until(c => c.type === "fragment");
for (let i = 1; i <= 5; i++) {
Expand Down
65 changes: 65 additions & 0 deletions packages/web/test/server/shell-task-grace.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* @jsxImportSource @solidjs/web
*/
// The shell waits one task for pending fragments before it flushes: data that
// resolves on the next turn of the event loop (an in-memory cache behind a
// server function, `setTimeout(0)`) renders inline, with no fallback, no
// template and no reveal. 1.x behaved the same; 2.0 had only a handful of
// microtask turns, so any read that crossed a task streamed its fallback.
import { describe, expect, test } from "vitest";
import { renderToStream } from "@solidjs/web";
import { createMemo, Loading } from "solid-js";

function collect(code: () => any): { chunks: string[]; done: Promise<string> } {
const chunks: string[] = [];
const done = new Promise<string>(resolve => {
renderToStream(code).pipe({
write: (c: string) => chunks.push(c),
end: () => resolve(chunks.join(""))
});
});
return { chunks, done };
}

const Page = (props: { read: () => Promise<string> }) => {
const value = createMemo(() => props.read());
return (
<main>
<Loading fallback={<p>FALLBACK</p>}>
<p>{value()}</p>
</Loading>
<footer>end</footer>
</main>
);
};

describe("shell flush grants pending fragments one task", () => {
test.each([
[
"a long microtask chain",
async () => {
for (let i = 0; i < 20; i++) await null;
return "DATA";
}
],
["the next task", () => new Promise<string>(r => setImmediate(() => r("DATA")))]
])("data ready after %s renders inline", async (_, read) => {
const { chunks, done } = collect(() => <Page read={read} />);
const html = await done;
expect(chunks[0]).toMatch(/<p[^>]*>DATA<\/p>/);
expect(html).not.toContain("FALLBACK");
expect(html).not.toContain("<template");
});

test("data still pending after the grace streams behind its fallback", async () => {
let release!: (v: string) => void;
const gate = new Promise<string>(r => (release = r));
const { chunks, done } = collect(() => <Page read={() => gate} />);
while (!chunks.length) await new Promise(r => setTimeout(r, 1));
expect(chunks[0]).toContain("FALLBACK");
expect(chunks[0]).toContain("<footer>end</footer>");
release("DATA");
const html = await done;
expect(html.indexOf("DATA")).toBeGreaterThan(chunks[0].length - 1);
});
});
Loading