[Float] handle noscript context for Resources (#25559)

stacked on https://github.com/facebook/react/pull/25569

On the client noscript already never renders children so no resources
will be extracted from this context. On the server we now track if we
are in a noscript context and turn off Resource semantics in this scope
This commit is contained in:
Josh Story
2022-10-26 23:11:31 -07:00
committed by GitHub
parent 17204056d5
commit 09def5990b
4 changed files with 287 additions and 20 deletions

View File

@@ -863,6 +863,10 @@ export function resourcesFromLink(props: Props): boolean {
}
}
if (props.onLoad || props.onError) {
// When a link has these props we can't treat it is a Resource but if we rendered it on the
// server it would look like a Resource in the rendered html (the onLoad/onError aren't emitted)
// Instead we expect the client to insert them rather than hydrate them which also guarantees
// that the onLoad and onError won't fire before the event handlers are attached
return true;
}

View File

@@ -274,15 +274,18 @@ type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
export type FormatContext = {
insertionMode: InsertionMode, // root/svg/html/mathml/table
selectedValue: null | string | Array<string>, // the selected value(s) inside a <select>, or null outside <select>
noscriptTagInScope: boolean,
};
function createFormatContext(
insertionMode: InsertionMode,
selectedValue: null | string,
noscriptTagInScope: boolean,
): FormatContext {
return {
insertionMode,
selectedValue,
noscriptTagInScope,
};
}
@@ -293,7 +296,7 @@ export function createRootFormatContext(namespaceURI?: string): FormatContext {
: namespaceURI === 'http://www.w3.org/1998/Math/MathML'
? MATHML_MODE
: ROOT_HTML_MODE;
return createFormatContext(insertionMode, null);
return createFormatContext(insertionMode, null, false);
}
export function getChildFormatContext(
@@ -302,38 +305,77 @@ export function getChildFormatContext(
props: Object,
): FormatContext {
switch (type) {
case 'noscript':
return createFormatContext(HTML_MODE, null, true);
case 'select':
return createFormatContext(
HTML_MODE,
props.value != null ? props.value : props.defaultValue,
parentContext.noscriptTagInScope,
);
case 'svg':
return createFormatContext(SVG_MODE, null);
return createFormatContext(
SVG_MODE,
null,
parentContext.noscriptTagInScope,
);
case 'math':
return createFormatContext(MATHML_MODE, null);
return createFormatContext(
MATHML_MODE,
null,
parentContext.noscriptTagInScope,
);
case 'foreignObject':
return createFormatContext(HTML_MODE, null);
return createFormatContext(
HTML_MODE,
null,
parentContext.noscriptTagInScope,
);
// Table parents are special in that their children can only be created at all if they're
// wrapped in a table parent. So we need to encode that we're entering this mode.
case 'table':
return createFormatContext(HTML_TABLE_MODE, null);
return createFormatContext(
HTML_TABLE_MODE,
null,
parentContext.noscriptTagInScope,
);
case 'thead':
case 'tbody':
case 'tfoot':
return createFormatContext(HTML_TABLE_BODY_MODE, null);
return createFormatContext(
HTML_TABLE_BODY_MODE,
null,
parentContext.noscriptTagInScope,
);
case 'colgroup':
return createFormatContext(HTML_COLGROUP_MODE, null);
return createFormatContext(
HTML_COLGROUP_MODE,
null,
parentContext.noscriptTagInScope,
);
case 'tr':
return createFormatContext(HTML_TABLE_ROW_MODE, null);
return createFormatContext(
HTML_TABLE_ROW_MODE,
null,
parentContext.noscriptTagInScope,
);
}
if (parentContext.insertionMode >= HTML_TABLE_MODE) {
// Whatever tag this was, it wasn't a table parent or other special parent, so we must have
// entered plain HTML again.
return createFormatContext(HTML_MODE, null);
return createFormatContext(
HTML_MODE,
null,
parentContext.noscriptTagInScope,
);
}
if (parentContext.insertionMode === ROOT_HTML_MODE) {
// We've emitted the root and is now in plain HTML mode.
return createFormatContext(HTML_MODE, null);
return createFormatContext(
HTML_MODE,
null,
parentContext.noscriptTagInScope,
);
}
return parentContext;
}
@@ -1155,8 +1197,13 @@ function pushBase(
props: Object,
responseState: ResponseState,
textEmbedded: boolean,
noscriptTagInScope: boolean,
): ReactNodeList {
if (enableFloat && resourcesFromElement('base', props)) {
if (
enableFloat &&
!noscriptTagInScope &&
resourcesFromElement('base', props)
) {
if (textEmbedded) {
// This link follows text but we aren't writing a tag. while not as efficient as possible we need
// to be safe and assume text will follow by inserting a textSeparator
@@ -1175,8 +1222,13 @@ function pushMeta(
props: Object,
responseState: ResponseState,
textEmbedded: boolean,
noscriptTagInScope: boolean,
): ReactNodeList {
if (enableFloat && resourcesFromElement('meta', props)) {
if (
enableFloat &&
!noscriptTagInScope &&
resourcesFromElement('meta', props)
) {
if (textEmbedded) {
// This link follows text but we aren't writing a tag. while not as efficient as possible we need
// to be safe and assume text will follow by inserting a textSeparator
@@ -1195,8 +1247,9 @@ function pushLink(
props: Object,
responseState: ResponseState,
textEmbedded: boolean,
noscriptTagInScope: boolean,
): ReactNodeList {
if (enableFloat && resourcesFromLink(props)) {
if (enableFloat && !noscriptTagInScope && resourcesFromLink(props)) {
if (textEmbedded) {
// This link follows text but we aren't writing a tag. while not as efficient as possible we need
// to be safe and assume text will follow by inserting a textSeparator
@@ -1318,6 +1371,7 @@ function pushTitle(
target: Array<Chunk | PrecomputedChunk>,
props: Object,
responseState: ResponseState,
noscriptTagInScope: boolean,
): ReactNodeList {
if (__DEV__) {
const children = props.children;
@@ -1359,7 +1413,11 @@ function pushTitle(
}
}
if (enableFloat && resourcesFromElement('title', props)) {
if (
enableFloat &&
!noscriptTagInScope &&
resourcesFromElement('title', props)
) {
// We have converted this link exclusively to a resource and no longer
// need to emit it
return null;
@@ -1520,8 +1578,9 @@ function pushScript(
props: Object,
responseState: ResponseState,
textEmbedded: boolean,
noscriptTagInScope: boolean,
): null {
if (enableFloat && resourcesFromScript(props)) {
if (enableFloat && !noscriptTagInScope && resourcesFromScript(props)) {
if (textEmbedded) {
// This link follows text but we aren't writing a tag. while not as efficient as possible we need
// to be safe and assume text will follow by inserting a textSeparator
@@ -1863,18 +1922,47 @@ export function pushStartInstance(
return pushStartMenuItem(target, props, responseState);
case 'title':
return enableFloat
? pushTitle(target, props, responseState)
? pushTitle(
target,
props,
responseState,
formatContext.noscriptTagInScope,
)
: pushStartTitle(target, props, responseState);
case 'link':
return pushLink(target, props, responseState, textEmbedded);
return pushLink(
target,
props,
responseState,
textEmbedded,
formatContext.noscriptTagInScope,
);
case 'script':
return enableFloat
? pushScript(target, props, responseState, textEmbedded)
? pushScript(
target,
props,
responseState,
textEmbedded,
formatContext.noscriptTagInScope,
)
: pushStartGenericElement(target, props, type, responseState);
case 'meta':
return pushMeta(target, props, responseState, textEmbedded);
return pushMeta(
target,
props,
responseState,
textEmbedded,
formatContext.noscriptTagInScope,
);
case 'base':
return pushBase(target, props, responseState, textEmbedded);
return pushBase(
target,
props,
responseState,
textEmbedded,
formatContext.noscriptTagInScope,
);
// Newline eating tags
case 'listing':
case 'pre': {

View File

@@ -72,6 +72,7 @@ export function createRootFormatContext(): FormatContext {
return {
insertionMode: HTML_MODE, // We skip the root mode because we don't want to emit the DOCTYPE in legacy mode.
selectedValue: null,
noscriptTagInScope: false,
};
}

View File

@@ -290,6 +290,9 @@ describe('ReactDOMFloat', () => {
<meta property="foo" content="bar" />
<link rel="foo" href="bar" onLoad={() => {}} />
<title>foo</title>
<noscript>
<link rel="icon" href="icon" />
</noscript>
<base target="foo" href="bar" />
<script async={true} src="foo" onLoad={() => {}} />
</head>
@@ -305,6 +308,7 @@ describe('ReactDOMFloat', () => {
<link rel="preload" href="foo" as="script" />
<meta property="foo" content="bar" />
<title>foo</title>
<noscript>&lt;link rel="icon" href="icon"/&gt;</noscript>
</head>
<body>foo</body>
</html>,
@@ -317,6 +321,9 @@ describe('ReactDOMFloat', () => {
<meta property="foo" content="bar" />
<link rel="foo" href="bar" onLoad={() => {}} />
<title>foo</title>
<noscript>
<link rel="icon" href="icon" />
</noscript>
<base target="foo" href="bar" />
<script async={true} src="foo" onLoad={() => {}} />
</head>
@@ -332,6 +339,7 @@ describe('ReactDOMFloat', () => {
<meta property="foo" content="bar" />
<title>foo</title>
<link rel="foo" href="bar" />
<noscript>&lt;link rel="icon" href="icon"/&gt;</noscript>
<script async="" src="foo" />
</head>
<body>foo</body>
@@ -5376,4 +5384,170 @@ describe('ReactDOMFloat', () => {
);
});
});
describe('noscript', () => {
// @gate enableFloat
it('should not turn children of noscript into resources', async () => {
function SomeResources() {
return (
<>
<link rel="stylesheet" href="foo" precedence="foo" />
<title>foo</title>
<link rel="foobar" href="foobar" />
<meta charSet="utf-8" />
<meta property="og:image" content="foo" />
<script async={true} src="script" />
</>
);
}
function Indirection({level, children}) {
if (level > 0) {
return <Indirection level={level - 1}>{children}</Indirection>;
} else {
return children;
}
}
function App() {
return (
<html>
<head>
<SomeResources />
<noscript>
<SomeResources />
<Indirection level={3}>
<SomeResources />
</Indirection>
</noscript>
<SomeResources />
</head>
</html>
);
}
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
pipe(writable);
});
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
{/* the actual resources */}
<meta charset="utf-8" />
<link rel="stylesheet" href="foo" data-precedence="foo" />
<script async="" src="script" />
<title>foo</title>
<link rel="foobar" href="foobar" />
<meta property="og:image" content="foo" />
{/* the noscript children are encoded as a textNode when scripting is enabled */}
<noscript>
&lt;link rel="stylesheet"
href="foo"/&gt;&lt;title&gt;foo&lt;/title&gt;&lt;link rel="foobar"
href="foobar"/&gt;&lt;meta charSet="utf-8"/&gt;&lt;meta
property="og:image" content="foo"/&gt;&lt;script async=""
src="script"&gt;&lt;/script&gt;&lt;link rel="stylesheet"
href="foo"/&gt;&lt;title&gt;foo&lt;/title&gt;&lt;link rel="foobar"
href="foobar"/&gt;&lt;meta charSet="utf-8"/&gt;&lt;meta
property="og:image" content="foo"/&gt;&lt;script async=""
src="script"&gt;&lt;/script&gt;
</noscript>
</head>
<body />
</html>,
);
const root = ReactDOMClient.hydrateRoot(document, <App />);
expect(Scheduler).toFlushWithoutYielding();
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
{/* the actual resources */}
<meta charset="utf-8" />
<link rel="stylesheet" href="foo" data-precedence="foo" />
<script async="" src="script" />
<title>foo</title>
<link rel="foobar" href="foobar" />
<meta property="og:image" content="foo" />
{/* the noscript children are encoded as a textNode when scripting is enabled */}
<noscript>
&lt;link rel="stylesheet"
href="foo"/&gt;&lt;title&gt;foo&lt;/title&gt;&lt;link rel="foobar"
href="foobar"/&gt;&lt;meta charSet="utf-8"/&gt;&lt;meta
property="og:image" content="foo"/&gt;&lt;script async=""
src="script"&gt;&lt;/script&gt;&lt;link rel="stylesheet"
href="foo"/&gt;&lt;title&gt;foo&lt;/title&gt;&lt;link rel="foobar"
href="foobar"/&gt;&lt;meta charSet="utf-8"/&gt;&lt;meta
property="og:image" content="foo"/&gt;&lt;script async=""
src="script"&gt;&lt;/script&gt;
</noscript>
</head>
<body />
</html>,
);
root.render(null);
expect(Scheduler).toFlushWithoutYielding();
// stylesheets and scripts currently don't unmount ever
// noscript is never hydrated so it also does not get cleared
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="foo" data-precedence="foo" />
<script async="" src="script" />
</head>
<body />
</html>,
);
});
it('noscript runs on the server but does not emit resources and does not run on the client', async () => {
function App() {
return (
<html>
<body>
<div>
foo
<noscript>
<Foo />
</noscript>
</div>
</body>
</html>
);
}
function Foo() {
Scheduler.unstable_yieldValue('Foo');
return <title>noscript title</title>;
}
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
pipe(writable);
});
expect(getMeaningfulChildren(container)).toEqual(
<html>
<head />
<body>
<div>
foo<noscript>&lt;title&gt;noscript title&lt;/title&gt;</noscript>
</div>
</body>
</html>,
);
expect(Scheduler).toHaveYielded(['Foo']);
ReactDOMClient.hydrateRoot(document, <App />);
expect(Scheduler).toFlushWithoutYielding();
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head />
<body>
<div>
foo<noscript>&lt;title&gt;noscript title&lt;/title&gt;</noscript>
</div>
</body>
</html>,
);
});
});
});