[Fizz] Gate rel="expect" behind enableFizzBlockingRender (#33183)

Enabled in experimental channel.

We know this is critical semantics to enforce at the HTML level since if
you don't then you can't add explicit boundaries after the fact.
However, this might have to go in a major release to allow for
upgrading.
This commit is contained in:
Sebastian Markbåge 2025-05-13 10:17:53 -04:00 committed by GitHub
parent 2bcf06b692
commit b94603b955
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 194 additions and 55 deletions

View File

@ -34,6 +34,7 @@ import {Children} from 'react';
import {
enableFizzExternalRuntime,
enableSrcObject,
enableFizzBlockingRender,
} from 'shared/ReactFeatureFlags';
import type {
@ -4146,18 +4147,23 @@ export function writeCompletedRoot(
// we need to track the paint time of the shell so we know how much to throttle the reveal.
writeShellTimeInstruction(destination, resumableState, renderState);
}
if (enableFizzBlockingRender) {
const preamble = renderState.preamble;
if (preamble.htmlChunks || preamble.headChunks) {
// If we rendered the whole document, then we emitted a rel="expect" that needs a
// matching target. Normally we use one of the bootstrap scripts for this but if
// there are none, then we need to emit a tag to complete the shell.
if ((resumableState.instructions & SentCompletedShellId) === NothingSent) {
if (
(resumableState.instructions & SentCompletedShellId) ===
NothingSent
) {
writeChunk(destination, startChunkForTag('template'));
writeCompletedShellIdAttribute(destination, resumableState);
writeChunk(destination, endOfStartTag);
writeChunk(destination, endChunkForTag('template'));
}
}
}
return writeBootstrap(destination, renderState);
}
@ -5040,12 +5046,14 @@ function writeBlockingRenderInstruction(
resumableState: ResumableState,
renderState: RenderState,
): void {
if (enableFizzBlockingRender) {
const idPrefix = resumableState.idPrefix;
const shellId = '\u00AB' + idPrefix + 'R\u00BB';
writeChunk(destination, blockingRenderChunkStart);
writeChunk(destination, stringToChunk(escapeTextForBrowser(shellId)));
writeChunk(destination, blockingRenderChunkEnd);
}
}
const completedShellIdAttributeStart = stringToPrecomputedChunk(' id="');

View File

@ -3590,7 +3590,9 @@ describe('ReactDOMFizzServer', () => {
(gate(flags => flags.shouldUseFizzExternalRuntime)
? '<script src="react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js" async=""></script>'
: '') +
'<link rel="expect" href="#«R»" blocking="render">',
(gate(flags => flags.enableFizzBlockingRender)
? '<link rel="expect" href="#«R»" blocking="render">'
: ''),
);
});
@ -4523,7 +4525,15 @@ describe('ReactDOMFizzServer', () => {
// the html should be as-is
expect(document.documentElement.innerHTML).toEqual(
'<head><script src="react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js" async=""></script><link rel="expect" href="#«R»" blocking="render"></head><body><p>hello world!</p><template id="«R»"></template></body>',
'<head><script src="react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js" async=""></script>' +
(gate(flags => flags.enableFizzBlockingRender)
? '<link rel="expect" href="#«R»" blocking="render">'
: '') +
'</head><body><p>hello world!</p>' +
(gate(flags => flags.enableFizzBlockingRender)
? '<template id="«R»"></template>'
: '') +
'</body>',
);
});
@ -6512,7 +6522,14 @@ describe('ReactDOMFizzServer', () => {
(gate(flags => flags.shouldUseFizzExternalRuntime)
? '<script src="react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js" async=""></script>'
: '') +
'<link rel="expect" href="#«R»" blocking="render"></head><body><script>try { foo() } catch (e) {} ;</script><template id="«R»"></template></body></html>',
(gate(flags => flags.enableFizzBlockingRender)
? '<link rel="expect" href="#«R»" blocking="render">'
: '') +
'</head><body><script>try { foo() } catch (e) {} ;</script>' +
(gate(flags => flags.enableFizzBlockingRender)
? '<template id="«R»"></template>'
: '') +
'</body></html>',
);
});

View File

@ -84,9 +84,15 @@ describe('ReactDOMFizzServerBrowser', () => {
),
);
const result = await readResult(stream);
if (gate(flags => flags.enableFizzBlockingRender)) {
expect(result).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
);
} else {
expect(result).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
);
}
});
it('should emit bootstrap script src at the end', async () => {
@ -529,7 +535,15 @@ describe('ReactDOMFizzServerBrowser', () => {
const result = await readResult(stream);
expect(result).toEqual(
'<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/><title>foo</title></head><body>bar<template id="«R»"></template></body></html>',
'<!DOCTYPE html><html><head>' +
(gate(flags => flags.enableFizzBlockingRender)
? '<link rel="expect" href="#«R»" blocking="render"/>'
: '') +
'<title>foo</title></head><body>bar' +
(gate(flags => flags.enableFizzBlockingRender)
? '<template id="«R»"></template>'
: '') +
'</body></html>',
);
});

View File

@ -71,8 +71,14 @@ describe('ReactDOMFizzServerEdge', () => {
setTimeout(resolve, 1);
});
if (gate(flags => flags.enableFizzBlockingRender)) {
expect(result).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body><main>hello</main><template id="«R»"></template></body></html>"`,
);
} else {
expect(result).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><head></head><body><main>hello</main></body></html>"`,
);
}
});
});

View File

@ -78,9 +78,15 @@ describe('ReactDOMFizzServerNode', () => {
pipe(writable);
});
// with Float, we emit empty heads if they are elided when rendering <html>
if (gate(flags => flags.enableFizzBlockingRender)) {
expect(output.result).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
);
} else {
expect(output.result).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
);
}
});
it('should emit bootstrap script src at the end', async () => {

View File

@ -195,9 +195,15 @@ describe('ReactDOMFizzStaticBrowser', () => {
),
);
const prelude = await readContent(result.prelude);
if (gate(flags => flags.enableFizzBlockingRender)) {
expect(prelude).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
);
} else {
expect(prelude).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
);
}
});
it('should emit bootstrap script src at the end', async () => {
@ -1438,8 +1444,15 @@ describe('ReactDOMFizzStaticBrowser', () => {
expect(await readContent(content)).toBe(
'<!DOCTYPE html><html lang="en"><head>' +
'<link rel="stylesheet" href="my-style" data-precedence="high"/>' +
'<link rel="expect" href="#«R»" blocking="render"/></head>' +
'<body>Hello<template id="«R»"></template></body></html>',
(gate(flags => flags.enableFizzBlockingRender)
? '<link rel="expect" href="#«R»" blocking="render"/>'
: '') +
'</head>' +
'<body>Hello' +
(gate(flags => flags.enableFizzBlockingRender)
? '<template id="«R»"></template>'
: '') +
'</body></html>',
);
});

View File

@ -63,9 +63,15 @@ describe('ReactDOMFizzStaticNode', () => {
</html>,
);
const prelude = await readContent(result.prelude);
if (gate(flags => flags.enableFizzBlockingRender)) {
expect(prelude).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
);
} else {
expect(prelude).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
);
}
});
// @gate experimental

View File

@ -704,8 +704,14 @@ describe('ReactDOMFloat', () => {
(gate(flags => flags.shouldUseFizzExternalRuntime)
? '<script src="react-dom/unstable_server-external-runtime" async=""></script>'
: '') +
'<link rel="expect" href="#«R»" blocking="render"/><title>foo</title></head>' +
'<body>bar<template id="«R»"></template>',
(gate(flags => flags.enableFizzBlockingRender)
? '<link rel="expect" href="#«R»" blocking="render"/>'
: '') +
'<title>foo</title></head>' +
'<body>bar' +
(gate(flags => flags.enableFizzBlockingRender)
? '<template id="«R»"></template>'
: ''),
'</body></html>',
]);
});

View File

@ -34,8 +34,15 @@ describe('ReactDOMFloat', () => {
);
expect(result).toEqual(
'<html><head><meta charSet="utf-8"/><link rel="expect" href="#«R»" blocking="render"/>' +
'<title>title</title><script src="foo"></script></head><template id="«R»"></template></html>',
'<html><head><meta charSet="utf-8"/>' +
(gate(flags => flags.enableFizzBlockingRender)
? '<link rel="expect" href="#«R»" blocking="render"/>'
: '') +
'<title>title</title><script src="foo"></script></head>' +
(gate(flags => flags.enableFizzBlockingRender)
? '<template id="«R»"></template>'
: '') +
'</html>',
);
});
});

View File

@ -78,14 +78,20 @@ describe('rendering React components at document', () => {
root = ReactDOMClient.hydrateRoot(testDocument, <Root hello="world" />);
});
expect(testDocument.body.innerHTML).toBe(
'Hello world' + '<template id="«R»"></template>',
'Hello world' +
(gate(flags => flags.enableFizzBlockingRender)
? '<template id="«R»"></template>'
: ''),
);
await act(() => {
root.render(<Root hello="moon" />);
});
expect(testDocument.body.innerHTML).toBe(
'Hello moon' + '<template id="«R»"></template>',
'Hello moon' +
(gate(flags => flags.enableFizzBlockingRender)
? '<template id="«R»"></template>'
: ''),
);
expect(body === testDocument.body).toBe(true);
@ -112,7 +118,10 @@ describe('rendering React components at document', () => {
root = ReactDOMClient.hydrateRoot(testDocument, <Root />);
});
expect(testDocument.body.innerHTML).toBe(
'Hello world' + '<template id="«R»"></template>',
'Hello world' +
(gate(flags => flags.enableFizzBlockingRender)
? '<template id="«R»"></template>'
: ''),
);
const originalDocEl = testDocument.documentElement;
@ -124,9 +133,15 @@ describe('rendering React components at document', () => {
expect(testDocument.firstChild).toBe(originalDocEl);
expect(testDocument.head).toBe(originalHead);
expect(testDocument.body).toBe(originalBody);
expect(originalBody.innerHTML).toBe('<template id="«R»"></template>');
expect(originalBody.innerHTML).toBe(
gate(flags => flags.enableFizzBlockingRender)
? '<template id="«R»"></template>'
: '',
);
expect(originalHead.innerHTML).toBe(
'<link rel="expect" href="#«R»" blocking="render">',
gate(flags => flags.enableFizzBlockingRender)
? '<link rel="expect" href="#«R»" blocking="render">'
: '',
);
});
@ -166,7 +181,10 @@ describe('rendering React components at document', () => {
});
expect(testDocument.body.innerHTML).toBe(
'Hello world' + '<template id="«R»"></template>',
'Hello world' +
(gate(flags => flags.enableFizzBlockingRender)
? '<template id="«R»"></template>'
: ''),
);
await act(() => {
@ -174,7 +192,9 @@ describe('rendering React components at document', () => {
});
expect(testDocument.body.innerHTML).toBe(
'<template id="«R»"></template>' + 'Goodbye world',
(gate(flags => flags.enableFizzBlockingRender)
? '<template id="«R»"></template>'
: '') + 'Goodbye world',
);
});
@ -205,7 +225,10 @@ describe('rendering React components at document', () => {
});
expect(testDocument.body.innerHTML).toBe(
'Hello world' + '<template id="«R»"></template>',
'Hello world' +
(gate(flags => flags.enableFizzBlockingRender)
? '<template id="«R»"></template>'
: ''),
);
});
@ -341,7 +364,10 @@ describe('rendering React components at document', () => {
expect(testDocument.body.innerHTML).toBe(
favorSafetyOverHydrationPerf
? 'Hello world'
: 'Goodbye world<template id="«R»"></template>',
: 'Goodbye world' +
(gate(flags => flags.enableFizzBlockingRender)
? '<template id="«R»"></template>'
: ''),
);
});

View File

@ -1921,14 +1921,28 @@ describe('ReactFlightDOM', () => {
expect(content1).toEqual(
'<!DOCTYPE html><html><head><link rel="preload" href="before1" as="style"/>' +
'<link rel="preload" href="after1" as="style"/>' +
'<link rel="expect" href="#«R»" blocking="render"/></head>' +
'<body><p>hello world</p><template id="«R»"></template></body></html>',
(gate(flags => flags.enableFizzBlockingRender)
? '<link rel="expect" href="#«R»" blocking="render"/>'
: '') +
'</head>' +
'<body><p>hello world</p>' +
(gate(flags => flags.enableFizzBlockingRender)
? '<template id="«R»"></template>'
: '') +
'</body></html>',
);
expect(content2).toEqual(
'<!DOCTYPE html><html><head><link rel="preload" href="before2" as="style"/>' +
'<link rel="preload" href="after2" as="style"/>' +
'<link rel="expect" href="#«R»" blocking="render"/></head>' +
'<body><p>hello world</p><template id="«R»"></template></body></html>',
(gate(flags => flags.enableFizzBlockingRender)
? '<link rel="expect" href="#«R»" blocking="render"/>'
: '') +
'</head>' +
'<body><p>hello world</p>' +
(gate(flags => flags.enableFizzBlockingRender)
? '<template id="«R»"></template>'
: '') +
'</body></html>',
);
});

View File

@ -1899,8 +1899,16 @@ describe('ReactFlightDOMBrowser', () => {
}
expect(content).toEqual(
'<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head>' +
'<body><p>hello world</p><template id="«R»"></template></body></html>',
'<!DOCTYPE html><html><head>' +
(gate(flags => flags.enableFizzBlockingRender)
? '<link rel="expect" href="#«R»" blocking="render"/>'
: '') +
'</head>' +
'<body><p>hello world</p>' +
(gate(flags => flags.enableFizzBlockingRender)
? '<template id="«R»"></template>'
: '') +
'</body></html>',
);
});

View File

@ -98,6 +98,8 @@ export const enableScrollEndPolyfill = __EXPERIMENTAL__;
export const enableSuspenseyImages = false;
export const enableFizzBlockingRender = __EXPERIMENTAL__; // rel="expect"
export const enableSrcObject = __EXPERIMENTAL__;
export const enableHydrationChangeEvent = __EXPERIMENTAL__;

View File

@ -83,6 +83,7 @@ export const enableViewTransition = false;
export const enableGestureTransition = false;
export const enableScrollEndPolyfill = true;
export const enableSuspenseyImages = false;
export const enableFizzBlockingRender = true;
export const enableSrcObject = false;
export const enableHydrationChangeEvent = true;
export const enableDefaultTransitionIndicator = false;

View File

@ -73,6 +73,7 @@ export const enableFastAddPropertiesInDiffing = false;
export const enableLazyPublicInstanceInFabric = false;
export const enableScrollEndPolyfill = true;
export const enableSuspenseyImages = false;
export const enableFizzBlockingRender = true;
export const enableSrcObject = false;
export const enableHydrationChangeEvent = false;
export const enableDefaultTransitionIndicator = false;

View File

@ -73,6 +73,7 @@ export const enableFastAddPropertiesInDiffing = true;
export const enableLazyPublicInstanceInFabric = false;
export const enableScrollEndPolyfill = true;
export const enableSuspenseyImages = false;
export const enableFizzBlockingRender = true;
export const enableSrcObject = false;
export const enableHydrationChangeEvent = false;
export const enableDefaultTransitionIndicator = false;

View File

@ -70,6 +70,7 @@ export const enableFastAddPropertiesInDiffing = false;
export const enableLazyPublicInstanceInFabric = false;
export const enableScrollEndPolyfill = true;
export const enableSuspenseyImages = false;
export const enableFizzBlockingRender = true;
export const enableSrcObject = false;
export const enableHydrationChangeEvent = false;
export const enableDefaultTransitionIndicator = false;

View File

@ -84,6 +84,7 @@ export const enableFastAddPropertiesInDiffing = false;
export const enableLazyPublicInstanceInFabric = false;
export const enableScrollEndPolyfill = true;
export const enableSuspenseyImages = false;
export const enableFizzBlockingRender = true;
export const enableSrcObject = false;
export const enableHydrationChangeEvent = false;
export const enableDefaultTransitionIndicator = false;

View File

@ -114,6 +114,7 @@ export const enableLazyPublicInstanceInFabric = false;
export const enableGestureTransition = false;
export const enableSuspenseyImages = false;
export const enableFizzBlockingRender = true;
export const enableSrcObject = false;
export const enableHydrationChangeEvent = false;
export const enableDefaultTransitionIndicator = false;