diff --git a/examples/chat/README.md b/examples/chat/README.md index a140acf5e..56acaf44e 100644 --- a/examples/chat/README.md +++ b/examples/chat/README.md @@ -110,14 +110,9 @@ again by itself, and it sends one only when its HTML has changed. The thread is cut along the line that matters: the finished messages are one piece, the reply being written is another. A token re-sends the reply and nothing else. -Two things to know when writing markup the server re-sends: - -- **Test locals, not properties, in a condition.** `{message.usage && …}` - renders once and then never updates; `const { usage } = message` and - `{usage && …}` works. The compiler wraps a condition it cannot see through - in a memo, and a read the server re-runs cannot create one. -- **One source per render, read at the top.** A watcher created per message, - inside the list, never settles: each retry of the render makes a new one. +One thing to know when writing markup the server re-sends: **one source per +render, read at the top.** A watcher created per message, inside the list, +never settles: each retry of the render makes a new one. ## What the browser owns diff --git a/examples/chat/server.js b/examples/chat/server.js index a528340dc..0af66ea1c 100644 --- a/examples/chat/server.js +++ b/examples/chat/server.js @@ -46,13 +46,14 @@ function encoder(req, res) { // Static assets compress once at a higher quality — they're cached, not streamed. const staticBrotli = new Map(); -function webRequest(req) { +function webRequest(req, signal) { const url = new URL(req.url || "/", `http://${req.headers.host || `localhost:${PORT}`}`); const method = req.method || "GET"; const body = method === "GET" || method === "HEAD" ? undefined : Readable.toWeb(req); return new Request(url, { method, headers: req.headers, + signal, body, ...(body ? { duplex: "half" } : {}) }); @@ -87,8 +88,11 @@ createServer(async (req, res) => { } } + // A tab that closes must end its render, so the thread's watcher stops. + const gone = new AbortController(); + res.on("close", () => gone.abort()); try { - const response = await handleRequest(webRequest(req)); + const response = await handleRequest(webRequest(req, gone.signal)); const cookies = response.headers.getSetCookie?.(); response.headers.forEach((value, key) => { if (key !== "set-cookie") res.setHeader(key, value); @@ -97,13 +101,19 @@ createServer(async (req, res) => { res.statusCode = response.status; const out = encoder(req, res) ?? res; if (response.body) { - for await (const chunk of response.body) { - out.write(chunk); + const reader = response.body.getReader(); + gone.signal.addEventListener("abort", () => reader.cancel().catch(() => {}), { once: true }); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + out.write(value); if (out !== res) out.flush(); } } out.end(); } catch (e) { + // The tab went away mid-response: nothing to report. + if (gone.signal.aborted) return res.destroy(); console.error(e); res.statusCode = 500; res.end(e.message); diff --git a/examples/chat/src/routes/thread.tsx b/examples/chat/src/routes/thread.tsx index 6951a5f68..d31bc68ee 100644 --- a/examples/chat/src/routes/thread.tsx +++ b/examples/chat/src/routes/thread.tsx @@ -145,12 +145,8 @@ function messagesMarkup(messages: Message[], writing: boolean, block: { onCopy: // One message as markup. A plain function, not a component: the server // re-runs the read that called it and sends the result, and there is nothing // here to keep between runs. -// -// Every condition below tests a local, not a property. The compiler wraps a -// condition it cannot see through (`message.usage && …`) in a memo, and a -// read the server re-runs cannot create one. function messageMarkup(message: Message, block: { onCopy: CopyHandler }) { - const { id, text, reasoning, error, usage } = message; + const { id, text } = message; if (message.role === "user") { return (