Skip to content

Fix getResource short-circuit leaving stylesheet state.loading=NotLoaded after ReactDOM.preload#36358

Draft
gaojude wants to merge 1 commit intofacebook:mainfrom
gaojude:getresource-preloadpropsmap-shortcircuit-bug
Draft

Fix getResource short-circuit leaving stylesheet state.loading=NotLoaded after ReactDOM.preload#36358
gaojude wants to merge 1 commit intofacebook:mainfrom
gaojude:getresource-preloadpropsmap-shortcircuit-bug

Conversation

@gaojude
Copy link
Copy Markdown

@gaojude gaojude commented Apr 27, 2026

Draft / WIP. Filing this as a draft to start a conversation; happy to add a regression test or refactor based on feedback.

Summary

When ReactDOM.preload(href, { as: 'style' }) runs before a fresh <link rel=\"stylesheet\" href={href} precedence> mounts inside a new <Suspense> boundary, getResource short-circuits on preloadPropsMap.has(key), skips the preloadStylesheet call, and leaves state.loading at NotLoaded. completeWork then throws SuspenseyCommitException and the boundary paints its fallback even though the preload bytes are already in the browser's cache.

Trace

In packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js, the stylesheet branch of getResource looked like this before the fix:

if (!preloadPropsMap.has(key)) {
  const preloadProps = preloadPropsFromStylesheet(qualifiedProps);
  preloadPropsMap.set(key, preloadProps);
  if (!instance) {
    preloadStylesheet(ownerDocument, key, preloadProps, resource.state);
  }
}

The preloadStylesheet call is the only path that queries the existing <link rel=\"preload\" as=\"style\"> and flips state.loading to Loaded. It is gated behind !preloadPropsMap.has(key), but preloadPropsMap is populated unconditionally by ReactDOM.preload(...) (the L handler). So the sequence

  1. ReactDOM.preload(href, { as: 'style' }) — populates preloadPropsMap, inserts <link rel=\"preload\" as=\"style\">
  2. Render reaches <link rel=\"stylesheet\" href={href} precedence> inside a fresh <Suspense> (= shellBoundary)
  3. getResource creates a fresh resource with state.loading = NotLoaded
  4. Stylesheet querySelector misses (no <link rel=\"stylesheet\"> in the document yet)
  5. preloadPropsMap.has(key) === truepreloadStylesheet not called → state.loading stays at NotLoaded
  6. completeWorkpreloadResourceAndSuspendIfNeeded → throws SuspenseyCommitException
  7. The Suspense fallback paints (visible empty shell)

Fix

Decouple the preloadStylesheet call from the preloadPropsMap.has(key) gate. Reuse the stored props when the map already has an entry so we still query for the matching <link rel=\"preload\"> and update state.loading accordingly:

let preloadProps: PreloadProps;
const existingPreloadProps = preloadPropsMap.get(key);
if (existingPreloadProps) {
  preloadProps = (existingPreloadProps: any);
} else {
  preloadProps = preloadPropsFromStylesheet(qualifiedProps);
  preloadPropsMap.set(key, preloadProps);
}
if (!instance) {
  preloadStylesheet(ownerDocument, key, preloadProps, resource.state);
}

Repro

A minimal Next.js repro lives at vercel/next.js#93296. The fixture:

  • 'use client' page that calls ReactDOM.preload(href, { as: 'style' }) for 3 hrefs in useEffect
  • A <Link> to /logs whose layout has <Suspense> and whose page is a client component rendering <link rel=\"stylesheet\" href={href} precedence> for the same hrefs

The Playwright test asserts the fallback IS painted (bug fires) and a SuspenseComponent fiber catches SuspenseyCommitException (flags & 16384 set, updateQueue empty). With this fix applied, the boundary commits with content instead.

Notes / open questions

  • I haven't added a unit test in ReactDOMFloat-test.js — the existing tests for this path are SSR + hydrate flows, and reproducing the client-only "preload-before-mount" sequence cleanly looks invasive. Happy to add one if there's a preferred shape.
  • The cast (existingPreloadProps: any) is because preloadPropsMap stores PreloadProps | PreloadModuleProps. Open to a narrower type if preferred.

…oaded after ReactDOM.preload

When ReactDOM.preload(href, { as: 'style' }) ran before a fresh
<link rel="stylesheet" href={href} precedence> mounted inside a new
Suspense boundary, getResource would short-circuit on
preloadPropsMap.has(key), skip the preloadStylesheet call, and leave
state.loading at NotLoaded. completeWork would then throw a
SuspenseyCommitException and the boundary would paint its fallback
even though the preload bytes were already in cache.

Decouple the preloadStylesheet call from the preloadPropsMap.has()
gate. Reuse the stored props when the map already has an entry so we
still query for the matching <link rel="preload"> and update
state.loading accordingly.
@meta-cla
Copy link
Copy Markdown

meta-cla Bot commented Apr 27, 2026

Hi @gaojude!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

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