From ed2904ce602a98c2f402d7739824f0475d7abaad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 08:49:10 -0400 Subject: [PATCH] T47 Ship: a gallery whose wasm fetch fails retries, then tells the host; the host shows it at once and waits a minute for a slow start --- AGENTS.md | 2 +- examples/gallery/index.html | 15 ++++++++++++++- frontend/e2e/live.spec.ts | 17 +++++++++++++++++ frontend/src/Live.tsx | 12 ++++++++++-- 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7c21557..322d035 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -184,7 +184,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - What cannot build for wasm32 is absent there through `cfg(not(target_family = "wasm"))`, never faked: the live terminal, `Terminal` and `Launch` with their grid, keys and search, and the gallery's capture harness. - What a browser cannot run draws natively alone: `ui::native_only` leaves a window's opener out on the web, and the web's menu bar keeps View alone. The site shows native captures instead, light and dark: a story whose demo needs the desktop (files, the terminal's shell, the tray, the Dock, window moves) as its capture, and a live story that opens windows with those windows below. `python3 scripts/shots.py` takes them with the native gallery, window in front, into `frontend/public/shots` and `frontend/src/shots.json`. - A story is a gallery section: its title is a literal, one per page. Under `ui::Story` the one asked for draws without its title; the rest drop their demos (`ui::Section`), so nothing of theirs lays out, loads or moves. Their builders still run, so a demo that starts work on its own, a clock, a load or a shell, starts it through `ui::live`, which a story grants its own section alone and fails on a title no section has. A story the page lacks fails in the page. -- Embedded in a page, the web gallery keeps gpui_web's hidden text field from taking focus, and hands focus back to the host, until a press or a key reaches it (`examples/gallery/index.html`); gpui_web focuses that field whenever its window is active. The host shows its own state until `{ ely: "ready" }`, sends the mode on ready and on every turn, and puts a skip link before the frame, since Tab inside gpui cycles within it. +- Embedded in a page, the web gallery keeps gpui_web's hidden text field from taking focus, and hands focus back to the host, until a press or a key reaches it (`examples/gallery/index.html`); gpui_web focuses that field whenever its window is active. It retries a failed start twice, since frames that fetch the wasm at once can fail one, then posts `{ ely: "failed", error }`. The host shows its own state until `{ ely: "ready" }` or that failure, sends the mode on ready and on every turn, and puts a skip link before the frame, since Tab inside gpui cycles within it. - The gallery's pictures go through `asset!` and `ui::picture` or `ui::resource`: a file natively, on the web an address under `ELY_GALLERY_ASSETS`, since gpui reads no files there. - On the web a paste reaches the focused field as the browser's paste event, through `EntityInputHandler::paste`, so Paste stays unbound there (`TextInput`, `CodeEditor`). Code that reads the clipboard itself awaits `read_from_clipboard_async`: gpui_web's `read_from_clipboard` answers at once and finds nothing (`tables::DataGrid`'s Cmd-V). - The first frame wears the host's mode through `Theme::set_mode_now`, a mode sent before the window opens included; `set_mode` fades from the colors on screen. diff --git a/examples/gallery/index.html b/examples/gallery/index.html index 6195fdd..93ba979 100644 --- a/examples/gallery/index.html +++ b/examples/gallery/index.html @@ -45,7 +45,20 @@ addEventListener("message", ({ data }) => { if (data && data.ely === "theme") setTheme(data.theme); }); - await init(); + // Frames that fetch the wasm at once can fail one; the next try finds it cached. + for (let attempt = 1; ; attempt++) { + try { + await init(); + break; + } catch (error) { + console.warn(`gallery: start failed, try ${attempt}`, error); + if (attempt === 3) { + window.parent.postMessage({ ely: "failed", error: String(error) }, "*"); + throw error; + } + await new Promise((wake) => setTimeout(wake, 1000 * attempt)); + } + } apply = set_theme; if (early) set_theme(early); // Tells the host that a mode it sends now lands. diff --git a/frontend/e2e/live.spec.ts b/frontend/e2e/live.spec.ts index 51b5924..eb480b2 100644 --- a/frontend/e2e/live.spec.ts +++ b/frontend/e2e/live.spec.ts @@ -20,6 +20,23 @@ test("a story runs live and follows the site's mode", async ({ page }) => { await expect(frame.locator("html")).toHaveClass(/dark/); }); +test("a frame whose wasm fetch fails once starts on its retry", async ({ page }) => { + let aborted = false; + await page.route("**/gallery/gallery_bg.wasm", (route) => { + if (aborted) return route.continue(); + aborted = true; + return route.abort(); + }); + await live(page, "/components/buttons/button/"); + expect(aborted).toBe(true); +}); + +test("a frame that cannot start says so at once", async ({ page }) => { + await page.route("**/gallery/gallery_bg.wasm", (route) => route.abort()); + await page.goto("/components/buttons/button/"); + await expect(page.locator(".live .live-state")).toContainText("Couldn't start", { timeout: 15_000 }); +}); + test("a starting story leaves focus with the host", async ({ page, isMobile }) => { test.skip(isMobile, "no hardware keyboard"); await page.goto("/"); diff --git a/frontend/src/Live.tsx b/frontend/src/Live.tsx index dc5abe2..364fefc 100644 --- a/frontend/src/Live.tsx +++ b/frontend/src/Live.tsx @@ -4,7 +4,7 @@ import { hold } from "./scroll"; import { mode, useMode } from "./theme"; const SPIN_AFTER = 400; -const GIVE_UP = 20000; +const GIVE_UP = 60000; type State = "starting" | "slow" | "ready" | "failed"; @@ -28,7 +28,15 @@ export function Live({ page, story, title }: { page: string; story: string; titl console.error(`live: ${page}/${story} sent no ready in ${GIVE_UP} ms`); }, GIVE_UP); const ready = (event: MessageEvent) => { - if (event.source !== frame.current?.contentWindow || event.data?.ely !== "ready") return; + if (event.source !== frame.current?.contentWindow) return; + if (event.data?.ely === "failed") { + clearTimeout(slow); + clearTimeout(failed); + setState("failed"); + console.error(`live: ${page}/${story} failed to start: ${event.data.error}`); + return; + } + if (event.data?.ely !== "ready") return; clearTimeout(slow); clearTimeout(failed); setState("ready");