testReactPlacementStreamingHydration
React: hooks, classes and contexts are placed;
import { CHROME, hydrated, withPage } from "#codebase/tcmts/cli/test/browser"; import { okIn, tempDir, withChildren } from "#codebase/tcmts/cli/test/e2e/harness"; import { APP, hashOf, serve, timed } from "#codebase/tcmts/cli/test/e2e/react"; import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; export const testReactPlacementStreamingHydration = async () => { if (!CHROME) return console.log("skipped: no Chrome"); await withChildren(async (keep) => { const dir = tempDir("react"); const tcm = (...args: string[]) => okIn(dir, ...args); tcm("init"); tcm("lib", "add", "tcm", "server", "--at", "lib.server"); fs.rmSync(path.join(dir, "scratch.ts")); fs.writeFileSync(path.join(dir, "ui.tsx"), APP); tcm("add", "ui.tsx"); assert.match( tcm("view", "ui.Counter"), /renders in the browser: it uses react\.useState/, "a hook through the React namespace", ); assert.match(tcm("view", "ui.Clock"), /renders in the browser: it is a class component/); assert.match(tcm("view", "ui.Themed"), /renders in the browser: it uses useContext/); assert.match( tcm("view", "ui.Page"), /renders on the server: it is an async component; the browser fetches its rendered tree by hash/, ); assert.match(tcm("view", "ui.Outside"), /renders on the server: it is an async component/); assert.match(tcm("view", "ui.Theme"), /runs anywhere/); const check = async (url: string) => { // a page at a path of its own (a client-side route): its scripts load from there too const at = new URL("/some/nested/route", url); const nested = await (await fetch(at, { headers: { accept: "text/html" } })).text(); const scripts = [...nested.matchAll(/<script[^>]*\ssrc="([^"]+)"/g)].map((m) => m[1]!); assert.ok(scripts.length, nested.slice(0, 400)); for (const s of scripts) assert.equal((await fetch(new URL(s, at))).status, 200, `${s}, from ${at.pathname}`); // cookies set while the page renders: before its headers go out, they're in them const first = await fetch(url, { headers: { accept: "text/html" } }); assert.match(first.headers.getSetCookie().join("\n"), /early=yes/); assert.doesNotMatch(first.headers.getSetCookie().join("\n"), /late=yes/); assert.match( await first.text(), /var __TCM_COOKIES__ = "[\w-]+"/, "later ones are left for the browser to collect", ); // the server component's own response, as Flight streams it: the tree at once, the boundary's contents when // they're ready const reply = await fetch(new URL(`/__tcm/rpc/${hashOf(dir, "ui.Page")}`, url), { method: "POST", headers: { "content-type": "application/json" }, body: "[{}]", }); assert.equal(reply.headers.get("content-type"), "text/x-component"); const pieces = await timed(reply); const tree = pieces.map((c) => c.text).join(""); const arrived = (re: RegExp) => pieces.find((c) => re.test(c.text))?.ms ?? -1; assert.match(tree, /"value":"dark"/, "the context, provided by reference, with its value"); assert.match(tree, /react\.suspense/); assert.ok( arrived(/slow part/) - arrived(/"loading"/) >= 800, `the slow part streams later (${arrived(/"loading"/)}ms, then ${arrived(/slow part/)}ms)`, ); // the page's HTML: the shell first, with the fallback, then the slow part, then the trees to hydrate with const chunks = await timed(await fetch(url, { headers: { accept: "text/html" } })); const html = chunks.map((c) => c.text).join(""); const shell = chunks.find((c) => c.text.includes('id="title"'))!; const rest = chunks.find((c) => c.text.includes("slow part"))!; assert.match( shell.text, /<b id="theme">dark<\/b>.*<p id="slow">loading<\/p>/s, "the first paint has the context's value and the fallback", ); assert.match( html, /<i id="default">light<\/i>/, "a component with no provider above it in the server tree reads the default", ); // the page's own <title> is its title: the shell's (the app's name) is left out assert.match(html, /<title>streaming page<\/title>/); assert.doesNotMatch(html, /<title>ui\.App<\/title>/); assert.ok( rest.ms - shell.ms >= 800, `the shell doesn't wait for the slow part (${shell.ms}ms, then ${rest.ms}ms)`, ); assert.ok(html.indexOf("__TCM_TREES__") > html.indexOf("slow part")); await withPage(url, async (p) => { assert.ok(await hydrated(p)); const text = (sel: string) => `document.querySelector(${JSON.stringify(sel)})?.textContent ?? ""`; assert.match( await p.waitFor<string>(text("#slow"), (t) => /slow part/.test(t ?? "")), /slow part, 1200ms/, ); await p.evaluate(`document.querySelector("#count").click()`); assert.equal(await p.waitFor<string>(text("#count"), (t) => t === "1", 3000), "1"); await p.evaluate(`document.querySelector("#clock").click()`); assert.equal( await p.waitFor<string>(text("#clock"), (t) => t === "ticks 1", 3000), "ticks 1", ); // the lazy component, sent by hash, loaded and rendered in the browser assert.equal(await p.evaluate(`document.querySelectorAll("#count").length`), 2); assert.match( await p.waitFor<string>("document.cookie", (c) => /late=yes/.test(c ?? "")), /early=yes.*late=yes|late=yes.*early=yes/, ); assert.deepEqual(p.errors, []); }); }; await check(await serve(keep, dir, "dev", "ui.App")); // production: the same, from the build's server-rendering bundle, which isn't served tcm("build", "ui.App"); const url = await serve(keep, dir, "start", "ui.App"); await check(url); const build = hashOf(dir, "ui.App"); assert.ok(fs.existsSync(path.join(dir, ".tcm", "apps", build, "server", "ssr.js"))); assert.equal((await fetch(new URL(`/_app/${build}/server/ssr.js`, url))).status, 404); }); }
uses
- tcmts.cli.test.browser.CHROME
- tcmts.cli.test.browser.hydrated
- tcmts.cli.test.browser.withPage
- tcmts.cli.test.e2e.harness.okIn
- tcmts.cli.test.e2e.harness.tempDir
- tcmts.cli.test.e2e.harness.withChildren
- tcmts.cli.test.e2e.react.APP
- tcmts.cli.test.e2e.react.hashOf
- tcmts.cli.test.e2e.react.serve
- tcmts.cli.test.e2e.react.timed