testReactPlacementStreamingHydration

tcmts.cli.test.e2e.react.testReactPlacementStreamingHydration · #iutuoex4 · term

React: hooks, classes and contexts are placed; streams; the first paint is server-rendered

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