Skip to content

fix(tests): modulepreload chunks in <head>, richer import error#357

Merged
brianmhunt merged 1 commit intomainfrom
fix/tests-import-retry
Apr 22, 2026
Merged

fix(tests): modulepreload chunks in <head>, richer import error#357
brianmhunt merged 1 commit intomainfrom
fix/tests-import-retry

Conversation

@brianmhunt
Copy link
Copy Markdown
Member

@brianmhunt brianmhunt commented Apr 22, 2026

Summary

Reported: on production tko.io under Safari, one spec occasionally fails with the opaque WebKit message `Importing a module script failed.` — most often a spec importing a large chunk graph (e.g. `builds/reference/spec/bindingGlobalsBehavior.js`, which pulls in the full reference build). Chromium handles the same graph fine.

Root cause

WebKit's module-loader race when multiple browsing contexts start fetching the same shared module graph simultaneously. Pool=4 + 4 iframes spawning at once = 4 parallel dependency walks over overlapping chunks = occasional opaque failure.

Known across frameworks:

Fix

  1. Bundler emits `chunks` list in manifest.json. `tko.io/scripts/bundle-tests.mjs` enumerates `chunk-*.js` files (via `Bun.Glob`) after the source-mode build and writes them into `manifest.json`.
  2. Astro renders `` for every chunk at SSR time. Frontmatter reads the manifest via `Bun.file` and emits a preload tag per chunk into ``, plus a `` for the classic `setup.js`. Browser starts warming the HTTP cache during initial HTML parse — before any JS executes. Same-origin iframes share the browser HTTP cache, so every iframe's dynamic `import()` resolves from cache.
  3. Run Astro under Bun. Flipped dev/build/preview/check scripts to `bun --bun astro` so the SSR context has `Bun.file` available (matches AGENTS.md: prefer native Bun APIs).
  4. Richer error on failure. `tests-frame.html`: on `import()` catch, refetch the spec URL (`cache: 'no-store'`) and surface HTTP status + content-length + content-type. Conditional `statusText` render (HTTP/2 leaves it empty). If the refetch reports 2xx, note that the failure is most likely in a transitive chunk rather than the top-level spec.
  5. Runtime safety net. `runSourceMode` still injects any `` tags that are missing from the rendered `` (e.g. if the deployed HTML predates the current bundle), so a stale-cache rollover window can't reintroduce the race.

Test plan

  • Local Chromium: 2708/0/42 in ~5.4s. No regression.
  • SSR output carries 37 `` tags + 1 ``.
  • Production Safari after merge: reload `/tests`; with the HTTP cache warmed during initial HTML parse, the race should be eliminated. If it still recurs the error message now carries HTTP status + content-length + transitive-chunk hint.

Copilot AI review requested due to automatic review settings April 22, 2026 14:04
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 22, 2026

Warning

Rate limit exceeded

@brianmhunt has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 11 minutes and 34 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 11 minutes and 34 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aa90a864-ad11-44b3-8669-55860354bf8f

📥 Commits

Reviewing files that changed from the base of the PR and between d81b095 and 31e3528.

📒 Files selected for processing (4)
  • tko.io/package.json
  • tko.io/public/tests-frame.html
  • tko.io/scripts/bundle-tests.mjs
  • tko.io/src/pages/tests.astro
📝 Walkthrough

Walkthrough

Enhanced test execution with improved error diagnostics and performance optimization. Added HTTP-based error diagnostics in frame error handling, introduced shared ESM chunk tracking in the test manifest, and implemented prefetching of shared chunks and setup files before iframe execution to warm the browser cache.

Changes

Cohort / File(s) Summary
Error Diagnostics & Spec URL
public/tests-frame.html
Introduced specUrl from query parameter; added async diagnose(err) helper that fetches HTTP headers (status, content-length, content-type) from the spec URL to enrich error messages; updated import error handling to invoke diagnostics and report detailed failure information.
Manifest Chunk Tracking
scripts/bundle-tests.mjs
Updated manifest generation to scan and include all esbuild-emitted shared ESM chunk files (chunk-*.js); exports new chunks array alongside existing specs mapping in public/tests/source/manifest.json.
Cache Prefetch Optimization
src/pages/tests.astro
Added Promise.all() prefetch step to warm the browser's HTTP cache by fetching all manifest chunks and /tests/source/setup.js before iframe startup, eliminating concurrent-fetch race conditions during dynamic module imports.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • Live in-browser test runner at /tests #353: Directly related modifications to the same test-runner files (tests-frame.html, bundle-tests.mjs, tests.astro) introducing specUrl-based dynamic imports, manifest chunks field, and prefetching logic.

Poem

🐰 A test frame hops with vigor bright,
Diagnosing errors with fetched insight,
Chunks are mapped and cached with care,
Prefetch warms paths through the air!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: adding modulepreload chunks and improving import error messages. Both are primary objectives addressed in the PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/tests-import-retry

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Improves the per-spec iframe harness to reduce and better diagnose intermittent WebKit dynamic import() failures when loading large module graphs.

Changes:

  • Adds a single retry (150ms delay) when dynamic-importing the spec module fails.
  • On final import failure, fetches the spec URL with cache: 'no-store' and includes HTTP status, content type, and size in both the in-frame error display and postMessage payload.

Comment thread tko.io/public/tests-frame.html Outdated
async function diagnose(err) {
try {
const res = await fetch(specUrl, { cache: 'no-store' })
const size = Number(res.headers.get('content-length')) || (await res.clone().text()).length
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback size calculation uses res.clone().text().length, which counts UTF-16 code units (not bytes) but is reported as B, and it forces the entire response body into memory. Consider using await res.clone().arrayBuffer() and byteLength for an accurate byte count (and optionally cap/skip reading the body when content-length is missing to avoid large allocations).

Suggested change
const size = Number(res.headers.get('content-length')) || (await res.clone().text()).length
const contentLength = Number(res.headers.get('content-length'))
const size = contentLength || (await res.clone().arrayBuffer()).byteLength

Copilot uses AI. Check for mistakes.
@brianmhunt brianmhunt force-pushed the fix/tests-import-retry branch from ed94653 to 4578697 Compare April 22, 2026 14:11
@brianmhunt brianmhunt changed the title fix(tests): retry + richer error on iframe import failure fix(tests): stagger iframe workers + richer import error Apr 22, 2026
@brianmhunt brianmhunt force-pushed the fix/tests-import-retry branch 2 times, most recently from 95dcab2 to ace70b7 Compare April 22, 2026 14:37
@brianmhunt brianmhunt changed the title fix(tests): stagger iframe workers + richer import error fix(tests): prefetch chunks, warm HTTP cache, richer import error Apr 22, 2026
@brianmhunt brianmhunt force-pushed the fix/tests-import-retry branch from ace70b7 to d81b095 Compare April 22, 2026 14:50
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tko.io/scripts/bundle-tests.mjs (1)

284-294: Stale chunk-*.js files from prior builds will leak into the manifest.

esbuild doesn't clean sourceOutputDir between runs, and this glob picks up anything matching chunk-*.js there. In the normal case esbuild content-hashes chunk names so additive leftovers are harmless, but when a spec is removed or dependency graph changes, stale hashed chunks remain on disk and will be listed in manifest.chunks — causing the parent page to prefetch dead bytes (wasted bandwidth, still 200s so no error). Consider either pruning sourceOutputDir before esbuild.build or capturing the real chunk list from the metafile output instead of globbing.

♻️ Sketch using esbuild metafile
   await esbuild.build({
     entryPoints,
     bundle: true,
     format: 'esm',
     splitting: true,
     platform: 'browser',
     target: 'es2022',
     outdir: sourceOutputDir,
+    metafile: true,
     tsconfig,
     alias,
     plugins: [distToSrcPlugin],
     define: { BUILD_VERSION: JSON.stringify(buildVersion) },
     external: ['mocha'],
     logLevel: 'warning'
-  })
+  }).then(result => result)

Then derive chunks by filtering Object.keys(result.metafile.outputs) for chunk-*.js basenames. Alternatively await fs.rm(sourceOutputDir, { recursive: true, force: true }) before mkdir.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tko.io/scripts/bundle-tests.mjs` around lines 284 - 294, The manifest is
currently populated by globbing the output dir (Bun.Glob('chunk-*.js')), which
picks up stale chunk files; instead capture the actual emitted chunks from
esbuild's build result.metafile to avoid leftover files. Modify the build
invocation to enable metafile (esbuild.build({ ..., metafile: true })) and then
derive chunks by filtering Object.keys(result.metafile.outputs) for basenames
matching chunk-*.js (then sort) rather than using Bun.Glob over sourceOutputDir;
reference the variables/functions: sourceOutputDir, chunks, manifest, Bun.Glob,
and result.metafile.outputs when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tko.io/src/pages/tests.astro`:
- Around line 532-535: The prefetch currently awaits fetch()s which leaves
response bodies unconsumed (breaking WebKit caching) and uses Promise.all which
aborts everything on any failure; update the prefetch logic that maps over
manifest.chunks and the setup.js fetch so that each fetch is checked for
response.ok and then its body is fully drained (e.g., call
response.arrayBuffer() or response.blob()) and wrap these per-request operations
in Promise.allSettled so failures don’t short-circuit starting the iframes; keep
the manifest.chunks mapping and the setup.js fetch but ensure each entry both
verifies response.ok and consumes the body before resolving, and use
Promise.allSettled to wait for all drains without blocking on any single
failure.

---

Nitpick comments:
In `@tko.io/scripts/bundle-tests.mjs`:
- Around line 284-294: The manifest is currently populated by globbing the
output dir (Bun.Glob('chunk-*.js')), which picks up stale chunk files; instead
capture the actual emitted chunks from esbuild's build result.metafile to avoid
leftover files. Modify the build invocation to enable metafile (esbuild.build({
..., metafile: true })) and then derive chunks by filtering
Object.keys(result.metafile.outputs) for basenames matching chunk-*.js (then
sort) rather than using Bun.Glob over sourceOutputDir; reference the
variables/functions: sourceOutputDir, chunks, manifest, Bun.Glob, and
result.metafile.outputs when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1092b177-6de2-40ff-b8c4-9db23002f142

📥 Commits

Reviewing files that changed from the base of the PR and between 4e20972 and d81b095.

📒 Files selected for processing (3)
  • tko.io/public/tests-frame.html
  • tko.io/scripts/bundle-tests.mjs
  • tko.io/src/pages/tests.astro

Comment thread tko.io/src/pages/tests.astro Outdated
Comment on lines +532 to +535
await Promise.all([
...(manifest.chunks || []).map(name => fetch(`/tests/source/${name}`)),
fetch('/tests/source/setup.js')
])
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does WebKit/Safari commit a fetch() response to the HTTP cache if the response body stream is never consumed?

💡 Result:

No, WebKit/Safari does not commit a fetch response to the HTTP cache if the response body stream is never consumed. The response is received and available (headers and status are accessible immediately after fetch resolves), but the body data is not stored in the cache unless the body stream is read or consumed. This is because caching requires the full response body to be downloaded and buffered for storage in the memory or disk cache (via CachedResource's resourceBuffer and setBodyDataFrom, which rely on data being loaded). If the ReadableStream body is not consumed (e.g., no .json, .text, or .blob call), the body data is discarded without being added to the cache, as evidenced by WebKit's caching implementation where uncached or unbuffered bodies prevent entry into MemoryCache::add. Apple docs confirm caching occurs for complete responses, and unconsumed streams lead to no persistent storage for future requests.

Citations:


🏁 Script executed:

# Check if the file exists and read the relevant section
fd -t f "tests.astro" tko.io/

Repository: knockout/tko

Length of output: 84


🏁 Script executed:

# Read the file around the specified lines
if [ -f "tko.io/src/pages/tests.astro" ]; then
  echo "=== Lines 530-540 of tests.astro ==="
  sed -n '530,540p' tko.io/src/pages/tests.astro
  echo ""
  echo "=== Full context: Lines 520-550 ==="
  sed -n '520,550p' tko.io/src/pages/tests.astro
fi

Repository: knockout/tko

Length of output: 2128


🏁 Script executed:

# Check if tko.io has a build script and understand the project structure
if [ -f "tko.io/package.json" ]; then
  echo "=== tko.io/package.json (scripts section) ==="
  jq '.scripts' tko.io/package.json 2>/dev/null || cat tko.io/package.json | head -30
fi

Repository: knockout/tko

Length of output: 720


Drain response bodies before spawning iframes — unconsumed streams prevent WebKit from caching.

The prefetch optimization correctly aims to warm the HTTP cache before iframes import chunks, but the current implementation fails on WebKit: awaiting fetch() resolves when headers arrive, but the response body remains an unconsumed ReadableStream. WebKit does not commit a response to the HTTP cache until the body is fully read and the stream closes. If Promise.all resolves before the body is buffered, iframes can race the same concurrent fetches and re-trigger the "Importing a module script failed." error this PR aims to eliminate.

Additionally, Promise.all provides no error isolation: if any fetch fails or returns non-OK, the entire prefetch aborts with no iframes started. Use Promise.allSettled or per-fetch error handling to let the run proceed; genuine failures will surface later via the richer import-error diagnostic.

♻️ Proposed fix
+        const warm = async (url) => {
+          try {
+            const r = await fetch(url)
+            // Drain the body so the browser commits the response
+            // to the HTTP cache before iframes start racing imports.
+            await r.arrayBuffer()
+          } catch { /* diagnostic path will surface real failures */ }
+        }
         await Promise.all([
-          ...(manifest.chunks || []).map(name => fetch(`/tests/source/${name}`)),
-          fetch('/tests/source/setup.js')
+          ...(manifest.chunks || []).map(name => warm(`/tests/source/${name}`)),
+          warm('/tests/source/setup.js')
         ])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tko.io/src/pages/tests.astro` around lines 532 - 535, The prefetch currently
awaits fetch()s which leaves response bodies unconsumed (breaking WebKit
caching) and uses Promise.all which aborts everything on any failure; update the
prefetch logic that maps over manifest.chunks and the setup.js fetch so that
each fetch is checked for response.ok and then its body is fully drained (e.g.,
call response.arrayBuffer() or response.blob()) and wrap these per-request
operations in Promise.allSettled so failures don’t short-circuit starting the
iframes; keep the manifest.chunks mapping and the setup.js fetch but ensure each
entry both verifies response.ok and consumes the body before resolving, and use
Promise.allSettled to wait for all drains without blocking on any single
failure.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

outdated

Current implementation uses tags rendered in at SSR time. That's the browser's first-class preload mechanism: the resource
loader owns the fetch, buffers the body fully, and commits to both the preload cache and HTTP cache before moving on. No unread ReadableStream problem — there is
no JS-side fetch() consuming headers.

The fetch-body-not-cached WebKit quirk is real (reason fetch() + Promise.all is a known bad warmup primitive), which is why exists in
the first place. Switching to it killed two birds: semantic correctness + cache commit guarantee.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brianmhunt, you're right — my comment was against the diff's fetch()-based approach, but the file already has the SSR-rendered <link rel="modulepreload"> solution in <head>. That's the correct primitive: the browser's resource loader owns the full fetch-buffer-cache-commit lifecycle, so neither the unconsumed ReadableStream problem nor the Promise.all failure-isolation issue applies. Acknowledged and thanks for the clear explanation.

(◕‿◕✿) — the <link rel="modulepreload"> approach is the right tool for the job. Good call.


✏️ Learnings added
Learnt from: brianmhunt
Repo: knockout/tko PR: 357
File: tko.io/src/pages/tests.astro:0-0
Timestamp: 2026-04-22T15:43:34.568Z
Learning: In `tko.io/src/pages/tests.astro`, warming the HTTP cache for shared ESM chunks (to prevent WebKit's concurrent-fetch "Importing a module script failed." race) is done via SSR-rendered `<link rel="modulepreload">` tags in `<head>`, not JS-side `fetch()` calls. The chunk list is read at Astro SSR time from `public/tests/source/manifest.json` using `Bun.file(...).json()`, and each chunk name is emitted as `<link rel="modulepreload" href="/tests/source/{name}">`. This is the correct approach: the browser resource loader owns the full fetch-buffer-cache-commit cycle, avoiding the WebKit quirk where an unconsumed `ReadableStream` from a JS `fetch()` is never committed to the HTTP cache.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: CR
Repo: knockout/tko PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-22T12:59:48.899Z
Learning: Applies to {packages,builds}/*/spec/**/*.ts : Test the binding layer against real DOM behavior using Playwright in a real-browser matrix (chromium, firefox, webkit) as the authoritative test environment

Learnt from: CR
Repo: knockout/tko PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-22T12:59:48.899Z
Learning: Applies to tko.io/**/*.{tsx,md,astro} : Run bun run build in tko.io/ for a clean Astro build before merging docs changes; verify every Open in Playground button for pages with runnable TSX examples

Learnt from: CR
Repo: knockout/tko PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-22T12:59:48.899Z
Learning: Applies to {packages,builds}/*/spec/**/*.{ts,js} : Test files must follow the pattern `packages/*/spec/**/*.ts` or `builds/*/spec/**/*.js` and use Vitest with Chai (expect) + Sinon (spies/stubs/timers)

Learnt from: CR
Repo: knockout/tko PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-22T12:59:48.900Z
Learning: Adversarial review is mandatory for in-scope changes (code, tests, public API, agent-facing docs, CI, tools/build.ts, vitest.config.ts, biome.json); spawn a fresh subagent and record findings in commit message audit lines, not PR description

@brianmhunt brianmhunt force-pushed the fix/tests-import-retry branch from d81b095 to b2a2d34 Compare April 22, 2026 14:59
@brianmhunt brianmhunt changed the title fix(tests): prefetch chunks, warm HTTP cache, richer import error fix(tests): modulepreload chunks in <head>, richer import error Apr 22, 2026
@brianmhunt brianmhunt force-pushed the fix/tests-import-retry branch from b2a2d34 to 5e8c5e5 Compare April 22, 2026 15:18
Reported: on production tko.io under Safari, one spec
occasionally fails with the opaque WebKit message
`Importing a module script failed.` — most often a spec that
imports a large chunk graph (e.g. `builds/reference/spec/
bindingGlobalsBehavior.js`, which imports the full reference
build). Chromium handles the same graph fine. The failure mode
is known across Astro, SvelteKit, Nuxt, Immich, and tracks
against upstream WebKit bug 242740.

Two changes:

1. **Stagger worker launch** in tests.astro. With pool=4 every
   worker used to spawn its first iframe simultaneously, hitting
   the same shared module-graph chunks concurrently — that's
   what triggers the WebKit race. A 120ms per-worker offset
   spaces the first-round fetches (360ms total head-start for
   pool=4) and removes the contention without materially
   slowing the run (still ~5.2s end-to-end; was ~5.4s).
2. **Richer error on failure** in tests-frame.html. Keep the
   original dynamic `import()` (no retry) but in the catch,
   re-fetch the spec URL with `cache: 'no-store'` and report
   HTTP status, content-length, and content-type alongside the
   original WebKit message in both the in-frame err div and
   the `import-error` postMessage. Gives something actionable
   when the failure does surface.
@brianmhunt brianmhunt force-pushed the fix/tests-import-retry branch from 5e8c5e5 to 31e3528 Compare April 22, 2026 15:38
@brianmhunt brianmhunt merged commit 4ae4546 into main Apr 22, 2026
9 checks passed
@brianmhunt brianmhunt deleted the fix/tests-import-retry branch April 22, 2026 15:43
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.

2 participants