[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><link rel="icon" href="icon"/></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><link rel="icon" href="icon"/></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
+ <link rel="stylesheet"
5444
+ href="foo"/><title>foo</title><link rel="foobar"
5445
+ href="foobar"/><meta charSet="utf-8"/><meta
5446
+ property="og:image" content="foo"/><script async=""
5447
+ src="script"></script><link rel="stylesheet"
5448
+ href="foo"/><title>foo</title><link rel="foobar"
5449
+ href="foobar"/><meta charSet="utf-8"/><meta
5450
+ property="og:image" content="foo"/><script async=""
5451
+ src="script"></script>
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
+ <link rel="stylesheet"
5473
+ href="foo"/><title>foo</title><link rel="foobar"
5474
+ href="foobar"/><meta charSet="utf-8"/><meta
5475
+ property="og:image" content="foo"/><script async=""
5476
+ src="script"></script><link rel="stylesheet"
5477
+ href="foo"/><title>foo</title><link rel="foobar"
5478
+ href="foobar"/><meta charSet="utf-8"/><meta
5479
+ property="og:image" content="foo"/><script async=""
5480
+ src="script"></script>
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><title>noscript title</title></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><title>noscript title</title></noscript>
5547
+ </div>
5548
+ </body>
5549
+ </html>,
5550
+ );
5551
+ });
5552
+ });
5553
});