[Fiber] track stylesheet preloads when explicitly preloaded (#36386)
Previously stylesheet resources would omit connecting with preloads inserted via `preload` which caused unecessary suspension of commits since the stylsheet resource would attempt to load the stylesheet again and delay the initial commit or commit a fallback (depending on whether the current screen should remain). This missing piece is that if you preload a stylesheet you must be able to use that sheets loading state when determining if the stylesheet is already loaded or not. This adds a pending indicator on client inserted prelaod links. We still assume SSR'd preloads are already loaded.
Josh Story committed
May 7, 2026 at 10:48 UTC
d5736f098edee62c44f27b053e6e48f5fa443803
3 files changed
+157
-22
packages/react-dom-bindings/src/client/ReactDOMComponentTree.js
+13
@@ -50,6 +50,7 @@ const internalEventHandlesSetKey = '__reactHandles$' + randomKey;
50
const internalRootNodeResourcesKey = '__reactResources$' + randomKey;
51
const internalHoistableMarker = '__reactMarker$' + randomKey;
52
const internalScrollTimer = '__reactScroll$' + randomKey;
53
+const internalLoadPendingKey = '__reactLoad$' + randomKey;
54
55
type InstanceUnion =
56
| Instance
@@ -386,6 +387,18 @@ export function clearScrollEndTimer(node: EventTarget): void {
387
(node: any)[internalScrollTimer] = undefined;
388
}
389
390
+export function markNodeAsPendingLoad(node: Node): void {
391
+ (node: any)[internalLoadPendingKey] = true;
392
+}
393
+
394
+export function clearPendingLoadOnNode(node: Node): void {
395
+ (node: any)[internalLoadPendingKey] = undefined;
396
+}
397
+
398
+export function isNodePendingLoad(node: Node): boolean {
399
+ return (node: any)[internalLoadPendingKey] === true;
400
+}
401
+
402
export function isOwnedInstance(node: Node): boolean {
403
if (enableInternalInstanceMap) {
404
return !!(
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+40
-22
@@ -56,6 +56,9 @@ import {
56
getResourcesFromRoot,
57
isMarkedHoistable,
58
markNodeAsHoistable,
59
+ markNodeAsPendingLoad,
60
+ clearPendingLoadOnNode,
61
+ isNodePendingLoad,
62
isOwnedInstance,
63
} from './ReactDOMComponentTree';
64
import {
@@ -5005,11 +5008,18 @@ function preload(href: string, as: string, options?: ?PreloadImplOptions) {
5008
as === 'script' &&
5009
ownerDocument.querySelector(getScriptSelectorFromKey(key))
5010
) {
5008
- // We already have a stylesheet for this key. We don't need to preload it.
5011
+ // We already have a script for this key. We don't need to preload it.
5012
return;
5013
}
5014
const instance = ownerDocument.createElement('link');
5015
setInitialProperties(instance, 'link', preloadProps);
5016
+ if (as === 'style') {
5017
+ // Stash a loading state on the preload link. it will clean itself up once settled
5018
+ markNodeAsPendingLoad(instance);
5019
+ instance.onload = instance.onerror = () => {
5020
+ clearPendingLoadOnNode(instance);
5021
+ };
5022
+ }
5023
markNodeAsHoistable(instance);
5024
(ownerDocument.head: any).appendChild(instance);
5025
}
@@ -5357,19 +5367,16 @@ export function getResource(
5367
resource.instance = instance;
5368
resource.state.loading = Loaded | Inserted;
5369
}
5360
- }
5361
-
5362
- if (!preloadPropsMap.has(key)) {
5363
- const preloadProps = preloadPropsFromStylesheet(qualifiedProps);
5364
- preloadPropsMap.set(key, preloadProps);
5365
- if (!instance) {
5366
- preloadStylesheet(
5367
- ownerDocument,
5368
- key,
5369
- preloadProps,
5370
- resource.state,
5371
- );
5370
+ } else {
5371
+ // We don't have an instance we need to preload it instead
5372
+ // $FlowFixMe[incompatible-type] -- the key we use here can only match non module preloads
5373
+ let preloadProps: void | PreloadProps = preloadPropsMap.get(key);
5374
+ if (!preloadProps) {
5375
+ preloadProps = preloadPropsFromStylesheet(qualifiedProps);
5376
+ preloadPropsMap.set(key, preloadProps);
5377
}
5378
+
5379
+ preloadStylesheet(ownerDocument, key, preloadProps, resource.state);
5380
}
5381
}
5382
if (currentProps && currentResource === null) {
@@ -5540,22 +5547,33 @@ function preloadStylesheet(
5547
preloadProps: PreloadProps,
5548
state: StylesheetState,
5549
) {
5543
- const preloadEl = ownerDocument.querySelector(
5550
+ let instance = ownerDocument.querySelector(
5551
getPreloadStylesheetSelectorFromKey(key),
5552
);
5546
- if (preloadEl) {
5547
- // If we find a preload already it was SSR'd and we won't have an actual
5548
- // loading state to track. For now we will just assume it is loaded
5549
- state.loading = Loaded;
5553
+ if (instance) {
5554
+ if (!isNodePendingLoad(instance)) {
5555
+ // If we find a preload already it was SSR'd and we won't have an actual
5556
+ // loading state to track. For now we will just assume it is loaded
5557
+ state.loading = Loaded;
5558
+ return;
5559
+ } else {
5560
+ // fall through and attach loading listeners
5561
+ }
5562
} else {
5551
- const instance = ownerDocument.createElement('link');
5552
- state.preload = instance;
5553
- instance.addEventListener('load', () => (state.loading |= Loaded));
5554
- instance.addEventListener('error', () => (state.loading |= Errored));
5563
+ instance = ownerDocument.createElement('link');
5564
+ markNodeAsPendingLoad(instance);
5565
+ instance.onload = instance.onerror = clearPendingLoadOnNode.bind(
5566
+ null,
5567
+ instance,
5568
+ );
5569
setInitialProperties(instance, 'link', preloadProps);
5570
markNodeAsHoistable(instance);
5571
(ownerDocument.head: any).appendChild(instance);
5572
}
5573
+ // $FlowFixMe: [incompatible-type] -- if instance is an Element it will also be an HTMLLinkElement
5574
+ state.preload = instance;
5575
+ instance.addEventListener('load', () => (state.loading |= Loaded));
5576
+ instance.addEventListener('error', () => (state.loading |= Errored));
5577
}
5578
5579
function preloadPropsFromStylesheet(
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+104
@@ -3674,6 +3674,110 @@ body {
3674
);
3675
});
3676
3677
+ it('does not suspend a transition on a stylesheet whose preload has already loaded', async () => {
3678
+ const root = ReactDOMClient.createRoot(document);
3679
+ root.render(
3680
+ <html>
3681
+ <body>
3682
+ <Suspense fallback="loading...">initial</Suspense>
3683
+ </body>
3684
+ </html>,
3685
+ );
3686
+ await waitForAll([]);
3687
+
3688
+ ReactDOM.preload('route.css', {as: 'style'});
3689
+ expect(getMeaningfulChildren(document.head)).toEqual(
3690
+ <link rel="preload" href="route.css" as="style" />,
3691
+ );
3692
+ expect(getMeaningfulChildren(document.body)).toEqual('initial');
3693
+
3694
+ loadPreloads(['route.css']);
3695
+ assertLog(['load preload: route.css']);
3696
+
3697
+ React.startTransition(() => {
3698
+ root.render(
3699
+ <html>
3700
+ <body>
3701
+ <Suspense fallback="loading...">
3702
+ <link rel="stylesheet" href="route.css" precedence="default" />
3703
+ next
3704
+ </Suspense>
3705
+ </body>
3706
+ </html>,
3707
+ );
3708
+ });
3709
+ await waitForAll([]);
3710
+
3711
+ expect(getMeaningfulChildren(document.head)).toEqual([
3712
+ <link rel="stylesheet" href="route.css" data-precedence="default" />,
3713
+ <link rel="preload" href="route.css" as="style" />,
3714
+ ]);
3715
+ expect(getMeaningfulChildren(document.body)).toEqual('next');
3716
+
3717
+ loadStylesheets(['route.css']);
3718
+ assertLog(['load stylesheet: route.css']);
3719
+ expect(getMeaningfulChildren(document.head)).toEqual([
3720
+ <link rel="stylesheet" href="route.css" data-precedence="default" />,
3721
+ <link rel="preload" href="route.css" as="style" />,
3722
+ ]);
3723
+ expect(getMeaningfulChildren(document.body)).toEqual('next');
3724
+ });
3725
+
3726
+ it('suspends a transition on a stylesheet whose preload has not loaded yet', async () => {
3727
+ const root = ReactDOMClient.createRoot(document);
3728
+ root.render(
3729
+ <html>
3730
+ <body>
3731
+ <Suspense fallback="loading...">initial</Suspense>
3732
+ </body>
3733
+ </html>,
3734
+ );
3735
+ await waitForAll([]);
3736
+
3737
+ ReactDOM.preload('route.css', {as: 'style'});
3738
+ expect(getMeaningfulChildren(document.head)).toEqual(
3739
+ <link rel="preload" href="route.css" as="style" />,
3740
+ );
3741
+ expect(getMeaningfulChildren(document.body)).toEqual('initial');
3742
+
3743
+ React.startTransition(() => {
3744
+ root.render(
3745
+ <html>
3746
+ <body>
3747
+ <Suspense fallback="loading...">
3748
+ <link rel="stylesheet" href="route.css" precedence="default" />
3749
+ next
3750
+ </Suspense>
3751
+ </body>
3752
+ </html>,
3753
+ );
3754
+ });
3755
+ await waitForAll([]);
3756
+
3757
+ expect(getMeaningfulChildren(document.head)).toEqual(
3758
+ <link rel="preload" href="route.css" as="style" />,
3759
+ );
3760
+ expect(getMeaningfulChildren(document.body)).toEqual('initial');
3761
+
3762
+ loadPreloads(['route.css']);
3763
+ assertLog(['load preload: route.css']);
3764
+ await waitForAll([]);
3765
+ expect(getMeaningfulChildren(document.head)).toEqual([
3766
+ <link rel="stylesheet" href="route.css" data-precedence="default" />,
3767
+ <link rel="preload" href="route.css" as="style" />,
3768
+ ]);
3769
+ expect(getMeaningfulChildren(document.body)).toEqual('initial');
3770
+
3771
+ loadStylesheets(['route.css']);
3772
+ assertLog(['load stylesheet: route.css']);
3773
+ await waitForAll([]);
3774
+ expect(getMeaningfulChildren(document.head)).toEqual([
3775
+ <link rel="stylesheet" href="route.css" data-precedence="default" />,
3776
+ <link rel="preload" href="route.css" as="style" />,
3777
+ ]);
3778
+ expect(getMeaningfulChildren(document.body)).toEqual('next');
3779
+ });
3780
+
3781
it('can suspend commits on more than one root for the same resource at the same time', async () => {
3782
document.body.innerHTML = '';
3783
const container1 = document.createElement('div');