Skip to content

feat(lazy): retry a failed chunk import once, cache-busted - #61

Merged
chiefcll merged 2 commits into
mainfrom
feat/lazy-import-retry
Sep 11, 2026
Merged

feat(lazy): retry a failed chunk import once, cache-busted#61
chiefcll merged 2 commits into
mainfrom
feat/lazy-import-retry

Conversation

@chiefcll

@chiefcll chiefcll commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Moves a fix that was sitting in app code into the primitive where it belongs. Two commits, independently revertible.

Why

Route-chunk fetch failures were the largest single bucket of uncaught JS exceptions in a shipped TV app — measured across a real fleet, ~71 impacted sessions over three days on one release, split between the webOS/Xumo SystemJS bundle and the Vizio/UWP native-ESM bundle.

The failure mode is worse than a slow navigation. The rejected import escapes lazy() and the route never renders — and because p memoises the promise, that single rejection is replayed for the rest of the session. A one-second dropout as the viewer presses OK on a rail leaves that route permanently broken, long after the network recovered.

fix(lazy): stop a failed preload raising an unhandled rejection

Pre-existing, and surfaced by writing tests for the retry. preload()'s internal .then is a fire-and-forget side effect — it caches the resolved component — while p is what the caller gets back. That side-effect promise had no rejection handler of its own, so a warmed route that failed to fetch raised an unhandledRejection even when the caller dutifully caught the promise it was handed, and surfaced in the host app as an uncaught exception.

Given the same .catch(() => {}) the hydration path already uses. The real failure still reaches the caller through p.

Sequenced first deliberately: the retry commit's tests exercise the failing-preload path, so fixing this second would have left an intermediate commit whose suite emits unhandled errors.

feat(lazy): retry a failed chunk import once, cache-busted

One retry. No timers, no back-off.

The retry re-imports under a cache-busting URL rather than re-running fn, because re-running only helps one of the two module systems:

  • SystemJS drops the failed load from its registry, so calling fn again really does re-fetch.
  • Native ESM does the opposite — the module map memoises the failure, so a second import() of the same specifier resolves straight to the cached rejection without touching the network. A plain retry is a no-op precisely where it is needed. A distinct URL gets a fresh module-map entry and actually re-fetches.

The chunk URL is recovered from the failure message, which both module systems include. When no URL can be found — a test stub, a custom resolver — it falls back to re-running fn, no worse than not retrying at all.

Cost is a duplicate module record for the one retried chunk. Its own imports resolve to their normal, already-cached URLs, so the duplication does not spread, and it only happens on a retry that would otherwise have left the route dead.

src/primitives/index.ts now re-exports lazy by name instead of export *, so the two helpers this needs for its tests stay out of the package's public API. lazy was LazyImport's only export, so nothing else changes.

Verified in a real consumer build

Not just unit-tested — the built dist/ was dropped into a Vite TV app and both targets built clean:

bundle emitted
modern (Rollup, native ESM) return import(cacheBust(url))
legacy (@vitejs/plugin-legacy, SystemJS) return module.import(cacheBust(url))

So the @vite-ignore dynamic import survives both Rollup's analysis and the SystemJS transform — the thing most likely to break silently. That app's browser-compat guard also passed on its Chrome 71 floor.

Tests

New tests/lazyImport.spec.ts — 12 tests. preload() is the seam: it drives the same load() the render paths use, so the retry is exercised without standing up a renderer.

Both helpers are unit-tested directly against the verbatim production failure strings for each module system, and the retry paths are covered through preload: no retry on success; cache-busted re-import on a URL-bearing failure (asserted by the loader not being called twice); fallback re-run when no URL is present; giving up after exactly one retry; loading once across repeated preloads; and no unhandled rejection on a failed preload.

Every fix has a verified negative control — reverting it fails exactly the tests that should fail:

mutation result
retry removed 2 failed, 10 passed
cache-bust replaced by a plain fn() re-run 2 failed, 10 passed
.js anchor dropped from the URL regex 1 failed, 11 passed
preload .catch removed 1 failed, 11 passed

Two notes for reviewers, both places where a test initially proved nothing:

  • The unhandled-rejection test listens on process, not window. Under jsdom a Node-level rejection is reported there, and a window 'unhandledrejection' listener never fires — the first version was written that way and passed whether or not the bug was present.
  • The cache-bust tests assert the loader is called once, not on the rejection message. The import cannot resolve under Node's ESM loader (file: and data: only), so the rejection belongs to the test environment, not the behaviour. The URL actually requested is pinned by the chunkUrlFromError / cacheBust unit tests instead.
$ pnpm run tsc                 → exit 0
$ npx prettier --check         → all files use Prettier style
$ npx eslint .                 → 0 errors (154 pre-existing warnings elsewhere in src/)
$ npx vitest run               → 19 files, 198 tests passed

🤖 Generated with Claude Code

chiefcll and others added 2 commits September 11, 2026 12:57
`preload()`'s internal `.then` is a fire-and-forget side effect — it caches
the resolved component — while `p` is what the caller gets back. That
side-effect promise had no rejection handler of its own, so a warmed route
that failed to fetch raised an unhandledRejection even when the caller
dutifully caught the promise it was handed, and surfaced in the host app
as an uncaught exception.

Give it the same `.catch(() => {})` the hydration path already uses. The
real failure still reaches the caller through `p`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Route-chunk fetch failures were the largest bucket of uncaught JS
exceptions in a shipped TV app — the rejected import escapes `lazy()` and
the route never renders. Because `p` memoises the promise, one transient
failure is replayed for the rest of the session: the route stays broken on
every later navigation, long after the network recovered.

Retry exactly once. The retry re-imports under a cache-busting URL rather
than re-running `fn`, because re-running only helps one of the two module
systems TV apps ship to. Under SystemJS (the `@vitejs/plugin-legacy`
output older browsers run) the failed load is dropped from the registry,
so calling `fn` again really does re-fetch. Native ESM does the opposite:
the module map memoises the *failure*, so a second `import()` of the same
specifier resolves straight to the cached rejection without touching the
network — a no-op precisely where the retry is needed. A distinct URL gets
a fresh module-map entry and actually re-fetches.

The chunk URL is recovered from the failure message, which both module
systems include. When no URL can be found — a test stub, a custom resolver
— it falls back to re-running `fn`, which is no worse than not retrying.

Cost is a duplicate module record for the one retried chunk; its own
imports resolve to their normal, already-cached URLs, so it does not
spread.

Verified in a real consumer build: the modern bundle emits
`import(cacheBust(url))` and the legacy bundle `module.import(...)`,
so the `@vite-ignore` dynamic import survives both Rollup and the SystemJS
transform, and the browser-compat guard passes on the Chrome 71 floor.

`src/primitives/index.ts` now re-exports `lazy` by name instead of
`export *`, so the two helpers this needs for its tests stay out of the
package's public API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chiefcll
chiefcll force-pushed the feat/lazy-import-retry branch from fc281d2 to a8ba26a Compare September 11, 2026 17:12
@chiefcll chiefcll changed the title feat(lazy): retry a failed chunk import once feat(lazy): retry a failed chunk import once, cache-busted Sep 11, 2026
@chiefcll
chiefcll merged commit b22d854 into main Sep 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant