Public: anyone can read this; the crew can change it.

The loading animation looks right in Chrome #11643

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/11624-the-loading-animation-looks-right-in-chr → main 4 commits fork 3 threads · 3 unresolved

  • Merged. mason/11624-the-loading-animation-looks-right-in-chr was merged into main as c50c968.

Review

Sign in to review this change.
3 files changed +201 −0
src/web/style/base.ts +15
@@ -8,6 +8,20 @@
88 * prose. Hierarchy comes from weight and color, not size.
99 */
1010
11 /**
12 * The page itself does not cross-fade. Chromium's default for a cross-document
13 * transition is a 250 ms plus-lighter fade of the whole root, so a page that
14 * reloads itself (the "Waking up" page asks again every 3 s) pulses as a
15 * second loading indicator over its own. Only the named parts (topbar, repo
16 * header) are held still. These pseudo-elements fall outside the
17 * reduced-motion rule's `*`, so this is also what keeps that promise.
18 */
19 const rootHolds = [
20 `::view-transition-group(root), ::view-transition-old(root), ::view-transition-new(root) { animation: none; }`,
21 `::view-transition-old(root) { display: none; }`,
22 `::view-transition-new(root) { mix-blend-mode: normal; }`,
23 ].join("\n");
24
1125 /**
1226 * The cascade order, declared once and up front, plus the one at-rule that
1327 * must sit outside every layer. Cross-document view transitions are pure
outdated

The comment above layers ("The cascade order, declared once... must sit outside every layer") now sits above this new block, so it no longer documents layers. Move rootHolds and its comment above that comment, or below layers. Also, three comment lines explain the same fact twice (the fade, then 'swaps at once, as in Firefox'); principle 1: say it once.

@@ -17,6 +31,7 @@
1731 export const layers = [
1832 `@layer reset, base, components, content, utilities;`,
1933 `@view-transition { navigation: auto; }`,
34 rootHolds,
2035 ].join("\n");
2136
2237 export const base = /* css */ `
test/helpers/chrome.ts +101
@@ -477,3 +477,104 @@ export async function clickAndFollow(
477477 rmSync(profile, { recursive: true, force: true });
478478 }
479479 }
480
481 /** What one navigation looked like while it loaded. */
482 export interface NavigationSeen {
483 url: string;
484 /** Running view-transition animations on the root, once the transition was ready. */
485 rootAnimations: number;
486 /** The most loading indicators (aria-busy, or an animation that never ends) seen at once. */
487 mostIndicators: number;
488 /** Indicators still going once the page had settled. */
489 leftOver: number;
490 /** Summed layout-shift score of the document. */
491 shift: number;
492 }
493
494 /**
495 * Open `urls` one after another by navigation in one headless Chromium (so a
496 * cross-document view transition has an old page to run from), and report
497 * each. `settleMs` is how long each page is watched; a page that reloads
498 * itself is watched past its reload, and the report is of the document that
499 * is there at the end.
500 */
501 export async function observeNavigations(
502 binary: string,
503 urls: readonly { url: string; settleMs?: number }[],
504 cookie: { name: string; value: string },
505 ): Promise<NavigationSeen[]> {
506 const profile = mkdtempSync(join(tmpdir(), "forge-nav-"));
507 const proc = Bun.spawn(
508 [binary, "--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage",
509 "--disable-background-networking", "--disable-crash-reporter", "--no-first-run",
510 `--user-data-dir=${profile}`, "--remote-debugging-port=0"],
511 { env: childEnv({}), stdin: "ignore", stdout: "ignore", stderr: "pipe" },
512 );
513 let ws: WebSocket | undefined;
514 try {
515 const browserWsUrl = await Promise.race([
516 readDebuggerUrl(proc.stderr),
517 Bun.sleep(10_000).then((): string => { throw new Error("chromium never printed its devtools address"); }),
518 ]);
519 const target = await findPageTarget(browserWsUrl);
520 ws = new WebSocket(target.webSocketDebuggerUrl);
521 await within(5_000, "opening the devtools socket", new Promise<void>((resolve, reject) => {
522 ws!.addEventListener("open", () => resolve());
523 ws!.addEventListener("error", () => reject(new Error("chromium's devtools socket refused the connection")));
524 }));
525 const cdp = new Cdp(ws);
526 await cdp.send("Page.enable");
527 await cdp.send("Network.enable");
528 await cdp.send("Network.setCookie", { name: cookie.name, value: cookie.value, url: urls[0]!.url });
529 // Every document watches itself from its first instant.
530 await cdp.send("Page.addScriptToEvaluateOnNewDocument", {
531 source: `(() => {
532 const seen = (window.__seen = { root: 0, most: 0, shift: 0 });
533 const indicators = () => {
534 const spinning = document.getAnimations().filter((a) => {
535 const t = a.effect && a.effect.getComputedTiming();
536 return !(a.effect && a.effect.pseudoElement) && t && t.iterations === Infinity;
537 }).length;
538 return spinning + document.querySelectorAll('[aria-busy="true"]').length;
539 };
540 window.__indicators = indicators;
541 const sample = () => { seen.most = Math.max(seen.most, indicators()); };
542 setInterval(sample, 20);
543 new PerformanceObserver((list) => {
544 for (const e of list.getEntries()) if (!e.hadRecentInput) seen.shift += e.value;
545 }).observe({ type: "layout-shift", buffered: true });
546 addEventListener("pagereveal", (e) => {
547 if (!e.viewTransition) return;
548 e.viewTransition.ready.then(() => {
549 seen.root = document.getAnimations().filter((a) => {
550 const p = a.effect && a.effect.pseudoElement;
551 return p && /\\(root\\)/.test(p) && a.playState === "running";
552 }).length;
553 }, () => {});
554 });
555 })();`,
556 });
557 const read = async (url: string, settleMs: number): Promise<NavigationSeen> => {
558 await Bun.sleep(settleMs);
559 const { result } = await cdp.send("Runtime.evaluate", {
560 expression: `JSON.stringify({ ...window.__seen, left: window.__indicators() })`,
561 returnByValue: true,
562 });
563 const s = JSON.parse(result.value) as { root: number; most: number; shift: number; left: number };
564 return { url, rootAnimations: s.root, mostIndicators: s.most, leftOver: s.left, shift: s.shift };
565 };
566 const out: NavigationSeen[] = [];
567 for (const { url, settleMs } of urls) {
568 const loaded = cdp.once("Page.loadEventFired");
569 await cdp.send("Page.navigate", { url });
570 await within(10_000, `loading ${url}`, loaded);
571 out.push(await read(url, settleMs ?? 700));
572 }
573 return out;
574 } finally {
575 ws?.close();
576 proc.kill("SIGKILL");
577 await proc.exited.catch(() => {});
578 rmSync(profile, { recursive: true, force: true });
579 }
580 }
test/loading-chrome.test.ts added +85
@@ -0,0 +1,85 @@
1 /**
2 * Loading looks right in Chrome (#11624). Pages opened by navigation in a
3 * real Chromium, the "Waking up" page and its own refresh among them: the
4 * page itself never cross-fades (that fade, run on every self-reload, was a
5 * second indicator over the first), at most one indicator shows at a time, it
6 * ends, and the layout does not shift past the budget. No live swap: the
7 * only one that moves a card is a 1.1 s background flash that cannot repeat.
8 */
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp, type App } from "../src/app.ts";
14 import { createSession } from "../src/core/sessions.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { wakingResponse } from "../src/web/pages/waking.ts";
18 import { findChromium } from "../src/looks/capture.ts";
19 import { chromiumRuns, observeNavigations } from "./helpers/chrome.ts";
20
21 const SHIFT_BUDGET = 0.1;
22 let dir: string;
23 let app: App;
24 let servers: Servers;
25 let token: string;
26
27 beforeAll(async () => {
28 dir = await mkdtemp(join(tmpdir(), "forge-loading-"));
29 app = createApp({ dataDir: dir, port: 0 });
30 servers = await serve(app);
31 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
32 if (!made.ok) throw new Error("no user");
33 token = createSession(app.db, made.value.id).token;
34 }, 60_000);
35 afterAll(async () => {
36 servers.stop();
37 app.close();
38 await rm(dir, { recursive: true, force: true });
39 });
40
41 const found = findChromium();
42 const chromium = found !== undefined && chromiumRuns(found) ? found : undefined;
43 if (chromium === undefined) console.log("SKIPPING: no chromium that starts on this machine, so loading is not watched");
44
45 describe.skipIf(chromium === undefined)("loading, watched in Chromium", () => {
46 test("the home, the waking page and its own reload: one indicator at a time, it ends, no fade, no shift", async () => {
outdated

This test only matches the CSS string against a regex of itself, so it passes for any edit that keeps the text and proves nothing about Chrome. expect(layers).toContain(rootHolds) is a tautology. Replace this with the browser assertion, or delete it.

47 // The sleeping forge's page, served on the app's own origin so it takes the app's stylesheet.
48 const front = Bun.serve({
49 port: 0,
50 fetch: async (request) => {
51 const url = new URL(request.url);
52 if (url.pathname === "/__waking") return wakingResponse(app, request, "demo");
53 const headers = new Headers(request.headers);
54 headers.delete("host");
55 headers.set("accept-encoding", "identity");
56 const upstream = await fetch(servers.url + url.pathname + url.search, { headers, redirect: "manual" });
57 const out = new Headers(upstream.headers);
58 for (const h of ["content-encoding", "content-length", "transfer-encoding"]) out.delete(h);
59 return new Response(await upstream.arrayBuffer(), { status: upstream.status, headers: out });
60 },
61 });
62 try {
63 const base = `http://127.0.0.1:${front.port}`;
64 const seen = await observeNavigations(
65 chromium!,
66 [
67 { url: `${base}/` },
68 { url: `${base}/explore` },
outdated

The issue is about the "Waking up" page that reloads itself every 3 s and the "Setting up" location.replace, but the test navigates /, /explore, /. Add the self-reloading page (a sleeping forge's address, or the markup it serves with its refresh) so the test fails without rootHolds on exactly the case reported. The live-swap step is a hand-built DOM replacement, so it does not test the real path either; drive the real swap or drop the claim from the test header. Also the test was skipped in the author's sandbox and has never run: show one green run in the check log before this lands.

69 { url: `${base}/__waking` },
70 // Past its 3 s refresh: the page asking again by itself.
71 { url: `${base}/__waking?again=1`, settleMs: 4_500 },
72 ],
73 { name: "forge_session", value: token },
74 );
75 for (const s of seen) {
76 expect(s.rootAnimations).toBe(0);
77 expect(s.mostIndicators).toBeLessThanOrEqual(1);
78 expect(s.leftOver).toBe(0);
79 expect(s.shift).toBeLessThanOrEqual(SHIFT_BUDGET);
80 }
81 } finally {
82 front.stop(true);
83 }
84 }, 60_000);
85 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs