@samitouri / QOS-React / commits / 09def5990b

[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

Josh Story committed Oct 26, 2022 at 23:11 UTC 09def5990b01d04433f852c9e6bc41039be052d9
4 files changed +287 -20
packages/react-dom-bindings/src/server/ReactDOMFloatServer.js
+4
@@ -863,6 +863,10 @@ export function resourcesFromLink(props: Props): boolean {
863 }
864 }
865 if (props.onLoad || props.onError) {
866 + // When a link has these props we can't treat it is a Resource but if we rendered it on the
867 + // server it would look like a Resource in the rendered html (the onLoad/onError aren't emitted)
868 + // Instead we expect the client to insert them rather than hydrate them which also guarantees
869 + // that the onLoad and onError won't fire before the event handlers are attached
870 return true;
871 }
872
packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js
+108 -20
@@ -274,15 +274,18 @@ type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
274 export type FormatContext = {
275 insertionMode: InsertionMode, // root/svg/html/mathml/table
276 selectedValue: null | string | Array<string>, // the selected value(s) inside a <select>, or null outside <select>
277 + noscriptTagInScope: boolean,
278 };
279
280 function createFormatContext(
281 insertionMode: InsertionMode,
282 selectedValue: null | string,
283 + noscriptTagInScope: boolean,
284 ): FormatContext {
285 return {
286 insertionMode,
287 selectedValue,
288 + noscriptTagInScope,
289 };
290 }
291
@@ -293,7 +296,7 @@ export function createRootFormatContext(namespaceURI?: string): FormatContext {
296 : namespaceURI === 'http://www.w3.org/1998/Math/MathML'
297 ? MATHML_MODE
298 : ROOT_HTML_MODE;
296 - return createFormatContext(insertionMode, null);
299 + return createFormatContext(insertionMode, null, false);
300 }
301
302 export function getChildFormatContext(
@@ -302,38 +305,77 @@ export function getChildFormatContext(
305 props: Object,
306 ): FormatContext {
307 switch (type) {
308 + case 'noscript':
309 + return createFormatContext(HTML_MODE, null, true);
310 case 'select':
311 return createFormatContext(
312 HTML_MODE,
313 props.value != null ? props.value : props.defaultValue,
314 + parentContext.noscriptTagInScope,
315 );
316 case 'svg':
311 - return createFormatContext(SVG_MODE, null);
317 + return createFormatContext(
318 + SVG_MODE,
319 + null,
320 + parentContext.noscriptTagInScope,
321 + );
322 case 'math':
313 - return createFormatContext(MATHML_MODE, null);
323 + return createFormatContext(
324 + MATHML_MODE,
325 + null,
326 + parentContext.noscriptTagInScope,
327 + );
328 case 'foreignObject':
315 - return createFormatContext(HTML_MODE, null);
329 + return createFormatContext(
330 + HTML_MODE,
331 + null,
332 + parentContext.noscriptTagInScope,
333 + );
334 // Table parents are special in that their children can only be created at all if they're
335 // wrapped in a table parent. So we need to encode that we're entering this mode.
336 case 'table':
319 - return createFormatContext(HTML_TABLE_MODE, null);
337 + return createFormatContext(
338 + HTML_TABLE_MODE,
339 + null,
340 + parentContext.noscriptTagInScope,
341 + );
342 case 'thead':
343 case 'tbody':
344 case 'tfoot':
323 - return createFormatContext(HTML_TABLE_BODY_MODE, null);
345 + return createFormatContext(
346 + HTML_TABLE_BODY_MODE,
347 + null,
348 + parentContext.noscriptTagInScope,
349 + );
350 case 'colgroup':
325 - return createFormatContext(HTML_COLGROUP_MODE, null);
351 + return createFormatContext(
352 + HTML_COLGROUP_MODE,
353 + null,
354 + parentContext.noscriptTagInScope,
355 + );
356 case 'tr':
327 - return createFormatContext(HTML_TABLE_ROW_MODE, null);
357 + return createFormatContext(
358 + HTML_TABLE_ROW_MODE,
359 + null,
360 + parentContext.noscriptTagInScope,
361 + );
362 }
363 if (parentContext.insertionMode >= HTML_TABLE_MODE) {
364 // Whatever tag this was, it wasn't a table parent or other special parent, so we must have
365 // entered plain HTML again.
332 - return createFormatContext(HTML_MODE, null);
366 + return createFormatContext(
367 + HTML_MODE,
368 + null,
369 + parentContext.noscriptTagInScope,
370 + );
371 }
372 if (parentContext.insertionMode === ROOT_HTML_MODE) {
373 // We've emitted the root and is now in plain HTML mode.
336 - return createFormatContext(HTML_MODE, null);
374 + return createFormatContext(
375 + HTML_MODE,
376 + null,
377 + parentContext.noscriptTagInScope,
378 + );
379 }
380 return parentContext;
381 }
@@ -1155,8 +1197,13 @@ function pushBase(
1197 props: Object,
1198 responseState: ResponseState,
1199 textEmbedded: boolean,
1200 + noscriptTagInScope: boolean,
1201 ): ReactNodeList {
1159 - if (enableFloat && resourcesFromElement('base', props)) {
1202 + if (
1203 + enableFloat &&
1204 + !noscriptTagInScope &&
1205 + resourcesFromElement('base', props)
1206 + ) {
1207 if (textEmbedded) {
1208 // This link follows text but we aren't writing a tag. while not as efficient as possible we need
1209 // to be safe and assume text will follow by inserting a textSeparator
@@ -1175,8 +1222,13 @@ function pushMeta(
1222 props: Object,
1223 responseState: ResponseState,
1224 textEmbedded: boolean,
1225 + noscriptTagInScope: boolean,
1226 ): ReactNodeList {
1179 - if (enableFloat && resourcesFromElement('meta', props)) {
1227 + if (
1228 + enableFloat &&
1229 + !noscriptTagInScope &&
1230 + resourcesFromElement('meta', props)
1231 + ) {
1232 if (textEmbedded) {
1233 // This link follows text but we aren't writing a tag. while not as efficient as possible we need
1234 // to be safe and assume text will follow by inserting a textSeparator
@@ -1195,8 +1247,9 @@ function pushLink(
1247 props: Object,
1248 responseState: ResponseState,
1249 textEmbedded: boolean,
1250 + noscriptTagInScope: boolean,
1251 ): ReactNodeList {
1199 - if (enableFloat && resourcesFromLink(props)) {
1252 + if (enableFloat && !noscriptTagInScope && resourcesFromLink(props)) {
1253 if (textEmbedded) {
1254 // This link follows text but we aren't writing a tag. while not as efficient as possible we need
1255 // to be safe and assume text will follow by inserting a textSeparator
@@ -1318,6 +1371,7 @@ function pushTitle(
1371 target: Array<Chunk | PrecomputedChunk>,
1372 props: Object,
1373 responseState: ResponseState,
1374 + noscriptTagInScope: boolean,
1375 ): ReactNodeList {
1376 if (__DEV__) {
1377 const children = props.children;
@@ -1359,7 +1413,11 @@ function pushTitle(
1413 }
1414 }
1415
1362 - if (enableFloat && resourcesFromElement('title', props)) {
1416 + if (
1417 + enableFloat &&
1418 + !noscriptTagInScope &&
1419 + resourcesFromElement('title', props)
1420 + ) {
1421 // We have converted this link exclusively to a resource and no longer
1422 // need to emit it
1423 return null;
@@ -1520,8 +1578,9 @@ function pushScript(
1578 props: Object,
1579 responseState: ResponseState,
1580 textEmbedded: boolean,
1581 + noscriptTagInScope: boolean,
1582 ): null {
1524 - if (enableFloat && resourcesFromScript(props)) {
1583 + if (enableFloat && !noscriptTagInScope && resourcesFromScript(props)) {
1584 if (textEmbedded) {
1585 // This link follows text but we aren't writing a tag. while not as efficient as possible we need
1586 // to be safe and assume text will follow by inserting a textSeparator
@@ -1863,18 +1922,47 @@ export function pushStartInstance(
1922 return pushStartMenuItem(target, props, responseState);
1923 case 'title':
1924 return enableFloat
1866 - ? pushTitle(target, props, responseState)
1925 + ? pushTitle(
1926 + target,
1927 + props,
1928 + responseState,
1929 + formatContext.noscriptTagInScope,
1930 + )
1931 : pushStartTitle(target, props, responseState);
1932 case 'link':
1869 - return pushLink(target, props, responseState, textEmbedded);
1933 + return pushLink(
1934 + target,
1935 + props,
1936 + responseState,
1937 + textEmbedded,
1938 + formatContext.noscriptTagInScope,
1939 + );
1940 case 'script':
1941 return enableFloat
1872 - ? pushScript(target, props, responseState, textEmbedded)
1942 + ? pushScript(
1943 + target,
1944 + props,
1945 + responseState,
1946 + textEmbedded,
1947 + formatContext.noscriptTagInScope,
1948 + )
1949 : pushStartGenericElement(target, props, type, responseState);
1950 case 'meta':
1875 - return pushMeta(target, props, responseState, textEmbedded);
1951 + return pushMeta(
1952 + target,
1953 + props,
1954 + responseState,
1955 + textEmbedded,
1956 + formatContext.noscriptTagInScope,
1957 + );
1958 case 'base':
1877 - return pushBase(target, props, responseState, textEmbedded);
1959 + return pushBase(
1960 + target,
1961 + props,
1962 + responseState,
1963 + textEmbedded,
1964 + formatContext.noscriptTagInScope,
1965 + );
1966 // Newline eating tags
1967 case 'listing':
1968 case 'pre': {
packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js
+1
@@ -72,6 +72,7 @@ export function createRootFormatContext(): FormatContext {
72 return {
73 insertionMode: HTML_MODE, // We skip the root mode because we don't want to emit the DOCTYPE in legacy mode.
74 selectedValue: null,
75 + noscriptTagInScope: false,
76 };
77 }
78
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+174
@@ -290,6 +290,9 @@ describe('ReactDOMFloat', () => {
290 <meta property="foo" content="bar" />
291 <link rel="foo" href="bar" onLoad={() => {}} />
292 <title>foo</title>
293 + <noscript>
294 + <link rel="icon" href="icon" />
295 + </noscript>
296 <base target="foo" href="bar" />
297 <script async={true} src="foo" onLoad={() => {}} />
298 </head>
@@ -305,6 +308,7 @@ describe('ReactDOMFloat', () => {
308 <link rel="preload" href="foo" as="script" />
309 <meta property="foo" content="bar" />
310 <title>foo</title>
311 + <noscript>&lt;link rel="icon" href="icon"/&gt;</noscript>
312 </head>
313 <body>foo</body>
314 </html>,
@@ -317,6 +321,9 @@ describe('ReactDOMFloat', () => {
321 <meta property="foo" content="bar" />
322 <link rel="foo" href="bar" onLoad={() => {}} />
323 <title>foo</title>
324 + <noscript>
325 + <link rel="icon" href="icon" />
326 + </noscript>
327 <base target="foo" href="bar" />
328 <script async={true} src="foo" onLoad={() => {}} />
329 </head>
@@ -332,6 +339,7 @@ describe('ReactDOMFloat', () => {
339 <meta property="foo" content="bar" />
340 <title>foo</title>
341 <link rel="foo" href="bar" />
342 + <noscript>&lt;link rel="icon" href="icon"/&gt;</noscript>
343 <script async="" src="foo" />
344 </head>
345 <body>foo</body>
@@ -5376,4 +5384,170 @@ describe('ReactDOMFloat', () => {
5384 );
5385 });
5386 });
5387 +
5388 + describe('noscript', () => {
5389 + // @gate enableFloat
5390 + it('should not turn children of noscript into resources', async () => {
5391 + function SomeResources() {
5392 + return (
5393 + <>
5394 + <link rel="stylesheet" href="foo" precedence="foo" />
5395 + <title>foo</title>
5396 + <link rel="foobar" href="foobar" />
5397 + <meta charSet="utf-8" />
5398 + <meta property="og:image" content="foo" />
5399 + <script async={true} src="script" />
5400 + </>
5401 + );
5402 + }
5403 + function Indirection({level, children}) {
5404 + if (level > 0) {
5405 + return <Indirection level={level - 1}>{children}</Indirection>;
5406 + } else {
5407 + return children;
5408 + }
5409 + }
5410 + function App() {
5411 + return (
5412 + <html>
5413 + <head>
5414 + <SomeResources />
5415 + <noscript>
5416 + <SomeResources />
5417 + <Indirection level={3}>
5418 + <SomeResources />
5419 + </Indirection>
5420 + </noscript>
5421 + <SomeResources />
5422 + </head>
5423 + </html>
5424 + );
5425 + }
5426 + await actIntoEmptyDocument(() => {
5427 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
5428 + pipe(writable);
5429 + });
5430 +
5431 + expect(getMeaningfulChildren(document)).toEqual(
5432 + <html>
5433 + <head>
5434 + {/* the actual resources */}
5435 + <meta charset="utf-8" />
5436 + <link rel="stylesheet" href="foo" data-precedence="foo" />
5437 + <script async="" src="script" />
5438 + <title>foo</title>
5439 + <link rel="foobar" href="foobar" />
5440 + <meta property="og:image" content="foo" />
5441 + {/* the noscript children are encoded as a textNode when scripting is enabled */}
5442 + <noscript>
5443 + &lt;link rel="stylesheet"
5444 + href="foo"/&gt;&lt;title&gt;foo&lt;/title&gt;&lt;link rel="foobar"
5445 + href="foobar"/&gt;&lt;meta charSet="utf-8"/&gt;&lt;meta
5446 + property="og:image" content="foo"/&gt;&lt;script async=""
5447 + src="script"&gt;&lt;/script&gt;&lt;link rel="stylesheet"
5448 + href="foo"/&gt;&lt;title&gt;foo&lt;/title&gt;&lt;link rel="foobar"
5449 + href="foobar"/&gt;&lt;meta charSet="utf-8"/&gt;&lt;meta
5450 + property="og:image" content="foo"/&gt;&lt;script async=""
5451 + src="script"&gt;&lt;/script&gt;
5452 + </noscript>
5453 + </head>
5454 + <body />
5455 + </html>,
5456 + );
5457 +
5458 + const root = ReactDOMClient.hydrateRoot(document, <App />);
5459 + expect(Scheduler).toFlushWithoutYielding();
5460 + expect(getMeaningfulChildren(document)).toEqual(
5461 + <html>
5462 + <head>
5463 + {/* the actual resources */}
5464 + <meta charset="utf-8" />
5465 + <link rel="stylesheet" href="foo" data-precedence="foo" />
5466 + <script async="" src="script" />
5467 + <title>foo</title>
5468 + <link rel="foobar" href="foobar" />
5469 + <meta property="og:image" content="foo" />
5470 + {/* the noscript children are encoded as a textNode when scripting is enabled */}
5471 + <noscript>
5472 + &lt;link rel="stylesheet"
5473 + href="foo"/&gt;&lt;title&gt;foo&lt;/title&gt;&lt;link rel="foobar"
5474 + href="foobar"/&gt;&lt;meta charSet="utf-8"/&gt;&lt;meta
5475 + property="og:image" content="foo"/&gt;&lt;script async=""
5476 + src="script"&gt;&lt;/script&gt;&lt;link rel="stylesheet"
5477 + href="foo"/&gt;&lt;title&gt;foo&lt;/title&gt;&lt;link rel="foobar"
5478 + href="foobar"/&gt;&lt;meta charSet="utf-8"/&gt;&lt;meta
5479 + property="og:image" content="foo"/&gt;&lt;script async=""
5480 + src="script"&gt;&lt;/script&gt;
5481 + </noscript>
5482 + </head>
5483 + <body />
5484 + </html>,
5485 + );
5486 +
5487 + root.render(null);
5488 + expect(Scheduler).toFlushWithoutYielding();
5489 + // stylesheets and scripts currently don't unmount ever
5490 + // noscript is never hydrated so it also does not get cleared
5491 + expect(getMeaningfulChildren(document)).toEqual(
5492 + <html>
5493 + <head>
5494 + <link rel="stylesheet" href="foo" data-precedence="foo" />
5495 + <script async="" src="script" />
5496 + </head>
5497 + <body />
5498 + </html>,
5499 + );
5500 + });
5501 +
5502 + it('noscript runs on the server but does not emit resources and does not run on the client', async () => {
5503 + function App() {
5504 + return (
5505 + <html>
5506 + <body>
5507 + <div>
5508 + foo
5509 + <noscript>
5510 + <Foo />
5511 + </noscript>
5512 + </div>
5513 + </body>
5514 + </html>
5515 + );
5516 + }
5517 + function Foo() {
5518 + Scheduler.unstable_yieldValue('Foo');
5519 +
5520 + return <title>noscript title</title>;
5521 + }
5522 +
5523 + await actIntoEmptyDocument(() => {
5524 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
5525 + pipe(writable);
5526 + });
5527 + expect(getMeaningfulChildren(container)).toEqual(
5528 + <html>
5529 + <head />
5530 + <body>
5531 + <div>
5532 + foo<noscript>&lt;title&gt;noscript title&lt;/title&gt;</noscript>
5533 + </div>
5534 + </body>
5535 + </html>,
5536 + );
5537 + expect(Scheduler).toHaveYielded(['Foo']);
5538 +
5539 + ReactDOMClient.hydrateRoot(document, <App />);
5540 + expect(Scheduler).toFlushWithoutYielding();
5541 + expect(getMeaningfulChildren(document)).toEqual(
5542 + <html>
5543 + <head />
5544 + <body>
5545 + <div>
5546 + foo<noscript>&lt;title&gt;noscript title&lt;/title&gt;</noscript>
5547 + </div>
5548 + </body>
5549 + </html>,
5550 + );
5551 + });
5552 + });
5553 });