Repository navigation
Don't cache HTTP errors in the module map - #10327
Conversation
e7b9493 to
b94700d
Compare
annevk
left a comment
There was a problem hiding this comment.
Looks good to me modulo nits.
cc @whatwg/modules
b94700d to
728c268
Compare
| <li><p>If <var>moduleMap</var>[(<var>url</var>, <var>moduleType</var>)] <span | ||
| data-x="map exists">exists</span>, run <var>onComplete</var> given | ||
| <var>moduleMap</var>[(<var>url</var>, <var>moduleType</var>)], and return.</p></li> |
There was a problem hiding this comment.
We need to handle the case in which moduleMap[(url, moduleType)] exists and is "fetching", since we should not pass "fetching" to onComplete.
Test case:
await Promise.all([
/* 1 */ import("./network-error.js").catch(() =>
/* 3 */ import("./network-error.js").catch(() => {})
),
/* 2 */ import("./network-error.js").catch(() => {})
]);This is what I think is happening (but please somebody with better understanding of event loops confirm):
/* 1 */setsmoduleMap["./network-error.js"]to"fetching", and performs the fetch. When that fetch is done, a task T1 is scheduled to the networking task source./* 2 */sees thatmoduleMap["./network-error.js"]is"fetching", and waits.- At some point the event loop runs T1, which will run the
processResponseConsumeBodysteps of fetch a single module script:- It changes
moduleMap[( url , moduleType )](it removes it), so a task T2 is scheduled to continue running/* 2 */. - It runs
onCompletewithnull, which will callFinishLoadingImportedModulewith an error completion in the last step ofHostLoadImportedModule. - This will then reject the dynamic import promise in
ContinueDynamicImport, thus queueing a microtask (in HostEnqueuePromiseJob) to run/* 1 */'s.catch()callback - Once this is done, we drain the microtask queue and thus execute
/* 3 */:moduleMapdoen't have a"./network-error.js"entry anymore, so it setsmoduleMap["./network-error.js"]to"fetching", and performs the fetch.
- It changes
- At this point we are done running T1, and we can start running T2.
moduleMap["./network-error.js"]exists (and has value"fetching"), so we runonCompletewith"fetching"which is wrong.
Once this is fixed the code above should probably be used for a WPT test.
|
The current safari behavior is that in this case: import("./network-error.js");
import("./network-error.js");after the first import fails, it performs a second fetch for the second one (rather than deduplicating them and immediately erroring for both.) And in this case: import("./network-error.js");
import("./network-error.js");
import("./network-error.js");it performs three fetches serially. |
|
I have addressed @nicolo-ribaudo's review comment by removing the race condition between notification and read of the value in the module map. PTAL again @annevk. Note that the behaviour here is not identical to Safari in the case where you a parallel dynamic import for the same specifier. In this PR, both dynamic imports will always resolve / reject with the same value. For dynamic imports started at the same time, you can't resolve one, but reject another. |
|
One change introduced by this PR is that now this code: import("x").then(() => {
console.log(1);
Promise.resolve().then(() => console.log(2));
});
import("x").then(() => {
console.log(3);
Promise.resolve().then(() => console.log(4));
});is guaranteed to log (on success) Previously, the possible behaviors where I think this change is ok/good, but if we want to revert it it can be done by scheduling tasks to call the various |
|
I'm working on getting a Chromium position on this. I am hopeful it will be positive. However, we'll likely need help with someone updating web platform tests, and adding new ones, including adding ones for the new scenario @nicolo-ribaudo points out, and for the differences between this and current Safari behavior. |
There was a problem hiding this comment.
From an editor perspective, these changes LGTM, although I've also asked if we can get @hiroshige-g's time to review.
However, I'm unsure why we're counting MIME type mismatches as permanent. I agree they're less likely to be transient in practice, but it seems a bit strange to have one class of bad response treated so differently than all others.
|
I'm generally supportive of the proposal. I think this change would steer the module fetching behavior more toward availability (vs determinism which was prioritized when the feature was originally designed), but that seems well-aligned with the current web author needs. My only question is about the introduction of the callbacks. Would you mind elaborating on the context of the change (why is the old |
|
I think the PR works consistently (at least basically) with the high-level semantics of:
I haven't investigated the behavoir/impact on the Chromium implementation though, as the behavior/invariants in the current Chromium/V8 are different from the current spec. Specific cases and behaviors follows below (which is probably OK?). 1: never re-request a module within a single module graph
This mechanism doesn't match with the current Chromium implementation that doesn't early-exit on failure, but probably this is implementable. 2: MIGHT re-request submodules across module graphsfor: The
3: failure order not guaranteedfor:
4: consistency over timeI was concerned that re-requesting would break consistency/invariants and cause crashes by chaning module scripts from null to non-null over time (e.g. module graphs look different over time and causes obsolete/inconsistent pointer access and state transition). But so far this doesn't look the case and the PR works consistently:
(Similar consistency/invariant checks must be done in Chromium/V8 implementation side when implementing.) |
|
@domenic I have updated the change to also not cache of mime type errors. @nyaxt Thanks for the feedback. The change to callback was necessary to be able to accurately reason about the state of each invocation. If implemented without the callbacks, there was a race condition where between the "in parallel wait until change" and the actual read of the With the callback change, the new moduleMap value for this entry, for this onCompleted call is passed explicitly to the onCompleted callback - thus removing the race condition. @hiroshige-g Thanks for the elaborate notes. This matches my mental model. |
This PR added and adjusted some WPT tests for the proposed change in whatwg/html#10327, which clarifies that the failed module fetches (HTTP/Network/MIME errors) should not be cached in the module map. Background In the current spec, the module map caches the module fetches regardless of the fetch result. This means that if a module fetch fails due to an HTTP error (e.g., 404 Not Found), subsequent dynamic imports of the same module will immediately fail with the same error without attempting to fetch the module again. This behavior can lead to issues where transient errors (like a temporary network glitch or a server hiccup) cause a module to be permanently unavailable for the duration of the page session. The proposed change in whatwg/html#10327 aims to address this by specifying that only successful module fetches should be cached. This means that if a module fetch fails, subsequent dynamic imports will attempt to fetch the module again, allowing developers to recover from transient errors more gracefully. Spec references - whatwg/html#6768 - whatwg/html#10327 These tests pass on a Chromium prototype implementation in https://chromium-review.googlesource.com/7039725
This PR added and adjusted some WPT tests for the proposed change in whatwg/html#10327, which clarifies that the failed module fetches (HTTP/Network/MIME errors) should not be cached in the module map. Background In the current spec, the module map caches the module fetches regardless of the fetch result. This means that if a module fetch fails due to an HTTP error (e.g., 404 Not Found), subsequent dynamic imports of the same module will immediately fail with the same error without attempting to fetch the module again. This behavior can lead to issues where transient errors (like a temporary network glitch or a server hiccup) cause a module to be permanently unavailable for the duration of the page session. The proposed change in whatwg/html#10327 aims to address this by specifying that only successful module fetches should be cached. This means that if a module fetch fails, subsequent dynamic imports will attempt to fetch the module again, allowing developers to recover from transient errors more gracefully. Spec references - whatwg/html#6768 - whatwg/html#10327 These tests pass on a Chromium prototype implementation in https://chromium-review.googlesource.com/7039725
|
Chromium is happy to support this. |
Is this covered by web-platform-tests/wpt#55505 ? Also, @jakearchibald had a concern at #6768 (comment) that seems unaddressed. |
@jakearchibald 's concern seems orthogonal to this. He opened #12657 to explore it further. |
|
It's only orthogonal if the conclusion of my question is that it could only be added as an opt-in. Which may be reasonable. |
IIUC you're interested in a behavior change. I suspect that can equally happen either before or after this change lands. |
|
Well, it would mean reverting this change, right? If an HTTP 'error' can produce a usable module, then we'd want it cached. |
For persistent errors that may contain some useful info, sure. So I think we'd want a "no cache" path (as defined in this PR), and then potentially carve out the persistent errors (e.g. 4XX) |
https://bugs.webkit.org/show_bug.cgi?id=319492 Reviewed by Keith Miller. This implements the spec changes from whatwg/html#10327 to avoid caching HTTP or network module import failures. No new tests, but lots of progressions. * LayoutTests/imported/w3c/web-platform-tests/html/semantics/scripting-1/the-script-element/json-module/repeated-imports.any-expected.txt: * LayoutTests/imported/w3c/web-platform-tests/html/semantics/scripting-1/the-script-element/json-module/repeated-imports.any.sharedworker-expected.txt: * LayoutTests/imported/w3c/web-platform-tests/html/semantics/scripting-1/the-script-element/json-module/repeated-imports.any.worker-expected.txt: * LayoutTests/imported/w3c/web-platform-tests/html/semantics/scripting-1/the-script-element/module/concurrent-failed-imports-expected.txt: * LayoutTests/imported/w3c/web-platform-tests/html/semantics/scripting-1/the-script-element/module/dynamic-import/concurrent-failed-imports.any-expected.txt: * LayoutTests/imported/w3c/web-platform-tests/html/semantics/scripting-1/the-script-element/module/dynamic-import/concurrent-failed-imports.any.sharedworker-expected.txt: * LayoutTests/imported/w3c/web-platform-tests/html/semantics/scripting-1/the-script-element/module/dynamic-import/concurrent-failed-imports.any.worker-expected.txt: * LayoutTests/imported/w3c/web-platform-tests/html/semantics/scripting-1/the-script-element/module/dynamic-import/repeated-imports.any-expected.txt: * LayoutTests/imported/w3c/web-platform-tests/html/semantics/scripting-1/the-script-element/module/dynamic-import/repeated-imports.any.sharedworker-expected.txt: * LayoutTests/imported/w3c/web-platform-tests/html/semantics/scripting-1/the-script-element/module/dynamic-import/repeated-imports.any.worker-expected.txt: * Source/JavaScriptCore/runtime/JSMicrotask.cpp: (JSC::moduleLoadTopRejected): Avoid registering failed module loads. (JSC::moduleLoadStoreError): Avoid registering failed module loads. (JSC::runInternalMicrotask): Avoid registering failed module loads. (JSC::moduleLoadTopSettled): Avoid registering failed module loads. * Source/JavaScriptCore/runtime/JSModuleLoader.cpp: (JSC::JSModuleLoader::loadModule): Remove failed modules. (JSC::JSModuleLoader::removeFailedFetchEntry): * Source/JavaScriptCore/runtime/JSModuleLoader.h: * LayoutTests/imported/w3c/web-platform-tests/html/semantics/scripting-1/the-script-element/module/instantiation-error-8-expected.txt: Added. Canonical link: https://commits.webkit.org/319474@main
https://bugs.webkit.org/show_bug.cgi?id=319492 https://commits.webkit.org/319474@main Reviewed by Keith Miller. This implements the spec changes from whatwg/html#10327 to avoid caching HTTP or network module import failures. * Source/JavaScriptCore/runtime/JSMicrotask.cpp: (JSC::moduleLoadTopSettled): Avoid registering failed module loads. (JSC::moduleLoadTopRejected): Avoid registering failed module loads. (JSC::moduleLoadStoreError): Avoid registering failed module loads. * Source/JavaScriptCore/runtime/JSModuleLoader.cpp: (JSC::JSModuleLoader::loadModule): Remove failed modules. (JSC::JSModuleLoader::removeFailedFetchEntry): * Source/JavaScriptCore/runtime/JSModuleLoader.h: (cherry picked from commit f324cca) Fork note: the upstream LayoutTests expectation files are not in this repository and are left out. The fork's else branch from #262, which recorded a non-Error fetch rejection as a fetch error in moduleLoadTopSettled, is removed along with upstream's Error branch: moduleLoadTopRejected no longer turns an unrecorded fetch rejection into a registered evaluation error, which is the hang #262 prevented, so a later importer of the key fetches it again instead. With that, the three functions are identical to upstream's.
…dashes A rejected load was cached forever, so one transient failure (exactly the tunnel case the lazy split serves) left math unformatted for the page's lifetime, and the hook started the promise with no rejection handler. The loader now rides a small asyncOnce utility: concurrent callers share one flight, a resolution is cached, and a rejection clears the slot so the next math-bearing mount or preload call retries. Current engines honor the retry with a real refetch (whatwg/html#10327 evicts failed fetches from the module map). Deliberately no same-mount retry, which would loop while offline. The utility exists so the semantics are testable with plain thunks: bun's mock.module evaluates factories eagerly and snapshots namespace values, so an import() rejection cannot be modeled by module mocks. Also removes the em dashes from the new comments per the repo comment rules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dent whatwg/html#10327 (merged 2026-07-15) evicts failed fetches from the module map so later imports retry; engines are mid-rollout, so the cached-failure model and the evict-and-refetch model both exist in the field. The comment now states the invariant that holds under both: boot-set hints name the same URLs the entry imports anyway, so they add no failure surface in either model. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ehavior Clearing the asyncOnce slot guarantees the next caller invokes import() again; whether that reaches the network is engine-dependent while whatwg/html#10327 rolls out. The comment now states the transitional contract instead of claiming current engines universally refetch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#41768) * perf(design-library): load KaTeX on demand instead of on the boot path MarkdownMessage statically imported rehype-katex and the KaTeX stylesheet, which pulls the katex renderer (~445 kB raw, ~85 kB gzip with its CSS) into every boot even though most messages carry no math. The plugin and stylesheet now load lazily, triggered by the loosest possible test: any dollar sign in the processed content. Every construct remark-math can treat as math contains one after convertLatexDelimiters rewrites the backslash forms, so a false positive costs a lazy chunk load while a false negative would leave real math unformatted forever; only the first is acceptable. Until the chunk lands, math shows as raw TeX and formats on the re-render; the module-level cache means a page pays this once. preloadMarkdownMath() warms the pipeline for synchronous renderers (renderToStaticMarkup runs no effects) and for surfaces that know math is coming. The tests warm it in a beforeAll. The css module declaration exists because a dynamic import() needs a module type where the old side-effect import did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(design-library): do not cache a failed KaTeX chunk load; drop em dashes A rejected load was cached forever, so one transient failure (exactly the tunnel case the lazy split serves) left math unformatted for the page's lifetime, and the hook started the promise with no rejection handler. The loader now rides a small asyncOnce utility: concurrent callers share one flight, a resolution is cached, and a rejection clears the slot so the next math-bearing mount or preload call retries. Current engines honor the retry with a real refetch (whatwg/html#10327 evicts failed fetches from the module map). Deliberately no same-mount retry, which would loop while offline. The utility exists so the semantics are testable with plain thunks: bun's mock.module evaluates factories eagerly and snapshots namespace values, so an import() rejection cannot be modeled by module mocks. Also removes the em dashes from the new comments per the repo comment rules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(design-library): separate the loader retry from engine refetch behavior Clearing the asyncOnce slot guarantees the next caller invokes import() again; whether that reaches the network is engine-dependent while whatwg/html#10327 rolls out. The comment now states the transitional contract instead of claiming current engines universally refetch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…e path (#41767) * perf(ingress): serve the boot graph's modulepreload hints on the phone path The ingress strips every modulepreload hint from the served index, so the phone cannot discover the four non-entry boot chunks until the entry chunk (623 kB gzip) has downloaded and its ~2.2 MB of JavaScript has parsed on phone-class CPU. Only then does the remaining 515 kB gzip begin to download: a two-wave boot over exactly the link where waves hurt most. The strip predates the boot-graph consolidation. It was written against a whole-chunk-graph index (~290 hints) whose request flood over the tunnel could drop an import and blank the app; today's index carries 4 hints, which sit within the browser's six-per-origin HTTP/1.1 pool and add no connections beyond what the entry's own imports open moments later. The strip is now gated instead of unconditional: hints are served up to MAX_PRESERVED_MODULE_PRELOADS (12) and stripped wholesale past it, so a build that regresses toward the pathological shape gets the old protective behavior. Verified against the production dist (4 in, 4 served; synthetic 294-hint index strips to 0). EDGE_TEMPLATE_VERSION bumps so detached edges are not reused on the old index fingerprint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(ingress): correct the failure-semantics comment on the preload gate A failed module fetch is cached in the module map and later imports return it without refetching, so "hints only" understated the semantics. The accurate invariant: boot-set hints add no failure surface because the entry's static imports fetch the same URLs through the same map, while the strip still guards the whole-graph shape where non-boot fetches amplify failure odds and poison later dynamic imports. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(ingress): state the module-map failure semantics as engine-dependent whatwg/html#10327 (merged 2026-07-15) evicts failed fetches from the module map so later imports retry; engines are mid-rollout, so the cached-failure model and the evict-and-refetch model both exist in the field. The comment now states the invariant that holds under both: boot-set hints name the same URLs the entry imports anyway, so they add no failure surface in either model. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Implements whatwg/html#10327 behind a flag to enable developers to retry failed module loads. It does so by removing module map entries from the map in case of a failure. The test changes delete failing expectations and fix test flakiness that resulted from a hardcoded UUID, by moving the tests to use a generated UUID (which requires .https serving for the test, and .sub serving for its resources). Bug: 534781954 Change-Id: I4b97894cfcfa6878077cd8a933ca54fdca0466e9 Binary-Size: +12B is the generated base::Feature for new runtime flag. Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8102202 Reviewed-by: Hiroshige Hayashizaki <hiroshige@chromium.org> Commit-Queue: Yoav Weiss (@Shopify) <yoavweiss@chromium.org> Cr-Commit-Position: refs/heads/main@{#1695318}
Implements whatwg/html#10327 behind a flag to enable developers to retry failed module loads. It does so by removing module map entries from the map in case of a failure. The test changes delete failing expectations and fix test flakiness that resulted from a hardcoded UUID, by moving the tests to use a generated UUID (which requires .https serving for the test, and .sub serving for its resources). Bug: 534781954 Change-Id: I4b97894cfcfa6878077cd8a933ca54fdca0466e9 Binary-Size: +12B is the generated base::Feature for new runtime flag. Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8102202 Reviewed-by: Hiroshige Hayashizaki <hiroshige@chromium.org> Commit-Queue: Yoav Weiss (@Shopify) <yoavweiss@chromium.org> Cr-Commit-Position: refs/heads/main@{#1695318}
Implements whatwg/html#10327 behind a flag to enable developers to retry failed module loads. It does so by removing module map entries from the map in case of a failure. The test changes delete failing expectations and fix test flakiness that resulted from a hardcoded UUID, by moving the tests to use a generated UUID (which requires .https serving for the test, and .sub serving for its resources). Bug: 534781954 Change-Id: I4b97894cfcfa6878077cd8a933ca54fdca0466e9 Binary-Size: +12B is the generated base::Feature for new runtime flag. Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8102202 Reviewed-by: Hiroshige Hayashizaki <hiroshige@chromium.org> Commit-Queue: Yoav Weiss (@Shopify) <yoavweiss@chromium.org> Cr-Commit-Position: refs/heads/main@{#1695318}
…stonly Automatic update from web-platform-tests Do not cache failed module imports Implements whatwg/html#10327 behind a flag to enable developers to retry failed module loads. It does so by removing module map entries from the map in case of a failure. The test changes delete failing expectations and fix test flakiness that resulted from a hardcoded UUID, by moving the tests to use a generated UUID (which requires .https serving for the test, and .sub serving for its resources). Bug: 534781954 Change-Id: I4b97894cfcfa6878077cd8a933ca54fdca0466e9 Binary-Size: +12B is the generated base::Feature for new runtime flag. Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8102202 Reviewed-by: Hiroshige Hayashizaki <hiroshige@chromium.org> Commit-Queue: Yoav Weiss (@Shopify) <yoavweiss@chromium.org> Cr-Commit-Position: refs/heads/main@{#1695318} -- wpt-commits: e4bdcd78c767c2437718e5964ffea6b0a035ccdd wpt-pr: 62592
Multiple parallel imports will still only fetch once, and if the fetch
fails, will both error. A subsequent import (dynamic import, a
dynamically inserted via
<script src="..." type="module">, or a staticimport from either of these) to the same specifier will result in a
re-fetch if the previous fetch failed.
Fixes #6768
(See WHATWG Working Mode: Changes for more details.)
/acknowledgements.html ( diff )
/webappapis.html ( diff )