A deploy under an open tab stops taking the next navigation with it #424

Merged
enricobuehler merged 4 commits from worktree-worktree-web-stale-assets into main 2026-08-28 11:11:46 +00:00
Owner

Routes are code-split, so every build names its chunks by content hash and a deploy replaces the whole .output. A tab opened before the deploy still holds the previous build's HTML, asks for /assets/*-<oldhash>.js, and the rejected dynamic import takes the page down — the blank page reported for the docs site, the console and the marketing site alike. defaultPreload: "intent" widens the window: a hover is enough to trip it.

Vite raises vite:preloadError for exactly this, and its preload helper wraps both the dependency preloads and the module import itself, so the event fires before the router ever sees the rejection. A full page load is the whole fix, because the fresh HTML names the new chunks.

@unom/ui had already shipped this. reloadOnStaleChunk has been exported the whole time and nothing had ever imported it, so the first version of this branch was a fourth implementation of a solved problem. Both routers now call the library instead. @unom/ui@0.10.0 grew the two things that using it turned up:

  • an optional resolveTarget, so the recovery lands on the page the user was navigating to instead of reloading in place;
  • a try/catch around the sessionStorage guard — blocked storage throws on getItem, and that throw escaping the listener leaves the tab on exactly the dead page the helper exists to prevent.

It installs once, so the callback reads a module-level ref rather than closing over a router: hydration builds a second router and discards the first, the same reason the console keeps its QueryClient in one.

Verified, with a control

Built the docs site, served it, and used Playwright to 404 the chunks an open tab requests while letting the "new build" answer normally after the recovery load. Without the handler the page collapses to 32 characters and no <main> — the reported symptom. With it: full load, lands on /docs, 4791 characters, zero page errors. Re-confirmed after the Vite 8 bump and again after switching to the library helper.

Dependencies

Both apps move to Vite 8, TypeScript 7, plugin-react 6, vite-tsconfig-paths 6, @types/node 26, plus everything in range. @unom/ui 0.10.0 widened its motion peer, which is what had pinned the console to motion 12 — it is now on 13.1. The console's lucide-react 0.469→1.34 and tailwind-merge 2.6→3.6 were fixing real peer mismatches rather than chasing numbers.

biome 2.5 in web/

biome is not in ci.yml's web gate, so formatting and import order had drifted; biome check --write accounts for most of that diff. Three findings were real:

  • Library lifted the whole entry list into state for a providers-card consumer that no longer exists — written on every grid load, read by nothing. The state, the onEntries prop threading it up and the effect firing it are gone.
  • flatten in SourceSettings spread its accumulator per allOf branch; Object.assign onto an accumulator that already starts as a fresh copy is the same result without the O(n²).
  • LogsCard's newestVisible dep carried a comment begging the next person not to "fix" it. It is a trigger, not a read, and removing it silently breaks follow-mode — now a biome-ignore, so the tool enforces it instead of prose hoping to.

public/ and storybook-static/ are excluded: biome was linting the favicon as inline JSX and asking it for a <title>.

Companion changes

  • @unom/ui@0.10.0 and @unom/app-ui@0.3.0 published; both repos' main also reconciled, because each had its published version sitting on an unmerged branch.
  • punktfunk-website carries the same fix (with locale-aware re-localization) on its own main.

docs-site: build + tsc clean. web: build + tsc + 22 tests + biome clean, bun.nix regenerated.

Routes are code-split, so every build names its chunks by content hash and a deploy replaces the whole `.output`. A tab opened before the deploy still holds the previous build's HTML, asks for `/assets/*-<oldhash>.js`, and the rejected dynamic import takes the page down — the blank page reported for the docs site, the console and the marketing site alike. `defaultPreload: "intent"` widens the window: a hover is enough to trip it. Vite raises `vite:preloadError` for exactly this, and its preload helper wraps both the dependency preloads and the module import itself, so the event fires before the router ever sees the rejection. A full page load is the whole fix, because the fresh HTML names the new chunks. **`@unom/ui` had already shipped this.** `reloadOnStaleChunk` has been exported the whole time and nothing had ever imported it, so the first version of this branch was a fourth implementation of a solved problem. Both routers now call the library instead. `@unom/ui@0.10.0` grew the two things that using it turned up: - an optional `resolveTarget`, so the recovery lands on the page the user was navigating to instead of reloading in place; - a try/catch around the sessionStorage guard — blocked storage throws on `getItem`, and that throw escaping the listener leaves the tab on exactly the dead page the helper exists to prevent. It installs once, so the callback reads a module-level ref rather than closing over a router: hydration builds a second router and discards the first, the same reason the console keeps its QueryClient in one. ### Verified, with a control Built the docs site, served it, and used Playwright to 404 the chunks an open tab requests while letting the "new build" answer normally after the recovery load. **Without the handler the page collapses to 32 characters and no `<main>`** — the reported symptom. With it: full load, lands on `/docs`, 4791 characters, zero page errors. Re-confirmed after the Vite 8 bump and again after switching to the library helper. ### Dependencies Both apps move to Vite 8, TypeScript 7, plugin-react 6, vite-tsconfig-paths 6, `@types/node` 26, plus everything in range. `@unom/ui` 0.10.0 widened its motion peer, which is what had pinned the console to motion 12 — it is now on 13.1. The console's lucide-react 0.469→1.34 and tailwind-merge 2.6→3.6 were fixing real peer mismatches rather than chasing numbers. ### biome 2.5 in `web/` biome is not in ci.yml's web gate, so formatting and import order had drifted; `biome check --write` accounts for most of that diff. Three findings were real: - Library lifted the whole entry list into state for a providers-card consumer that no longer exists — written on every grid load, read by nothing. The state, the `onEntries` prop threading it up and the effect firing it are gone. - `flatten` in SourceSettings spread its accumulator per `allOf` branch; `Object.assign` onto an accumulator that already starts as a fresh copy is the same result without the O(n²). - LogsCard's `newestVisible` dep carried a comment begging the next person not to "fix" it. It is a trigger, not a read, and removing it silently breaks follow-mode — now a `biome-ignore`, so the tool enforces it instead of prose hoping to. `public/` and `storybook-static/` are excluded: biome was linting the favicon as inline JSX and asking it for a `<title>`. ### Companion changes - `@unom/ui@0.10.0` and `@unom/app-ui@0.3.0` published; both repos' `main` also reconciled, because each had its published version sitting on an unmerged branch. - `punktfunk-website` carries the same fix (with locale-aware re-localization) on its own `main`. docs-site: build + tsc clean. web: build + tsc + 22 tests + biome clean, `bun.nix` regenerated.
enricobuehler added 4 commits 2026-08-28 00:24:37 +00:00
Routes are code-split, so every build names its chunks by content hash and a deploy
replaces the whole `.output`. A tab opened before the deploy still holds the previous
build's HTML, so the first navigation asks for `/assets/*-<oldhash>.js` — which the new
server has never heard of. The dynamic import rejects, nothing catches it, and the page
goes blank. `defaultPreload: "intent"` widens the window: a hover is enough to trip it.

Vite raises `vite:preloadError` for exactly this, and its preload helper wraps both the
dependency preloads and the module import itself, so the event fires before the router
ever sees the rejection. A full page load is the entire fix — the fresh HTML names the
new chunks. Navigating to `latestLocation` rather than reloading in place means a click
that tripped this still lands on the page the user asked for.

Deliberately not `preventDefault()`: that suppresses Vite's rethrow and resolves the
import with `undefined`, handing the router a broken module on the way out. A
sessionStorage stamp keeps a genuinely broken deploy from spinning in a reload loop, and
its read is wrapped because storage can be blocked outright.

Verified on the built docs site under Playwright, serving 404s for the chunks an open tab
asks for: without the handler the page collapses to 32 characters and no <main>, with it
the tab reloads onto /docs and renders in full. Re-verified after the Vite 8 bump.
Everything in range moves to current — TanStack router/start/query, fumadocs, radix,
recharts, orval, storybook, biome, paraglide, playwright — and the majors that were
holding both apps a generation back go with them: Vite 7 to 8, @vitejs/plugin-react 5 to
6, vite-tsconfig-paths 5 to 6, @types/node 22 to 26, TypeScript 5.9 to 7. The marketing
site already ran the Vite 8 line against TanStack Start, so this closes a split the
monorepo was carrying for no reason.

Two of the console's majors were fixing a mismatch rather than chasing a number:
@unom/ui@0.9.2 asks for lucide-react ^1.17 and depends on tailwind-merge ^3.6, while the
console was pinned to lucide-react 0.469 and tailwind-merge 2.6.

motion is deliberately left on 12.43. 13.1 is available, but @unom/ui declares motion
^12.40 as a peer and inlines framer-motion 12.38, so moving the app alone would put two
motion runtimes in one tree — and a split motion context is exactly the failure that
neither a typecheck nor a build can see. It moves when @unom/ui does.

docs-site: build + tsc clean. web: build + tsc + 22 tests clean, bun.nix regenerated.
@unom/ui has exported `reloadOnStaleChunk` for exactly this the whole time and nothing had
ever imported it — the copy added a few commits ago was a fourth implementation of a solved
problem. Both routers now call the library.

0.10.0 grew the two things using it turned up: an optional `resolveTarget`, so the recovery
lands on the page the user was navigating to rather than reloading in place, and a try/catch
around the sessionStorage guard, because blocked storage throws on `getItem` and that throw
escaping the listener leaves the tab on exactly the dead page the helper exists to prevent.

The helper installs once, so the callback reads a module-level ref rather than closing over a
router: hydration builds a second router and discards the first, which is the same reason the
console keeps its QueryClient in one.

Deps: @unom/ui 0.10.0 and motion 13.1 (0.10.0 widened the peer, which is what had pinned us to
12), plus @unom/app-ui 0.3.0 for the docs site.

docs-site: build + tsc clean, and the deploy simulation still recovers onto /docs with the
library helper in place. web: build + tsc + 22 tests clean, bun.nix regenerated.
chore(web): biome 2.5, and the findings it surfaced
ci / docs-drift (pull_request) Successful in 30s
ci / bun-nix (pull_request) Successful in 32s
ci / web (pull_request) Successful in 52s
ci / docs-site (pull_request) Successful in 1m46s
ci / rust-arm64 (pull_request) Successful in 3m4s
ci / rust (pull_request) Successful in 7m51s
nix / flake (pull_request) Successful in 7m43s
23f53d46a7
biome is not in ci.yml's web gate, so formatting and import order had drifted; `biome check
--write` accounts for most of this diff. `biome migrate` moved the deprecated
linter.rules.recommended to rules.preset. public/ and storybook-static/ are excluded — biome
was linting the favicon as if it were inline JSX and asking it for a <title>.

Three findings were real:

- Library lifted the whole entry list into state so the providers card could count owners.
  That consumer is gone: `entries` was written on every grid load and read by nothing. The
  state, the `onEntries` prop threading it up, and the effect firing it are all removed —
  including from LibraryGrid, where the callback had no callers left.
- `flatten` in SourceSettings spread the accumulator per `allOf` branch, rebuilding the object
  each time. Object.assign onto an accumulator that already starts as a fresh copy of the node
  is the same result without the O(n²).
- LogsCard's `newestVisible` dep already carried a comment begging the next person not to
  "fix" it — it is a trigger, not a read, and removing it silently breaks follow-mode. That
  warning is now a biome-ignore, so the tool enforces it instead of prose hoping to.

Build, tsc, 22 tests and biome all clean.
enricobuehler merged commit 2b46b5c2da into main 2026-08-28 11:11:46 +00:00
enricobuehler deleted branch worktree-worktree-web-stale-assets 2026-08-28 11:11:48 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#424