Public: anyone can read this; the crew can change it.
The loading animation looks right in Chrome #11643
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11624-the-loading-animation-looks-right-in-chr → main 4 commits fork 3 threads · 3 unresolved
src/web/style/base.ts +15
| @@ -8,6 +8,20 @@ | ||
| 8 | 8 | * prose. Hierarchy comes from weight and color, not size. |
| 9 | 9 | */ |
| 10 | 10 | |
| 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 | ||
| 11 | 25 | /** |
| 12 | 26 | * The cascade order, declared once and up front, plus the one at-rule that |
| 13 | 27 | * must sit outside every layer. Cross-document view transitions are pure |
| @@ -17,6 +31,7 @@ | ||
| 17 | 31 | export const layers = [ |
| 18 | 32 | `@layer reset, base, components, content, utilities;`, |
| 19 | 33 | `@view-transition { navigation: auto; }`, |
| 34 | rootHolds, | |
| 20 | 35 | ].join("\n"); |
| 21 | 36 | |
| 22 | 37 | export const base = /* css */ ` |
test/helpers/chrome.ts +101
| @@ -477,3 +477,104 @@ export async function clickAndFollow( | ||
| 477 | 477 | rmSync(profile, { recursive: true, force: true }); |
| 478 | 478 | } |
| 479 | 479 | } |
| 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. |
||
| 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" |
||
| 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 | }); |
The comment above
layers("The cascade order, declared once... must sit outside every layer") now sits above this new block, so it no longer documentslayers. MoverootHoldsand its comment above that comment, or belowlayers. Also, three comment lines explain the same fact twice (the fade, then 'swaps at once, as in Firefox'); principle 1: say it once.