feat(lazy): retry a failed chunk import once, cache-busted - #61
Merged
Conversation
`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
force-pushed
the
feat/lazy-import-retry
branch
from
September 11, 2026 17:12
fc281d2 to
a8ba26a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 becausepmemoises 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 rejectionPre-existing, and surfaced by writing tests for the retry.
preload()'s internal.thenis a fire-and-forget side effect — it caches the resolved component — whilepis 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 anunhandledRejectioneven 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 throughp.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-bustedOne 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:fnagain really does re-fetch.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.tsnow re-exportslazyby name instead ofexport *, so the two helpers this needs for its tests stay out of the package's public API.lazywas 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:return import(cacheBust(url))@vitejs/plugin-legacy, SystemJS)return module.import(cacheBust(url))So the
@vite-ignoredynamic 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 sameload()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:
fn()re-run.jsanchor dropped from the URL regex.catchremovedTwo notes for reviewers, both places where a test initially proved nothing:
process, notwindow. Under jsdom a Node-level rejection is reported there, and awindow'unhandledrejection'listener never fires — the first version was written that way and passed whether or not the bug was present.file:anddata:only), so the rejection belongs to the test environment, not the behaviour. The URL actually requested is pinned by thechunkUrlFromError/cacheBustunit tests instead.🤖 Generated with Claude Code