From 81dbfa4b49331d32969532997b4bdc15a5cd22ca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:11:00 +0000 Subject: [PATCH] chat example: drop the local-condition workaround, end renders when the tab closes Live holes no longer stop updating when a condition reads a property (#4023, fixed in #4062), so messageMarkup reads message.usage and friends directly and the README drops the "test locals" rule. server.js now aborts the request and cancels the body when the client disconnects, as server-dbmon does, so a closed tab stops its thread watcher. Both servers stay quiet about the abort. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LZkrATgvFJjgFRdDB4Drcq --- examples/chat/README.md | 11 +++-------- examples/chat/server.js | 18 ++++++++++++++---- examples/chat/src/routes/thread.tsx | 16 ++++++---------- examples/server-dbmon/server.js | 2 ++ 4 files changed, 25 insertions(+), 22 deletions(-) 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 (
  • @@ -167,10 +163,10 @@ function messageMarkup(message: Message, block: { onCopy: CopyHandler }) { const again = !generating && (failed ? "Retry" : "Regenerate"); return (
  • - {reasoning && ( + {message.reasoning && (
    Reasoning -

    {reasoning}

    +

    {message.reasoning}

    )}
    @@ -197,10 +193,10 @@ function messageMarkup(message: Message, block: { onCopy: CopyHandler }) { )} {stopped && Stopped} - {failed && {error}} - {usage && ( + {failed && {message.error}} + {message.usage && ( - {usage.tokens} tokens · {usage.seconds}s + {message.usage.tokens} tokens · {message.usage.seconds}s )} {again && ( diff --git a/examples/server-dbmon/server.js b/examples/server-dbmon/server.js index 244c1a278..e228086a7 100644 --- a/examples/server-dbmon/server.js +++ b/examples/server-dbmon/server.js @@ -113,6 +113,8 @@ createServer(async (req, res) => { } 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);