2 Commits
Author SHA1 Message Date
Tommy D. Rossi 7d6e1f97ed Serve the browser example through Bun's dev server with React Fast Refresh
An edit to `examples/chat.tsx` now hot-updates the running page instead of
reloading it. Components swap in place and `useState` survives, so the GPUI
canvas, the composer text and the scroll position all stay where they were.
A reload re-downloads a ~19 MB Wasm module; this avoids that entirely.

`scripts/web.ts` drops the `Bun.build` step and the hand-written static file
map. It now registers `examples/web.html` as a Bun HTML route with
`development: { hmr: true }`, so Bun bundles the page, watches the graph and
applies the React Fast Refresh transform.

Fast Refresh never talks to a renderer directly. It goes through the React
DevTools global hook:

    Bun HMR runtime
      -> refreshRuntime.injectIntoGlobalHook(window)
    @gpuix/react reconciler.ts
      -> reconciler.injectIntoDevTools()
      -> hands the hook scheduleRefresh + setRefreshHandler
    react-refresh
      -> performReactRefresh() -> scheduleRefresh(root)

That injection already existed. What was missing was a bundler that runs the
transform, and a test proving the wiring holds.

`examples/web-chat.tsx` must NOT call `import.meta.hot.accept("./chat", ...)`.
Bun runs an importer's dependency-accept callback even when the imported module
already self-accepted for Fast Refresh, so the callback remounted the tree on
top of a successful refresh and wiped every hook. That looks exactly like Fast
Refresh being broken, and it is not.

The Wasm half is a singleton: `WebGpuixRenderer::init` fails with "GPUIX web is
already running" once its thread-local app exists, and gpui_web appends its own
canvas to <body>. Living in node_modules is not what protects it, because Bun
bundles it into the same client registry. What protects it is that Bun re-runs
only the changed module and then walks upward through its importers, so an
unchanged dependency stays evaluated and cached.

packages/native/.cargo/config.toml links with `--shared-memory`, so the
generated `WebAssembly.Memory` is `shared: true` and the document must be
cross-origin isolated. `Bun.serve` has no way to add headers to an HTML route
(oven-sh/bun#16873), so the bundled document is registered on a private path and
re-sent from `/` with COOP + COEP. Only the top-level document needs them:
`require-corp` constrains cross-origin subresources, and every asset the dev
server emits is same-origin.

`packages/react/src/__tests__/fast-refresh.test.tsx` mounts through the GPU test
renderer, clicks to set state, swaps the component family, and asserts the new
output keeps the old state. It fails when `injectIntoDevTools()` is removed.

This matters because the failure mode is silent. Without the injection there is
no error and no page reload: Bun still accepts the update and still calls
`performReactRefresh()`, which iterates zero mounted roots and schedules
nothing. The bundle changes and the painted UI stays stale.

Do not assert on the return value of `injectIntoDevTools()`. It ends in
`hook.checkDCE ? true : false` and react-refresh installs no `checkDCE`, so a
working injection still reports `false`.

- `--build-only` / `--rebuild` / auto-build-if-missing in scripts/web.ts, and
  the missing check tests both wasm-bindgen outputs, not just the `.wasm`
- `examples/tsconfig.json` gets `noEmit: true`; nothing there is ever compiled
  by tsc and a stray `tsc -p .` littered a `.js` next to every `.tsx`
- `@types/bun` as a root devDependency plus a `typeRoots` entry so
  `scripts/tsconfig.json` types `Bun.serve` and the `*.html` bundler import
- README documents that automation locator bounds are canvas pixels, not CSS
  pixels. Do not use `window.devicePixelRatio`; a tool can override the viewport
  scale after GPUI has sized its canvas. Use `canvas.width / canvas.clientWidth`

Validated in Chrome with playwriter: text edits and a module-level colour
constant edit both repaint the canvas with state preserved, and a revert
restores a byte-identical screenshot.

Session: ses_fc233909fffeHxAGjrf2jkWSet
2026-08-26 17:59:44 +02:00
Tommy D. Rossi 1d2b470936 Add bun run dev, a watch loop for the Rust side
There is no hot reload for the native half and there cannot be. require()
of a .node file calls process.dlopen, Node has no matching unload, and the
live state (the winit event loop, the wgpu device, the open window, the
selection registry) lives in thread-locals of the loaded library. A second
load would get empty thread-locals and a dead window, and napi registers
the module once per process.

The rebuild is fast enough that it does not matter. Measured after
touching one file:

    cargo check --lib             1.5s
    cargo build --lib             4.9s
    bun run build:debug (napi)   ~2s
    one vitest screenshot file   ~2s

scripts/dev.ts watches packages/native/src, rebuilds, and refreshes a
target. A Rust edit reaches fresh PNGs in about four seconds.

    bun run dev                            rebuild, re-render screenshots
    bun scripts/dev.ts --shots diff        only tests matching "diff"
    bun scripts/dev.ts --app native-text   rebuild, restart an example app

Screenshot mode is the default because Preview.app reloads a PNG on write
and, unlike a live window, the PNG can be read by an agent.

Builds are serialized: a burst of saves otherwise starts overlapping cargo
runs that then block on cargo's own lock and report confusing timings.

scripts/ gets its own package.json and tsconfig.json so the watcher is
type-checked (`bun x tsc -p scripts`) rather than only being run.
2026-08-19 22:58:22 +02:00