From d8089f2cf27a17872a13805fce415b74fd3b107e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 18 Apr 2023 14:57:33 -0400 Subject: [PATCH 01/38] [Flight Reply] Encode FormData (#26663) Builds on top of https://github.com/facebook/react/pull/26661 This lets you pass FormData objects through the Flight Reply serialization. It does that by prefixing each entry with the ID of the reference and then the decoding side creates a new FormData object containing only those fields (without the prefix). Ideally this should be more generic. E.g. you should be able to pass Blobs, Streams and Typed Arrays by reference inside plain objects too. You should also be able to send Blobs and FormData in the regular Flight serialization too so that they can go both directions. They should be symmetrical. We'll get around to adding more of those features in the Flight protocol as we go. --------- Co-authored-by: Sophie Alpert --- fixtures/flight/src/App.js | 4 +- fixtures/flight/src/Form.js | 27 ++++++ fixtures/flight/src/actions.js | 4 + .../src/ReactFlightReplyClient.js | 30 +++++- .../src/ReactFlightDOMClientBrowser.js | 2 +- .../src/ReactFlightDOMServerBrowser.js | 18 +--- .../src/ReactFlightDOMServerEdge.js | 18 +--- .../src/ReactFlightDOMServerNode.js | 27 ++---- .../src/__tests__/ReactFlightDOMReply-test.js | 94 +++++++++++++++++++ .../src/ReactFlightReplyServer.js | 86 +++++++++++++---- 10 files changed, 241 insertions(+), 69 deletions(-) create mode 100644 fixtures/flight/src/Form.js diff --git a/fixtures/flight/src/App.js b/fixtures/flight/src/App.js index e3ba9462d63f6..5e6fe4927d202 100644 --- a/fixtures/flight/src/App.js +++ b/fixtures/flight/src/App.js @@ -7,8 +7,9 @@ import {Counter as Counter2} from './Counter2.js'; import ShowMore from './ShowMore.js'; import Button from './Button.js'; +import Form from './Form.js'; -import {like} from './actions.js'; +import {like, greet} from './actions.js'; export default async function App() { const res = await fetch('http://localhost:3001/todos'); @@ -33,6 +34,7 @@ export default async function App() {

Lorem ipsum

+
diff --git a/fixtures/flight/src/Form.js b/fixtures/flight/src/Form.js new file mode 100644 index 0000000000000..3e8d8c244c08f --- /dev/null +++ b/fixtures/flight/src/Form.js @@ -0,0 +1,27 @@ +'use client'; + +import * as React from 'react'; + +export default function Form({action, children}) { + const [isPending, setIsPending] = React.useState(false); + + return ( + { + e.preventDefault(); + setIsPending(true); + try { + const formData = new FormData(e.target); + const result = await action(formData); + alert(result); + } catch (error) { + console.error(error); + } finally { + setIsPending(false); + } + }}> + + +
+ ); +} diff --git a/fixtures/flight/src/actions.js b/fixtures/flight/src/actions.js index 687f3f39da0dc..7143c31a39d7c 100644 --- a/fixtures/flight/src/actions.js +++ b/fixtures/flight/src/actions.js @@ -3,3 +3,7 @@ export async function like() { return new Promise((resolve, reject) => resolve('Liked')); } + +export async function greet(formData) { + return 'Hi ' + formData.get('name') + '!'; +} diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js index 18fc2834e1da7..eb3d8f83e6e54 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -74,6 +74,11 @@ function serializeSymbolReference(name: string): string { return '$S' + name; } +function serializeFormDataReference(id: number): string { + // Why K? F is "Function". D is "Date". What else? + return '$K' + id.toString(16); +} + function serializeNumber(number: number): string | number { if (Number.isFinite(number)) { if (number === 0 && 1 / number === -Infinity) { @@ -112,6 +117,7 @@ function escapeStringValue(value: string): string { export function processReply( root: ReactServerValue, + formFieldPrefix: string, resolve: (string | FormData) => void, reject: (error: mixed) => void, ): void { @@ -171,7 +177,7 @@ export function processReply( // $FlowFixMe[incompatible-type] We know it's not null because we assigned it above. const data: FormData = formData; // eslint-disable-next-line react-internal/safe-string-coercion - data.append('' + promiseId, partJSON); + data.append(formFieldPrefix + promiseId, partJSON); pendingParts--; if (pendingParts === 0) { resolve(data); @@ -185,6 +191,24 @@ export function processReply( ); return serializePromiseID(promiseId); } + // TODO: Should we the Object.prototype.toString.call() to test for cross-realm objects? + if (value instanceof FormData) { + if (formData === null) { + // Upgrade to use FormData to allow us to use rich objects as its values. + formData = new FormData(); + } + const data: FormData = formData; + const refId = nextPartId++; + // Copy all the form fields with a prefix for this reference. + // These must come first in the form order because we assume that all the + // fields are available before this is referenced. + const prefix = formFieldPrefix + refId + '_'; + // $FlowFixMe[prop-missing]: FormData has forEach. + value.forEach((originalValue: string | File, originalKey: string) => { + data.append(prefix + originalKey, originalValue); + }); + return serializeFormDataReference(refId); + } if (!isArray(value)) { const iteratorFn = getIteratorFn(value); if (iteratorFn) { @@ -268,7 +292,7 @@ export function processReply( // The reference to this function came from the same client so we can pass it back. const refId = nextPartId++; // eslint-disable-next-line react-internal/safe-string-coercion - formData.set('' + refId, metaDataJSON); + formData.set(formFieldPrefix + refId, metaDataJSON); return serializeServerReferenceID(refId); } throw new Error( @@ -308,7 +332,7 @@ export function processReply( resolve(json); } else { // Otherwise, we use FormData to let us stream in the result. - formData.set('0', json); + formData.set(formFieldPrefix + '0', json); if (pendingParts === 0) { // $FlowFixMe[incompatible-call] this has already been refined. resolve(formData); diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js index f847a636e6c89..c835d0b81d7ff 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js @@ -124,7 +124,7 @@ function encodeReply( string | URLSearchParams | FormData, > /* We don't use URLSearchParams yet but maybe */ { return new Promise((resolve, reject) => { - processReply(value, resolve, reject); + processReply(value, '', resolve, reject); }); } diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js index d549c10693c55..777e4271e6e1e 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js @@ -22,8 +22,6 @@ import { import { createResponse, close, - resolveField, - resolveFile, getRoot, } from 'react-server/src/ReactFlightReplyServer'; @@ -79,20 +77,12 @@ function decodeReply( body: string | FormData, webpackMap: ServerManifest, ): Thenable { - const response = createResponse(webpackMap); if (typeof body === 'string') { - resolveField(response, 0, body); - } else { - // $FlowFixMe[prop-missing] Flow doesn't know that forEach exists. - body.forEach((value: string | File, key: string) => { - const id = +key; - if (typeof value === 'string') { - resolveField(response, id, value); - } else { - resolveFile(response, id, value); - } - }); + const form = new FormData(); + form.append('0', body); + body = form; } + const response = createResponse(webpackMap, '', body); close(response); return getRoot(response); } diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js index d549c10693c55..777e4271e6e1e 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js @@ -22,8 +22,6 @@ import { import { createResponse, close, - resolveField, - resolveFile, getRoot, } from 'react-server/src/ReactFlightReplyServer'; @@ -79,20 +77,12 @@ function decodeReply( body: string | FormData, webpackMap: ServerManifest, ): Thenable { - const response = createResponse(webpackMap); if (typeof body === 'string') { - resolveField(response, 0, body); - } else { - // $FlowFixMe[prop-missing] Flow doesn't know that forEach exists. - body.forEach((value: string | File, key: string) => { - const id = +key; - if (typeof value === 'string') { - resolveField(response, id, value); - } else { - resolveFile(response, id, value); - } - }); + const form = new FormData(); + form.append('0', body); + body = form; } + const response = createResponse(webpackMap, '', body); close(response); return getRoot(response); } diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js index 98d4291de9847..b1cda7e1042a1 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js @@ -30,7 +30,6 @@ import { reportGlobalError, close, resolveField, - resolveFile, resolveFileInfo, resolveFileChunk, resolveFileComplete, @@ -88,10 +87,9 @@ function decodeReplyFromBusboy( busboyStream: Busboy, webpackMap: ServerManifest, ): Thenable { - const response = createResponse(webpackMap); + const response = createResponse(webpackMap, ''); busboyStream.on('field', (name, value) => { - const id = +name; - resolveField(response, id, value); + resolveField(response, name, value); }); busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => { if (encoding.toLowerCase() === 'base64') { @@ -101,13 +99,12 @@ function decodeReplyFromBusboy( 'the wrong assumption, we can easily fix it.', ); } - const id = +name; - const file = resolveFileInfo(response, id, filename, mimeType); + const file = resolveFileInfo(response, name, filename, mimeType); value.on('data', chunk => { resolveFileChunk(response, file, chunk); }); value.on('end', () => { - resolveFileComplete(response, file); + resolveFileComplete(response, name, file); }); }); busboyStream.on('finish', () => { @@ -123,20 +120,12 @@ function decodeReply( body: string | FormData, webpackMap: ServerManifest, ): Thenable { - const response = createResponse(webpackMap); if (typeof body === 'string') { - resolveField(response, 0, body); - } else { - // $FlowFixMe[prop-missing] Flow doesn't know that forEach exists. - body.forEach((value: string | File, key: string) => { - const id = +key; - if (typeof value === 'string') { - resolveField(response, id, value); - } else { - resolveFile(response, id, value); - } - }); + const form = new FormData(); + form.append('0', body); + body = form; } + const response = createResponse(webpackMap, '', body); close(response); return getRoot(response); } diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js index f21fea4d41c4d..b49c158721126 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js @@ -30,6 +30,20 @@ describe('ReactFlightDOMReply', () => { ReactServerDOMClient = require('react-server-dom-webpack/client'); }); + // This method should exist on File but is not implemented in JSDOM + async function arrayBuffer(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = function () { + return resolve(reader.result); + }; + reader.onerror = function () { + return reject(reader.error); + }; + reader.readAsArrayBuffer(file); + }); + } + it('can pass undefined as a reply', async () => { const body = await ReactServerDOMClient.encodeReply(undefined); const missing = await ReactServerDOMServer.decodeReply( @@ -94,4 +108,84 @@ describe('ReactFlightDOMReply', () => { expect(n).toEqual(90071992547409910000n); }); + + it('can pass FormData as a reply', async () => { + const formData = new FormData(); + formData.set('hello', 'world'); + formData.append('list', '1'); + formData.append('list', '2'); + formData.append('list', '3'); + const typedArray = new Uint8Array([0, 1, 2, 3]); + const blob = new Blob([typedArray]); + formData.append('blob', blob, 'filename.blob'); + + const body = await ReactServerDOMClient.encodeReply(formData); + const formData2 = await ReactServerDOMServer.decodeReply( + body, + webpackServerMap, + ); + + expect(formData2).not.toBe(formData); + expect(Array.from(formData2).length).toBe(5); + expect(formData2.get('hello')).toBe('world'); + expect(formData2.getAll('list')).toEqual(['1', '2', '3']); + const blob2 = formData.get('blob'); + expect(blob2.size).toBe(4); + expect(blob2.name).toBe('filename.blob'); + expect(blob2.type).toBe(''); + const typedArray2 = new Uint8Array(await arrayBuffer(blob2)); + expect(typedArray2).toEqual(typedArray); + }); + + it('can pass multiple Files in FormData', async () => { + const typedArrayA = new Uint8Array([0, 1, 2, 3]); + const typedArrayB = new Uint8Array([4, 5]); + const blobA = new Blob([typedArrayA]); + const blobB = new Blob([typedArrayB]); + const formData = new FormData(); + formData.append('filelist', 'string'); + formData.append('filelist', blobA); + formData.append('filelist', blobB); + + const body = await ReactServerDOMClient.encodeReply(formData); + const formData2 = await ReactServerDOMServer.decodeReply( + body, + webpackServerMap, + ); + + const filelist2 = formData2.getAll('filelist'); + expect(filelist2.length).toBe(3); + expect(filelist2[0]).toBe('string'); + const blobA2 = filelist2[1]; + expect(blobA2.size).toBe(4); + expect(blobA2.name).toBe('blob'); + expect(blobA2.type).toBe(''); + const typedArrayA2 = new Uint8Array(await arrayBuffer(blobA2)); + expect(typedArrayA2).toEqual(typedArrayA); + const blobB2 = filelist2[2]; + expect(blobB2.size).toBe(2); + expect(blobB2.name).toBe('blob'); + expect(blobB2.type).toBe(''); + const typedArrayB2 = new Uint8Array(await arrayBuffer(blobB2)); + expect(typedArrayB2).toEqual(typedArrayB); + }); + + it('can pass two independent FormData with same keys', async () => { + const formDataA = new FormData(); + formDataA.set('greeting', 'hello'); + const formDataB = new FormData(); + formDataB.set('greeting', 'hi'); + + const body = await ReactServerDOMClient.encodeReply({ + a: formDataA, + b: formDataB, + }); + const {a: formDataA2, b: formDataB2} = + await ReactServerDOMServer.decodeReply(body, webpackServerMap); + + expect(Array.from(formDataA2).length).toBe(1); + expect(Array.from(formDataB2).length).toBe(1); + expect(formDataA2.get('greeting')).toBe('hello'); + expect(formDataB2.get('greeting')).toBe('hi'); + }); }); diff --git a/packages/react-server/src/ReactFlightReplyServer.js b/packages/react-server/src/ReactFlightReplyServer.js index a39ccb91bae2a..c3d5bef11c62d 100644 --- a/packages/react-server/src/ReactFlightReplyServer.js +++ b/packages/react-server/src/ReactFlightReplyServer.js @@ -131,6 +131,8 @@ Chunk.prototype.then = function ( export type Response = { _bundlerConfig: ServerManifest, + _prefix: string, + _formData: FormData, _chunks: Map>, _fromJSON: (key: string, value: JSONValue) => any, }; @@ -309,7 +311,17 @@ function getChunk(response: Response, id: number): SomeChunk { const chunks = response._chunks; let chunk = chunks.get(id); if (!chunk) { - chunk = createPendingChunk(response); + const prefix = response._prefix; + const key = prefix + id; + // Check if we have this field in the backing store already. + const backingEntry = response._formData.get(key); + if (backingEntry != null) { + // We assume that this is a string entry for now. + chunk = createResolvedModelChunk(response, (backingEntry: any)); + } else { + // We're still waiting on this entry to stream in. + chunk = createPendingChunk(response); + } chunks.set(id, chunk); } return chunk; @@ -397,6 +409,23 @@ function parseModelString( key, ); } + case 'K': { + // FormData + const stringId = value.substring(2); + const formPrefix = response._prefix + stringId + '_'; + const data = new FormData(); + const backingFormData = response._formData; + // We assume that the reference to FormData always comes after each + // entry that it references so we can assume they all exist in the + // backing store already. + // $FlowFixMe[prop-missing] FormData has forEach on it. + backingFormData.forEach((entry: File | string, entryKey: string) => { + if (entryKey.startsWith(formPrefix)) { + data.append(entryKey.substr(formPrefix.length), entry); + } + }); + return data; + } case 'I': { // $Infinity return Infinity; @@ -452,10 +481,16 @@ function parseModelString( return value; } -export function createResponse(bundlerConfig: ServerManifest): Response { +export function createResponse( + bundlerConfig: ServerManifest, + formFieldPrefix: string, + backingFormData?: FormData = new FormData(), +): Response { const chunks: Map> = new Map(); const response: Response = { _bundlerConfig: bundlerConfig, + _prefix: formFieldPrefix, + _formData: backingFormData, _chunks: chunks, _fromJSON: function (this: any, key: string, value: JSONValue) { if (typeof value === 'string') { @@ -470,31 +505,45 @@ export function createResponse(bundlerConfig: ServerManifest): Response { export function resolveField( response: Response, - id: number, - model: string, + key: string, + value: string, ): void { - const chunks = response._chunks; - const chunk = chunks.get(id); - if (!chunk) { - chunks.set(id, createResolvedModelChunk(response, model)); - } else { - resolveModelChunk(chunk, model); + // Add this field to the backing store. + response._formData.append(key, value); + const prefix = response._prefix; + if (key.startsWith(prefix)) { + const chunks = response._chunks; + const id = +key.substr(prefix.length); + const chunk = chunks.get(id); + if (chunk) { + // We were waiting on this key so now we can resolve it. + resolveModelChunk(chunk, value); + } } } -export function resolveFile(response: Response, id: number, file: File): void { - throw new Error('Not implemented.'); +export function resolveFile(response: Response, key: string, file: File): void { + // Add this field to the backing store. + response._formData.append(key, file); } -export opaque type FileHandle = {}; +export opaque type FileHandle = { + chunks: Array, + filename: string, + mime: string, +}; export function resolveFileInfo( response: Response, - id: number, + key: string, filename: string, mime: string, ): FileHandle { - throw new Error('Not implemented.'); + return { + chunks: [], + filename, + mime, + }; } export function resolveFileChunk( @@ -502,14 +551,17 @@ export function resolveFileChunk( handle: FileHandle, chunk: Uint8Array, ): void { - throw new Error('Not implemented.'); + handle.chunks.push(chunk); } export function resolveFileComplete( response: Response, + key: string, handle: FileHandle, ): void { - throw new Error('Not implemented.'); + // Add this file to the backing store. + const file = new File(handle.chunks, handle.filename, {type: handle.mime}); + response._formData.append(key, file); } export function close(response: Response): void { From 96fd2fb726130d2980e6d450f5d9e468f922b8b9 Mon Sep 17 00:00:00 2001 From: Mengdi Chen Date: Tue, 18 Apr 2023 20:39:22 -0400 Subject: [PATCH 02/38] (patch)[DevTools] bug fix: backend injection logic not working for undocked devtools window (#26665) bugfix for #26492 This bug would cause users unable to use the devtools (component tree empty). The else-if logic is broken when user switch to undocked devtools mode (separate window) because `sender.tab` would exist in that case. image Tested on Chrome with a local build --- packages/react-devtools-extensions/src/background.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react-devtools-extensions/src/background.js b/packages/react-devtools-extensions/src/background.js index 89d87724ddac2..8861b63f5894f 100644 --- a/packages/react-devtools-extensions/src/background.js +++ b/packages/react-devtools-extensions/src/background.js @@ -172,6 +172,7 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { chrome.runtime.onMessage.addListener((request, sender) => { const tab = sender.tab; + // sender.tab.id from content script points to the tab that injected the content script if (tab) { const id = tab.id; // This is sent from the hook content script. @@ -214,7 +215,10 @@ chrome.runtime.onMessage.addListener((request, sender) => { break; } } - } else if (request.payload?.tabId) { + } + // sender.tab.id from devtools page may not exist, or point to the undocked devtools window + // so we use the payload to get the tab id + if (request.payload?.tabId) { const tabId = request.payload?.tabId; // This is sent from the devtools page when it is ready for injecting the backend if (request.payload.type === 'react-devtools-inject-backend-manager') { From c6db19f9cdec34bca3625a483a2f85181193b885 Mon Sep 17 00:00:00 2001 From: Sophie Alpert Date: Tue, 18 Apr 2023 20:52:03 -0700 Subject: [PATCH 03/38] [Flight] Serialize Date (#26622) This is kind of annoying because Date implements toJSON so JSON.stringify turns it into a string before calling our replacer function. --- .../react-client/src/ReactFlightClient.js | 4 ++ .../src/ReactFlightReplyClient.js | 27 +++++++- .../src/__tests__/ReactFlight-test.js | 69 ++++++++++++++----- .../src/__tests__/ReactFlightDOMReply-test.js | 9 +++ .../src/ReactFlightReplyServer.js | 4 ++ .../react-server/src/ReactFlightServer.js | 24 ++++++- 6 files changed, 118 insertions(+), 19 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 05ecf17268419..85b20a4c1f134 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -580,6 +580,10 @@ export function parseModelString( // Special encoding for `undefined` which can't be serialized as JSON otherwise. return undefined; } + case 'D': { + // Date + return new Date(Date.parse(value.substring(2))); + } case 'n': { // BigInt return BigInt(value.substring(2)); diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js index eb3d8f83e6e54..224af305d64b2 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -101,6 +101,12 @@ function serializeUndefined(): string { return '$undefined'; } +function serializeDateFromDateJSON(dateJSON: string): string { + // JSON.stringify automatically calls Date.prototype.toJSON which calls toISOString. + // We need only tack on a $D prefix. + return '$D' + dateJSON; +} + function serializeBigInt(n: bigint): string { return '$n' + n.toString(10); } @@ -133,10 +139,16 @@ export function processReply( value: ReactServerValue, ): ReactJSONValue { const parent = this; + + // Make sure that `parent[key]` wasn't JSONified before `value` was passed to us if (__DEV__) { // $FlowFixMe[incompatible-use] - const originalValue = this[key]; - if (typeof originalValue === 'object' && originalValue !== value) { + const originalValue = parent[key]; + if ( + typeof originalValue === 'object' && + originalValue !== value && + !(originalValue instanceof Date) + ) { if (objectName(originalValue) !== 'Object') { console.error( 'Only plain objects can be passed to Server Functions from the Client. ' + @@ -266,6 +278,17 @@ export function processReply( } if (typeof value === 'string') { + // TODO: Maybe too clever. If we support URL there's no similar trick. + if (value[value.length - 1] === 'Z') { + // Possibly a Date, whose toJSON automatically calls toISOString + // $FlowFixMe[incompatible-use] + const originalValue = parent[key]; + // $FlowFixMe[method-unbinding] + if (originalValue instanceof Date) { + return serializeDateFromDateJSON(value); + } + } + return escapeStringValue(value); } diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 9fc2da89a0180..cb240482273d1 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -306,6 +306,23 @@ describe('ReactFlight', () => { ); }); + it('can transport Date', async () => { + function ComponentClient({prop}) { + return `prop: ${prop.toISOString()}`; + } + const Component = clientReference(ComponentClient); + + const model = ; + + const transport = ReactNoopFlightServer.render(model); + + await act(async () => { + ReactNoop.render(await ReactNoopFlightClient.read(transport)); + }); + + expect(ReactNoop).toMatchRenderedOutput('prop: 2009-02-13T23:31:30.123Z'); + }); + it('can render a lazy component as a shared component on the server', async () => { function SharedComponent({text}) { return ( @@ -675,28 +692,39 @@ describe('ReactFlight', () => { }); it('should warn in DEV if a toJSON instance is passed to a host component', () => { + const obj = { + toJSON() { + return 123; + }, + }; expect(() => { - const transport = ReactNoopFlightServer.render( - , - ); + const transport = ReactNoopFlightServer.render(); ReactNoopFlightClient.read(transport); }).toErrorDev( 'Only plain objects can be passed to Client Components from Server Components. ' + - 'Date objects are not supported.', + 'Objects with toJSON methods are not supported. ' + + 'Convert it manually to a simple value before passing it to props.\n' + + ' \n' + + ' ^^^^^^^^^^^^^^^^^^^^', {withoutStack: true}, ); }); it('should warn in DEV if a toJSON instance is passed to a host component child', () => { + class MyError extends Error { + toJSON() { + return 123; + } + } expect(() => { const transport = ReactNoopFlightServer.render( -
Current date: {new Date()}
, +
Womp womp: {new MyError('spaghetti')}
, ); ReactNoopFlightClient.read(transport); }).toErrorDev( - 'Date objects cannot be rendered as text children. Try formatting it using toString().\n' + - '
Current date: {Date}
\n' + - ' ^^^^^^', + 'Error objects cannot be rendered as text children. Try formatting it using toString().\n' + + '
Womp womp: {Error}
\n' + + ' ^^^^^^^', {withoutStack: true}, ); }); @@ -728,37 +756,46 @@ describe('ReactFlight', () => { }); it('should warn in DEV if a toJSON instance is passed to a Client Component', () => { + const obj = { + toJSON() { + return 123; + }, + }; function ClientImpl({value}) { return
{value}
; } const Client = clientReference(ClientImpl); expect(() => { - const transport = ReactNoopFlightServer.render( - , - ); + const transport = ReactNoopFlightServer.render(); ReactNoopFlightClient.read(transport); }).toErrorDev( 'Only plain objects can be passed to Client Components from Server Components. ' + - 'Date objects are not supported.', + 'Objects with toJSON methods are not supported.', {withoutStack: true}, ); }); it('should warn in DEV if a toJSON instance is passed to a Client Component child', () => { + const obj = { + toJSON() { + return 123; + }, + }; function ClientImpl({children}) { return
{children}
; } const Client = clientReference(ClientImpl); expect(() => { const transport = ReactNoopFlightServer.render( - Current date: {new Date()}, + Current date: {obj}, ); ReactNoopFlightClient.read(transport); }).toErrorDev( 'Only plain objects can be passed to Client Components from Server Components. ' + - 'Date objects are not supported.\n' + - ' <>Current date: {Date}\n' + - ' ^^^^^^', + 'Objects with toJSON methods are not supported. ' + + 'Convert it manually to a simple value before passing it to props.\n' + + ' <>Current date: {{toJSON: function}}\n' + + ' ^^^^^^^^^^^^^^^^^^^^', {withoutStack: true}, ); }); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js index b49c158721126..59af378829f26 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js @@ -188,4 +188,13 @@ describe('ReactFlightDOMReply', () => { expect(formDataA2.get('greeting')).toBe('hello'); expect(formDataB2.get('greeting')).toBe('hi'); }); + + it('can pass a Date as a reply', async () => { + const d = new Date(1234567890123); + const body = await ReactServerDOMClient.encodeReply(d); + const d2 = await ReactServerDOMServer.decodeReply(body, webpackServerMap); + + expect(d).toEqual(d2); + expect(d % 1000).toEqual(123); // double-check the milliseconds made it through + }); }); diff --git a/packages/react-server/src/ReactFlightReplyServer.js b/packages/react-server/src/ReactFlightReplyServer.js index c3d5bef11c62d..4ba9b8785f034 100644 --- a/packages/react-server/src/ReactFlightReplyServer.js +++ b/packages/react-server/src/ReactFlightReplyServer.js @@ -447,6 +447,10 @@ function parseModelString( // Special encoding for `undefined` which can't be serialized as JSON otherwise. return undefined; } + case 'D': { + // Date + return new Date(Date.parse(value.substring(2))); + } case 'n': { // BigInt return BigInt(value.substring(2)); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 17741ad7863af..dc37f750ae277 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -571,6 +571,12 @@ function serializeUndefined(): string { return '$undefined'; } +function serializeDateFromDateJSON(dateJSON: string): string { + // JSON.stringify automatically calls Date.prototype.toJSON which calls toISOString. + // We need only tack on a $D prefix. + return '$D' + dateJSON; +} + function serializeBigInt(n: bigint): string { return '$n' + n.toString(10); } @@ -687,10 +693,15 @@ export function resolveModelToJSON( key: string, value: ReactClientValue, ): ReactJSONValue { + // Make sure that `parent[key]` wasn't JSONified before `value` was passed to us if (__DEV__) { // $FlowFixMe[incompatible-use] const originalValue = parent[key]; - if (typeof originalValue === 'object' && originalValue !== value) { + if ( + typeof originalValue === 'object' && + originalValue !== value && + !(originalValue instanceof Date) + ) { if (objectName(originalValue) !== 'Object') { const jsxParentType = jsxChildrenParents.get(parent); if (typeof jsxParentType === 'string') { @@ -892,6 +903,17 @@ export function resolveModelToJSON( } if (typeof value === 'string') { + // TODO: Maybe too clever. If we support URL there's no similar trick. + if (value[value.length - 1] === 'Z') { + // Possibly a Date, whose toJSON automatically calls toISOString + // $FlowFixMe[incompatible-use] + const originalValue = parent[key]; + // $FlowFixMe[method-unbinding] + if (originalValue instanceof Date) { + return serializeDateFromDateJSON(value); + } + } + return escapeStringValue(value); } From a227bcd4f4754b0a3f44f84e1e63a79d0ae130d3 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Wed, 19 Apr 2023 10:05:16 +0100 Subject: [PATCH 04/38] =?UTF-8?q?chore[devtools/release-scripts]:=20update?= =?UTF-8?q?=20messages=20/=20fixed=20npm=20view=20com=E2=80=A6=20(#26660)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some minor changes, observed while working on 24.7.5 release: - Updated numeration of text instructions - `reactjs.org` -> `react.dev` - Fixed using `npm view` command for node 16+, `publish-release` script currently fails if used with node 16+ --- packages/react-devtools-extensions/chrome/test.js | 2 +- packages/react-devtools-extensions/edge/test.js | 2 +- packages/react-devtools-extensions/firefox/test.js | 2 +- scripts/devtools/prepare-release.js | 6 +++--- scripts/devtools/publish-release.js | 12 +++++++++++- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/react-devtools-extensions/chrome/test.js b/packages/react-devtools-extensions/chrome/test.js index 833990525e721..868f1c90c90a5 100644 --- a/packages/react-devtools-extensions/chrome/test.js +++ b/packages/react-devtools-extensions/chrome/test.js @@ -7,7 +7,7 @@ const {resolve} = require('path'); const {argv} = require('yargs'); const EXTENSION_PATH = resolve('./chrome/build/unpacked'); -const START_URL = argv.url || 'https://reactjs.org/'; +const START_URL = argv.url || 'https://react.dev/'; chromeLaunch(START_URL, { args: [ diff --git a/packages/react-devtools-extensions/edge/test.js b/packages/react-devtools-extensions/edge/test.js index f24b403da655b..5d9c416c279b9 100644 --- a/packages/react-devtools-extensions/edge/test.js +++ b/packages/react-devtools-extensions/edge/test.js @@ -9,7 +9,7 @@ const {resolve} = require('path'); const {argv} = require('yargs'); const EXTENSION_PATH = resolve('./edge/build/unpacked'); -const START_URL = argv.url || 'https://reactjs.org/'; +const START_URL = argv.url || 'https://react.dev/'; const extargs = `--load-extension=${EXTENSION_PATH}`; diff --git a/packages/react-devtools-extensions/firefox/test.js b/packages/react-devtools-extensions/firefox/test.js index b2e9e86e6ec01..30328b16d1f2b 100644 --- a/packages/react-devtools-extensions/firefox/test.js +++ b/packages/react-devtools-extensions/firefox/test.js @@ -8,7 +8,7 @@ const {resolve} = require('path'); const {argv} = require('yargs'); const EXTENSION_PATH = resolve('./firefox/build/unpacked'); -const START_URL = argv.url || 'https://reactjs.org/'; +const START_URL = argv.url || 'https://react.dev/'; const firefoxVersion = process.env.WEB_EXT_FIREFOX; diff --git a/scripts/devtools/prepare-release.js b/scripts/devtools/prepare-release.js index 6b1f67f533b77..6ea4e60741b23 100755 --- a/scripts/devtools/prepare-release.js +++ b/scripts/devtools/prepare-release.js @@ -209,11 +209,11 @@ async function reviewChangelogPrompt() { console.log(` ${chalk.bold(CHANGELOG_PATH)}`); console.log(''); console.log('Please review the new changelog text for the following:'); - console.log(' 1. Organize the list into Features vs Bugfixes'); console.log(' 1. Filter out any non-user-visible changes (e.g. typo fixes)'); - console.log(' 1. Combine related PRs into a single bullet list.'); + console.log(' 2. Organize the list into Features vs Bugfixes'); + console.log(' 3. Combine related PRs into a single bullet list'); console.log( - ' 1. Replacing the "USERNAME" placeholder text with the GitHub username(s)' + ' 4. Replacing the "USERNAME" placeholder text with the GitHub username(s)' ); console.log(''); console.log(` ${chalk.bold.green(`open ${CHANGELOG_PATH}`)}`); diff --git a/scripts/devtools/publish-release.js b/scripts/devtools/publish-release.js index 3e44e74d9a170..01ace5fc1bcc8 100755 --- a/scripts/devtools/publish-release.js +++ b/scripts/devtools/publish-release.js @@ -82,7 +82,17 @@ async function publishToNPM() { // If so we might be resuming from a previous run. // We could infer this by comparing the build-info.json, // But for now the easiest way is just to ask if this is expected. - const info = await execRead(`npm view ${npmPackage}@${version}`); + const info = await execRead(`npm view ${npmPackage}@${version}`) + // Early versions of npm view gives empty response, but newer versions give 404 error. + // Catch the error to keep it consistent. + .catch(childProcessError => { + if (childProcessError.stderr.startsWith('npm ERR! code E404')) { + return null; + } + + throw childProcessError; + }); + if (info) { console.log(''); console.log( From b90e8ebaa5b692bcee4198eacb56f9123b62dd10 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Wed, 19 Apr 2023 10:05:31 +0100 Subject: [PATCH 05/38] cleanup[devtools]: remove named hooks & profiler changed hook indices feature flags (#26635) ## Summary Removing `enableNamedHooksFeature`, `enableProfilerChangedHookIndices`, `enableProfilerComponentTree` feature flags, they are the same for all configurations. --- .../src/backend/renderer.js | 71 +++++-------------- .../config/DevToolsFeatureFlags.core-fb.js | 3 - .../config/DevToolsFeatureFlags.core-oss.js | 3 - .../config/DevToolsFeatureFlags.default.js | 3 - .../DevToolsFeatureFlags.extension-fb.js | 3 - .../DevToolsFeatureFlags.extension-oss.js | 3 - .../Components/InspectedElementContext.js | 39 +++++----- .../Components/InspectedElementHooksTree.js | 23 +++--- .../src/devtools/views/Profiler/Profiler.js | 7 +- .../devtools/views/Profiler/WhatChanged.js | 3 +- 10 files changed, 44 insertions(+), 114 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index 45745684f5841..bf165bf8a35c3 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -92,10 +92,7 @@ import { SERVER_CONTEXT_SYMBOL_STRING, } from './ReactSymbols'; import {format} from './utils'; -import { - enableProfilerChangedHookIndices, - enableStyleXFeatures, -} from 'react-devtools-feature-flags'; +import {enableStyleXFeatures} from 'react-devtools-feature-flags'; import is from 'shared/objectIs'; import hasOwnProperty from 'shared/hasOwnProperty'; import {getStyleXData} from './StyleX/utils'; @@ -1265,19 +1262,12 @@ export function attach( }; // Only traverse the hooks list once, depending on what info we're returning. - if (enableProfilerChangedHookIndices) { - const indices = getChangedHooksIndices( - prevFiber.memoizedState, - nextFiber.memoizedState, - ); - data.hooks = indices; - data.didHooksChange = indices !== null && indices.length > 0; - } else { - data.didHooksChange = didHooksChange( - prevFiber.memoizedState, - nextFiber.memoizedState, - ); - } + const indices = getChangedHooksIndices( + prevFiber.memoizedState, + nextFiber.memoizedState, + ); + data.hooks = indices; + data.didHooksChange = indices !== null && indices.length > 0; return data; } @@ -1458,12 +1448,13 @@ export function attach( return false; } - function didHooksChange(prev: any, next: any): boolean { + function getChangedHooksIndices(prev: any, next: any): null | Array { if (prev == null || next == null) { - return false; + return null; } - // We can't report anything meaningful for hooks changes. + const indices = []; + let index = 0; if ( next.hasOwnProperty('baseState') && next.hasOwnProperty('memoizedState') && @@ -1472,45 +1463,15 @@ export function attach( ) { while (next !== null) { if (didStatefulHookChange(prev, next)) { - return true; - } else { - next = next.next; - prev = prev.next; + indices.push(index); } + next = next.next; + prev = prev.next; + index++; } } - return false; - } - - function getChangedHooksIndices(prev: any, next: any): null | Array { - if (enableProfilerChangedHookIndices) { - if (prev == null || next == null) { - return null; - } - - const indices = []; - let index = 0; - if ( - next.hasOwnProperty('baseState') && - next.hasOwnProperty('memoizedState') && - next.hasOwnProperty('next') && - next.hasOwnProperty('queue') - ) { - while (next !== null) { - if (didStatefulHookChange(prev, next)) { - indices.push(index); - } - next = next.next; - prev = prev.next; - index++; - } - } - - return indices; - } - - return null; + return indices; } function getChangedKeys(prev: any, next: any): null | Array { diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js index cfe58d74ebb1f..9c29e361fae91 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js @@ -15,11 +15,8 @@ export const consoleManagedByDevToolsDuringStrictMode = false; export const enableLogger = true; -export const enableNamedHooksFeature = true; -export const enableProfilerChangedHookIndices = true; export const enableStyleXFeatures = true; export const isInternalFacebookBuild = true; -export const enableProfilerComponentTree = true; /************************************************************************ * Do not edit the code below. diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js index d773689888181..060e5e808ede2 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js @@ -15,11 +15,8 @@ export const consoleManagedByDevToolsDuringStrictMode = false; export const enableLogger = false; -export const enableNamedHooksFeature = true; -export const enableProfilerChangedHookIndices = true; export const enableStyleXFeatures = false; export const isInternalFacebookBuild = false; -export const enableProfilerComponentTree = true; /************************************************************************ * Do not edit the code below. diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js index f3d5e6f33e818..15b764f8d352b 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js @@ -15,8 +15,5 @@ export const consoleManagedByDevToolsDuringStrictMode = true; export const enableLogger = false; -export const enableNamedHooksFeature = true; -export const enableProfilerChangedHookIndices = true; export const enableStyleXFeatures = false; export const isInternalFacebookBuild = false; -export const enableProfilerComponentTree = true; diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js index c13b8183047d2..b25db375eff29 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js @@ -15,11 +15,8 @@ export const consoleManagedByDevToolsDuringStrictMode = true; export const enableLogger = true; -export const enableNamedHooksFeature = true; -export const enableProfilerChangedHookIndices = true; export const enableStyleXFeatures = true; export const isInternalFacebookBuild = true; -export const enableProfilerComponentTree = true; /************************************************************************ * Do not edit the code below. diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js index b4a19f2425764..144ddb301f848 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js @@ -15,11 +15,8 @@ export const consoleManagedByDevToolsDuringStrictMode = true; export const enableLogger = false; -export const enableNamedHooksFeature = true; -export const enableProfilerChangedHookIndices = true; export const enableStyleXFeatures = false; export const isInternalFacebookBuild = false; -export const enableProfilerComponentTree = true; /************************************************************************ * Do not edit the code below. diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js index ad17311d5e656..13bc39ba2f467 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js @@ -36,7 +36,6 @@ import {loadModule} from 'react-devtools-shared/src/dynamicImportCache'; import FetchFileWithCachingContext from 'react-devtools-shared/src/devtools/views/Components/FetchFileWithCachingContext'; import HookNamesModuleLoaderContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext'; import {SettingsContext} from '../Settings/SettingsContext'; -import {enableNamedHooksFeature} from 'react-devtools-feature-flags'; import type {HookNames} from 'react-devtools-shared/src/types'; import type {ReactNodeList} from 'shared/ReactTypes'; @@ -128,28 +127,26 @@ export function InspectedElementContextController({ if (!elementHasChanged && element !== null) { inspectedElement = inspectElement(element, state.path, store, bridge); - if (enableNamedHooksFeature) { - if (typeof hookNamesModuleLoader === 'function') { - if (parseHookNames || alreadyLoadedHookNames) { - const hookNamesModule = loadModule(hookNamesModuleLoader); - if (hookNamesModule !== null) { - const {parseHookNames: loadHookNamesFunction, purgeCachedMetadata} = - hookNamesModule; + if (typeof hookNamesModuleLoader === 'function') { + if (parseHookNames || alreadyLoadedHookNames) { + const hookNamesModule = loadModule(hookNamesModuleLoader); + if (hookNamesModule !== null) { + const {parseHookNames: loadHookNamesFunction, purgeCachedMetadata} = + hookNamesModule; - purgeCachedMetadataRef.current = purgeCachedMetadata; + purgeCachedMetadataRef.current = purgeCachedMetadata; - if ( - inspectedElement !== null && - inspectedElement.hooks !== null && - loadHookNamesFunction !== null - ) { - hookNames = loadHookNames( - element, - inspectedElement.hooks, - loadHookNamesFunction, - fetchFileWithCaching, - ); - } + if ( + inspectedElement !== null && + inspectedElement.hooks !== null && + loadHookNamesFunction !== null + ) { + hookNames = loadHookNames( + element, + inspectedElement.hooks, + loadHookNamesFunction, + fetchFileWithCaching, + ); } } } diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.js index 61260d6d05d38..b1cae27bc40d1 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.js @@ -22,10 +22,6 @@ import styles from './InspectedElementHooksTree.css'; import useContextMenu from '../../ContextMenu/useContextMenu'; import {meta} from '../../../hydration'; import {getHookSourceLocationKey} from 'react-devtools-shared/src/hookNamesCache'; -import { - enableNamedHooksFeature, - enableProfilerChangedHookIndices, -} from 'react-devtools-feature-flags'; import HookNamesModuleLoaderContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext'; import isArray from 'react-devtools-shared/src/isArray'; @@ -90,8 +86,7 @@ export function InspectedElementHooksTree({ data-testname="InspectedElementHooksTree">
hooks
- {enableNamedHooksFeature && - typeof hookNamesModuleLoader === 'function' && + {typeof hookNamesModuleLoader === 'function' && (!parseHookNames || hookParsingFailed) && ( 0; let name = hook.name; - if (enableProfilerChangedHookIndices) { - if (hookID !== null) { - name = ( - <> - {hookID + 1} - {name} - - ); - } + if (hookID !== null) { + name = ( + <> + {hookID + 1} + {name} + + ); } const type = typeof value; diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js b/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js index 5cac9fc6ef2b3..cb06c98f933a2 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js @@ -34,7 +34,6 @@ import {SettingsModalContextController} from 'react-devtools-shared/src/devtools import portaledContent from '../portaledContent'; import {StoreContext} from '../context'; import {TimelineContext} from 'react-devtools-timeline/src/TimelineContext'; -import {enableProfilerComponentTree} from 'react-devtools-feature-flags'; import styles from './Profiler.css'; @@ -56,8 +55,6 @@ function Profiler(_: {}) { const {supportsTimeline} = useContext(StoreContext); const isLegacyProfilerSelected = selectedTabID !== 'timeline'; - const isRightColumnVisible = - isLegacyProfilerSelected || enableProfilerComponentTree; let view = null; if (didRecordCommits || selectedTabID === 'timeline') { @@ -151,9 +148,7 @@ function Profiler(_: {}) {
- {isRightColumnVisible && ( -
{sidebar}
- )} +
{sidebar}
diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/WhatChanged.js b/packages/react-devtools-shared/src/devtools/views/Profiler/WhatChanged.js index 71002cbea99d0..8ecae81af571c 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/WhatChanged.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/WhatChanged.js @@ -9,7 +9,6 @@ import * as React from 'react'; import {useContext} from 'react'; -import {enableProfilerChangedHookIndices} from 'react-devtools-feature-flags'; import {ProfilerContext} from '../Profiler/ProfilerContext'; import {StoreContext} from '../context'; @@ -103,7 +102,7 @@ export default function WhatChanged({fiberID}: Props): React.Node { } if (didHooksChange) { - if (enableProfilerChangedHookIndices && Array.isArray(hooks)) { + if (Array.isArray(hooks)) { changes.push(
• {hookIndicesToString(hooks)} From 1f248bdd7199979b050e4040ceecfe72dd977fd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 19 Apr 2023 11:46:29 -0400 Subject: [PATCH 06/38] Switching checked to null should leave the current value (#26667) I accidentally made a behavior change in the refactor. It turns out that when switching off `checked` to an uncontrolled component, we used to revert to the concept of "initialChecked" which used to be stored on state. When there's a diff to this computed prop and the value of props.checked is null, then we end up in a case where it sets `checked` to `initialChecked`: https://github.com/facebook/react/blob/5cbe6258bc436b1683080a6d978c27849f1d9a22/packages/react-dom-bindings/src/client/ReactDOMInput.js#L69 Since we never changed `initialChecked` and it's not relevant if non-null `checked` changes value, the only way this "change" could trigger was if we move from having `checked` to having null. This wasn't really consistent with how `value` works, where we instead leave the current value in place regardless. So this is a "bug fix" that changes `checked` to be consistent with `value` and just leave the current value in place. This case should already have a warning in it regardless since it's going from controlled to uncontrolled. Related to that, there was also another issue observed in https://github.com/facebook/react/pull/26596#discussion_r1162295872 and https://github.com/facebook/react/pull/26588 We need to atomically apply mutations on radio buttons. I fixed this by setting the name to empty before doing mutations to value/checked/type in updateInput, and then set the name to whatever it should be. Setting the name is what ends up atomically applying the changes. --------- Co-authored-by: Sophie Alpert --- .../src/client/ReactDOMComponent.js | 126 ++---------------- .../src/client/ReactDOMInput.js | 65 ++++++++- .../src/__tests__/ReactDOMComponent-test.js | 7 +- .../src/__tests__/ReactDOMInput-test.js | 91 ++++++++++++- .../__tests__/ChangeEventPlugin-test.js | 20 +-- 5 files changed, 169 insertions(+), 140 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index 098285224c171..e37c61a35dc97 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -834,6 +834,7 @@ export function setInitialProperties( // listeners still fire for the invalid event. listenToNonDelegatedEvent('invalid', domElement); + let name = null; let type = null; let value = null; let defaultValue = null; @@ -848,31 +849,16 @@ export function setInitialProperties( continue; } switch (propKey) { + case 'name': { + name = propValue; + break; + } case 'type': { - // Fast path since 'type' is very common on inputs - if ( - propValue != null && - typeof propValue !== 'function' && - typeof propValue !== 'symbol' && - typeof propValue !== 'boolean' - ) { - type = propValue; - if (__DEV__) { - checkAttributeStringCoercion(propValue, propKey); - } - domElement.setAttribute(propKey, propValue); - } + type = propValue; break; } case 'checked': { checked = propValue; - const checkedValue = - propValue != null ? propValue : props.defaultChecked; - const inputElement: HTMLInputElement = (domElement: any); - inputElement.checked = - !!checkedValue && - typeof checkedValue !== 'function' && - checkedValue !== 'symbol'; break; } case 'defaultChecked': { @@ -904,7 +890,6 @@ export function setInitialProperties( } // TODO: Make sure we check if this is still unmounted or do any clean // up necessary since we never stop tracking anymore. - track((domElement: any)); validateInputProps(domElement, props); initInput( domElement, @@ -913,8 +898,10 @@ export function setInitialProperties( checked, defaultChecked, type, + name, false, ); + track((domElement: any)); return; } case 'select': { @@ -1010,9 +997,9 @@ export function setInitialProperties( } // TODO: Make sure we check if this is still unmounted or do any clean // up necessary since we never stop tracking anymore. - track((domElement: any)); validateTextareaProps(domElement, props); initTextarea(domElement, value, defaultValue, children); + track((domElement: any)); return; } case 'option': { @@ -1305,14 +1292,6 @@ export function updateProperties( if (lastProps.hasOwnProperty(propKey) && lastProp != null) { switch (propKey) { case 'checked': { - if (!nextProps.hasOwnProperty(propKey)) { - const checkedValue = nextProps.defaultChecked; - const inputElement: HTMLInputElement = (domElement: any); - inputElement.checked = - !!checkedValue && - typeof checkedValue !== 'function' && - checkedValue !== 'symbol'; - } break; } case 'value': { @@ -1341,22 +1320,6 @@ export function updateProperties( switch (propKey) { case 'type': { type = nextProp; - // Fast path since 'type' is very common on inputs - if (nextProp !== lastProp) { - if ( - nextProp != null && - typeof nextProp !== 'function' && - typeof nextProp !== 'symbol' && - typeof nextProp !== 'boolean' - ) { - if (__DEV__) { - checkAttributeStringCoercion(nextProp, propKey); - } - domElement.setAttribute(propKey, nextProp); - } else { - domElement.removeAttribute(propKey); - } - } break; } case 'name': { @@ -1365,15 +1328,6 @@ export function updateProperties( } case 'checked': { checked = nextProp; - if (nextProp !== lastProp) { - const checkedValue = - nextProp != null ? nextProp : nextProps.defaultChecked; - const inputElement: HTMLInputElement = (domElement: any); - inputElement.checked = - !!checkedValue && - typeof checkedValue !== 'function' && - checkedValue !== 'symbol'; - } break; } case 'defaultChecked': { @@ -1453,23 +1407,6 @@ export function updateProperties( } } - // Update checked *before* name. - // In the middle of an update, it is possible to have multiple checked. - // When a checked radio tries to change name, browser makes another radio's checked false. - if ( - name != null && - typeof name !== 'function' && - typeof name !== 'symbol' && - typeof name !== 'boolean' - ) { - if (__DEV__) { - checkAttributeStringCoercion(name, 'name'); - } - domElement.setAttribute('name', name); - } else { - domElement.removeAttribute('name'); - } - // Update the wrapper around inputs *after* updating props. This has to // happen after updating the rest of props. Otherwise HTML5 input validations // raise warnings and prevent the new value from being assigned. @@ -1481,6 +1418,7 @@ export function updateProperties( checked, defaultChecked, type, + name, ); return; } @@ -1822,33 +1760,12 @@ export function updatePropertiesWithDiff( const propValue = updatePayload[i + 1]; switch (propKey) { case 'type': { - // Fast path since 'type' is very common on inputs - if ( - propValue != null && - typeof propValue !== 'function' && - typeof propValue !== 'symbol' && - typeof propValue !== 'boolean' - ) { - if (__DEV__) { - checkAttributeStringCoercion(propValue, propKey); - } - domElement.setAttribute(propKey, propValue); - } else { - domElement.removeAttribute(propKey); - } break; } case 'name': { break; } case 'checked': { - const checkedValue = - propValue != null ? propValue : nextProps.defaultChecked; - const inputElement: HTMLInputElement = (domElement: any); - inputElement.checked = - !!checkedValue && - typeof checkedValue !== 'function' && - checkedValue !== 'symbol'; break; } case 'defaultChecked': { @@ -1916,23 +1833,6 @@ export function updatePropertiesWithDiff( } } - // Update checked *before* name. - // In the middle of an update, it is possible to have multiple checked. - // When a checked radio tries to change name, browser makes another radio's checked false. - if ( - name != null && - typeof name !== 'function' && - typeof name !== 'symbol' && - typeof name !== 'boolean' - ) { - if (__DEV__) { - checkAttributeStringCoercion(name, 'name'); - } - domElement.setAttribute('name', name); - } else { - domElement.removeAttribute('name'); - } - // Update the wrapper around inputs *after* updating props. This has to // happen after updating the rest of props. Otherwise HTML5 input validations // raise warnings and prevent the new value from being assigned. @@ -1944,6 +1844,7 @@ export function updatePropertiesWithDiff( checked, defaultChecked, type, + name, ); return; } @@ -2970,7 +2871,6 @@ export function diffHydratedProperties( listenToNonDelegatedEvent('invalid', domElement); // TODO: Make sure we check if this is still unmounted or do any clean // up necessary since we never stop tracking anymore. - track((domElement: any)); validateInputProps(domElement, props); // For input and textarea we current always set the value property at // post mount to force it to diverge from attributes. However, for @@ -2984,8 +2884,10 @@ export function diffHydratedProperties( props.checked, props.defaultChecked, props.type, + props.name, true, ); + track((domElement: any)); break; case 'option': validateOptionProps(domElement, props); @@ -3008,9 +2910,9 @@ export function diffHydratedProperties( listenToNonDelegatedEvent('invalid', domElement); // TODO: Make sure we check if this is still unmounted or do any clean // up necessary since we never stop tracking anymore. - track((domElement: any)); validateTextareaProps(domElement, props); initTextarea(domElement, props.value, props.defaultValue, props.children); + track((domElement: any)); break; } diff --git a/packages/react-dom-bindings/src/client/ReactDOMInput.js b/packages/react-dom-bindings/src/client/ReactDOMInput.js index 4e8f9b32aa7e3..2096238d762f6 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMInput.js +++ b/packages/react-dom-bindings/src/client/ReactDOMInput.js @@ -89,9 +89,30 @@ export function updateInput( checked: ?boolean, defaultChecked: ?boolean, type: ?string, + name: ?string, ) { const node: HTMLInputElement = (element: any); + // Temporarily disconnect the input from any radio buttons. + // Changing the type or name as the same time as changing the checked value + // needs to be atomically applied. We can only ensure that by disconnecting + // the name while do the mutations and then reapply the name after that's done. + node.name = ''; + + if ( + type != null && + typeof type !== 'function' && + typeof type !== 'symbol' && + typeof type !== 'boolean' + ) { + if (__DEV__) { + checkAttributeStringCoercion(type, 'type'); + } + node.type = type; + } else { + node.removeAttribute('type'); + } + if (value != null) { if (type === 'number') { if ( @@ -157,6 +178,20 @@ export function updateInput( if (checked != null && node.checked !== !!checked) { node.checked = checked; } + + if ( + name != null && + typeof name !== 'function' && + typeof name !== 'symbol' && + typeof name !== 'boolean' + ) { + if (__DEV__) { + checkAttributeStringCoercion(name, 'name'); + } + node.name = name; + } else { + node.removeAttribute('name'); + } } export function initInput( @@ -166,10 +201,23 @@ export function initInput( checked: ?boolean, defaultChecked: ?boolean, type: ?string, + name: ?string, isHydrating: boolean, ) { const node: HTMLInputElement = (element: any); + if ( + type != null && + typeof type !== 'function' && + typeof type !== 'symbol' && + typeof type !== 'boolean' + ) { + if (__DEV__) { + checkAttributeStringCoercion(type, 'type'); + } + node.type = type; + } + if (value != null || defaultValue != null) { const isButton = type === 'submit' || type === 'reset'; @@ -235,10 +283,6 @@ export function initInput( // will sometimes influence the value of checked (even after detachment). // Reference: https://bugs.chromium.org/p/chromium/issues/detail?id=608416 // We need to temporarily unset name to avoid disrupting radio button groups. - const name = node.name; - if (name !== '') { - node.name = ''; - } const checkedOrDefault = checked != null ? checked : defaultChecked; // TODO: This 'function' or 'symbol' check isn't replicated in other places @@ -276,7 +320,16 @@ export function initInput( node.defaultChecked = !!initialChecked; } - if (name !== '') { + // Name needs to be set at the end so that it applies atomically to connected radio buttons. + if ( + name != null && + typeof name !== 'function' && + typeof name !== 'symbol' && + typeof name !== 'boolean' + ) { + if (__DEV__) { + checkAttributeStringCoercion(name, 'name'); + } node.name = name; } } @@ -291,6 +344,7 @@ export function restoreControlledInputState(element: Element, props: Object) { props.checked, props.defaultChecked, props.type, + props.name, ); const name = props.name; if (props.type === 'radio' && name != null) { @@ -347,6 +401,7 @@ export function restoreControlledInputState(element: Element, props: Object) { otherProps.checked, otherProps.defaultChecked, otherProps.type, + otherProps.name, ); } } diff --git a/packages/react-dom/src/__tests__/ReactDOMComponent-test.js b/packages/react-dom/src/__tests__/ReactDOMComponent-test.js index a195bd67bce52..f5493c059fc4d 100644 --- a/packages/react-dom/src/__tests__/ReactDOMComponent-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMComponent-test.js @@ -1143,7 +1143,8 @@ describe('ReactDOMComponent', () => { 'the value changing from a defined to undefined, which should not happen. Decide between ' + 'using a controlled or uncontrolled input element for the lifetime of the component.', ); - expect(nodeValueSetter).toHaveBeenCalledTimes(1); + // This leaves the current checked value in place, just like text inputs. + expect(nodeValueSetter).toHaveBeenCalledTimes(0); expect(() => { ReactDOM.render( @@ -1156,13 +1157,13 @@ describe('ReactDOMComponent', () => { 'using a controlled or uncontrolled input element for the lifetime of the component.', ); - expect(nodeValueSetter).toHaveBeenCalledTimes(2); + expect(nodeValueSetter).toHaveBeenCalledTimes(1); ReactDOM.render( , container, ); - expect(nodeValueSetter).toHaveBeenCalledTimes(3); + expect(nodeValueSetter).toHaveBeenCalledTimes(2); }); it('should ignore attribute list for elements with the "is" attribute', () => { diff --git a/packages/react-dom/src/__tests__/ReactDOMInput-test.js b/packages/react-dom/src/__tests__/ReactDOMInput-test.js index 0c736174e4545..0f02d928c770b 100644 --- a/packages/react-dom/src/__tests__/ReactDOMInput-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMInput-test.js @@ -1191,7 +1191,7 @@ describe('ReactDOMInput', () => { updated: false, }; onClick = () => { - this.setState({updated: true}); + this.setState({updated: !this.state.updated}); }; render() { const {updated} = this.state; @@ -1222,6 +1222,62 @@ describe('ReactDOMInput', () => { expect(firstRadioNode.checked).toBe(false); dispatchEventOnNode(buttonNode, 'click'); expect(firstRadioNode.checked).toBe(true); + dispatchEventOnNode(buttonNode, 'click'); + expect(firstRadioNode.checked).toBe(false); + }); + + it("shouldn't get tricked by changing radio names, part 2", () => { + ReactDOM.render( +
+ {}} + /> + {}} + /> +
, + container, + ); + expect(container.querySelector('input[name="a"][value="1"]').checked).toBe( + true, + ); + expect(container.querySelector('input[name="a"][value="2"]').checked).toBe( + false, + ); + + ReactDOM.render( +
+ {}} + /> + {}} + /> +
, + container, + ); + expect(container.querySelector('input[name="a"][value="1"]').checked).toBe( + true, + ); + expect(container.querySelector('input[name="b"][value="2"]').checked).toBe( + true, + ); }); it('should control radio buttons if the tree updates during render', () => { @@ -1720,8 +1776,18 @@ describe('ReactDOMInput', () => { ) { const el = originalCreateElement.apply(this, arguments); let value = ''; + let typeProp = ''; if (type === 'input') { + Object.defineProperty(el, 'type', { + get: function () { + return typeProp; + }, + set: function (val) { + typeProp = String(val); + log.push('set property type'); + }, + }); Object.defineProperty(el, 'value', { get: function () { return value; @@ -1751,10 +1817,10 @@ describe('ReactDOMInput', () => { ); expect(log).toEqual([ - 'set attribute type', 'set attribute min', 'set attribute max', 'set attribute step', + 'set property type', 'set property value', ]); }); @@ -1810,6 +1876,14 @@ describe('ReactDOMInput', () => { HTMLInputElement.prototype, 'value', ).set; + const getType = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'type', + ).get; + const setType = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'type', + ).set; if (type === 'input') { Object.defineProperty(el, 'defaultValue', { get: function () { @@ -1829,6 +1903,15 @@ describe('ReactDOMInput', () => { setValue.call(this, val); }, }); + Object.defineProperty(el, 'type', { + get: function () { + return getType.call(this); + }, + set: function (val) { + log.push(`node.type = ${strify(val)}`); + setType.call(this, val); + }, + }); spyOnDevAndProd(el, 'setAttribute').mockImplementation(function ( name, val, @@ -1843,14 +1926,14 @@ describe('ReactDOMInput', () => { if (disableInputAttributeSyncing) { expect(log).toEqual([ - 'node.setAttribute("type", "date")', + 'node.type = "date"', 'node.defaultValue = "1980-01-01"', // TODO: it's possible this reintroduces the bug because we don't assign `value` at all. // Need to check this on mobile Safari and Chrome. ]); } else { expect(log).toEqual([ - 'node.setAttribute("type", "date")', + 'node.type = "date"', // value must be assigned before defaultValue. This fixes an issue where the // visually displayed value of date inputs disappears on mobile Safari and Chrome: // https://github.com/facebook/react/issues/7233 diff --git a/packages/react-dom/src/events/plugins/__tests__/ChangeEventPlugin-test.js b/packages/react-dom/src/events/plugins/__tests__/ChangeEventPlugin-test.js index f4f75bfd52233..ae27973a0c603 100644 --- a/packages/react-dom/src/events/plugins/__tests__/ChangeEventPlugin-test.js +++ b/packages/react-dom/src/events/plugins/__tests__/ChangeEventPlugin-test.js @@ -12,7 +12,6 @@ let React; let ReactDOM; let ReactDOMClient; -let ReactFeatureFlags; let Scheduler; let act; let waitForAll; @@ -39,7 +38,6 @@ describe('ChangeEventPlugin', () => { beforeEach(() => { jest.resetModules(); - ReactFeatureFlags = require('shared/ReactFeatureFlags'); // TODO pull this into helper method, reduce repetition. // mock the browser APIs which are used in schedule: // - calling 'window.postMessage' should actually fire postmessage handlers @@ -100,13 +98,8 @@ describe('ChangeEventPlugin', () => { node.dispatchEvent(new Event('input', {bubbles: true, cancelable: true})); node.dispatchEvent(new Event('change', {bubbles: true, cancelable: true})); - if (ReactFeatureFlags.disableInputAttributeSyncing) { - // TODO: figure out why. This might be a bug. - expect(called).toBe(1); - } else { - // There should be no React change events because the value stayed the same. - expect(called).toBe(0); - } + // There should be no React change events because the value stayed the same. + expect(called).toBe(0); }); it('should consider initial text value to be current (capture)', () => { @@ -124,13 +117,8 @@ describe('ChangeEventPlugin', () => { node.dispatchEvent(new Event('input', {bubbles: true, cancelable: true})); node.dispatchEvent(new Event('change', {bubbles: true, cancelable: true})); - if (ReactFeatureFlags.disableInputAttributeSyncing) { - // TODO: figure out why. This might be a bug. - expect(called).toBe(1); - } else { - // There should be no React change events because the value stayed the same. - expect(called).toBe(0); - } + // There should be no React change events because the value stayed the same. + expect(called).toBe(0); }); it('should not invoke a change event for textarea same value', () => { From 6d394e3d26a1e785d0510d732eedcdd2be3e92b5 Mon Sep 17 00:00:00 2001 From: Jan Kassens Date: Wed, 19 Apr 2023 12:11:40 -0400 Subject: [PATCH 07/38] [actions] commit from special branches iff they exist (#26673) This creates 2 special branches. If these special branches exist, we'll commit build artifacts from these branches, main otherwise. --- .github/workflows/commit_artifacts.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/commit_artifacts.yml b/.github/workflows/commit_artifacts.yml index fe6da856587db..11676a0dc04b5 100644 --- a/.github/workflows/commit_artifacts.yml +++ b/.github/workflows/commit_artifacts.yml @@ -1,13 +1,22 @@ -name: Commit Artifacts for Facebook WWW and fbsource +name: Commit Artifacts for Meta WWW and fbsource on: push: - branches: [main] + branches: [main, meta-www, meta-fbsource] jobs: download_artifacts: runs-on: ubuntu-latest + outputs: + www_branch_count: ${{ steps.check_branches.outputs.www_branch_count }} + fbsource_branch_count: ${{ steps.check_branches.outputs.fbsource_branch_count }} steps: + - uses: actions/checkout@v3 + - name: "Check branches" + id: check_branches + run: | + echo "www_branch_count=$(git ls-remote --heads origin "refs/heads/meta-www" | wc -l)" >> "$GITHUB_OUTPUT" + echo "fbsource_branch_count=$(git ls-remote --heads origin "refs/heads/meta-fbsource" | wc -l)" >> "$GITHUB_OUTPUT" - name: Download and unzip artifacts uses: actions/github-script@v6 env: @@ -168,6 +177,7 @@ jobs: commit_www_artifacts: needs: download_artifacts + if: ${{ (github.ref == 'refs/heads/main' && needs.download_artifacts.outputs.www_branch_count == '0') || github.ref == 'refs/heads/meta-www' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -204,6 +214,7 @@ jobs: commit_fbsource_artifacts: needs: download_artifacts runs-on: ubuntu-latest + if: ${{ (github.ref == 'refs/heads/main' && needs.download_artifacts.outputs.fbsource_branch_count == '0') || github.ref == 'refs/heads/meta-fbsource' }} steps: - uses: actions/checkout@v3 with: From cd2b79dedd6d81abb0b01d38396afa083feaf9e9 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 19 Apr 2023 13:33:11 -0400 Subject: [PATCH 08/38] Initial (client-only) async actions support (#26621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements initial (client-only) support for async actions behind a flag. This is an experimental feature and the design isn't completely finalized but we're getting closer. It will be layered alongside other features we're working on, so it may not feel complete when considered in isolation. The basic description is you can pass an async function to `startTransition` and all the transition updates that are scheduled inside that async function will be grouped together. The `isPending` flag will be set to true immediately, and only set back to false once the async action has completed (as well as all the updates that it triggers). The ideal behavior would be that all updates spawned by the async action are automatically inferred and grouped together; however, doing this properly requires the upcoming (stage 2) Async Context API, which is not yet implemented by browsers. In the meantime, we will fake this by grouping together all transition updates that occur until the async function has terminated. This can lead to overgrouping between unrelated actions, which is not wrong per se, just not ideal. If the `useTransition` hook is removed from the UI before an async action has completed — for example, if the user navigates to a new page — subsequent transitions will no longer be grouped with together with that action. Another consequence of the lack of Async Context is that if you call `setState` inside an action but after an `await`, it must be wrapped in `startTransition` in order to be grouped properly. If we didn't require this, then there would be no way to distinguish action updates from urgent updates caused by user input, too. This is an unfortunate footgun but we can likely detect the most common mistakes using a lint rule. Once Async Context lands in browsers, we can start warning in dev if we detect an update that hasn't been wrapped in `startTransition`. Then, longer term, once the feature is ubiquitous, we can rely on it for real and allow you to call `setState` without the additional wrapper. Things that are _not_ yet implemented in this PR, but will be added as follow ups: - Support for non-hook form of `startTransition` - Canceling the async action scope if the `useTransition` hook is deleted from the UI - Anything related to server actions --- .../src/ReactFiberAsyncAction.js | 130 +++++ .../react-reconciler/src/ReactFiberHooks.js | 111 ++-- .../src/ReactFiberRootScheduler.js | 20 +- .../src/ReactFiberWorkLoop.js | 26 +- .../src/__tests__/ReactAsyncActions-test.js | 530 ++++++++++++++++++ packages/shared/ReactFeatureFlags.js | 2 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + .../ReactFeatureFlags.test-renderer.native.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../forks/ReactFeatureFlags.www-dynamic.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + 13 files changed, 771 insertions(+), 55 deletions(-) create mode 100644 packages/react-reconciler/src/ReactFiberAsyncAction.js create mode 100644 packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js diff --git a/packages/react-reconciler/src/ReactFiberAsyncAction.js b/packages/react-reconciler/src/ReactFiberAsyncAction.js new file mode 100644 index 0000000000000..4c1b67e11eed2 --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberAsyncAction.js @@ -0,0 +1,130 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Wakeable} from 'shared/ReactTypes'; +import type {Lane} from './ReactFiberLane'; +import {requestTransitionLane} from './ReactFiberRootScheduler'; + +interface AsyncActionImpl { + lane: Lane; + listeners: Array<(false) => mixed>; + count: number; + then( + onFulfill: (value: boolean) => mixed, + onReject: (error: mixed) => mixed, + ): void; +} + +interface PendingAsyncAction extends AsyncActionImpl { + status: 'pending'; +} + +interface FulfilledAsyncAction extends AsyncActionImpl { + status: 'fulfilled'; + value: boolean; +} + +interface RejectedAsyncAction extends AsyncActionImpl { + status: 'rejected'; + reason: mixed; +} + +type AsyncAction = + | PendingAsyncAction + | FulfilledAsyncAction + | RejectedAsyncAction; + +let currentAsyncAction: AsyncAction | null = null; + +export function requestAsyncActionContext( + actionReturnValue: mixed, +): AsyncAction | false { + if ( + actionReturnValue !== null && + typeof actionReturnValue === 'object' && + typeof actionReturnValue.then === 'function' + ) { + // This is an async action. + // + // Return a thenable that resolves once the action scope (i.e. the async + // function passed to startTransition) has finished running. The fulfilled + // value is `false` to represent that the action is not pending. + const thenable: Wakeable = (actionReturnValue: any); + if (currentAsyncAction === null) { + // There's no outer async action scope. Create a new one. + const asyncAction: AsyncAction = { + lane: requestTransitionLane(), + listeners: [], + count: 0, + status: 'pending', + value: false, + reason: undefined, + then(resolve: boolean => mixed) { + asyncAction.listeners.push(resolve); + }, + }; + attachPingListeners(thenable, asyncAction); + currentAsyncAction = asyncAction; + return asyncAction; + } else { + // Inherit the outer scope. + const asyncAction: AsyncAction = (currentAsyncAction: any); + attachPingListeners(thenable, asyncAction); + return asyncAction; + } + } else { + // This is not an async action, but it may be part of an outer async action. + if (currentAsyncAction === null) { + // There's no outer async action scope. + return false; + } else { + // Inherit the outer scope. + return currentAsyncAction; + } + } +} + +export function peekAsyncActionContext(): AsyncAction | null { + return currentAsyncAction; +} + +function attachPingListeners(thenable: Wakeable, asyncAction: AsyncAction) { + asyncAction.count++; + thenable.then( + () => { + if (--asyncAction.count === 0) { + const fulfilledAsyncAction: FulfilledAsyncAction = (asyncAction: any); + fulfilledAsyncAction.status = 'fulfilled'; + completeAsyncActionScope(asyncAction); + } + }, + (error: mixed) => { + if (--asyncAction.count === 0) { + const rejectedAsyncAction: RejectedAsyncAction = (asyncAction: any); + rejectedAsyncAction.status = 'rejected'; + rejectedAsyncAction.reason = error; + completeAsyncActionScope(asyncAction); + } + }, + ); + return asyncAction; +} + +function completeAsyncActionScope(action: AsyncAction) { + if (currentAsyncAction === action) { + currentAsyncAction = null; + } + + const listeners = action.listeners; + action.listeners = []; + for (let i = 0; i < listeners.length; i++) { + const listener = listeners[i]; + listener(false); + } +} diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 4be1994114a32..7bafad3c6f827 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -15,6 +15,7 @@ import type { StartTransitionOptions, Usable, Thenable, + RejectedThenable, } from 'shared/ReactTypes'; import type { Fiber, @@ -41,6 +42,7 @@ import { enableUseEffectEventHook, enableLegacyCache, debugRenderPhaseSideEffectsForStrictMode, + enableAsyncActions, } from 'shared/ReactFeatureFlags'; import { REACT_CONTEXT_TYPE, @@ -143,6 +145,7 @@ import { } from './ReactFiberThenable'; import type {ThenableState} from './ReactFiberThenable'; import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent'; +import {requestAsyncActionContext} from './ReactFiberAsyncAction'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -947,38 +950,40 @@ if (enableUseMemoCacheHook) { }; } +function useThenable(thenable: Thenable): T { + // Track the position of the thenable within this fiber. + const index = thenableIndexCounter; + thenableIndexCounter += 1; + if (thenableState === null) { + thenableState = createThenableState(); + } + const result = trackUsedThenable(thenableState, thenable, index); + if ( + currentlyRenderingFiber.alternate === null && + (workInProgressHook === null + ? currentlyRenderingFiber.memoizedState === null + : workInProgressHook.next === null) + ) { + // Initial render, and either this is the first time the component is + // called, or there were no Hooks called after this use() the previous + // time (perhaps because it threw). Subsequent Hook calls should use the + // mount dispatcher. + if (__DEV__) { + ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV; + } else { + ReactCurrentDispatcher.current = HooksDispatcherOnMount; + } + } + return result; +} + function use(usable: Usable): T { if (usable !== null && typeof usable === 'object') { // $FlowFixMe[method-unbinding] if (typeof usable.then === 'function') { // This is a thenable. const thenable: Thenable = (usable: any); - - // Track the position of the thenable within this fiber. - const index = thenableIndexCounter; - thenableIndexCounter += 1; - - if (thenableState === null) { - thenableState = createThenableState(); - } - const result = trackUsedThenable(thenableState, thenable, index); - if ( - currentlyRenderingFiber.alternate === null && - (workInProgressHook === null - ? currentlyRenderingFiber.memoizedState === null - : workInProgressHook.next === null) - ) { - // Initial render, and either this is the first time the component is - // called, or there were no Hooks called after this use() the previous - // time (perhaps because it threw). Subsequent Hook calls should use the - // mount dispatcher. - if (__DEV__) { - ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV; - } else { - ReactCurrentDispatcher.current = HooksDispatcherOnMount; - } - } - return result; + return useThenable(thenable); } else if ( usable.$$typeof === REACT_CONTEXT_TYPE || usable.$$typeof === REACT_SERVER_CONTEXT_TYPE @@ -2400,8 +2405,8 @@ function updateDeferredValueImpl(hook: Hook, prevValue: T, value: T): T { } function startTransition( - setPending: boolean => void, - callback: () => void, + setPending: (Thenable | boolean) => void, + callback: () => mixed, options?: StartTransitionOptions, ): void { const previousPriority = getCurrentUpdatePriority(); @@ -2427,8 +2432,36 @@ function startTransition( } try { - setPending(false); - callback(); + if (enableAsyncActions) { + const returnValue = callback(); + + // `isPending` is either `false` or a thenable that resolves to `false`, + // depending on whether the action scope is an async function. In the + // async case, the resulting render will suspend until the async action + // scope has finished. + const isPending = requestAsyncActionContext(returnValue); + setPending(isPending); + } else { + // Async actions are not enabled. + setPending(false); + callback(); + } + } catch (error) { + if (enableAsyncActions) { + // This is a trick to get the `useTransition` hook to rethrow the error. + // When it unwraps the thenable with the `use` algorithm, the error + // will be thrown. + const rejectedThenable: RejectedThenable = { + then() {}, + status: 'rejected', + reason: error, + }; + setPending(rejectedThenable); + } else { + // The error rethrowing behavior is only enabled when the async actions + // feature is on, even for sync actions. + throw error; + } } finally { setCurrentUpdatePriority(previousPriority); @@ -2454,21 +2487,26 @@ function mountTransition(): [ boolean, (callback: () => void, options?: StartTransitionOptions) => void, ] { - const [isPending, setPending] = mountState(false); + const [, setPending] = mountState((false: Thenable | boolean)); // The `start` method never changes. const start = startTransition.bind(null, setPending); const hook = mountWorkInProgressHook(); hook.memoizedState = start; - return [isPending, start]; + return [false, start]; } function updateTransition(): [ boolean, (callback: () => void, options?: StartTransitionOptions) => void, ] { - const [isPending] = updateState(false); + const [booleanOrThenable] = updateState(false); const hook = updateWorkInProgressHook(); const start = hook.memoizedState; + const isPending = + typeof booleanOrThenable === 'boolean' + ? booleanOrThenable + : // This will suspend until the async action scope has finished. + useThenable(booleanOrThenable); return [isPending, start]; } @@ -2476,9 +2514,14 @@ function rerenderTransition(): [ boolean, (callback: () => void, options?: StartTransitionOptions) => void, ] { - const [isPending] = rerenderState(false); + const [booleanOrThenable] = rerenderState(false); const hook = updateWorkInProgressHook(); const start = hook.memoizedState; + const isPending = + typeof booleanOrThenable === 'boolean' + ? booleanOrThenable + : // This will suspend until the async action scope has finished. + useThenable(booleanOrThenable); return [isPending, start]; } diff --git a/packages/react-reconciler/src/ReactFiberRootScheduler.js b/packages/react-reconciler/src/ReactFiberRootScheduler.js index 86f882a025726..8e0c65b8552df 100644 --- a/packages/react-reconciler/src/ReactFiberRootScheduler.js +++ b/packages/react-reconciler/src/ReactFiberRootScheduler.js @@ -22,6 +22,7 @@ import { markStarvedLanesAsExpired, markRootEntangled, mergeLanes, + claimNextTransitionLane, } from './ReactFiberLane'; import { CommitContext, @@ -78,7 +79,7 @@ let mightHavePendingSyncWork: boolean = false; let isFlushingWork: boolean = false; -let currentEventTransitionLane: Lane = NoLanes; +let currentEventTransitionLane: Lane = NoLane; export function ensureRootIsScheduled(root: FiberRoot): void { // This function is called whenever a root receives an update. It does two @@ -491,10 +492,17 @@ function scheduleImmediateTask(cb: () => mixed) { } } -export function getCurrentEventTransitionLane(): Lane { +export function requestTransitionLane(): Lane { + // The algorithm for assigning an update to a lane should be stable for all + // updates at the same priority within the same event. To do this, the + // inputs to the algorithm must be the same. + // + // The trick we use is to cache the first of each of these inputs within an + // event. Then reset the cached values once we can be sure the event is + // over. Our heuristic for that is whenever we enter a concurrent work loop. + if (currentEventTransitionLane === NoLane) { + // All transitions within the same event are assigned the same lane. + currentEventTransitionLane = claimNextTransitionLane(); + } return currentEventTransitionLane; } - -export function setCurrentEventTransitionLane(lane: Lane): void { - currentEventTransitionLane = lane; -} diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 89781a4cfad26..96c4ea9255111 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -129,7 +129,6 @@ import { NoLanes, NoLane, SyncLane, - claimNextTransitionLane, claimNextRetryLane, includesSyncLane, isSubsetOfLanes, @@ -278,10 +277,10 @@ import { flushSyncWorkOnAllRoots, flushSyncWorkOnLegacyRootsOnly, getContinuationForRoot, - getCurrentEventTransitionLane, - setCurrentEventTransitionLane, + requestTransitionLane, } from './ReactFiberRootScheduler'; import {getMaskedContext, getUnmaskedContext} from './ReactFiberContext'; +import {peekAsyncActionContext} from './ReactFiberAsyncAction'; const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; @@ -633,18 +632,15 @@ export function requestUpdateLane(fiber: Fiber): Lane { transition._updatedFibers.add(fiber); } - // The algorithm for assigning an update to a lane should be stable for all - // updates at the same priority within the same event. To do this, the - // inputs to the algorithm must be the same. - // - // The trick we use is to cache the first of each of these inputs within an - // event. Then reset the cached values once we can be sure the event is - // over. Our heuristic for that is whenever we enter a concurrent work loop. - if (getCurrentEventTransitionLane() === NoLane) { - // All transitions within the same event are assigned the same lane. - setCurrentEventTransitionLane(claimNextTransitionLane()); - } - return getCurrentEventTransitionLane(); + + const asyncAction = peekAsyncActionContext(); + return asyncAction !== null + ? // We're inside an async action scope. Reuse the same lane. + asyncAction.lane + : // We may or may not be inside an async action scope. If we are, this + // is the first update in that scope. Either way, we need to get a + // fresh transition lane. + requestTransitionLane(); } // Updates originating inside certain React methods, like flushSync, have diff --git a/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js b/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js new file mode 100644 index 0000000000000..3556c096918bd --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js @@ -0,0 +1,530 @@ +let React; +let ReactNoop; +let Scheduler; +let act; +let assertLog; +let useTransition; +let useState; +let textCache; + +describe('ReactAsyncActions', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + act = require('internal-test-utils').act; + assertLog = require('internal-test-utils').assertLog; + useTransition = React.useTransition; + useState = React.useState; + + textCache = new Map(); + }); + + function resolveText(text) { + const record = textCache.get(text); + if (record === undefined) { + const newRecord = { + status: 'resolved', + value: text, + }; + textCache.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'resolved'; + record.value = text; + thenable.pings.forEach(t => t()); + } + } + + function readText(text) { + const record = textCache.get(text); + if (record !== undefined) { + switch (record.status) { + case 'pending': + Scheduler.log(`Suspend! [${text}]`); + throw record.value; + case 'rejected': + throw record.value; + case 'resolved': + return record.value; + } + } else { + Scheduler.log(`Suspend! [${text}]`); + const thenable = { + pings: [], + then(resolve) { + if (newRecord.status === 'pending') { + thenable.pings.push(resolve); + } else { + Promise.resolve().then(() => resolve(newRecord.value)); + } + }, + }; + + const newRecord = { + status: 'pending', + value: thenable, + }; + textCache.set(text, newRecord); + + throw thenable; + } + } + + function getText(text) { + const record = textCache.get(text); + if (record === undefined) { + const thenable = { + pings: [], + then(resolve) { + if (newRecord.status === 'pending') { + thenable.pings.push(resolve); + } else { + Promise.resolve().then(() => resolve(newRecord.value)); + } + }, + }; + const newRecord = { + status: 'pending', + value: thenable, + }; + textCache.set(text, newRecord); + return thenable; + } else { + switch (record.status) { + case 'pending': + return record.value; + case 'rejected': + return Promise.reject(record.value); + case 'resolved': + return Promise.resolve(record.value); + } + } + } + + function Text({text}) { + Scheduler.log(text); + return text; + } + + function AsyncText({text}) { + readText(text); + Scheduler.log(text); + return text; + } + + // @gate enableAsyncActions + test('isPending remains true until async action finishes', async () => { + let startTransition; + function App() { + const [isPending, _start] = useTransition(); + startTransition = _start; + return ; + } + + const root = ReactNoop.createRoot(); + await act(() => { + root.render(); + }); + assertLog(['Pending: false']); + expect(root).toMatchRenderedOutput('Pending: false'); + + // At the start of an async action, isPending is set to true. + await act(() => { + startTransition(async () => { + Scheduler.log('Async action started'); + await getText('Wait'); + Scheduler.log('Async action ended'); + }); + }); + assertLog(['Async action started', 'Pending: true']); + expect(root).toMatchRenderedOutput('Pending: true'); + + // Once the action finishes, isPending is set back to false. + await act(() => resolveText('Wait')); + assertLog(['Async action ended', 'Pending: false']); + expect(root).toMatchRenderedOutput('Pending: false'); + }); + + // @gate enableAsyncActions + test('multiple updates in an async action scope are entangled together', async () => { + let startTransition; + function App({text}) { + const [isPending, _start] = useTransition(); + startTransition = _start; + return ( + <> + + + + + + + + ); + } + + const root = ReactNoop.createRoot(); + await act(() => { + root.render(); + }); + assertLog(['Pending: false', 'A']); + expect(root).toMatchRenderedOutput( + <> + Pending: false + A + , + ); + + await act(() => { + startTransition(async () => { + Scheduler.log('Async action started'); + await getText('Yield before updating'); + Scheduler.log('Async action ended'); + startTransition(() => root.render()); + }); + }); + assertLog(['Async action started', 'Pending: true', 'A']); + expect(root).toMatchRenderedOutput( + <> + Pending: true + A + , + ); + + await act(() => resolveText('Yield before updating')); + assertLog(['Async action ended', 'Pending: false', 'B']); + expect(root).toMatchRenderedOutput( + <> + Pending: false + B + , + ); + }); + + // @gate enableAsyncActions + test('multiple async action updates in the same scope are entangled together', async () => { + let setStepA; + function A() { + const [step, setStep] = useState(0); + setStepA = setStep; + return ; + } + + let setStepB; + function B() { + const [step, setStep] = useState(0); + setStepB = setStep; + return ; + } + + let setStepC; + function C() { + const [step, setStep] = useState(0); + setStepC = setStep; + return ; + } + + let startTransition; + function App() { + const [isPending, _start] = useTransition(); + startTransition = _start; + return ( + <> + + + + + , , + + + ); + } + + const root = ReactNoop.createRoot(); + resolveText('A0'); + resolveText('B0'); + resolveText('C0'); + await act(() => { + root.render(); + }); + assertLog(['Pending: false', 'A0', 'B0', 'C0']); + expect(root).toMatchRenderedOutput( + <> + Pending: false + A0, B0, C0 + , + ); + + await act(() => { + startTransition(async () => { + Scheduler.log('Async action started'); + setStepA(1); + await getText('Wait before updating B'); + startTransition(() => setStepB(1)); + await getText('Wait before updating C'); + startTransition(() => setStepC(1)); + Scheduler.log('Async action ended'); + }); + }); + assertLog(['Async action started', 'Pending: true', 'A0', 'B0', 'C0']); + expect(root).toMatchRenderedOutput( + <> + Pending: true + A0, B0, C0 + , + ); + + // This will schedule an update on B, but nothing will render yet because + // the async action scope hasn't finished. + await act(() => resolveText('Wait before updating B')); + assertLog([]); + expect(root).toMatchRenderedOutput( + <> + Pending: true + A0, B0, C0 + , + ); + + // This will schedule an update on C, and also the async action scope + // will end. This will allow React to attempt to render the updates. + await act(() => resolveText('Wait before updating C')); + assertLog(['Async action ended', 'Pending: false', 'Suspend! [A1]']); + expect(root).toMatchRenderedOutput( + <> + Pending: true + A0, B0, C0 + , + ); + + // Progressively load the all the data. Because they are all entangled + // together, only when the all of A, B, and C updates are unblocked is the + // render allowed to proceed. + await act(() => resolveText('A1')); + assertLog(['Pending: false', 'A1', 'Suspend! [B1]']); + expect(root).toMatchRenderedOutput( + <> + Pending: true + A0, B0, C0 + , + ); + await act(() => resolveText('B1')); + assertLog(['Pending: false', 'A1', 'B1', 'Suspend! [C1]']); + expect(root).toMatchRenderedOutput( + <> + Pending: true + A0, B0, C0 + , + ); + + // Finally, all the data has loaded and the transition is complete. + await act(() => resolveText('C1')); + assertLog(['Pending: false', 'A1', 'B1', 'C1']); + expect(root).toMatchRenderedOutput( + <> + Pending: false + A1, B1, C1 + , + ); + }); + + // @gate enableAsyncActions + test('urgent updates are not blocked during an async action', async () => { + let setStepA; + function A() { + const [step, setStep] = useState(0); + setStepA = setStep; + return ; + } + + let setStepB; + function B() { + const [step, setStep] = useState(0); + setStepB = setStep; + return ; + } + + let startTransition; + function App() { + const [isPending, _start] = useTransition(); + startTransition = _start; + return ( + <> + + + + + , + + + ); + } + + const root = ReactNoop.createRoot(); + await act(() => { + root.render(); + }); + assertLog(['Pending: false', 'A0', 'B0']); + expect(root).toMatchRenderedOutput( + <> + Pending: false + A0, B0 + , + ); + + await act(() => { + startTransition(async () => { + Scheduler.log('Async action started'); + startTransition(() => setStepA(1)); + await getText('Wait'); + Scheduler.log('Async action ended'); + }); + }); + assertLog(['Async action started', 'Pending: true', 'A0', 'B0']); + expect(root).toMatchRenderedOutput( + <> + Pending: true + A0, B0 + , + ); + + // Update B at urgent priority. This should be allowed to finish. + await act(() => setStepB(1)); + assertLog(['B1']); + expect(root).toMatchRenderedOutput( + <> + Pending: true + A0, B1 + , + ); + + // Finish the async action. + await act(() => resolveText('Wait')); + assertLog(['Async action ended', 'Pending: false', 'A1', 'B1']); + expect(root).toMatchRenderedOutput( + <> + Pending: false + A1, B1 + , + ); + }); + + // @gate enableAsyncActions + test("if a sync action throws, it's rethrown from the `useTransition`", async () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return ; + } + return this.props.children; + } + } + + let startTransition; + function App() { + const [isPending, _start] = useTransition(); + startTransition = _start; + return ; + } + + const root = ReactNoop.createRoot(); + await act(() => { + root.render( + + + , + ); + }); + assertLog(['Pending: false']); + expect(root).toMatchRenderedOutput('Pending: false'); + + await act(() => { + startTransition(() => { + throw new Error('Oops!'); + }); + }); + assertLog(['Pending: true', 'Oops!', 'Oops!']); + expect(root).toMatchRenderedOutput('Oops!'); + }); + + // @gate enableAsyncActions + test("if an async action throws, it's rethrown from the `useTransition`", async () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return ; + } + return this.props.children; + } + } + + let startTransition; + function App() { + const [isPending, _start] = useTransition(); + startTransition = _start; + return ; + } + + const root = ReactNoop.createRoot(); + await act(() => { + root.render( + + + , + ); + }); + assertLog(['Pending: false']); + expect(root).toMatchRenderedOutput('Pending: false'); + + await act(() => { + startTransition(async () => { + Scheduler.log('Async action started'); + await getText('Wait'); + throw new Error('Oops!'); + }); + }); + assertLog(['Async action started', 'Pending: true']); + expect(root).toMatchRenderedOutput('Pending: true'); + + await act(() => resolveText('Wait')); + assertLog(['Oops!', 'Oops!']); + expect(root).toMatchRenderedOutput('Oops!'); + }); + + // @gate !enableAsyncActions + test('when enableAsyncActions is disabled, and a sync action throws, `isPending` is turned off', async () => { + let startTransition; + function App() { + const [isPending, _start] = useTransition(); + startTransition = _start; + return ; + } + + const root = ReactNoop.createRoot(); + await act(() => { + root.render(); + }); + assertLog(['Pending: false']); + expect(root).toMatchRenderedOutput('Pending: false'); + + await act(() => { + expect(() => { + startTransition(() => { + throw new Error('Oops!'); + }); + }).toThrow('Oops!'); + }); + assertLog(['Pending: true', 'Pending: false']); + expect(root).toMatchRenderedOutput('Pending: false'); + }); +}); diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 58500c8239738..5bb3bd112888d 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -120,6 +120,8 @@ export const enableFizzExternalRuntime = true; // Performance related test export const diffInCommitPhase = __EXPERIMENTAL__; +export const enableAsyncActions = __EXPERIMENTAL__; + // ----------------------------------------------------------------------------- // Chopping Block // diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index cc7dc4ef91d47..0e2c915731459 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -82,6 +82,7 @@ export const useModernStrictMode = false; export const enableFizzExternalRuntime = false; export const diffInCommitPhase = true; +export const enableAsyncActions = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index ebb4f2b154db5..ef559e28b59f9 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -72,6 +72,7 @@ export const enableFizzExternalRuntime = false; export const enableDeferRootSchedulingToMicrotask = true; export const diffInCommitPhase = true; +export const enableAsyncActions = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index b733725c9dc85..221663c3a8c8a 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -72,6 +72,7 @@ export const enableFizzExternalRuntime = false; export const enableDeferRootSchedulingToMicrotask = true; export const diffInCommitPhase = true; +export const enableAsyncActions = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js index 2554dd117a293..d4a0e763f7bc3 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js @@ -69,6 +69,7 @@ export const useModernStrictMode = false; export const enableDeferRootSchedulingToMicrotask = true; export const diffInCommitPhase = true; +export const enableAsyncActions = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 25a318e81cf24..bff726e27c01a 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -74,6 +74,7 @@ export const enableFizzExternalRuntime = false; export const enableDeferRootSchedulingToMicrotask = true; export const diffInCommitPhase = true; +export const enableAsyncActions = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index 2a45a1969cad6..ad284d30c545c 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -26,6 +26,7 @@ export const enableTransitionTracing = __VARIANT__; export const enableCustomElementPropertySupport = __VARIANT__; export const enableDeferRootSchedulingToMicrotask = __VARIANT__; export const diffInCommitPhase = __VARIANT__; +export const enableAsyncActions = __VARIANT__; // Enable this flag to help with concurrent mode debugging. // It logs information to the console about React scheduling, rendering, and commit phases. diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index d6143be018d9f..25fb6d818c2cc 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -29,6 +29,7 @@ export const { enableCustomElementPropertySupport, enableDeferRootSchedulingToMicrotask, diffInCommitPhase, + enableAsyncActions, } = dynamicFeatureFlags; // On WWW, __EXPERIMENTAL__ is used for a new modern build. From c826dc50de288758a0b783b2fd37b40a3b512fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 19 Apr 2023 16:31:08 -0400 Subject: [PATCH 09/38] Add (Client) Functions as Form Actions (#26674) This lets you pass a function to `
` or `
+ + ); + } + + const stream = await ReactDOMServer.renderToReadableStream(); + await readIntoContainer(stream); + await act(async () => { + ReactDOMClient.hydrateRoot(container, ); + }); + + expect(savedTitle).toBe(null); + expect(deletedTitle).toBe(null); + + submit(inputRef.current); + expect(savedTitle).toBe('Hello'); + expect(deletedTitle).toBe(null); + savedTitle = null; + + submit(buttonRef.current); + expect(savedTitle).toBe(null); + expect(deletedTitle).toBe('Hello'); + deletedTitle = null; + + expect(rootActionCalled).toBe(false); + }); + + // @gate enableFormActions || !__DEV__ + it('should warn when passing a function action during SSR and string during hydration', async () => { + function action(formData) {} + function App({isClient}) { + return ( +
+ +
+ ); + } + + const stream = await ReactDOMServer.renderToReadableStream(); + await readIntoContainer(stream); + await expect(async () => { + await act(async () => { + ReactDOMClient.hydrateRoot(container, ); + }); + }).toErrorDev( + 'Prop `action` did not match. Server: "function" Client: "action"', + ); + }); + + // @gate enableFormActions || !__DEV__ + it('should warn when passing a string during SSR and function during hydration', async () => { + function action(formData) {} + function App({isClient}) { + return ( +
+ +
+ ); + } + + const stream = await ReactDOMServer.renderToReadableStream(); + await readIntoContainer(stream); + await expect(async () => { + await act(async () => { + ReactDOMClient.hydrateRoot(container, ); + }); + }).toErrorDev( + 'Prop `action` did not match. Server: "action" Client: "function action(formData) {}"', + ); + }); +}); diff --git a/packages/react-dom/src/__tests__/ReactDOMForm-test.js b/packages/react-dom/src/__tests__/ReactDOMForm-test.js new file mode 100644 index 0000000000000..d52ab9087d943 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js @@ -0,0 +1,453 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +global.IS_REACT_ACT_ENVIRONMENT = true; + +// Our current version of JSDOM doesn't implement the event dispatching +// so we polyfill it. +const NativeFormData = global.FormData; +const FormDataPolyfill = function FormData(form) { + const formData = new NativeFormData(form); + const formDataEvent = new Event('formdata', { + bubbles: true, + cancelable: false, + }); + formDataEvent.formData = formData; + form.dispatchEvent(formDataEvent); + return formData; +}; +NativeFormData.prototype.constructor = FormDataPolyfill; +global.FormData = FormDataPolyfill; + +describe('ReactDOMForm', () => { + let act; + let container; + let React; + let ReactDOM; + let ReactDOMClient; + + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactDOM = require('react-dom'); + ReactDOMClient = require('react-dom/client'); + act = require('internal-test-utils').act; + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + function submit(submitter) { + const form = submitter.form || submitter; + if (!submitter.form) { + submitter = undefined; + } + const submitEvent = new Event('submit', {bubbles: true, cancelable: true}); + submitEvent.submitter = submitter; + const returnValue = form.dispatchEvent(submitEvent); + if (!returnValue) { + return; + } + const action = + (submitter && submitter.getAttribute('formaction')) || form.action; + if (!/\s*javascript:/i.test(action)) { + throw new Error('Navigate to: ' + action); + } + } + + // @gate enableFormActions + it('should allow passing a function to form action', async () => { + const ref = React.createRef(); + let foo; + + function action(formData) { + foo = formData.get('foo'); + } + + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render( +
+ +
, + ); + }); + + submit(ref.current); + + expect(foo).toBe('bar'); + + // Try updating the action + + function action2(formData) { + foo = formData.get('foo') + '2'; + } + + await act(async () => { + root.render( +
+ +
, + ); + }); + + submit(ref.current); + + expect(foo).toBe('bar2'); + }); + + // @gate enableFormActions + it('should allow passing a function to an input/button formAction', async () => { + const inputRef = React.createRef(); + const buttonRef = React.createRef(); + let rootActionCalled = false; + let savedTitle = null; + let deletedTitle = null; + + function action(formData) { + rootActionCalled = true; + } + + function saveItem(formData) { + savedTitle = formData.get('title'); + } + + function deleteItem(formData) { + deletedTitle = formData.get('title'); + } + + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render( +
+ + + +
, + ); + }); + + expect(savedTitle).toBe(null); + expect(deletedTitle).toBe(null); + + submit(inputRef.current); + expect(savedTitle).toBe('Hello'); + expect(deletedTitle).toBe(null); + savedTitle = null; + + submit(buttonRef.current); + expect(savedTitle).toBe(null); + expect(deletedTitle).toBe('Hello'); + deletedTitle = null; + + // Try updating the actions + + function saveItem2(formData) { + savedTitle = formData.get('title') + '2'; + } + + function deleteItem2(formData) { + deletedTitle = formData.get('title') + '2'; + } + + await act(async () => { + root.render( +
+ + + +
, + ); + }); + + expect(savedTitle).toBe(null); + expect(deletedTitle).toBe(null); + + submit(inputRef.current); + expect(savedTitle).toBe('Hello2'); + expect(deletedTitle).toBe(null); + savedTitle = null; + + submit(buttonRef.current); + expect(savedTitle).toBe(null); + expect(deletedTitle).toBe('Hello2'); + + expect(rootActionCalled).toBe(false); + }); + + // @gate enableFormActions || !__DEV__ + it('should allow preventing default to block the action', async () => { + const ref = React.createRef(); + let actionCalled = false; + + function action(formData) { + actionCalled = true; + } + + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render( +
e.preventDefault()}> + +
, + ); + }); + + submit(ref.current); + + expect(actionCalled).toBe(false); + }); + + // @gate enableFormActions + it('should only submit the inner of nested forms', async () => { + const ref = React.createRef(); + let data; + + function outerAction(formData) { + data = formData.get('data') + 'outer'; + } + function innerAction(formData) { + data = formData.get('data') + 'inner'; + } + + const root = ReactDOMClient.createRoot(container); + await expect(async () => { + await act(async () => { + // This isn't valid HTML but just in case. + root.render( +
+ + + +
+ , + ); + }); + }).toErrorDev([ + 'Warning: validateDOMNesting(...):
cannot appear as a descendant of .' + + '\n in form (at **)' + + '\n in form (at **)', + ]); + + submit(ref.current); + + expect(data).toBe('innerinner'); + }); + + // @gate enableFormActions + it('should only submit once if one root is nested inside the other', async () => { + const ref = React.createRef(); + let outerCalled = 0; + let innerCalled = 0; + let bubbledSubmit = false; + + function outerAction(formData) { + outerCalled++; + } + + function innerAction(formData) { + innerCalled++; + } + + const innerContainerRef = React.createRef(); + const outerRoot = ReactDOMClient.createRoot(container); + await act(async () => { + outerRoot.render( + // Nesting forms isn't valid HTML but just in case. +
(bubbledSubmit = true)}> + +
+ +
, + ); + }); + + const innerRoot = ReactDOMClient.createRoot(innerContainerRef.current); + await act(async () => { + innerRoot.render( +
+ +
, + ); + }); + + submit(ref.current); + + expect(bubbledSubmit).toBe(true); + expect(outerCalled).toBe(0); + expect(innerCalled).toBe(1); + }); + + // @gate enableFormActions + it('should only submit once if a portal is nested inside its own root', async () => { + const ref = React.createRef(); + let outerCalled = 0; + let innerCalled = 0; + let bubbledSubmit = false; + + function outerAction(formData) { + outerCalled++; + } + + function innerAction(formData) { + innerCalled++; + } + + const innerContainer = document.createElement('div'); + const innerContainerRef = React.createRef(); + const outerRoot = ReactDOMClient.createRoot(container); + await act(async () => { + outerRoot.render( + // Nesting forms isn't valid HTML but just in case. +
(bubbledSubmit = true)}> +
+
+ {ReactDOM.createPortal( + + + , + innerContainer, + )} + +
, + ); + }); + + innerContainerRef.current.appendChild(innerContainer); + + submit(ref.current); + + expect(bubbledSubmit).toBe(true); + expect(outerCalled).toBe(0); + expect(innerCalled).toBe(1); + }); + + // @gate enableFormActions + it('can read the clicked button in the formdata event', async () => { + const ref = React.createRef(); + let button; + let title; + + function action(formData) { + button = formData.get('button'); + title = formData.get('title'); + } + + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render( + // TODO: Test button element too. +
+ + + +
, + ); + }); + + container.addEventListener('formdata', e => { + // Process in the formdata event somehow + if (e.formData.get('button') === 'delete') { + e.formData.delete('title'); + } + }); + + submit(ref.current); + + expect(button).toBe('delete'); + expect(title).toBe(null); + }); + + // @gate enableFormActions || !__DEV__ + it('allows a non-function formaction to override a function one', async () => { + const ref = React.createRef(); + let actionCalled = false; + + function action(formData) { + actionCalled = true; + } + + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render( +
+ +
, + ); + }); + + let nav; + try { + submit(ref.current); + } catch (x) { + nav = x.message; + } + expect(nav).toBe('Navigate to: http://example.com/submit'); + expect(actionCalled).toBe(false); + }); + + // @gate enableFormActions || !__DEV__ + it('allows a non-react html formaction to be invoked', async () => { + let actionCalled = false; + + function action(formData) { + actionCalled = true; + } + + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render( +
+ `, + }} + />, + ); + }); + + const node = container.getElementsByTagName('input')[0]; + let nav; + try { + submit(node); + } catch (x) { + nav = x.message; + } + expect(nav).toBe('Navigate to: http://example.com/submit'); + expect(actionCalled).toBe(false); + }); +}); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.js index 5bdfba529641b..ef232d76610cd 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.js @@ -108,12 +108,15 @@ describe('ReactDOMServerIntegration - Untrusted URLs', () => { expect(e.action).toBe('javascript:notfine'); }); - itRenders('a javascript protocol button formAction', async render => { - const e = await render(, 1); + itRenders('a javascript protocol input formAction', async render => { + const e = await render( + , + 1, + ); expect(e.getAttribute('formAction')).toBe('javascript:notfine'); }); - itRenders('a javascript protocol input formAction', async render => { + itRenders('a javascript protocol button formAction', async render => { const e = await render( , 1, @@ -268,12 +271,14 @@ describe('ReactDOMServerIntegration - Untrusted URLs - disableJavaScriptURLs', ( expect(e.action).toBe(EXPECTED_SAFE_URL); }); - itRenders('a javascript protocol button formAction', async render => { - const e = await render(); + itRenders('a javascript protocol input formAction', async render => { + const e = await render( + , + ); expect(e.getAttribute('formAction')).toBe(EXPECTED_SAFE_URL); }); - itRenders('a javascript protocol input formAction', async render => { + itRenders('a javascript protocol button formAction', async render => { const e = await render( , ); diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 5bb3bd112888d..83350f83ee5e0 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -85,6 +85,8 @@ export const enableLegacyCache = __EXPERIMENTAL__; export const enableCacheElement = __EXPERIMENTAL__; export const enableFetchInstrumentation = true; +export const enableFormActions = __EXPERIMENTAL__; + export const enableTransitionTracing = false; // No known bugs, but needs performance testing diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 0e2c915731459..370207d9d08be 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -32,6 +32,7 @@ export const enableCache = false; export const enableLegacyCache = false; export const enableCacheElement = true; export const enableFetchInstrumentation = false; +export const enableFormActions = true; // Doesn't affect Native export const enableSchedulerDebugging = false; export const debugRenderPhaseSideEffectsForStrictMode = true; export const disableJavaScriptURLs = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index ef559e28b59f9..1b9aa4631074b 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -23,6 +23,7 @@ export const enableCache = false; export const enableLegacyCache = false; export const enableCacheElement = false; export const enableFetchInstrumentation = false; +export const enableFormActions = true; // Doesn't affect Native export const disableJavaScriptURLs = false; export const disableCommentsAsDOMContainers = true; export const disableInputAttributeSyncing = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 221663c3a8c8a..c2e8fd14f5fab 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -23,6 +23,7 @@ export const enableCache = true; export const enableLegacyCache = __EXPERIMENTAL__; export const enableCacheElement = __EXPERIMENTAL__; export const enableFetchInstrumentation = true; +export const enableFormActions = true; // Doesn't affect Test Renderer export const disableJavaScriptURLs = false; export const disableCommentsAsDOMContainers = true; export const disableInputAttributeSyncing = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js index d4a0e763f7bc3..ce6fe31f10b1f 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js @@ -23,6 +23,7 @@ export const enableCache = true; export const enableLegacyCache = false; export const enableCacheElement = true; export const enableFetchInstrumentation = false; +export const enableFormActions = true; // Doesn't affect Test Renderer export const disableJavaScriptURLs = false; export const disableCommentsAsDOMContainers = true; export const disableInputAttributeSyncing = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index bff726e27c01a..4fa45a914202a 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -23,6 +23,7 @@ export const enableCache = true; export const enableLegacyCache = true; export const enableCacheElement = true; export const enableFetchInstrumentation = false; +export const enableFormActions = true; // Doesn't affect Test Renderer export const enableSchedulerDebugging = false; export const disableJavaScriptURLs = false; export const disableCommentsAsDOMContainers = true; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 25fb6d818c2cc..e578b08dd8fa8 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -73,6 +73,8 @@ export const enableLegacyCache = true; export const enableCacheElement = true; export const enableFetchInstrumentation = false; +export const enableFormActions = true; + export const disableJavaScriptURLs = true; // TODO: www currently relies on this feature. It's disabled in open source. From 767f52237cf7892ad07726f21e3e8bacfc8af839 Mon Sep 17 00:00:00 2001 From: Sophie Alpert Date: Wed, 19 Apr 2023 14:26:01 -0700 Subject: [PATCH 10/38] Use .slice() for all substring-ing (#26677) - substr is Annex B - substring silently flips its arguments if they're in the "wrong order", which is confusing - slice is better than sliced bread (no pun intended) and also it works the same way on Arrays so there's less to remember --- > I'd be down to just lint and enforce a single form just for the potential compression savings by using a repeated string. _Originally posted by @sebmarkbage in https://github.com/facebook/react/pull/26663#discussion_r1170455401_ --- .eslintrc.js | 9 +++- fixtures/concurrent/time-slicing/src/index.js | 2 +- fixtures/dom/src/react-loader.js | 2 +- .../ESLintRuleExhaustiveDeps-test.js | 2 +- .../__tests__/ESLintRulesOfHooks-test.js | 2 +- .../src/ExhaustiveDeps.js | 2 +- .../react-client/src/ReactFlightClient.js | 18 ++++---- .../src/ReactFlightClientStream.js | 12 +++--- .../react-debug-tools/src/ReactDebugHooks.js | 4 +- .../ReactHooksInspectionIntegration-test.js | 2 +- packages/react-devtools-extensions/deploy.js | 2 +- .../__serializers__/hookSerializer.js | 2 +- .../src/backend/renderer.js | 2 +- .../src/devtools/utils.js | 2 +- .../src/devtools/views/utils.js | 8 ++-- packages/react-devtools-shared/src/utils.js | 2 +- .../src/EventTooltip.js | 2 +- .../src/content-views/utils/text.js | 2 +- .../src/import-worker/preprocessData.js | 42 +++++++++---------- .../src/utils/formatting.js | 2 +- .../src/client/DOMPropertyOperations.js | 2 +- .../src/server/escapeTextForBrowser.js | 4 +- .../src/__tests__/ReactNewContext-test.js | 2 +- .../src/ReactFlightClientConfigNodeBundler.js | 4 +- .../ReactFlightClientConfigWebpackBundler.js | 4 +- .../ReactFlightServerConfigWebpackBundler.js | 4 +- .../__tests__/ReactFlightDOMBrowser-test.js | 4 +- .../src/ReactFlightReplyServer.js | 20 ++++----- packages/shared/ReactSerializationErrors.js | 2 +- scripts/babel/transform-test-gate-pragma.js | 4 +- scripts/jest/setupHostConfigs.js | 2 +- scripts/jest/setupTests.js | 4 +- scripts/release/utils.js | 2 +- scripts/rollup/build-all-release-channels.js | 6 +-- scripts/rollup/packaging.js | 2 +- 35 files changed, 97 insertions(+), 90 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index bcab1b2756c1c..53e01fe8c16a7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -236,7 +236,14 @@ module.exports = { 'no-inner-declarations': [ERROR, 'functions'], 'no-multi-spaces': ERROR, 'no-restricted-globals': [ERROR].concat(restrictedGlobals), - 'no-restricted-syntax': [ERROR, 'WithStatement'], + 'no-restricted-syntax': [ + ERROR, + 'WithStatement', + { + selector: 'MemberExpression[property.name=/^(?:substring|substr)$/]', + message: 'Prefer string.slice() over .substring() and .substr().', + }, + ], 'no-shadow': ERROR, 'no-unused-vars': [ERROR, {args: 'none'}], 'no-use-before-define': OFF, diff --git a/fixtures/concurrent/time-slicing/src/index.js b/fixtures/concurrent/time-slicing/src/index.js index 2b99f803f55c5..6a880584f0b55 100644 --- a/fixtures/concurrent/time-slicing/src/index.js +++ b/fixtures/concurrent/time-slicing/src/index.js @@ -22,7 +22,7 @@ class App extends PureComponent { } const multiplier = input.length !== 0 ? input.length : 1; const complexity = - (parseInt(window.location.search.substring(1), 10) / 100) * 25 || 25; + (parseInt(window.location.search.slice(1), 10) / 100) * 25 || 25; const data = _.range(5).map(t => _.range(complexity * multiplier).map((j, i) => { return { diff --git a/fixtures/dom/src/react-loader.js b/fixtures/dom/src/react-loader.js index c2f7b108abb16..b2a37c49e5ae2 100644 --- a/fixtures/dom/src/react-loader.js +++ b/fixtures/dom/src/react-loader.js @@ -11,7 +11,7 @@ import semver from 'semver'; function parseQuery(qstr) { var query = {}; - var a = qstr.substr(1).split('&'); + var a = qstr.slice(1).split('&'); for (var i = 0; i < a.length; i++) { var b = a[i].split('='); diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js index 706613e401b28..a7b2abbe80d0b 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js @@ -18,7 +18,7 @@ const ReactHooksESLintRule = ReactHooksESLintPlugin.rules['exhaustive-deps']; function normalizeIndent(strings) { const codeLines = strings[0].split('\n'); const leftPadding = codeLines[1].match(/\s+/)[0]; - return codeLines.map(line => line.substr(leftPadding.length)).join('\n'); + return codeLines.map(line => line.slice(leftPadding.length)).join('\n'); } // *************************************************** diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js index aa2bcf9846d31..7b90afb75a742 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js @@ -26,7 +26,7 @@ ESLintTester.setDefaultConfig({ function normalizeIndent(strings) { const codeLines = strings[0].split('\n'); const leftPadding = codeLines[1].match(/\s+/)[0]; - return codeLines.map(line => line.substr(leftPadding.length)).join('\n'); + return codeLines.map(line => line.slice(leftPadding.length)).join('\n'); } // *************************************************** diff --git a/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js b/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js index da1e3e754e1d8..0b8b61b14fa54 100644 --- a/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js +++ b/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js @@ -1103,7 +1103,7 @@ export default { extraWarning = ` You can also do a functional update '${ setStateRecommendation.setter - }(${setStateRecommendation.missingDep.substring( + }(${setStateRecommendation.missingDep.slice( 0, 1, )} => ...)' if you only need '${ diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 85b20a4c1f134..ec1e5d34e7c7c 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -515,11 +515,11 @@ export function parseModelString( switch (value[1]) { case '$': { // This was an escaped string value. - return value.substring(1); + return value.slice(1); } case 'L': { // Lazy node - const id = parseInt(value.substring(2), 16); + const id = parseInt(value.slice(2), 16); const chunk = getChunk(response, id); // We create a React.lazy wrapper around any lazy values. // When passed into React, we'll know how to suspend on this. @@ -527,21 +527,21 @@ export function parseModelString( } case '@': { // Promise - const id = parseInt(value.substring(2), 16); + const id = parseInt(value.slice(2), 16); const chunk = getChunk(response, id); return chunk; } case 'S': { // Symbol - return Symbol.for(value.substring(2)); + return Symbol.for(value.slice(2)); } case 'P': { // Server Context Provider - return getOrCreateServerContext(value.substring(2)).Provider; + return getOrCreateServerContext(value.slice(2)).Provider; } case 'F': { // Server Reference - const id = parseInt(value.substring(2), 16); + const id = parseInt(value.slice(2), 16); const chunk = getChunk(response, id); switch (chunk.status) { case RESOLVED_MODEL: @@ -582,15 +582,15 @@ export function parseModelString( } case 'D': { // Date - return new Date(Date.parse(value.substring(2))); + return new Date(Date.parse(value.slice(2))); } case 'n': { // BigInt - return BigInt(value.substring(2)); + return BigInt(value.slice(2)); } default: { // We assume that anything else is a reference ID. - const id = parseInt(value.substring(1), 16); + const id = parseInt(value.slice(1), 16); const chunk = getChunk(response, id); switch (chunk.status) { case RESOLVED_MODEL: diff --git a/packages/react-client/src/ReactFlightClientStream.js b/packages/react-client/src/ReactFlightClientStream.js index 81633e696696d..d261da8b88b21 100644 --- a/packages/react-client/src/ReactFlightClientStream.js +++ b/packages/react-client/src/ReactFlightClientStream.js @@ -35,7 +35,7 @@ function processFullRow(response: Response, row: string): void { return; } const colon = row.indexOf(':', 0); - const id = parseInt(row.substring(0, colon), 16); + const id = parseInt(row.slice(0, colon), 16); const tag = row[colon + 1]; // When tags that are not text are added, check them here before // parsing the row as text. @@ -43,11 +43,11 @@ function processFullRow(response: Response, row: string): void { // } switch (tag) { case 'I': { - resolveModule(response, id, row.substring(colon + 2)); + resolveModule(response, id, row.slice(colon + 2)); return; } case 'E': { - const errorInfo = JSON.parse(row.substring(colon + 2)); + const errorInfo = JSON.parse(row.slice(colon + 2)); if (__DEV__) { resolveErrorDev( response, @@ -63,7 +63,7 @@ function processFullRow(response: Response, row: string): void { } default: { // We assume anything else is JSON. - resolveModel(response, id, row.substring(colon + 1)); + resolveModel(response, id, row.slice(colon + 1)); return; } } @@ -76,13 +76,13 @@ export function processStringChunk( ): void { let linebreak = chunk.indexOf('\n', offset); while (linebreak > -1) { - const fullrow = response._partialRow + chunk.substring(offset, linebreak); + const fullrow = response._partialRow + chunk.slice(offset, linebreak); processFullRow(response, fullrow); response._partialRow = ''; offset = linebreak + 1; linebreak = chunk.indexOf('\n', offset); } - response._partialRow += chunk.substring(offset); + response._partialRow += chunk.slice(offset); } export function processBinaryChunk( diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 44d99922dfbc9..bed4c2fead5eb 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -513,10 +513,10 @@ function parseCustomHookName(functionName: void | string): string { if (startIndex === -1) { startIndex = 0; } - if (functionName.substr(startIndex, 3) === 'use') { + if (functionName.slice(startIndex, startIndex + 3) === 'use') { startIndex += 3; } - return functionName.substr(startIndex); + return functionName.slice(startIndex); } function buildTree( diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js index b026f7edb2605..5e4860ce7045b 100644 --- a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js +++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js @@ -869,7 +869,7 @@ describe('ReactHooksInspectionIntegration', () => { const Suspense = React.Suspense; function Foo(props) { - const [value] = React.useState(props.defaultValue.substr(0, 3)); + const [value] = React.useState(props.defaultValue.slice(0, 3)); return
{value}
; } Foo.defaultProps = { diff --git a/packages/react-devtools-extensions/deploy.js b/packages/react-devtools-extensions/deploy.js index f177240909a55..37d06e4817893 100644 --- a/packages/react-devtools-extensions/deploy.js +++ b/packages/react-devtools-extensions/deploy.js @@ -27,7 +27,7 @@ const main = async buildId => { const json = JSON.parse(file); const alias = json.alias[0]; - const commit = execSync('git rev-parse HEAD').toString().trim().substr(0, 7); + const commit = execSync('git rev-parse HEAD').toString().trim().slice(0, 7); let date = new Date(); date = `${date.toLocaleDateString()} – ${date.toLocaleTimeString()}`; diff --git a/packages/react-devtools-shared/src/__tests__/__serializers__/hookSerializer.js b/packages/react-devtools-shared/src/__tests__/__serializers__/hookSerializer.js index 2bf84086edb71..20a5425b1ab62 100644 --- a/packages/react-devtools-shared/src/__tests__/__serializers__/hookSerializer.js +++ b/packages/react-devtools-shared/src/__tests__/__serializers__/hookSerializer.js @@ -13,7 +13,7 @@ function serializeHook(hook) { // Remove user-specific portions of this file path. let fileName = hook.hookSource.fileName; const index = fileName.lastIndexOf('/react-devtools-shared/'); - fileName = fileName.substring(index + 1); + fileName = fileName.slice(index + 1); let subHooks = hook.subHooks; if (subHooks) { diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index bf165bf8a35c3..7b12e583a9b76 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -4315,7 +4315,7 @@ export function attach( if (pseudoKey === undefined) { throw new Error('Expected root pseudo key to be known.'); } - const name = pseudoKey.substring(0, pseudoKey.lastIndexOf(':')); + const name = pseudoKey.slice(0, pseudoKey.lastIndexOf(':')); const counter = rootDisplayNameCounter.get(name); if (counter === undefined) { throw new Error('Expected counter to be known.'); diff --git a/packages/react-devtools-shared/src/devtools/utils.js b/packages/react-devtools-shared/src/devtools/utils.js index d1aa778d19847..6a6fb08f7e8f3 100644 --- a/packages/react-devtools-shared/src/devtools/utils.js +++ b/packages/react-devtools-shared/src/devtools/utils.js @@ -155,7 +155,7 @@ export function sanitizeForParse(value: any): any | string { value.charAt(0) === "'" && value.charAt(value.length - 1) === "'" ) { - return '"' + value.substr(1, value.length - 2) + '"'; + return '"' + value.slice(1, value.length - 1) + '"'; } } return value; diff --git a/packages/react-devtools-shared/src/devtools/views/utils.js b/packages/react-devtools-shared/src/devtools/views/utils.js index 9634f1455635c..7b7ac10e13ebd 100644 --- a/packages/react-devtools-shared/src/devtools/views/utils.js +++ b/packages/react-devtools-shared/src/devtools/views/utils.js @@ -36,10 +36,10 @@ export function createRegExp(string: string): RegExp { // Allow /regex/ syntax with optional last / if (string[0] === '/') { // Cut off first slash - string = string.substring(1); + string = string.slice(1); // Cut off last slash, but only if it's there if (string[string.length - 1] === '/') { - string = string.substring(0, string.length - 1); + string = string.slice(0, string.length - 1); } try { return new RegExp(string, 'i'); @@ -186,9 +186,9 @@ export function truncateText(text: string, maxLength: number): string { const {length} = text; if (length > maxLength) { return ( - text.substr(0, Math.floor(maxLength / 2)) + + text.slice(0, Math.floor(maxLength / 2)) + '…' + - text.substr(length - Math.ceil(maxLength / 2) - 1) + text.slice(length - Math.ceil(maxLength / 2) - 1) ); } else { return text; diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js index 5d6a9f3b60bd2..bf2b7852f16cf 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -693,7 +693,7 @@ function truncateForDisplay( length: number = MAX_PREVIEW_STRING_LENGTH, ) { if (string.length > length) { - return string.substr(0, length) + '…'; + return string.slice(0, length) + '…'; } else { return string; } diff --git a/packages/react-devtools-timeline/src/EventTooltip.js b/packages/react-devtools-timeline/src/EventTooltip.js index ba3685f646fb2..3d3698c09f94f 100644 --- a/packages/react-devtools-timeline/src/EventTooltip.js +++ b/packages/react-devtools-timeline/src/EventTooltip.js @@ -249,7 +249,7 @@ const TooltipNetworkMeasure = ({ let urlToDisplay = url; if (urlToDisplay.length > MAX_TOOLTIP_TEXT_LENGTH) { const half = Math.floor(MAX_TOOLTIP_TEXT_LENGTH / 2); - urlToDisplay = url.substr(0, half) + '…' + url.substr(url.length - half); + urlToDisplay = url.slice(0, half) + '…' + url.slice(url.length - half); } const timestampBegin = sendRequestTimestamp; diff --git a/packages/react-devtools-timeline/src/content-views/utils/text.js b/packages/react-devtools-timeline/src/content-views/utils/text.js index 2305975153c68..000a41cb0cdb6 100644 --- a/packages/react-devtools-timeline/src/content-views/utils/text.js +++ b/packages/react-devtools-timeline/src/content-views/utils/text.js @@ -45,7 +45,7 @@ export function trimText( while (startIndex <= stopIndex) { const currentIndex = Math.floor((startIndex + stopIndex) / 2); const trimmedText = - currentIndex === maxIndex ? text : text.substr(0, currentIndex) + '…'; + currentIndex === maxIndex ? text : text.slice(0, currentIndex) + '…'; if (getTextWidth(context, trimmedText) <= width) { if (longestValidIndex < currentIndex) { diff --git a/packages/react-devtools-timeline/src/import-worker/preprocessData.js b/packages/react-devtools-timeline/src/import-worker/preprocessData.js index 82b6a37129bcb..d6fe8faa6e3f6 100644 --- a/packages/react-devtools-timeline/src/import-worker/preprocessData.js +++ b/packages/react-devtools-timeline/src/import-worker/preprocessData.js @@ -476,10 +476,10 @@ function processTimelineEvent( break; case 'blink.user_timing': if (name.startsWith('--react-version-')) { - const [reactVersion] = name.substr(16).split('-'); + const [reactVersion] = name.slice(16).split('-'); currentProfilerData.reactVersion = reactVersion; } else if (name.startsWith('--profiler-version-')) { - const [versionString] = name.substr(19).split('-'); + const [versionString] = name.slice(19).split('-'); profilerVersion = parseInt(versionString, 10); if (profilerVersion !== SCHEDULING_PROFILER_VERSION) { throw new InvalidProfileError( @@ -487,7 +487,7 @@ function processTimelineEvent( ); } } else if (name.startsWith('--react-lane-labels-')) { - const [laneLabelTuplesString] = name.substr(20).split('-'); + const [laneLabelTuplesString] = name.slice(20).split('-'); updateLaneToLabelMap(currentProfilerData, laneLabelTuplesString); } else if (name.startsWith('--component-')) { processReactComponentMeasure( @@ -497,7 +497,7 @@ function processTimelineEvent( state, ); } else if (name.startsWith('--schedule-render-')) { - const [laneBitmaskString] = name.substr(18).split('-'); + const [laneBitmaskString] = name.slice(18).split('-'); currentProfilerData.schedulingEvents.push({ type: 'schedule-render', @@ -506,7 +506,7 @@ function processTimelineEvent( warning: null, }); } else if (name.startsWith('--schedule-forced-update-')) { - const [laneBitmaskString, componentName] = name.substr(25).split('-'); + const [laneBitmaskString, componentName] = name.slice(25).split('-'); const forceUpdateEvent = { type: 'schedule-force-update', @@ -524,7 +524,7 @@ function processTimelineEvent( currentProfilerData.schedulingEvents.push(forceUpdateEvent); } else if (name.startsWith('--schedule-state-update-')) { - const [laneBitmaskString, componentName] = name.substr(24).split('-'); + const [laneBitmaskString, componentName] = name.slice(24).split('-'); const stateUpdateEvent = { type: 'schedule-state-update', @@ -542,7 +542,7 @@ function processTimelineEvent( currentProfilerData.schedulingEvents.push(stateUpdateEvent); } else if (name.startsWith('--error-')) { - const [componentName, phase, message] = name.substr(8).split('-'); + const [componentName, phase, message] = name.slice(8).split('-'); currentProfilerData.thrownErrors.push({ componentName, @@ -553,7 +553,7 @@ function processTimelineEvent( }); } else if (name.startsWith('--suspense-suspend-')) { const [id, componentName, phase, laneBitmaskString, promiseName] = name - .substr(19) + .slice(19) .split('-'); const lanes = getLanesFromTransportDecimalBitmask(laneBitmaskString); @@ -604,7 +604,7 @@ function processTimelineEvent( currentProfilerData.suspenseEvents.push(suspenseEvent); state.unresolvedSuspenseEvents.set(id, suspenseEvent); } else if (name.startsWith('--suspense-resolved-')) { - const [id] = name.substr(20).split('-'); + const [id] = name.slice(20).split('-'); const suspenseEvent = state.unresolvedSuspenseEvents.get(id); if (suspenseEvent != null) { state.unresolvedSuspenseEvents.delete(id); @@ -613,7 +613,7 @@ function processTimelineEvent( suspenseEvent.resolution = 'resolved'; } } else if (name.startsWith('--suspense-rejected-')) { - const [id] = name.substr(20).split('-'); + const [id] = name.slice(20).split('-'); const suspenseEvent = state.unresolvedSuspenseEvents.get(id); if (suspenseEvent != null) { state.unresolvedSuspenseEvents.delete(id); @@ -637,7 +637,7 @@ function processTimelineEvent( state.potentialLongNestedUpdate = null; } - const [laneBitmaskString] = name.substr(15).split('-'); + const [laneBitmaskString] = name.slice(15).split('-'); throwIfIncomplete('render', state.measureStack); if (getLastType(state.measureStack) !== 'render-idle') { @@ -682,7 +682,7 @@ function processTimelineEvent( ); } else if (name.startsWith('--commit-start-')) { state.nextRenderShouldGenerateNewBatchID = true; - const [laneBitmaskString] = name.substr(15).split('-'); + const [laneBitmaskString] = name.slice(15).split('-'); markWorkStarted( 'commit', @@ -705,7 +705,7 @@ function processTimelineEvent( state.measureStack, ); } else if (name.startsWith('--layout-effects-start-')) { - const [laneBitmaskString] = name.substr(23).split('-'); + const [laneBitmaskString] = name.slice(23).split('-'); markWorkStarted( 'layout-effects', @@ -722,7 +722,7 @@ function processTimelineEvent( state.measureStack, ); } else if (name.startsWith('--passive-effects-start-')) { - const [laneBitmaskString] = name.substr(24).split('-'); + const [laneBitmaskString] = name.slice(24).split('-'); markWorkStarted( 'passive-effects', @@ -739,7 +739,7 @@ function processTimelineEvent( state.measureStack, ); } else if (name.startsWith('--react-internal-module-start-')) { - const stackFrameStart = name.substr(30); + const stackFrameStart = name.slice(30); if (!state.internalModuleStackStringSet.has(stackFrameStart)) { state.internalModuleStackStringSet.add(stackFrameStart); @@ -749,7 +749,7 @@ function processTimelineEvent( state.internalModuleCurrentStackFrame = parsedStackFrameStart; } } else if (name.startsWith('--react-internal-module-stop-')) { - const stackFrameStop = name.substr(29); + const stackFrameStop = name.slice(29); if (!state.internalModuleStackStringSet.has(stackFrameStop)) { state.internalModuleStackStringSet.add(stackFrameStop); @@ -833,7 +833,7 @@ function processReactComponentMeasure( state: ProcessorState, ): void { if (name.startsWith('--component-render-start-')) { - const [componentName] = name.substr(25).split('-'); + const [componentName] = name.slice(25).split('-'); assertNoOverlappingComponentMeasure(state); @@ -856,7 +856,7 @@ function processReactComponentMeasure( currentProfilerData.componentMeasures.push(componentMeasure); } } else if (name.startsWith('--component-layout-effect-mount-start-')) { - const [componentName] = name.substr(38).split('-'); + const [componentName] = name.slice(38).split('-'); assertNoOverlappingComponentMeasure(state); @@ -879,7 +879,7 @@ function processReactComponentMeasure( currentProfilerData.componentMeasures.push(componentMeasure); } } else if (name.startsWith('--component-layout-effect-unmount-start-')) { - const [componentName] = name.substr(40).split('-'); + const [componentName] = name.slice(40).split('-'); assertNoOverlappingComponentMeasure(state); @@ -902,7 +902,7 @@ function processReactComponentMeasure( currentProfilerData.componentMeasures.push(componentMeasure); } } else if (name.startsWith('--component-passive-effect-mount-start-')) { - const [componentName] = name.substr(39).split('-'); + const [componentName] = name.slice(39).split('-'); assertNoOverlappingComponentMeasure(state); @@ -925,7 +925,7 @@ function processReactComponentMeasure( currentProfilerData.componentMeasures.push(componentMeasure); } } else if (name.startsWith('--component-passive-effect-unmount-start-')) { - const [componentName] = name.substr(41).split('-'); + const [componentName] = name.slice(41).split('-'); assertNoOverlappingComponentMeasure(state); diff --git a/packages/react-devtools-timeline/src/utils/formatting.js b/packages/react-devtools-timeline/src/utils/formatting.js index 59aadc8c0a40a..725197b138351 100644 --- a/packages/react-devtools-timeline/src/utils/formatting.js +++ b/packages/react-devtools-timeline/src/utils/formatting.js @@ -26,7 +26,7 @@ export function formatDuration(ms: number): string { export function trimString(string: string, length: number): string { if (string.length > length) { - return `${string.substr(0, length - 1)}…`; + return `${string.slice(0, length - 1)}…`; } return string; } diff --git a/packages/react-dom-bindings/src/client/DOMPropertyOperations.js b/packages/react-dom-bindings/src/client/DOMPropertyOperations.js index ac311c72e01b6..ad14b6498c835 100644 --- a/packages/react-dom-bindings/src/client/DOMPropertyOperations.js +++ b/packages/react-dom-bindings/src/client/DOMPropertyOperations.js @@ -203,7 +203,7 @@ export function setValueForPropertyOnCustomComponent( ) { if (name[0] === 'o' && name[1] === 'n') { const useCapture = name.endsWith('Capture'); - const eventName = name.substr(2, useCapture ? name.length - 9 : undefined); + const eventName = name.slice(2, useCapture ? name.length - 7 : undefined); const prevProps = getFiberCurrentPropsFromNode(node); const prevValue = prevProps != null ? prevProps[name] : null; diff --git a/packages/react-dom-bindings/src/server/escapeTextForBrowser.js b/packages/react-dom-bindings/src/server/escapeTextForBrowser.js index bccad868209dd..842d1b1328fdb 100644 --- a/packages/react-dom-bindings/src/server/escapeTextForBrowser.js +++ b/packages/react-dom-bindings/src/server/escapeTextForBrowser.js @@ -88,14 +88,14 @@ function escapeHtml(string: string) { } if (lastIndex !== index) { - html += str.substring(lastIndex, index); + html += str.slice(lastIndex, index); } lastIndex = index + 1; html += escape; } - return lastIndex !== index ? html + str.substring(lastIndex, index) : html; + return lastIndex !== index ? html + str.slice(lastIndex, index) : html; } // end code copied and modified from escape-html diff --git a/packages/react-reconciler/src/__tests__/ReactNewContext-test.js b/packages/react-reconciler/src/__tests__/ReactNewContext-test.js index 7f8520088d64f..b6be6309e00c1 100644 --- a/packages/react-reconciler/src/__tests__/ReactNewContext-test.js +++ b/packages/react-reconciler/src/__tests__/ReactNewContext-test.js @@ -1635,7 +1635,7 @@ describe('ReactNewContext', () => { const LIMIT = 100; for (let i = 0; i < LIMIT; i++) { - const seed = Math.random().toString(36).substr(2, 5); + const seed = Math.random().toString(36).slice(2, 7); const actions = randomActions(5); try { simulate(seed, actions); diff --git a/packages/react-server-dom-webpack/src/ReactFlightClientConfigNodeBundler.js b/packages/react-server-dom-webpack/src/ReactFlightClientConfigNodeBundler.js index 7fc449fd68d52..e34299680ab2a 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightClientConfigNodeBundler.js +++ b/packages/react-server-dom-webpack/src/ReactFlightClientConfigNodeBundler.js @@ -69,8 +69,8 @@ export function resolveServerReference( id: ServerReferenceId, ): ClientReference { const idx = id.lastIndexOf('#'); - const specifier = id.substr(0, idx); - const name = id.substr(idx + 1); + const specifier = id.slice(0, idx); + const name = id.slice(idx + 1); return {specifier, name}; } diff --git a/packages/react-server-dom-webpack/src/ReactFlightClientConfigWebpackBundler.js b/packages/react-server-dom-webpack/src/ReactFlightClientConfigWebpackBundler.js index 1c739c88b1c50..853a5db74d826 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightClientConfigWebpackBundler.js +++ b/packages/react-server-dom-webpack/src/ReactFlightClientConfigWebpackBundler.js @@ -85,8 +85,8 @@ export function resolveServerReference( // probably go back to encoding path and name separately on the client reference. const idx = id.lastIndexOf('#'); if (idx !== -1) { - name = id.substr(idx + 1); - resolvedModuleData = bundlerConfig[id.substr(0, idx)]; + name = id.slice(idx + 1); + resolvedModuleData = bundlerConfig[id.slice(0, idx)]; } if (!resolvedModuleData) { throw new Error( diff --git a/packages/react-server-dom-webpack/src/ReactFlightServerConfigWebpackBundler.js b/packages/react-server-dom-webpack/src/ReactFlightServerConfigWebpackBundler.js index 49c1b2c1b7947..a24583c315e0a 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightServerConfigWebpackBundler.js +++ b/packages/react-server-dom-webpack/src/ReactFlightServerConfigWebpackBundler.js @@ -71,8 +71,8 @@ export function resolveClientReferenceMetadata( // probably go back to encoding path and name separately on the client reference. const idx = modulePath.lastIndexOf('#'); if (idx !== -1) { - name = modulePath.substr(idx + 1); - resolvedModuleData = config[modulePath.substr(0, idx)]; + name = modulePath.slice(idx + 1); + resolvedModuleData = config[modulePath.slice(0, idx)]; } if (!resolvedModuleData) { throw new Error( diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index 55c55e19f8779..2847801d9499c 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -87,8 +87,8 @@ describe('ReactFlightDOMBrowser', () => { // probably go back to encoding path and name separately on the client reference. const idx = ref.lastIndexOf('#'); if (idx !== -1) { - name = ref.substr(idx + 1); - resolvedModuleData = webpackServerMap[ref.substr(0, idx)]; + name = ref.slice(idx + 1); + resolvedModuleData = webpackServerMap[ref.slice(0, idx)]; } if (!resolvedModuleData) { throw new Error( diff --git a/packages/react-server/src/ReactFlightReplyServer.js b/packages/react-server/src/ReactFlightReplyServer.js index 4ba9b8785f034..88b846f283506 100644 --- a/packages/react-server/src/ReactFlightReplyServer.js +++ b/packages/react-server/src/ReactFlightReplyServer.js @@ -374,21 +374,21 @@ function parseModelString( switch (value[1]) { case '$': { // This was an escaped string value. - return value.substring(1); + return value.slice(1); } case '@': { // Promise - const id = parseInt(value.substring(2), 16); + const id = parseInt(value.slice(2), 16); const chunk = getChunk(response, id); return chunk; } case 'S': { // Symbol - return Symbol.for(value.substring(2)); + return Symbol.for(value.slice(2)); } case 'F': { // Server Reference - const id = parseInt(value.substring(2), 16); + const id = parseInt(value.slice(2), 16); const chunk = getChunk(response, id); if (chunk.status === RESOLVED_MODEL) { initializeModelChunk(chunk); @@ -411,7 +411,7 @@ function parseModelString( } case 'K': { // FormData - const stringId = value.substring(2); + const stringId = value.slice(2); const formPrefix = response._prefix + stringId + '_'; const data = new FormData(); const backingFormData = response._formData; @@ -421,7 +421,7 @@ function parseModelString( // $FlowFixMe[prop-missing] FormData has forEach on it. backingFormData.forEach((entry: File | string, entryKey: string) => { if (entryKey.startsWith(formPrefix)) { - data.append(entryKey.substr(formPrefix.length), entry); + data.append(entryKey.slice(formPrefix.length), entry); } }); return data; @@ -449,15 +449,15 @@ function parseModelString( } case 'D': { // Date - return new Date(Date.parse(value.substring(2))); + return new Date(Date.parse(value.slice(2))); } case 'n': { // BigInt - return BigInt(value.substring(2)); + return BigInt(value.slice(2)); } default: { // We assume that anything else is a reference ID. - const id = parseInt(value.substring(1), 16); + const id = parseInt(value.slice(1), 16); const chunk = getChunk(response, id); switch (chunk.status) { case RESOLVED_MODEL: @@ -517,7 +517,7 @@ export function resolveField( const prefix = response._prefix; if (key.startsWith(prefix)) { const chunks = response._chunks; - const id = +key.substr(prefix.length); + const id = +key.slice(prefix.length); const chunk = chunks.get(id); if (chunk) { // We were waiting on this key so now we can resolve it. diff --git a/packages/shared/ReactSerializationErrors.js b/packages/shared/ReactSerializationErrors.js index 4e9627ca92ae5..e7ccc3638af13 100644 --- a/packages/shared/ReactSerializationErrors.js +++ b/packages/shared/ReactSerializationErrors.js @@ -90,7 +90,7 @@ export function describeValueForErrorMessage(value: mixed): string { switch (typeof value) { case 'string': { return JSON.stringify( - value.length <= 10 ? value : value.substr(0, 10) + '...', + value.length <= 10 ? value : value.slice(0, 10) + '...', ); } case 'object': { diff --git a/scripts/babel/transform-test-gate-pragma.js b/scripts/babel/transform-test-gate-pragma.js index bdef629812ad3..bc83487604ce3 100644 --- a/scripts/babel/transform-test-gate-pragma.js +++ b/scripts/babel/transform-test-gate-pragma.js @@ -74,7 +74,7 @@ function transform(babel) { continue; } - const next3 = code.substring(i, i + 3); + const next3 = code.slice(i, i + 3); if (next3 === '===') { tokens.push({type: '=='}); i += 3; @@ -86,7 +86,7 @@ function transform(babel) { continue; } - const next2 = code.substring(i, i + 2); + const next2 = code.slice(i, i + 2); switch (next2) { case '&&': case '||': diff --git a/scripts/jest/setupHostConfigs.js b/scripts/jest/setupHostConfigs.js index 2ed0fe91a8828..85fe96cd2ac06 100644 --- a/scripts/jest/setupHostConfigs.js +++ b/scripts/jest/setupHostConfigs.js @@ -116,7 +116,7 @@ function mockAllConfigs(rendererInfo) { // We want the reconciler to pick up the host config for this renderer. jest.mock(path, () => { let idx = path.lastIndexOf('/'); - let forkPath = path.substr(0, idx) + '/forks' + path.substr(idx); + let forkPath = path.slice(0, idx) + '/forks' + path.slice(idx); return jest.requireActual(`${forkPath}.${rendererInfo.shortName}.js`); }); }); diff --git a/scripts/jest/setupTests.js b/scripts/jest/setupTests.js index 159e8565a6c41..ddd1c2a03268d 100644 --- a/scripts/jest/setupTests.js +++ b/scripts/jest/setupTests.js @@ -76,7 +76,7 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) { // Don't throw yet though b'c it might be accidentally caught and suppressed. const stack = new Error().stack; unexpectedConsoleCallStacks.push([ - stack.substr(stack.indexOf('\n') + 1), + stack.slice(stack.indexOf('\n') + 1), util.format(format, ...args), ]); }; @@ -178,7 +178,7 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) { const args = matches[2] .split('&') .filter(s => s.startsWith('args[]=')) - .map(s => s.substr('args[]='.length)) + .map(s => s.slice('args[]='.length)) .map(decodeURIComponent); const format = errorMap[code]; let argIndex = 0; diff --git a/scripts/release/utils.js b/scripts/release/utils.js index efee7b25bc501..2066ac2fe3a43 100644 --- a/scripts/release/utils.js +++ b/scripts/release/utils.js @@ -118,7 +118,7 @@ const getDateStringForCommit = async commit => { // On CI environment, this string is wrapped with quotes '...'s if (dateString.startsWith("'")) { - dateString = dateString.substr(1, 8); + dateString = dateString.slice(1, 9); } return dateString; diff --git a/scripts/rollup/build-all-release-channels.js b/scripts/rollup/build-all-release-channels.js index 957bfb842cb55..238d9b5a7f5fb 100644 --- a/scripts/rollup/build-all-release-channels.js +++ b/scripts/rollup/build-all-release-channels.js @@ -36,7 +36,7 @@ let dateString = String( // On CI environment, this string is wrapped with quotes '...'s if (dateString.startsWith("'")) { - dateString = dateString.substr(1, 8); + dateString = dateString.slice(1, 9); } // Build the artifacts using a placeholder React version. We'll then do a string @@ -173,7 +173,7 @@ function processStable(buildDir) { } updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/facebook-www', - ReactVersion + '-www-classic-' + hash.digest('hex').substr(0, 8) + ReactVersion + '-www-classic-' + hash.digest('hex').slice(0, 8) ); } @@ -227,7 +227,7 @@ function processExperimental(buildDir, version) { } updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/facebook-www', - ReactVersion + '-www-modern-' + hash.digest('hex').substr(0, 8) + ReactVersion + '-www-modern-' + hash.digest('hex').slice(0, 8) ); } diff --git a/scripts/rollup/packaging.js b/scripts/rollup/packaging.js index 442ca962c70a9..b49472c1bea91 100644 --- a/scripts/rollup/packaging.js +++ b/scripts/rollup/packaging.js @@ -172,7 +172,7 @@ function getTarOptions(tgzName, packageName) { entries: [CONTENTS_FOLDER], map(header) { if (header.name.indexOf(CONTENTS_FOLDER + '/') === 0) { - header.name = header.name.substring(CONTENTS_FOLDER.length + 1); + header.name = header.name.slice(CONTENTS_FOLDER.length + 1); } }, }, From 22d5942675fcbd8b15b532284b49db4cb00d7144 Mon Sep 17 00:00:00 2001 From: Sophie Alpert Date: Wed, 19 Apr 2023 18:41:46 -0700 Subject: [PATCH 11/38] Add two event system cleanup TODOs (#26678) There is so much old stuff in these files. I am weeping. --- .../src/events/plugins/SimpleEventPlugin.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js b/packages/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js index 2470f63be83ef..e4b97c891183c 100644 --- a/packages/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js +++ b/packages/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js @@ -72,6 +72,8 @@ function extractEvents( // Firefox creates a keypress event for function keys too. This removes // the unwanted keypress events. Enter is however both printable and // non-printable. One would expect Tab to be as well (but it isn't). + // TODO: Fixed in https://bugzilla.mozilla.org/show_bug.cgi?id=968056. Can + // probably remove. if (getEventCharCode(((nativeEvent: any): KeyboardEvent)) === 0) { return; } @@ -95,6 +97,8 @@ function extractEvents( case 'click': // Firefox creates a click event on right mouse clicks. This removes the // unwanted click events. + // TODO: Fixed in https://phabricator.services.mozilla.com/D26793. Can + // probably remove. if (nativeEvent.button === 2) { return; } From 7f8c501f682bd4abe24826a93538059d717ba39e Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Thu, 20 Apr 2023 13:34:25 +0100 Subject: [PATCH 12/38] React DevTools 4.27.5 -> 4.27.6 (#26684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full list of changes: * Use .slice() for all substring-ing ([sophiebits](https://github.com/sophiebits) in [#26677](https://github.com/facebook/react/pull/26677)) * cleanup[devtools]: remove named hooks & profiler changed hook indices feature flags ([hoxyq](https://github.com/hoxyq) in [#26635](https://github.com/facebook/react/pull/26635)) * chore[devtools/release-scripts]: update messages / fixed npm view com… ([hoxyq](https://github.com/hoxyq) in [#26660](https://github.com/facebook/react/pull/26660)) * (patch)[DevTools] bug fix: backend injection logic not working for undocked devtools window ([mondaychen](https://github.com/mondaychen) in [#26665](https://github.com/facebook/react/pull/26665)) * use backend manager to support multiple backends in extension ([mondaychen](https://github.com/mondaychen) in [#26615](https://github.com/facebook/react/pull/26615)) --- packages/react-devtools-core/package.json | 2 +- .../react-devtools-extensions/chrome/manifest.json | 4 ++-- packages/react-devtools-extensions/edge/manifest.json | 4 ++-- .../react-devtools-extensions/firefox/manifest.json | 2 +- packages/react-devtools-inline/package.json | 2 +- packages/react-devtools-timeline/package.json | 2 +- packages/react-devtools/CHANGELOG.md | 11 +++++++++++ packages/react-devtools/package.json | 4 ++-- 8 files changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/react-devtools-core/package.json b/packages/react-devtools-core/package.json index 4a1929c4fe0ba..b98522c90d100 100644 --- a/packages/react-devtools-core/package.json +++ b/packages/react-devtools-core/package.json @@ -1,6 +1,6 @@ { "name": "react-devtools-core", - "version": "4.27.5", + "version": "4.27.6", "description": "Use react-devtools outside of the browser", "license": "MIT", "main": "./dist/backend.js", diff --git a/packages/react-devtools-extensions/chrome/manifest.json b/packages/react-devtools-extensions/chrome/manifest.json index 8d16734396e1b..c1ee738a0c7fe 100644 --- a/packages/react-devtools-extensions/chrome/manifest.json +++ b/packages/react-devtools-extensions/chrome/manifest.json @@ -2,8 +2,8 @@ "manifest_version": 3, "name": "React Developer Tools", "description": "Adds React debugging tools to the Chrome Developer Tools.", - "version": "4.27.5", - "version_name": "4.27.5", + "version": "4.27.6", + "version_name": "4.27.6", "minimum_chrome_version": "102", "icons": { "16": "icons/16-production.png", diff --git a/packages/react-devtools-extensions/edge/manifest.json b/packages/react-devtools-extensions/edge/manifest.json index 5a3a1b2e1e383..7e387e821c664 100644 --- a/packages/react-devtools-extensions/edge/manifest.json +++ b/packages/react-devtools-extensions/edge/manifest.json @@ -2,8 +2,8 @@ "manifest_version": 3, "name": "React Developer Tools", "description": "Adds React debugging tools to the Microsoft Edge Developer Tools.", - "version": "4.27.5", - "version_name": "4.27.5", + "version": "4.27.6", + "version_name": "4.27.6", "minimum_chrome_version": "102", "icons": { "16": "icons/16-production.png", diff --git a/packages/react-devtools-extensions/firefox/manifest.json b/packages/react-devtools-extensions/firefox/manifest.json index f7ab4467a56ef..1ef0c106af703 100644 --- a/packages/react-devtools-extensions/firefox/manifest.json +++ b/packages/react-devtools-extensions/firefox/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "React Developer Tools", "description": "Adds React debugging tools to the Firefox Developer Tools.", - "version": "4.27.5", + "version": "4.27.6", "applications": { "gecko": { "id": "@react-devtools", diff --git a/packages/react-devtools-inline/package.json b/packages/react-devtools-inline/package.json index fc31fed4585f0..2ccf571aff0c3 100644 --- a/packages/react-devtools-inline/package.json +++ b/packages/react-devtools-inline/package.json @@ -1,6 +1,6 @@ { "name": "react-devtools-inline", - "version": "4.27.5", + "version": "4.27.6", "description": "Embed react-devtools within a website", "license": "MIT", "main": "./dist/backend.js", diff --git a/packages/react-devtools-timeline/package.json b/packages/react-devtools-timeline/package.json index ea38bf48261bb..da6b2a70b0ba2 100644 --- a/packages/react-devtools-timeline/package.json +++ b/packages/react-devtools-timeline/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "react-devtools-timeline", - "version": "4.27.5", + "version": "4.27.6", "license": "MIT", "dependencies": { "@elg/speedscope": "1.9.0-a6f84db", diff --git a/packages/react-devtools/CHANGELOG.md b/packages/react-devtools/CHANGELOG.md index 8a9572618a665..5bff10793ef91 100644 --- a/packages/react-devtools/CHANGELOG.md +++ b/packages/react-devtools/CHANGELOG.md @@ -4,6 +4,17 @@ --- +### 4.27.6 +April 20, 2023 + +#### Bugfixes +* Fixed backend injection logic for undocked devtools window ([mondaychen](https://github.com/mondaychen) in [#26665](https://github.com/facebook/react/pull/26665)) + +#### Other +* Use backend manager to support multiple backends in extension ([mondaychen](https://github.com/mondaychen) in [#26615](https://github.com/facebook/react/pull/26615)) + +--- + ### 4.27.5 April 17, 2023 diff --git a/packages/react-devtools/package.json b/packages/react-devtools/package.json index b65aa6fea1c9d..7da085a121d49 100644 --- a/packages/react-devtools/package.json +++ b/packages/react-devtools/package.json @@ -1,6 +1,6 @@ { "name": "react-devtools", - "version": "4.27.5", + "version": "4.27.6", "description": "Use react-devtools outside of the browser", "license": "MIT", "repository": { @@ -27,7 +27,7 @@ "electron": "^23.1.2", "ip": "^1.1.4", "minimist": "^1.2.3", - "react-devtools-core": "4.27.5", + "react-devtools-core": "4.27.6", "update-notifier": "^2.1.0" } } From d73d7d59086218b0fa42d0a79c32a0365952650b Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 20 Apr 2023 14:23:22 -0400 Subject: [PATCH 13/38] Add `alwaysThrottleRetries` flag (#26685) This puts the change introduced by #26611 behind a flag until Meta is able to roll it out. Disabling the flag reverts back to the old behavior, where retries are throttled if there's still data remaining in the tree, but not if all the data has finished loading. The new behavior is still enabled in the public builds. --- .../src/ReactFiberWorkLoop.js | 6 ++++- .../ReactSuspenseWithNoopRenderer-test.js | 24 ++++++++++++++++--- packages/shared/ReactFeatureFlags.js | 2 ++ .../ReactFeatureFlags.native-fb-dynamic.js | 1 + .../forks/ReactFeatureFlags.native-fb.js | 7 ++++-- .../forks/ReactFeatureFlags.native-oss.js | 2 ++ .../forks/ReactFeatureFlags.test-renderer.js | 2 ++ .../ReactFeatureFlags.test-renderer.native.js | 2 ++ .../ReactFeatureFlags.test-renderer.www.js | 2 ++ .../forks/ReactFeatureFlags.www-dynamic.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + scripts/flow/xplat.js | 1 + 12 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 96c4ea9255111..e6764086ea851 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -39,6 +39,7 @@ import { enableTransitionTracing, useModernStrictMode, disableLegacyContext, + alwaysThrottleRetries, } from 'shared/ReactFeatureFlags'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import is from 'shared/objectIs'; @@ -1115,7 +1116,10 @@ function finishConcurrentRender( workInProgressTransitions, ); } else { - if (includesOnlyRetries(lanes)) { + if ( + includesOnlyRetries(lanes) && + (alwaysThrottleRetries || exitStatus === RootSuspended) + ) { // This render only included retries, no updates. Throttle committing // retries so that we don't show too many loading states too quickly. const msUntilTimeout = diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js index 21710468315f9..e6019ffd40583 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js @@ -1779,10 +1779,28 @@ describe('ReactSuspenseWithNoopRenderer', () => { await resolveText('B'); expect(ReactNoop).toMatchRenderedOutput(); - // Restart and render the complete content. The tree will finish but we - // won't commit the result yet because the fallback appeared recently. + // Restart and render the complete content. await waitForAll(['A', 'B']); - expect(ReactNoop).toMatchRenderedOutput(); + + if (gate(flags => flags.alwaysThrottleRetries)) { + // Correct behavior: + // + // The tree will finish but we won't commit the result yet because the fallback appeared recently. + expect(ReactNoop).toMatchRenderedOutput(); + } else { + // Old behavior, gated until this rolls out at Meta: + // + // TODO: Because this render was the result of a retry, and a fallback + // was shown recently, we should suspend and remain on the fallback for + // little bit longer. We currently only do this if there's still + // remaining fallbacks in the tree, but we should do it for all retries. + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + } }); assertLog([]); expect(ReactNoop).toMatchRenderedOutput( diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 83350f83ee5e0..6129bb1476f59 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -124,6 +124,8 @@ export const diffInCommitPhase = __EXPERIMENTAL__; export const enableAsyncActions = __EXPERIMENTAL__; +export const alwaysThrottleRetries = true; + // ----------------------------------------------------------------------------- // Chopping Block // diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js index 2270b18ae3340..92449bcd6a8ca 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js @@ -22,6 +22,7 @@ import typeof * as DynamicFlagsType from 'ReactNativeInternalFeatureFlags'; export const enableUseRefAccessWarning = __VARIANT__; export const enableDeferRootSchedulingToMicrotask = __VARIANT__; +export const alwaysThrottleRetries = __VARIANT__; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): DynamicFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 370207d9d08be..9345b0bb85ff3 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -17,8 +17,11 @@ import * as dynamicFlags from 'ReactNativeInternalFeatureFlags'; // We destructure each value before re-exporting to avoid a dynamic look-up on // the exports object every time a flag is read. -export const {enableUseRefAccessWarning, enableDeferRootSchedulingToMicrotask} = - dynamicFlags; +export const { + enableUseRefAccessWarning, + enableDeferRootSchedulingToMicrotask, + alwaysThrottleRetries, +} = dynamicFlags; // The rest of the flags are static for better dead code elimination. export const enableDebugTracing = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 1b9aa4631074b..c5888b2837c50 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -75,5 +75,7 @@ export const enableDeferRootSchedulingToMicrotask = true; export const diffInCommitPhase = true; export const enableAsyncActions = false; +export const alwaysThrottleRetries = true; + // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index c2e8fd14f5fab..63f3c9f6eb206 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -75,5 +75,7 @@ export const enableDeferRootSchedulingToMicrotask = true; export const diffInCommitPhase = true; export const enableAsyncActions = false; +export const alwaysThrottleRetries = true; + // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js index ce6fe31f10b1f..771362d6bfecb 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js @@ -72,5 +72,7 @@ export const enableDeferRootSchedulingToMicrotask = true; export const diffInCommitPhase = true; export const enableAsyncActions = false; +export const alwaysThrottleRetries = true; + // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 4fa45a914202a..433d18d918247 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -77,5 +77,7 @@ export const enableDeferRootSchedulingToMicrotask = true; export const diffInCommitPhase = true; export const enableAsyncActions = false; +export const alwaysThrottleRetries = true; + // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index ad284d30c545c..827d1413b5949 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -27,6 +27,7 @@ export const enableCustomElementPropertySupport = __VARIANT__; export const enableDeferRootSchedulingToMicrotask = __VARIANT__; export const diffInCommitPhase = __VARIANT__; export const enableAsyncActions = __VARIANT__; +export const alwaysThrottleRetries = __VARIANT__; // Enable this flag to help with concurrent mode debugging. // It logs information to the console about React scheduling, rendering, and commit phases. diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index e578b08dd8fa8..a4a64c0d93e81 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -30,6 +30,7 @@ export const { enableDeferRootSchedulingToMicrotask, diffInCommitPhase, enableAsyncActions, + alwaysThrottleRetries, } = dynamicFeatureFlags; // On WWW, __EXPERIMENTAL__ is used for a new modern build. diff --git a/scripts/flow/xplat.js b/scripts/flow/xplat.js index 8e14cf5a04185..37d1deee75275 100644 --- a/scripts/flow/xplat.js +++ b/scripts/flow/xplat.js @@ -10,4 +10,5 @@ declare module 'ReactNativeInternalFeatureFlags' { declare export var enableUseRefAccessWarning: boolean; declare export var enableDeferRootSchedulingToMicrotask: boolean; + declare export var alwaysThrottleRetries: boolean; } From e5708b3ea9190c1285c9081ff338e46be9ff39bc Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 20 Apr 2023 14:27:02 -0700 Subject: [PATCH 14/38] [Tests][Fizz] Better HTML parsing behavior for Fizz tests (#26570) In anticipation of making Fiber use the document global for dispatching Float methods that arrive from Flight I needed to update some tests that commonly recreated the JSDOM instance after importing react. This change updates a few tests to only create JSDOM once per test, before importing react-dom/client. Additionally the current act implementation for server streaming did not adequately model streaming semantics so I rewrite the act implementation in a way that better mirrors how a browser would parse incoming HTML. The new act implementation does the following 1. the first time it processes meaningful streamed content it figures out whether it is rendering into the existing document container or if it needs to reset the document. this is based on whether the streamed content contains tags `` or `` etc... 2. Once the streaming container is set it will typically continue to stream into that container for future calls to act. The exception is if the streaming container is the `` in which case it will switch to streaming into the body once it receives a `` tag. This means for tests that render something like a `
...
` it will naturally stream into the default `
...` and for tests that render a full document the HTML will parse like a real browser would (with some very minor edge case differences) I also refactored the way we move nodes from buffered content into the document and execute any scripts we find. Previously we were using window.eval and I switched this to just setting the external script content as script text. Additionally the nonce logic is reworked to be a bit simpler. --- .../src/__tests__/ReactDOMFizzServer-test.js | 835 ++++++++++-------- .../src/__tests__/ReactDOMFloat-test.js | 343 ++++--- .../react-dom/src/test-utils/FizzTestUtils.js | 140 +-- 3 files changed, 759 insertions(+), 559 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index da59ac66faef5..aa9222adb0766 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -10,7 +10,7 @@ 'use strict'; import { - replaceScriptsAndMove, + insertNodesAndExecuteScripts, mergeOptions, stripExternalRuntimeInNodes, withLoadingReadyState, @@ -29,8 +29,6 @@ let useSyncExternalStoreWithSelector; let use; let PropTypes; let textCache; -let window; -let document; let writable; let CSPnonce = null; let container; @@ -43,20 +41,32 @@ let waitForAll; let assertLog; let waitForPaint; let clientAct; - -function resetJSDOM(markup) { - // Test Environment - const jsdom = new JSDOM(markup, { - runScripts: 'dangerously', - }); - window = jsdom.window; - document = jsdom.window.document; -} +let streamingContainer; describe('ReactDOMFizzServer', () => { beforeEach(() => { jest.resetModules(); JSDOM = require('jsdom').JSDOM; + + const jsdom = new JSDOM( + '
', + { + runScripts: 'dangerously', + }, + ); + // We mock matchMedia. for simplicity it only matches 'all' or '' and misses everything else + Object.defineProperty(jsdom.window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: query === 'all' || query === '', + media: query, + })), + }); + streamingContainer = null; + global.window = jsdom.window; + global.document = jsdom.window.document; + container = document.getElementById('container'); + Scheduler = require('scheduler'); React = require('react'); ReactDOMClient = require('react-dom/client'); @@ -93,9 +103,6 @@ describe('ReactDOMFizzServer', () => { textCache = new Map(); - resetJSDOM('
'); - container = document.getElementById('container'); - buffer = ''; hasErrored = false; @@ -140,6 +147,9 @@ describe('ReactDOMFizzServer', () => { .join(''); } + const bodyStartMatch = /| .*?>)/; + const headStartMatch = /| .*?>)/; + async function act(callback) { await callback(); // Await one turn around the event loop. @@ -153,40 +163,123 @@ describe('ReactDOMFizzServer', () => { // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. // We also want to execute any scripts that are embedded. // We assume that we have now received a proper fragment of HTML. - const bufferedContent = buffer; + let bufferedContent = buffer; buffer = ''; - const fakeBody = document.createElement('body'); - fakeBody.innerHTML = bufferedContent; - const parent = - container.nodeName === '#document' ? container.body : container; - await withLoadingReadyState(async () => { - while (fakeBody.firstChild) { - const node = fakeBody.firstChild; - await replaceScriptsAndMove(window, CSPnonce, node, parent); - } - }, document); - } - - async function actIntoEmptyDocument(callback) { - await callback(); - // Await one turn around the event loop. - // This assumes that we'll flush everything we have so far. - await new Promise(resolve => { - setImmediate(resolve); - }); - if (hasErrored) { - throw fatalError; + if (!bufferedContent) { + return; } - // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. - // We also want to execute any scripts that are embedded. - // We assume that we have now received a proper fragment of HTML. - const bufferedContent = buffer; - resetJSDOM(bufferedContent); - container = document; - buffer = ''; + await withLoadingReadyState(async () => { - await replaceScriptsAndMove(window, CSPnonce, document.documentElement); + const bodyMatch = bufferedContent.match(bodyStartMatch); + const headMatch = bufferedContent.match(headStartMatch); + + if (streamingContainer === null) { + // This is the first streamed content. We decide here where to insert it. If we get , , or + // we abandon the pre-built document and start from scratch. If we get anything else we assume it goes into the + // container. This is not really production behavior because you can't correctly stream into a deep div effectively + // but it's pragmatic for tests. + + if ( + bufferedContent.startsWith('') || + bufferedContent.startsWith('') || + bufferedContent.startsWith('') || + bufferedContent.startsWith(' without a which is almost certainly a bug in React', + ); + } + + if (bufferedContent.startsWith('')) { + // we can just use the whole document + const tempDom = new JSDOM(bufferedContent); + + // Wipe existing head and body content + document.head.innerHTML = ''; + document.body.innerHTML = ''; + + // Copy the attributes over + const tempHtmlNode = tempDom.window.document.documentElement; + for (let i = 0; i < tempHtmlNode.attributes.length; i++) { + const attr = tempHtmlNode.attributes[i]; + document.documentElement.setAttribute(attr.name, attr.value); + } + + if (headMatch) { + // We parsed a head open tag. we need to copy head attributes and insert future + // content into + streamingContainer = document.head; + const tempHeadNode = tempDom.window.document.head; + for (let i = 0; i < tempHeadNode.attributes.length; i++) { + const attr = tempHeadNode.attributes[i]; + document.head.setAttribute(attr.name, attr.value); + } + const source = document.createElement('head'); + source.innerHTML = tempHeadNode.innerHTML; + await insertNodesAndExecuteScripts(source, document.head, CSPnonce); + } + + if (bodyMatch) { + // We parsed a body open tag. we need to copy head attributes and insert future + // content into + streamingContainer = document.body; + const tempBodyNode = tempDom.window.document.body; + for (let i = 0; i < tempBodyNode.attributes.length; i++) { + const attr = tempBodyNode.attributes[i]; + document.body.setAttribute(attr.name, attr.value); + } + const source = document.createElement('body'); + source.innerHTML = tempBodyNode.innerHTML; + await insertNodesAndExecuteScripts(source, document.body, CSPnonce); + } + + if (!headMatch && !bodyMatch) { + throw new Error('expected or after '); + } + } else { + // we assume we are streaming into the default container' + streamingContainer = container; + const div = document.createElement('div'); + div.innerHTML = bufferedContent; + await insertNodesAndExecuteScripts(div, container, CSPnonce); + } + } else if (streamingContainer === document.head) { + bufferedContent = '' + bufferedContent; + const tempDom = new JSDOM(bufferedContent); + + const tempHeadNode = tempDom.window.document.head; + const source = document.createElement('head'); + source.innerHTML = tempHeadNode.innerHTML; + await insertNodesAndExecuteScripts(source, document.head, CSPnonce); + + if (bodyMatch) { + streamingContainer = document.body; + + const tempBodyNode = tempDom.window.document.body; + for (let i = 0; i < tempBodyNode.attributes.length; i++) { + const attr = tempBodyNode.attributes[i]; + document.body.setAttribute(attr.name, attr.value); + } + const bodySource = document.createElement('body'); + bodySource.innerHTML = tempBodyNode.innerHTML; + await insertNodesAndExecuteScripts( + bodySource, + document.body, + CSPnonce, + ); + } + } else { + const div = document.createElement('div'); + div.innerHTML = bufferedContent; + await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce); + } }, document); } @@ -3467,7 +3560,7 @@ describe('ReactDOMFizzServer', () => { }); it('accepts an integrity property for bootstrapScripts and bootstrapModules', async () => { - await actIntoEmptyDocument(() => { + await act(() => { const {pipe} = renderToPipeableStream( @@ -3584,7 +3677,7 @@ describe('ReactDOMFizzServer', () => { // @gate enableFizzExternalRuntime it('supports option to load runtime as an external script', async () => { - await actIntoEmptyDocument(() => { + await act(() => { const {pipe} = renderToPipeableStream( @@ -3631,7 +3724,7 @@ describe('ReactDOMFizzServer', () => {
); } - await actIntoEmptyDocument(() => { + await act(() => { const {pipe} = renderToPipeableStream(); pipe(writable); }); @@ -3644,7 +3737,7 @@ describe('ReactDOMFizzServer', () => { }); it('does not send the external runtime for static pages', async () => { - await actIntoEmptyDocument(() => { + await act(() => { const {pipe} = renderToPipeableStream( @@ -4446,7 +4539,7 @@ describe('ReactDOMFizzServer', () => { ); } - await actIntoEmptyDocument(() => { + await act(() => { const {pipe} = renderToPipeableStream( @@ -4456,17 +4549,13 @@ describe('ReactDOMFizzServer', () => { ); pipe(writable); }); - await actIntoEmptyDocument(() => { + await act(() => { resolveText('body'); }); - await actIntoEmptyDocument(() => { + await act(() => { resolveText('nooutput'); }); - // We need to use actIntoEmptyDocument because act assumes that buffered - // content should be fake streamed into the body which is normally true - // but in this test the entire shell was delayed and we need the initial - // construction to be done to get the parsing right - await actIntoEmptyDocument(() => { + await act(() => { resolveText('head'); }); expect(getVisibleChildren(document)).toEqual( @@ -4487,7 +4576,7 @@ describe('ReactDOMFizzServer', () => { chunks.push(chunk); }); - await actIntoEmptyDocument(() => { + await act(() => { const {pipe} = renderToPipeableStream( @@ -4953,23 +5042,21 @@ describe('ReactDOMFizzServer', () => { }); describe('title children', () => { - function prepareJSDOMForTitle() { - resetJSDOM('\u0000'); - container = document.getElementsByTagName('head')[0]; - } - it('should accept a single string child', async () => { // a Single string child function App() { - return hello; + return ( + + hello + + ); } - prepareJSDOMForTitle(); await act(() => { const {pipe} = renderToPipeableStream(); pipe(writable); }); - expect(getVisibleChildren(container)).toEqual(hello); + expect(getVisibleChildren(document.head)).toEqual(hello); const errors = []; ReactDOMClient.hydrateRoot(container, , { @@ -4979,21 +5066,24 @@ describe('ReactDOMFizzServer', () => { }); await waitForAll([]); expect(errors).toEqual([]); - expect(getVisibleChildren(container)).toEqual(hello); + expect(getVisibleChildren(document.head)).toEqual(hello); }); it('should accept children array of length 1 containing a string', async () => { // a Single string child function App() { - return {['hello']}; + return ( + + {['hello']} + + ); } - prepareJSDOMForTitle(); await act(() => { const {pipe} = renderToPipeableStream(); pipe(writable); }); - expect(getVisibleChildren(container)).toEqual(hello); + expect(getVisibleChildren(document.head)).toEqual(hello); const errors = []; ReactDOMClient.hydrateRoot(container, , { @@ -5003,16 +5093,18 @@ describe('ReactDOMFizzServer', () => { }); await waitForAll([]); expect(errors).toEqual([]); - expect(getVisibleChildren(container)).toEqual(hello); + expect(getVisibleChildren(document.head)).toEqual(hello); }); it('should warn in dev when given an array of length 2 or more', async () => { function App() { - return {['hello1', 'hello2']}; + return ( + + {['hello1', 'hello2']} + + ); } - prepareJSDOMForTitle(); - await expect(async () => { await act(() => { const {pipe} = renderToPipeableStream(); @@ -5023,15 +5115,15 @@ describe('ReactDOMFizzServer', () => { ]); if (gate(flags => flags.enableFloat)) { - expect(getVisibleChildren(container)).toEqual(); + expect(getVisibleChildren(document.head)).toEqual(<title />); } else { - expect(getVisibleChildren(container)).toEqual( + expect(getVisibleChildren(document.head)).toEqual( <title>{'hello1<!-- -->hello2'}, ); } const errors = []; - ReactDOMClient.hydrateRoot(container, , { + ReactDOMClient.hydrateRoot(document.head, , { onRecoverableError(error) { errors.push(error.message); }, @@ -5040,7 +5132,7 @@ describe('ReactDOMFizzServer', () => { if (gate(flags => flags.enableFloat)) { expect(errors).toEqual([]); // with float, the title doesn't render on the client or on the server - expect(getVisibleChildren(container)).toEqual(); + expect(getVisibleChildren(document.head)).toEqual(<title />); } else { expect(errors).toEqual( [ @@ -5051,7 +5143,7 @@ describe('ReactDOMFizzServer', () => { 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', ].filter(Boolean), ); - expect(getVisibleChildren(container)).toEqual( + expect(getVisibleChildren(document.head)).toEqual( <title>{['hello1', 'hello2']}, ); } @@ -5064,16 +5156,14 @@ describe('ReactDOMFizzServer', () => { function App() { return ( - <> + <IndirectTitle /> - + ); } - prepareJSDOMForTitle(); - if (gate(flags => flags.enableFloat)) { await expect(async () => { await act(() => { @@ -5096,15 +5186,15 @@ describe('ReactDOMFizzServer', () => { if (gate(flags => flags.enableFloat)) { // object titles are toStringed when float is on - expect(getVisibleChildren(container)).toEqual( + expect(getVisibleChildren(document.head)).toEqual( {'[object Object]'}, ); } else { - expect(getVisibleChildren(container)).toEqual(hello); + expect(getVisibleChildren(document.head)).toEqual(hello); } const errors = []; - ReactDOMClient.hydrateRoot(container, , { + ReactDOMClient.hydrateRoot(document.head, , { onRecoverableError(error) { errors.push(error.message); }, @@ -5113,344 +5203,341 @@ describe('ReactDOMFizzServer', () => { expect(errors).toEqual([]); if (gate(flags => flags.enableFloat)) { // object titles are toStringed when float is on - expect(getVisibleChildren(container)).toEqual( + expect(getVisibleChildren(document.head)).toEqual( {'[object Object]'}, ); } else { - expect(getVisibleChildren(container)).toEqual(hello); + expect(getVisibleChildren(document.head)).toEqual(hello); } }); + }); - // @gate enableUseHook - it('basic use(promise)', async () => { - const promiseA = Promise.resolve('A'); - const promiseB = Promise.resolve('B'); - const promiseC = Promise.resolve('C'); + // @gate enableUseHook + it('basic use(promise)', async () => { + const promiseA = Promise.resolve('A'); + const promiseB = Promise.resolve('B'); + const promiseC = Promise.resolve('C'); - function Async() { - return use(promiseA) + use(promiseB) + use(promiseC); - } + function Async() { + return use(promiseA) + use(promiseB) + use(promiseC); + } - function App() { - return ( - - - - ); - } + function App() { + return ( + + + + ); + } - await act(() => { - const {pipe} = renderToPipeableStream(); - pipe(writable); - }); + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); - // TODO: The `act` implementation in this file doesn't unwrap microtasks - // automatically. We can't use the same `act` we use for Fiber tests - // because that relies on the mock Scheduler. Doesn't affect any public - // API but we might want to fix this for our own internal tests. - // - // For now, wait for each promise in sequence. - await act(async () => { - await promiseA; - }); - await act(async () => { - await promiseB; - }); - await act(async () => { - await promiseC; - }); + // TODO: The `act` implementation in this file doesn't unwrap microtasks + // automatically. We can't use the same `act` we use for Fiber tests + // because that relies on the mock Scheduler. Doesn't affect any public + // API but we might want to fix this for our own internal tests. + // + // For now, wait for each promise in sequence. + await act(async () => { + await promiseA; + }); + await act(async () => { + await promiseB; + }); + await act(async () => { + await promiseC; + }); - expect(getVisibleChildren(container)).toEqual('ABC'); + expect(getVisibleChildren(container)).toEqual('ABC'); - ReactDOMClient.hydrateRoot(container, ); - await waitForAll([]); - expect(getVisibleChildren(container)).toEqual('ABC'); - }); + ReactDOMClient.hydrateRoot(container, ); + await waitForAll([]); + expect(getVisibleChildren(container)).toEqual('ABC'); + }); - // @gate enableUseHook - it('basic use(context)', async () => { - const ContextA = React.createContext('default'); - const ContextB = React.createContext('B'); - const ServerContext = React.createServerContext( - 'ServerContext', - 'default', + // @gate enableUseHook + it('basic use(context)', async () => { + const ContextA = React.createContext('default'); + const ContextB = React.createContext('B'); + const ServerContext = React.createServerContext('ServerContext', 'default'); + function Client() { + return use(ContextA) + use(ContextB); + } + function ServerComponent() { + return use(ServerContext); + } + function Server() { + return ( + + + ); - function Client() { - return use(ContextA) + use(ContextB); - } - function ServerComponent() { - return use(ServerContext); - } - function Server() { - return ( - - - - ); - } - function App() { - return ( - <> - - - - - - ); - } - - await act(() => { - const {pipe} = renderToPipeableStream(); - pipe(writable); - }); - expect(getVisibleChildren(container)).toEqual(['AB', 'C']); + } + function App() { + return ( + <> + + + + + + ); + } - // Hydration uses a different renderer runtime (Fiber instead of Fizz). - // We reset _currentRenderer here to not trigger a warning about multiple - // renderers concurrently using these contexts - ContextA._currentRenderer = null; - ServerContext._currentRenderer = null; - ReactDOMClient.hydrateRoot(container, ); - await waitForAll([]); - expect(getVisibleChildren(container)).toEqual(['AB', 'C']); + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); }); + expect(getVisibleChildren(container)).toEqual(['AB', 'C']); - // @gate enableUseHook - it('use(promise) in multiple components', async () => { - const promiseA = Promise.resolve('A'); - const promiseB = Promise.resolve('B'); - const promiseC = Promise.resolve('C'); - const promiseD = Promise.resolve('D'); - - function Child({prefix}) { - return prefix + use(promiseC) + use(promiseD); - } + // Hydration uses a different renderer runtime (Fiber instead of Fizz). + // We reset _currentRenderer here to not trigger a warning about multiple + // renderers concurrently using these contexts + ContextA._currentRenderer = null; + ServerContext._currentRenderer = null; + ReactDOMClient.hydrateRoot(container, ); + await waitForAll([]); + expect(getVisibleChildren(container)).toEqual(['AB', 'C']); + }); - function Parent() { - return ; - } + // @gate enableUseHook + it('use(promise) in multiple components', async () => { + const promiseA = Promise.resolve('A'); + const promiseB = Promise.resolve('B'); + const promiseC = Promise.resolve('C'); + const promiseD = Promise.resolve('D'); - function App() { - return ( - - - - ); - } + function Child({prefix}) { + return prefix + use(promiseC) + use(promiseD); + } - await act(() => { - const {pipe} = renderToPipeableStream(); - pipe(writable); - }); + function Parent() { + return ; + } - // TODO: The `act` implementation in this file doesn't unwrap microtasks - // automatically. We can't use the same `act` we use for Fiber tests - // because that relies on the mock Scheduler. Doesn't affect any public - // API but we might want to fix this for our own internal tests. - // - // For now, wait for each promise in sequence. - await act(async () => { - await promiseA; - }); - await act(async () => { - await promiseB; - }); - await act(async () => { - await promiseC; - }); - await act(async () => { - await promiseD; - }); + function App() { + return ( + + + + ); + } - expect(getVisibleChildren(container)).toEqual('ABCD'); + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); - ReactDOMClient.hydrateRoot(container, ); - await waitForAll([]); - expect(getVisibleChildren(container)).toEqual('ABCD'); + // TODO: The `act` implementation in this file doesn't unwrap microtasks + // automatically. We can't use the same `act` we use for Fiber tests + // because that relies on the mock Scheduler. Doesn't affect any public + // API but we might want to fix this for our own internal tests. + // + // For now, wait for each promise in sequence. + await act(async () => { + await promiseA; + }); + await act(async () => { + await promiseB; + }); + await act(async () => { + await promiseC; + }); + await act(async () => { + await promiseD; }); - // @gate enableUseHook - it('using a rejected promise will throw', async () => { - const promiseA = Promise.resolve('A'); - const promiseB = Promise.reject(new Error('Oops!')); - const promiseC = Promise.resolve('C'); + expect(getVisibleChildren(container)).toEqual('ABCD'); - // Jest/Node will raise an unhandled rejected error unless we await this. It - // works fine in the browser, though. - await expect(promiseB).rejects.toThrow('Oops!'); + ReactDOMClient.hydrateRoot(container, ); + await waitForAll([]); + expect(getVisibleChildren(container)).toEqual('ABCD'); + }); - function Async() { - return use(promiseA) + use(promiseB) + use(promiseC); - } + // @gate enableUseHook + it('using a rejected promise will throw', async () => { + const promiseA = Promise.resolve('A'); + const promiseB = Promise.reject(new Error('Oops!')); + const promiseC = Promise.resolve('C'); - class ErrorBoundary extends React.Component { - state = {error: null}; - static getDerivedStateFromError(error) { - return {error}; - } - render() { - if (this.state.error) { - return this.state.error.message; - } - return this.props.children; + // Jest/Node will raise an unhandled rejected error unless we await this. It + // works fine in the browser, though. + await expect(promiseB).rejects.toThrow('Oops!'); + + function Async() { + return use(promiseA) + use(promiseB) + use(promiseC); + } + + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return this.state.error.message; } + return this.props.children; } + } - function App() { - return ( - - - - - - ); - } + function App() { + return ( + + + + + + ); + } - const reportedServerErrors = []; - await act(() => { - const {pipe} = renderToPipeableStream(, { - onError(error) { - reportedServerErrors.push(error); - }, - }); - pipe(writable); + const reportedServerErrors = []; + await act(() => { + const {pipe} = renderToPipeableStream(, { + onError(error) { + reportedServerErrors.push(error); + }, }); + pipe(writable); + }); - // TODO: The `act` implementation in this file doesn't unwrap microtasks - // automatically. We can't use the same `act` we use for Fiber tests - // because that relies on the mock Scheduler. Doesn't affect any public - // API but we might want to fix this for our own internal tests. - // - // For now, wait for each promise in sequence. - await act(async () => { - await promiseA; - }); - await act(async () => { - await expect(promiseB).rejects.toThrow('Oops!'); - }); - await act(async () => { - await promiseC; - }); + // TODO: The `act` implementation in this file doesn't unwrap microtasks + // automatically. We can't use the same `act` we use for Fiber tests + // because that relies on the mock Scheduler. Doesn't affect any public + // API but we might want to fix this for our own internal tests. + // + // For now, wait for each promise in sequence. + await act(async () => { + await promiseA; + }); + await act(async () => { + await expect(promiseB).rejects.toThrow('Oops!'); + }); + await act(async () => { + await promiseC; + }); - expect(getVisibleChildren(container)).toEqual('Loading...'); - expect(reportedServerErrors.length).toBe(1); - expect(reportedServerErrors[0].message).toBe('Oops!'); + expect(getVisibleChildren(container)).toEqual('Loading...'); + expect(reportedServerErrors.length).toBe(1); + expect(reportedServerErrors[0].message).toBe('Oops!'); - const reportedClientErrors = []; - ReactDOMClient.hydrateRoot(container, , { - onRecoverableError(error) { - reportedClientErrors.push(error); - }, - }); - await waitForAll([]); - expect(getVisibleChildren(container)).toEqual('Oops!'); - expect(reportedClientErrors.length).toBe(1); - if (__DEV__) { - expect(reportedClientErrors[0].message).toBe('Oops!'); - } else { - expect(reportedClientErrors[0].message).toBe( - 'The server could not finish this Suspense boundary, likely due to ' + - 'an error during server rendering. Switched to client rendering.', - ); - } + const reportedClientErrors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + reportedClientErrors.push(error); + }, }); + await waitForAll([]); + expect(getVisibleChildren(container)).toEqual('Oops!'); + expect(reportedClientErrors.length).toBe(1); + if (__DEV__) { + expect(reportedClientErrors[0].message).toBe('Oops!'); + } else { + expect(reportedClientErrors[0].message).toBe( + 'The server could not finish this Suspense boundary, likely due to ' + + 'an error during server rendering. Switched to client rendering.', + ); + } + }); - // @gate enableUseHook - it("use a promise that's already been instrumented and resolved", async () => { - const thenable = { - status: 'fulfilled', - value: 'Hi', - then() {}, - }; - - // This will never suspend because the thenable already resolved - function App() { - return use(thenable); - } + // @gate enableUseHook + it("use a promise that's already been instrumented and resolved", async () => { + const thenable = { + status: 'fulfilled', + value: 'Hi', + then() {}, + }; - await act(() => { - const {pipe} = renderToPipeableStream(); - pipe(writable); - }); - expect(getVisibleChildren(container)).toEqual('Hi'); + // This will never suspend because the thenable already resolved + function App() { + return use(thenable); + } - ReactDOMClient.hydrateRoot(container, ); - await waitForAll([]); - expect(getVisibleChildren(container)).toEqual('Hi'); + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); }); + expect(getVisibleChildren(container)).toEqual('Hi'); - // @gate enableUseHook - it('unwraps thenable that fulfills synchronously without suspending', async () => { - function App() { - const thenable = { - then(resolve) { - // This thenable immediately resolves, synchronously, without waiting - // a microtask. - resolve('Hi'); - }, - }; - try { - return ; - } catch { - throw new Error( - '`use` should not suspend because the thenable resolved synchronously.', - ); - } + ReactDOMClient.hydrateRoot(container, ); + await waitForAll([]); + expect(getVisibleChildren(container)).toEqual('Hi'); + }); + + // @gate enableUseHook + it('unwraps thenable that fulfills synchronously without suspending', async () => { + function App() { + const thenable = { + then(resolve) { + // This thenable immediately resolves, synchronously, without waiting + // a microtask. + resolve('Hi'); + }, + }; + try { + return ; + } catch { + throw new Error( + '`use` should not suspend because the thenable resolved synchronously.', + ); } - // Because the thenable resolves synchronously, we should be able to finish - // rendering synchronously, with no fallback. - await act(() => { - const {pipe} = renderToPipeableStream(); - pipe(writable); - }); - expect(getVisibleChildren(container)).toEqual('Hi'); + } + // Because the thenable resolves synchronously, we should be able to finish + // rendering synchronously, with no fallback. + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); }); + expect(getVisibleChildren(container)).toEqual('Hi'); + }); - it('promise as node', async () => { - const promise = Promise.resolve('Hi'); - await act(async () => { - const {pipe} = renderToPipeableStream(promise); - pipe(writable); - }); - - // TODO: The `act` implementation in this file doesn't unwrap microtasks - // automatically. We can't use the same `act` we use for Fiber tests - // because that relies on the mock Scheduler. Doesn't affect any public - // API but we might want to fix this for our own internal tests. - await act(async () => { - await promise; - }); - - expect(getVisibleChildren(container)).toEqual('Hi'); + it('promise as node', async () => { + const promise = Promise.resolve('Hi'); + await act(async () => { + const {pipe} = renderToPipeableStream(promise); + pipe(writable); }); - it('context as node', async () => { - const Context = React.createContext('Hi'); - await act(async () => { - const {pipe} = renderToPipeableStream(Context); - pipe(writable); - }); - expect(getVisibleChildren(container)).toEqual('Hi'); + // TODO: The `act` implementation in this file doesn't unwrap microtasks + // automatically. We can't use the same `act` we use for Fiber tests + // because that relies on the mock Scheduler. Doesn't affect any public + // API but we might want to fix this for our own internal tests. + await act(async () => { + await promise; }); - it('recursive Usable as node', async () => { - const Context = React.createContext('Hi'); - const promiseForContext = Promise.resolve(Context); - await act(async () => { - const {pipe} = renderToPipeableStream(promiseForContext); - pipe(writable); - }); + expect(getVisibleChildren(container)).toEqual('Hi'); + }); - // TODO: The `act` implementation in this file doesn't unwrap microtasks - // automatically. We can't use the same `act` we use for Fiber tests - // because that relies on the mock Scheduler. Doesn't affect any public - // API but we might want to fix this for our own internal tests. - await act(async () => { - await promiseForContext; - }); + it('context as node', async () => { + const Context = React.createContext('Hi'); + await act(async () => { + const {pipe} = renderToPipeableStream(Context); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual('Hi'); + }); - expect(getVisibleChildren(container)).toEqual('Hi'); + it('recursive Usable as node', async () => { + const Context = React.createContext('Hi'); + const promiseForContext = Promise.resolve(Context); + await act(async () => { + const {pipe} = renderToPipeableStream(promiseForContext); + pipe(writable); }); + + // TODO: The `act` implementation in this file doesn't unwrap microtasks + // automatically. We can't use the same `act` we use for Fiber tests + // because that relies on the mock Scheduler. Doesn't affect any public + // API but we might want to fix this for our own internal tests. + await act(async () => { + await promiseForContext; + }); + + expect(getVisibleChildren(container)).toEqual('Hi'); }); describe('useEffectEvent', () => { @@ -5555,7 +5642,7 @@ describe('ReactDOMFizzServer', () => { }); it('can render scripts with simple children', async () => { - await actIntoEmptyDocument(async () => { + await act(async () => { const {pipe} = renderToPipeableStream( @@ -5583,7 +5670,7 @@ describe('ReactDOMFizzServer', () => { }; try { - await actIntoEmptyDocument(async () => { + await act(async () => { const {pipe} = renderToPipeableStream( diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 28fb79c1a75d5..728b91564c1e2 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -10,7 +10,7 @@ 'use strict'; import { - replaceScriptsAndMove, + insertNodesAndExecuteScripts, mergeOptions, withLoadingReadyState, } from '../test-utils/FizzTestUtils'; @@ -24,8 +24,6 @@ let ReactDOMFizzServer; let Suspense; let textCache; let loadCache; -let window; -let document; let writable; const CSPnonce = null; let container; @@ -38,28 +36,32 @@ let waitForThrow; let assertLog; let Scheduler; let clientAct; - -function resetJSDOM(markup) { - // Test Environment - const jsdom = new JSDOM(markup, { - runScripts: 'dangerously', - }); - // We mock matchMedia. for simplicity it only matches 'all' or '' and misses everything else - Object.defineProperty(jsdom.window, 'matchMedia', { - writable: true, - value: jest.fn().mockImplementation(query => ({ - matches: query === 'all' || query === '', - media: query, - })), - }); - window = jsdom.window; - document = jsdom.window.document; -} +let streamingContainer; describe('ReactDOMFloat', () => { beforeEach(() => { jest.resetModules(); JSDOM = require('jsdom').JSDOM; + + const jsdom = new JSDOM( + '
', + { + runScripts: 'dangerously', + }, + ); + // We mock matchMedia. for simplicity it only matches 'all' or '' and misses everything else + Object.defineProperty(jsdom.window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: query === 'all' || query === '', + media: query, + })), + }); + streamingContainer = null; + global.window = jsdom.window; + global.document = jsdom.window.document; + container = document.getElementById('container'); + React = require('react'); ReactDOM = require('react-dom'); ReactDOMClient = require('react-dom/client'); @@ -77,9 +79,6 @@ describe('ReactDOMFloat', () => { textCache = new Map(); loadCache = new Set(); - resetJSDOM('
'); - container = document.getElementById('container'); - buffer = ''; hasErrored = false; @@ -100,6 +99,9 @@ describe('ReactDOMFloat', () => { } }); + const bodyStartMatch = /| .*?>)/; + const headStartMatch = /| .*?>)/; + async function act(callback) { await callback(); // Await one turn around the event loop. @@ -113,44 +115,123 @@ describe('ReactDOMFloat', () => { // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. // We also want to execute any scripts that are embedded. // We assume that we have now received a proper fragment of HTML. - const bufferedContent = buffer; + let bufferedContent = buffer; buffer = ''; - const fakeBody = document.createElement('body'); - fakeBody.innerHTML = bufferedContent; - const parent = - container.nodeName === '#document' ? container.body : container; - await withLoadingReadyState(async () => { - while (fakeBody.firstChild) { - const node = fakeBody.firstChild; - await replaceScriptsAndMove( - document.defaultView, - CSPnonce, - node, - parent, - ); - } - }, document); - } - async function actIntoEmptyDocument(callback) { - await callback(); - // Await one turn around the event loop. - // This assumes that we'll flush everything we have so far. - await new Promise(resolve => { - setImmediate(resolve); - }); - if (hasErrored) { - throw fatalError; + if (!bufferedContent) { + return; } - // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. - // We also want to execute any scripts that are embedded. - // We assume that we have now received a proper fragment of HTML. - const bufferedContent = buffer; - resetJSDOM(bufferedContent); - container = document; - buffer = ''; + await withLoadingReadyState(async () => { - await replaceScriptsAndMove(window, null, document.documentElement); + const bodyMatch = bufferedContent.match(bodyStartMatch); + const headMatch = bufferedContent.match(headStartMatch); + + if (streamingContainer === null) { + // This is the first streamed content. We decide here where to insert it. If we get , , or + // we abandon the pre-built document and start from scratch. If we get anything else we assume it goes into the + // container. This is not really production behavior because you can't correctly stream into a deep div effectively + // but it's pragmatic for tests. + + if ( + bufferedContent.startsWith('') || + bufferedContent.startsWith('') || + bufferedContent.startsWith('') || + bufferedContent.startsWith(' without a which is almost certainly a bug in React', + ); + } + + if (bufferedContent.startsWith('')) { + // we can just use the whole document + const tempDom = new JSDOM(bufferedContent); + + // Wipe existing head and body content + document.head.innerHTML = ''; + document.body.innerHTML = ''; + + // Copy the attributes over + const tempHtmlNode = tempDom.window.document.documentElement; + for (let i = 0; i < tempHtmlNode.attributes.length; i++) { + const attr = tempHtmlNode.attributes[i]; + document.documentElement.setAttribute(attr.name, attr.value); + } + + if (headMatch) { + // We parsed a head open tag. we need to copy head attributes and insert future + // content into + streamingContainer = document.head; + const tempHeadNode = tempDom.window.document.head; + for (let i = 0; i < tempHeadNode.attributes.length; i++) { + const attr = tempHeadNode.attributes[i]; + document.head.setAttribute(attr.name, attr.value); + } + const source = document.createElement('head'); + source.innerHTML = tempHeadNode.innerHTML; + await insertNodesAndExecuteScripts(source, document.head, CSPnonce); + } + + if (bodyMatch) { + // We parsed a body open tag. we need to copy head attributes and insert future + // content into + streamingContainer = document.body; + const tempBodyNode = tempDom.window.document.body; + for (let i = 0; i < tempBodyNode.attributes.length; i++) { + const attr = tempBodyNode.attributes[i]; + document.body.setAttribute(attr.name, attr.value); + } + const source = document.createElement('body'); + source.innerHTML = tempBodyNode.innerHTML; + await insertNodesAndExecuteScripts(source, document.body, CSPnonce); + } + + if (!headMatch && !bodyMatch) { + throw new Error('expected or after '); + } + } else { + // we assume we are streaming into the default container' + streamingContainer = container; + const div = document.createElement('div'); + div.innerHTML = bufferedContent; + await insertNodesAndExecuteScripts(div, container, CSPnonce); + } + } else if (streamingContainer === document.head) { + bufferedContent = '' + bufferedContent; + const tempDom = new JSDOM(bufferedContent); + + const tempHeadNode = tempDom.window.document.head; + const source = document.createElement('head'); + source.innerHTML = tempHeadNode.innerHTML; + await insertNodesAndExecuteScripts(source, document.head, CSPnonce); + + if (bodyMatch) { + streamingContainer = document.body; + + const tempBodyNode = tempDom.window.document.body; + for (let i = 0; i < tempBodyNode.attributes.length; i++) { + const attr = tempBodyNode.attributes[i]; + document.body.setAttribute(attr.name, attr.value); + } + const bodySource = document.createElement('body'); + bodySource.innerHTML = tempBodyNode.innerHTML; + await insertNodesAndExecuteScripts( + bodySource, + document.body, + CSPnonce, + ); + } + } else { + const div = document.createElement('div'); + div.innerHTML = bufferedContent; + await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce); + } }, document); } @@ -350,7 +431,7 @@ describe('ReactDOMFloat', () => { // @gate enableFloat it('can hydrate non Resources in head when Resources are also inserted there', async () => { - await actIntoEmptyDocument(() => { + await act(() => { const {pipe} = renderToPipeableStream( @@ -375,7 +456,7 @@ describe('ReactDOMFloat', () => { foo - + foo @@ -406,7 +487,7 @@ describe('ReactDOMFloat', () => { foo - + ']); + ).toEqual(['']); expect(getVisibleChildren(document)).toEqual( diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index e93af6fad421d..0aeef3357bd83 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -3981,7 +3981,7 @@ body { }); // @gate enableFloat - it('creates a preload resource when ReactDOM.preinit(..., {as: "script" }) is called outside of render on the client', async () => { + it('creates a script resource when ReactDOM.preinit(..., {as: "script" }) is called outside of render on the client', async () => { function App() { React.useEffect(() => { ReactDOM.preinit('foo', {as: 'script'}); diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js index e79fa16452bda..2167175a90b1b 100644 --- a/packages/react-dom/src/client/ReactDOMRoot.js +++ b/packages/react-dom/src/client/ReactDOMRoot.js @@ -13,8 +13,6 @@ import type { TransitionTracingCallbacks, } from 'react-reconciler/src/ReactInternalTypes'; -import ReactDOMSharedInternals from '../ReactDOMSharedInternals'; -const {Dispatcher} = ReactDOMSharedInternals; import {ReactDOMClientDispatcher} from 'react-dom-bindings/src/client/ReactFiberConfigDOM'; import {queueExplicitHydrationTarget} from 'react-dom-bindings/src/events/ReactDOMEventReplaying'; import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols'; @@ -25,13 +23,19 @@ import { disableCommentsAsDOMContainers, } from 'shared/ReactFeatureFlags'; +import ReactDOMSharedInternals from '../ReactDOMSharedInternals'; +const {Dispatcher} = ReactDOMSharedInternals; +if (enableFloat && typeof document !== 'undefined') { + // Set the default dispatcher to the client dispatcher + Dispatcher.current = ReactDOMClientDispatcher; +} + export type RootType = { render(children: ReactNodeList): void, unmount(): void, _internalRoot: FiberRoot | null, ... }; - export type CreateRootOptions = { unstable_strictMode?: boolean, unstable_concurrentUpdatesByDefault?: boolean, diff --git a/packages/react-native-renderer/src/server/ReactFizzConfigNative.js b/packages/react-native-renderer/src/server/ReactFizzConfigNative.js index 54650e851f614..4e348d1e8ac20 100644 --- a/packages/react-native-renderer/src/server/ReactFizzConfigNative.js +++ b/packages/react-native-renderer/src/server/ReactFizzConfigNative.js @@ -339,8 +339,7 @@ export function hoistResources( boundaryResources: BoundaryResources, ) {} -export function prepareToRender(resources: Resources) {} -export function cleanupAfterRender(previousDispatcher: mixed) {} +export function prepareHostDispatcher() {} export function createResources() {} export function createBoundaryResources() {} export function setCurrentlyRenderingBoundaryResourcesTarget( diff --git a/packages/react-native-renderer/src/server/ReactFlightServerConfigNative.js b/packages/react-native-renderer/src/server/ReactFlightServerConfigNative.js index cc92e90d8b342..e13d4c5ab7205 100644 --- a/packages/react-native-renderer/src/server/ReactFlightServerConfigNative.js +++ b/packages/react-native-renderer/src/server/ReactFlightServerConfigNative.js @@ -8,3 +8,12 @@ */ export const isPrimaryRenderer = true; + +export type Hints = null; +export type HintModel = ''; + +export function createHints(): null { + return null; +} + +export function prepareHostDispatcher() {} diff --git a/packages/react-noop-renderer/src/ReactNoopFlightServer.js b/packages/react-noop-renderer/src/ReactNoopFlightServer.js index 52794a6221c3f..32b3aa2c4f21e 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightServer.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightServer.js @@ -63,6 +63,7 @@ const ReactNoopFlightServer = ReactFlightServer({ ) { return saveModule(reference.value); }, + prepareHostDispatcher() {}, }); type Options = { diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index cd5edd94d29d6..c8f4fcd2ca4d7 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -279,8 +279,7 @@ const ReactNoopServer = ReactFizzServer({ setCurrentlyRenderingBoundaryResourcesTarget(resources: BoundaryResources) {}, - prepareToRender() {}, - cleanupAfterRender() {}, + prepareHostDispatcher() {}, }); type Options = { diff --git a/packages/react-server-dom-relay/src/ReactFlightDOMRelayClient.js b/packages/react-server-dom-relay/src/ReactFlightDOMRelayClient.js index 43d24623e53bb..4268993e76faf 100644 --- a/packages/react-server-dom-relay/src/ReactFlightDOMRelayClient.js +++ b/packages/react-server-dom-relay/src/ReactFlightDOMRelayClient.js @@ -17,6 +17,7 @@ import { resolveModule, resolveErrorDev, resolveErrorProd, + resolveHint, close, getRoot, } from 'react-client/src/ReactFlightClient'; @@ -30,10 +31,14 @@ export function resolveRow(response: Response, chunk: RowEncoding): void { } else if (chunk[0] === 'I') { // $FlowFixMe[incompatible-call] unable to refine on array indices resolveModule(response, chunk[1], chunk[2]); + } else if (chunk[0] === 'H') { + // $FlowFixMe[incompatible-call] unable to refine on array indices + resolveHint(response, chunk[1], chunk[2]); } else { if (__DEV__) { resolveErrorDev( response, + // $FlowFixMe[incompatible-call]: Flow doesn't support disjoint unions on tuples. chunk[1], // $FlowFixMe[incompatible-call]: Flow doesn't support disjoint unions on tuples. // $FlowFixMe[prop-missing] diff --git a/packages/react-server-dom-relay/src/ReactFlightDOMRelayProtocol.js b/packages/react-server-dom-relay/src/ReactFlightDOMRelayProtocol.js index 73b793f0d715c..5e7ae5ae8784c 100644 --- a/packages/react-server-dom-relay/src/ReactFlightDOMRelayProtocol.js +++ b/packages/react-server-dom-relay/src/ReactFlightDOMRelayProtocol.js @@ -7,6 +7,7 @@ * @flow */ +import type {HintModel} from 'react-server/src/ReactFlightServerConfig'; import type {ClientReferenceMetadata} from 'ReactFlightDOMRelayServerIntegration'; export type JSONValue = @@ -20,6 +21,7 @@ export type JSONValue = export type RowEncoding = | ['O', number, JSONValue] | ['I', number, ClientReferenceMetadata] + | ['H', string, HintModel] | ['P', number, string] | ['S', number, string] | [ diff --git a/packages/react-server-dom-relay/src/ReactFlightServerConfigDOMRelay.js b/packages/react-server-dom-relay/src/ReactFlightServerConfigDOMRelay.js index 0f709b7dbcc24..e270e543ab95b 100644 --- a/packages/react-server-dom-relay/src/ReactFlightServerConfigDOMRelay.js +++ b/packages/react-server-dom-relay/src/ReactFlightServerConfigDOMRelay.js @@ -7,6 +7,7 @@ * @flow */ +import type {HintModel} from 'react-server/src/ReactFlightServerConfig'; import type {RowEncoding, JSONValue} from './ReactFlightDOMRelayProtocol'; import type { @@ -191,6 +192,16 @@ export function processImportChunk( return ['I', id, clientReferenceMetadata]; } +export function processHintChunk( + request: Request, + id: number, + code: string, + model: HintModel, +): Chunk { + // The hint is already a JSON serializable value. + return ['H', code, model]; +} + export function scheduleWork(callback: () => void) { callback(); } @@ -198,8 +209,7 @@ export function scheduleWork(callback: () => void) { export function flushBuffered(destination: Destination) {} export const supportsRequestStorage = false; -export const requestStorage: AsyncLocalStorage> = - (null: any); +export const requestStorage: AsyncLocalStorage = (null: any); export function beginWriting(destination: Destination) {} diff --git a/packages/react-server-dom-relay/src/ReactServerStreamConfigFB.js b/packages/react-server-dom-relay/src/ReactServerStreamConfigFB.js index bc550d63ea7ea..88d7d3c52ae40 100644 --- a/packages/react-server-dom-relay/src/ReactServerStreamConfigFB.js +++ b/packages/react-server-dom-relay/src/ReactServerStreamConfigFB.js @@ -24,8 +24,7 @@ export function scheduleWork(callback: () => void) { export function flushBuffered(destination: Destination) {} export const supportsRequestStorage = false; -export const requestStorage: AsyncLocalStorage> = - (null: any); +export const requestStorage: AsyncLocalStorage = (null: any); export function beginWriting(destination: Destination) {} diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js index cb30243215fc5..fb99875425279 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -25,9 +25,11 @@ let clientModuleError; let webpackMap; let Stream; let React; +let ReactDOM; let ReactDOMClient; let ReactServerDOMServer; let ReactServerDOMClient; +let ReactDOMFizzServer; let Suspense; let ErrorBoundary; @@ -42,6 +44,8 @@ describe('ReactFlightDOM', () => { Stream = require('stream'); React = require('react'); + ReactDOM = require('react-dom'); + ReactDOMFizzServer = require('react-dom/server.node'); use = React.use; Suspense = React.Suspense; ReactDOMClient = require('react-dom/client'); @@ -1153,4 +1157,292 @@ describe('ReactFlightDOM', () => { ); expect(reportedErrors).toEqual([theError]); }); + + // @gate enableUseHook + it('should support ReactDOM.preload when rendering in Fiber', async () => { + function Component() { + return

hello world

; + } + + const ClientComponent = clientExports(Component); + + async function ServerComponent() { + ReactDOM.preload('before', {as: 'style'}); + await 1; + ReactDOM.preload('after', {as: 'style'}); + return ; + } + + const {writable, readable} = getTestStream(); + const {pipe} = ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ); + pipe(writable); + + let response = null; + function getResponse() { + if (response === null) { + response = ReactServerDOMClient.createFromReadableStream(readable); + } + return response; + } + + function App() { + return getResponse(); + } + + // We pause to allow the float call after the await point to process before the + // HostDispatcher gets set for Fiber by createRoot. This is only needed in testing + // because the module graphs are not different and the HostDispatcher is shared. + // In a real environment the Fiber and Flight code would each have their own independent + // dispatcher. + // @TODO consider what happens when Server-Components-On-The-Client exist. we probably + // want to use the Fiber HostDispatcher there too since it is more about the host than the runtime + // but we need to make sure that actually makes sense + await 1; + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + expect(document.head.innerHTML).toBe( + '' + + '', + ); + expect(container.innerHTML).toBe('

hello world

'); + }); + + // @gate enableUseHook + it('should support ReactDOM.preload when rendering in Fizz', async () => { + function Component() { + return

hello world

; + } + + const ClientComponent = clientExports(Component); + + async function ServerComponent() { + ReactDOM.preload('before', {as: 'style'}); + await 1; + ReactDOM.preload('after', {as: 'style'}); + return ; + } + + const {writable: flightWritable, readable: flightReadable} = + getTestStream(); + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + // In a real environment you would want to call the render during the Fizz render. + // The reason we cannot do this in our test is because we don't actually have two separate + // module graphs and we are contriving the sequencing to work in a way where + // the right HostDispatcher is in scope during the Flight Server Float calls and the + // Flight Client hint dispatches + const {pipe} = ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ); + pipe(flightWritable); + + let response = null; + function getResponse() { + if (response === null) { + response = + ReactServerDOMClient.createFromReadableStream(flightReadable); + } + return response; + } + + function App() { + return ( + + {getResponse()} + + ); + } + + await act(async () => { + ReactDOMFizzServer.renderToPipeableStream().pipe(fizzWritable); + }); + + const decoder = new TextDecoder(); + const reader = fizzReadable.getReader(); + let content = ''; + while (true) { + const {done, value} = await reader.read(); + if (done) { + content += decoder.decode(); + break; + } + content += decoder.decode(value, {stream: true}); + } + + expect(content).toEqual( + '' + + '

hello world

', + ); + }); + + it('supports Float hints from concurrent Flight -> Fizz renders', async () => { + function Component() { + return

hello world

; + } + + const ClientComponent = clientExports(Component); + + async function ServerComponent1() { + ReactDOM.preload('before1', {as: 'style'}); + await 1; + ReactDOM.preload('after1', {as: 'style'}); + return ; + } + + async function ServerComponent2() { + ReactDOM.preload('before2', {as: 'style'}); + await 1; + ReactDOM.preload('after2', {as: 'style'}); + return ; + } + + const {writable: flightWritable1, readable: flightReadable1} = + getTestStream(); + const {writable: flightWritable2, readable: flightReadable2} = + getTestStream(); + + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ).pipe(flightWritable1); + + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ).pipe(flightWritable2); + + const responses = new Map(); + function getResponse(stream) { + let response = responses.get(stream); + if (!response) { + response = ReactServerDOMClient.createFromReadableStream(stream); + responses.set(stream, response); + } + return response; + } + + function App({stream}) { + return ( + + {getResponse(stream)} + + ); + } + + // pausing to let Flight runtime tick. This is a test only artifact of the fact that + // we aren't operating separate module graphs for flight and fiber. In a real app + // each would have their own dispatcher and there would be no cross dispatching. + await 1; + + const {writable: fizzWritable1, readable: fizzReadable1} = getTestStream(); + const {writable: fizzWritable2, readable: fizzReadable2} = getTestStream(); + await act(async () => { + ReactDOMFizzServer.renderToPipeableStream( + , + ).pipe(fizzWritable1); + ReactDOMFizzServer.renderToPipeableStream( + , + ).pipe(fizzWritable2); + }); + + async function read(stream) { + const decoder = new TextDecoder(); + const reader = stream.getReader(); + let buffer = ''; + while (true) { + const {done, value} = await reader.read(); + if (done) { + buffer += decoder.decode(); + break; + } + buffer += decoder.decode(value, {stream: true}); + } + return buffer; + } + + const [content1, content2] = await Promise.all([ + read(fizzReadable1), + read(fizzReadable2), + ]); + + expect(content1).toEqual( + '' + + '

hello world

', + ); + expect(content2).toEqual( + '' + + '

hello world

', + ); + }); + + it('supports deduping hints by Float key', async () => { + function Component() { + return

hello world

; + } + + const ClientComponent = clientExports(Component); + + async function ServerComponent() { + ReactDOM.prefetchDNS('dns'); + ReactDOM.preconnect('preconnect'); + ReactDOM.preload('load', {as: 'style'}); + ReactDOM.preinit('init', {as: 'script'}); + // again but vary preconnect to demonstrate crossOrigin participates in the key + ReactDOM.prefetchDNS('dns'); + ReactDOM.preconnect('preconnect', {crossOrigin: 'anonymous'}); + ReactDOM.preload('load', {as: 'style'}); + ReactDOM.preinit('init', {as: 'script'}); + await 1; + // after an async point + ReactDOM.prefetchDNS('dns'); + ReactDOM.preconnect('preconnect', {crossOrigin: 'use-credentials'}); + ReactDOM.preload('load', {as: 'style'}); + ReactDOM.preinit('init', {as: 'script'}); + return ; + } + + const {writable, readable} = getTestStream(); + + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ).pipe(writable); + + const hintRows = []; + async function collectHints(stream) { + const decoder = new TextDecoder(); + const reader = stream.getReader(); + let buffer = ''; + while (true) { + const {done, value} = await reader.read(); + if (done) { + buffer += decoder.decode(); + if (buffer.includes(':H')) { + hintRows.push(buffer); + } + break; + } + buffer += decoder.decode(value, {stream: true}); + let line; + while ((line = buffer.indexOf('\n')) > -1) { + const row = buffer.slice(0, line); + buffer = buffer.slice(line + 1); + if (row.includes(':H')) { + hintRows.push(row); + } + } + } + } + + await collectHints(readable); + expect(hintRows.length).toEqual(6); + }); }); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index 2847801d9499c..c846493de8430 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -21,7 +21,9 @@ let webpackMap; let webpackServerMap; let act; let React; +let ReactDOM; let ReactDOMClient; +let ReactDOMFizzServer; let ReactServerDOMServer; let ReactServerDOMClient; let Suspense; @@ -37,7 +39,9 @@ describe('ReactFlightDOMBrowser', () => { webpackMap = WebpackMock.webpackMap; webpackServerMap = WebpackMock.webpackServerMap; React = require('react'); + ReactDOM = require('react-dom'); ReactDOMClient = require('react-dom/client'); + ReactDOMFizzServer = require('react-dom/server.browser'); ReactServerDOMServer = require('react-server-dom-webpack/server.browser'); ReactServerDOMClient = require('react-server-dom-webpack/client'); Suspense = React.Suspense; @@ -1062,4 +1066,118 @@ describe('ReactFlightDOMBrowser', () => { expect(thrownError.digest).toBe('test-error-digest'); } }); + + it('supports Float hints before the first await in server components in Fiber', async () => { + function Component() { + return

hello world

; + } + + const ClientComponent = clientExports(Component); + + async function ServerComponent() { + ReactDOM.preload('before', {as: 'style'}); + await 1; + ReactDOM.preload('after', {as: 'style'}); + return ; + } + + const stream = ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ); + + let response = null; + function getResponse() { + if (response === null) { + response = ReactServerDOMClient.createFromReadableStream(stream); + } + return response; + } + + function App() { + return getResponse(); + } + + // pausing to let Flight runtime tick. This is a test only artifact of the fact that + // we aren't operating separate module graphs for flight and fiber. In a real app + // each would have their own dispatcher and there would be no cross dispatching. + await 1; + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + expect(document.head.innerHTML).toBe( + '', + ); + expect(container.innerHTML).toBe('

hello world

'); + }); + + it('Does not support Float hints in server components anywhere in Fizz', async () => { + // In environments that do not support AsyncLocalStorage the Flight client has no ability + // to scope hint dispatching to a specific Request. In Fiber this isn't a problem because + // the Browser scope acts like a singleton and we can dispatch away. But in Fizz we need to have + // a reference to Resources and this is only possible during render unless you support AsyncLocalStorage. + function Component() { + return

hello world

; + } + + const ClientComponent = clientExports(Component); + + async function ServerComponent() { + ReactDOM.preload('before', {as: 'style'}); + await 1; + ReactDOM.preload('after', {as: 'style'}); + return ; + } + + const stream = ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ); + + let response = null; + function getResponse() { + if (response === null) { + response = ReactServerDOMClient.createFromReadableStream(stream); + } + return response; + } + + function App() { + return ( + + {getResponse()} + + ); + } + + // pausing to let Flight runtime tick. This is a test only artifact of the fact that + // we aren't operating separate module graphs for flight and fiber. In a real app + // each would have their own dispatcher and there would be no cross dispatching. + await 1; + + let fizzStream; + await act(async () => { + fizzStream = await ReactDOMFizzServer.renderToReadableStream(); + }); + + const decoder = new TextDecoder(); + const reader = fizzStream.getReader(); + let content = ''; + while (true) { + const {done, value} = await reader.read(); + if (done) { + content += decoder.decode(); + break; + } + content += decoder.decode(value, {stream: true}); + } + + expect(content).toEqual( + '' + + '

hello world

', + ); + }); }); diff --git a/packages/react-server-native-relay/src/ReactFlightClientConfigNativeRelay.js b/packages/react-server-native-relay/src/ReactFlightClientConfigNativeRelay.js index e21df4d13e80c..7cdd99d1db2ce 100644 --- a/packages/react-server-native-relay/src/ReactFlightClientConfigNativeRelay.js +++ b/packages/react-server-native-relay/src/ReactFlightClientConfigNativeRelay.js @@ -95,3 +95,5 @@ const dummy = {}; export function parseModel(response: Response, json: UninitializedModel): T { return (parseModelRecursively(response, dummy, '', json): any); } + +export function dispatchHint(code: string, model: mixed) {} diff --git a/packages/react-server-native-relay/src/ReactFlightServerConfigNativeRelay.js b/packages/react-server-native-relay/src/ReactFlightServerConfigNativeRelay.js index ab815ae2f0144..7feb8628f2486 100644 --- a/packages/react-server-native-relay/src/ReactFlightServerConfigNativeRelay.js +++ b/packages/react-server-native-relay/src/ReactFlightServerConfigNativeRelay.js @@ -187,6 +187,17 @@ export function processImportChunk( return ['I', id, clientReferenceMetadata]; } +export function processHintChunk( + request: Request, + id: number, + code: string, + model: JSONValue, +): Chunk { + throw new Error( + 'React Internal Error: processHintChunk is not implemented for Native-Relay. The fact that this method was called means there is a bug in React.', + ); +} + export function scheduleWork(callback: () => void) { callback(); } @@ -194,8 +205,7 @@ export function scheduleWork(callback: () => void) { export function flushBuffered(destination: Destination) {} export const supportsRequestStorage = false; -export const requestStorage: AsyncLocalStorage> = - (null: any); +export const requestStorage: AsyncLocalStorage = (null: any); export function beginWriting(destination: Destination) {} diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index eab8c6c50e366..489c440ee78d5 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -71,11 +71,12 @@ import { writeHoistables, writePostamble, hoistResources, - prepareToRender, - cleanupAfterRender, setCurrentlyRenderingBoundaryResourcesTarget, createResources, createBoundaryResources, + prepareHostDispatcher, + supportsRequestStorage, + requestStorage, } from './ReactFizzConfig'; import { constructClassInstance, @@ -210,6 +211,7 @@ const CLOSED = 2; export opaque type Request = { destination: null | Destination, + flushScheduled: boolean, +responseState: ResponseState, +progressiveChunkSize: number, status: 0 | 1 | 2, @@ -277,11 +279,13 @@ export function createRequest( onShellError: void | ((error: mixed) => void), onFatalError: void | ((error: mixed) => void), ): Request { + prepareHostDispatcher(); const pingedTasks: Array = []; const abortSet: Set = new Set(); const resources: Resources = createResources(); const request: Request = { destination: null, + flushScheduled: false, responseState, progressiveChunkSize: progressiveChunkSize === undefined @@ -332,10 +336,22 @@ export function createRequest( return request; } +let currentRequest: null | Request = null; + +export function resolveRequest(): null | Request { + if (currentRequest) return currentRequest; + if (supportsRequestStorage) { + const store = requestStorage.getStore(); + if (store) return store; + } + return null; +} + function pingTask(request: Request, task: Task): void { const pingedTasks = request.pingedTasks; pingedTasks.push(task); - if (pingedTasks.length === 1) { + if (request.pingedTasks.length === 1) { + request.flushScheduled = request.destination !== null; scheduleWork(() => performWork(request)); } } @@ -1947,7 +1963,9 @@ export function performWork(request: Request): void { ReactCurrentCache.current = DefaultCacheDispatcher; } - const previousHostDispatcher = prepareToRender(request.resources); + const prevRequest = currentRequest; + currentRequest = request; + let prevGetCurrentStackImpl; if (__DEV__) { prevGetCurrentStackImpl = ReactDebugCurrentFrame.getCurrentStack; @@ -1975,7 +1993,6 @@ export function performWork(request: Request): void { if (enableCache) { ReactCurrentCache.current = prevCacheDispatcher; } - cleanupAfterRender(previousHostDispatcher); if (__DEV__) { ReactDebugCurrentFrame.getCurrentStack = prevGetCurrentStackImpl; @@ -1990,6 +2007,7 @@ export function performWork(request: Request): void { // we'll to restore the context to what it was before returning. switchContext(prevContext); } + currentRequest = prevRequest; } } @@ -2389,6 +2407,7 @@ function flushCompletedQueues( // We don't need to check any partially completed segments because // either they have pending task or they're complete. ) { + request.flushScheduled = false; if (enableFloat) { writePostamble(destination, request.responseState); } @@ -2411,7 +2430,27 @@ function flushCompletedQueues( } export function startWork(request: Request): void { - scheduleWork(() => performWork(request)); + request.flushScheduled = request.destination !== null; + if (supportsRequestStorage) { + scheduleWork(() => requestStorage.run(request, performWork, request)); + } else { + scheduleWork(() => performWork(request)); + } +} + +function enqueueFlush(request: Request): void { + if ( + request.flushScheduled === false && + // If there are pinged tasks we are going to flush anyway after work completes + request.pingedTasks.length === 0 && + // If there is no destination there is nothing we can flush to. A flush will + // happen when we start flowing again + request.destination !== null + ) { + const destination = request.destination; + request.flushScheduled = true; + scheduleWork(() => flushCompletedQueues(request, destination)); + } } export function startFlowing(request: Request, destination: Destination): void { @@ -2456,3 +2495,11 @@ export function abort(request: Request, reason: mixed): void { fatalError(request, error); } } + +export function flushResources(request: Request): void { + enqueueFlush(request); +} + +export function getResources(request: Request): Resources { + return request.resources; +} diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index dc37f750ae277..a4048e9924b41 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -16,6 +16,8 @@ import type { ClientReferenceKey, ServerReference, ServerReferenceId, + Hints, + HintModel, } from './ReactFlightServerConfig'; import type {ContextSnapshot} from './ReactFlightNewContext'; import type {ThenableState} from './ReactFlightThenable'; @@ -44,6 +46,7 @@ import { processErrorChunkProd, processErrorChunkDev, processReferenceChunk, + processHintChunk, resolveClientReferenceMetadata, getServerReferenceId, getServerReferenceBoundArguments, @@ -52,6 +55,8 @@ import { isServerReference, supportsRequestStorage, requestStorage, + prepareHostDispatcher, + createHints, } from './ReactFlightServerConfig'; import { @@ -61,11 +66,7 @@ import { getThenableStateAfterSuspending, resetHooksForRequest, } from './ReactFlightHooks'; -import { - DefaultCacheDispatcher, - getCurrentCache, - setCurrentCache, -} from './ReactFlightCache'; +import {DefaultCacheDispatcher} from './flight/ReactFlightServerCache'; import { pushProvider, popProvider, @@ -148,15 +149,18 @@ type Task = { export type Request = { status: 0 | 1 | 2, + flushScheduled: boolean, fatalError: mixed, destination: null | Destination, bundlerConfig: ClientManifest, cache: Map, nextChunkId: number, pendingChunks: number, + hints: Hints, abortableTasks: Set, pingedTasks: Array, completedImportChunks: Array, + completedHintChunks: Array, completedJSONChunks: Array, completedErrorChunks: Array, writtenSymbols: Map, @@ -196,21 +200,26 @@ export function createRequest( 'Currently React only supports one RSC renderer at a time.', ); } + prepareHostDispatcher(); ReactCurrentCache.current = DefaultCacheDispatcher; const abortSet: Set = new Set(); const pingedTasks: Array = []; + const hints = createHints(); const request: Request = { status: OPEN, + flushScheduled: false, fatalError: null, destination: null, bundlerConfig, cache: new Map(), nextChunkId: 0, pendingChunks: 0, + hints, abortableTasks: abortSet, pingedTasks: pingedTasks, completedImportChunks: ([]: Array), + completedHintChunks: ([]: Array), completedJSONChunks: ([]: Array), completedErrorChunks: ([]: Array), writtenSymbols: new Map(), @@ -232,6 +241,17 @@ export function createRequest( return request; } +let currentRequest: null | Request = null; + +export function resolveRequest(): null | Request { + if (currentRequest) return currentRequest; + if (supportsRequestStorage) { + const store = requestStorage.getStore(); + if (store) return store; + } + return null; +} + function createRootContext( reqContext?: Array<[string, ServerContextJSONValue]>, ) { @@ -320,6 +340,23 @@ function serializeThenable(request: Request, thenable: Thenable): number { return newTask.id; } +export function emitHint( + request: Request, + code: string, + model: HintModel, +): void { + emitHintChunk(request, code, model); + enqueueFlush(request); +} + +export function getHints(request: Request): Hints { + return request.hints; +} + +export function getCache(request: Request): Map { + return request.cache; +} + function readThenable(thenable: Thenable): T { if (thenable.status === 'fulfilled') { return thenable.value; @@ -502,6 +539,7 @@ function pingTask(request: Request, task: Task): void { const pingedTasks = request.pingedTasks; pingedTasks.push(task); if (pingedTasks.length === 1) { + request.flushScheduled = request.destination !== null; scheduleWork(() => performWork(request)); } } @@ -1082,6 +1120,16 @@ function emitImportChunk( request.completedImportChunks.push(processedChunk); } +function emitHintChunk(request: Request, code: string, model: HintModel): void { + const processedChunk = processHintChunk( + request, + request.nextChunkId++, + code, + model, + ); + request.completedHintChunks.push(processedChunk); +} + function emitSymbolChunk(request: Request, id: number, name: string): void { const symbolReference = serializeSymbolReference(name); const processedChunk = processReferenceChunk(request, id, symbolReference); @@ -1195,9 +1243,9 @@ function retryTask(request: Request, task: Task): void { function performWork(request: Request): void { const prevDispatcher = ReactCurrentDispatcher.current; - const prevCache = getCurrentCache(); ReactCurrentDispatcher.current = HooksDispatcher; - setCurrentCache(request.cache); + const prevRequest = currentRequest; + currentRequest = request; prepareToUseHooksForRequest(request); try { @@ -1215,8 +1263,8 @@ function performWork(request: Request): void { fatalError(request, error); } finally { ReactCurrentDispatcher.current = prevDispatcher; - setCurrentCache(prevCache); resetHooksForRequest(); + currentRequest = prevRequest; } } @@ -1250,6 +1298,21 @@ function flushCompletedChunks( } } importsChunks.splice(0, i); + + // Next comes hints. + const hintChunks = request.completedHintChunks; + i = 0; + for (; i < hintChunks.length; i++) { + const chunk = hintChunks[i]; + const keepWriting: boolean = writeChunkAndReturn(destination, chunk); + if (!keepWriting) { + request.destination = null; + i++; + break; + } + } + hintChunks.splice(0, i); + // Next comes model data. const jsonChunks = request.completedJSONChunks; i = 0; @@ -1264,6 +1327,7 @@ function flushCompletedChunks( } } jsonChunks.splice(0, i); + // Finally, errors are sent. The idea is that it's ok to delay // any error messages and prioritize display of other parts of // the page. @@ -1281,6 +1345,7 @@ function flushCompletedChunks( } errorChunks.splice(0, i); } finally { + request.flushScheduled = false; completeWriting(destination); } flushBuffered(destination); @@ -1291,13 +1356,29 @@ function flushCompletedChunks( } export function startWork(request: Request): void { + request.flushScheduled = request.destination !== null; if (supportsRequestStorage) { - scheduleWork(() => requestStorage.run(request.cache, performWork, request)); + scheduleWork(() => requestStorage.run(request, performWork, request)); } else { scheduleWork(() => performWork(request)); } } +function enqueueFlush(request: Request): void { + if ( + request.flushScheduled === false && + // If there are pinged tasks we are going to flush anyway after work completes + request.pingedTasks.length === 0 && + // If there is no destination there is nothing we can flush to. A flush will + // happen when we start flowing again + request.destination !== null + ) { + const destination = request.destination; + request.flushScheduled = true; + scheduleWork(() => flushCompletedChunks(request, destination)); + } +} + export function startFlowing(request: Request, destination: Destination): void { if (request.status === CLOSING) { request.status = CLOSED; diff --git a/packages/react-server/src/ReactFlightServerConfigBundlerCustom.js b/packages/react-server/src/ReactFlightServerConfigBundlerCustom.js index e11c154d05f32..b1fbc93ee61b1 100644 --- a/packages/react-server/src/ReactFlightServerConfigBundlerCustom.js +++ b/packages/react-server/src/ReactFlightServerConfigBundlerCustom.js @@ -23,3 +23,4 @@ export const resolveClientReferenceMetadata = export const getServerReferenceId = $$$config.getServerReferenceId; export const getServerReferenceBoundArguments = $$$config.getServerReferenceBoundArguments; +export const prepareHostDispatcher = $$$config.prepareHostDispatcher; diff --git a/packages/react-server/src/ReactFlightServerConfigStream.js b/packages/react-server/src/ReactFlightServerConfigStream.js index 4377a313b374a..2180228f6bff2 100644 --- a/packages/react-server/src/ReactFlightServerConfigStream.js +++ b/packages/react-server/src/ReactFlightServerConfigStream.js @@ -75,11 +75,6 @@ import type {Chunk} from './ReactServerStreamConfig'; export type {Destination, Chunk} from './ReactServerStreamConfig'; -export { - supportsRequestStorage, - requestStorage, -} from './ReactServerStreamConfig'; - const stringify = JSON.stringify; function serializeRowHeader(tag: string, id: number) { @@ -156,6 +151,17 @@ export function processImportChunk( return stringToChunk(row); } +export function processHintChunk( + request: Request, + id: number, + code: string, + model: JSONValue, +): Chunk { + const json: string = stringify(model); + const row = serializeRowHeader('H' + code, id) + json + '\n'; + return stringToChunk(row); +} + export { scheduleWork, flushBuffered, diff --git a/packages/react-server/src/ReactServerStreamConfigBrowser.js b/packages/react-server/src/ReactServerStreamConfigBrowser.js index aa9cac7b2c373..28c16a92ede25 100644 --- a/packages/react-server/src/ReactServerStreamConfigBrowser.js +++ b/packages/react-server/src/ReactServerStreamConfigBrowser.js @@ -21,10 +21,6 @@ export function flushBuffered(destination: Destination) { // transform streams. https://github.com/whatwg/streams/issues/960 } -export const supportsRequestStorage = false; -export const requestStorage: AsyncLocalStorage> = - (null: any); - const VIEW_SIZE = 512; let currentView = null; let writtenBytes = 0; diff --git a/packages/react-server/src/ReactServerStreamConfigBun.js b/packages/react-server/src/ReactServerStreamConfigBun.js index 9cc88c4086475..b71b6542f36eb 100644 --- a/packages/react-server/src/ReactServerStreamConfigBun.js +++ b/packages/react-server/src/ReactServerStreamConfigBun.js @@ -26,10 +26,6 @@ export function flushBuffered(destination: Destination) { // transform streams. https://github.com/whatwg/streams/issues/960 } -// AsyncLocalStorage is not available in bun -export const supportsRequestStorage = false; -export const requestStorage = (null: any); - export function beginWriting(destination: Destination) {} export function writeChunk( diff --git a/packages/react-server/src/ReactServerStreamConfigEdge.js b/packages/react-server/src/ReactServerStreamConfigEdge.js index db6bfb14fee8b..e41bf7940134b 100644 --- a/packages/react-server/src/ReactServerStreamConfigEdge.js +++ b/packages/react-server/src/ReactServerStreamConfigEdge.js @@ -21,11 +21,6 @@ export function flushBuffered(destination: Destination) { // transform streams. https://github.com/whatwg/streams/issues/960 } -// For now, we get this from the global scope, but this will likely move to a module. -export const supportsRequestStorage = typeof AsyncLocalStorage === 'function'; -export const requestStorage: AsyncLocalStorage> = - supportsRequestStorage ? new AsyncLocalStorage() : (null: any); - const VIEW_SIZE = 512; let currentView = null; let writtenBytes = 0; diff --git a/packages/react-server/src/ReactServerStreamConfigNode.js b/packages/react-server/src/ReactServerStreamConfigNode.js index 5d33ce7d6576d..0313daf307a12 100644 --- a/packages/react-server/src/ReactServerStreamConfigNode.js +++ b/packages/react-server/src/ReactServerStreamConfigNode.js @@ -8,8 +8,8 @@ */ import type {Writable} from 'stream'; + import {TextEncoder} from 'util'; -import {AsyncLocalStorage} from 'async_hooks'; interface MightBeFlushable { flush?: () => void; @@ -34,10 +34,6 @@ export function flushBuffered(destination: Destination) { } } -export const supportsRequestStorage = true; -export const requestStorage: AsyncLocalStorage> = - new AsyncLocalStorage(); - const VIEW_SIZE = 2048; let currentView = null; let writtenBytes = 0; diff --git a/packages/react-server/src/ReactFlightCache.js b/packages/react-server/src/flight/ReactFlightServerCache.js similarity index 60% rename from packages/react-server/src/ReactFlightCache.js rename to packages/react-server/src/flight/ReactFlightServerCache.js index 7ac8aaa66222f..d386dbb9f2bb4 100644 --- a/packages/react-server/src/ReactFlightCache.js +++ b/packages/react-server/src/flight/ReactFlightServerCache.js @@ -9,24 +9,17 @@ import type {CacheDispatcher} from 'react-reconciler/src/ReactInternalTypes'; -import { - supportsRequestStorage, - requestStorage, -} from './ReactFlightServerConfig'; +import {resolveRequest, getCache} from '../ReactFlightServer'; function createSignal(): AbortSignal { return new AbortController().signal; } function resolveCache(): Map { - if (currentCache) return currentCache; - if (supportsRequestStorage) { - const cache = requestStorage.getStore(); - if (cache) return cache; + const request = resolveRequest(); + if (request) { + return getCache(request); } - // Since we override the dispatcher all the time, we're effectively always - // active and so to support cache() and fetch() outside of render, we yield - // an empty Map. return new Map(); } @@ -51,16 +44,3 @@ export const DefaultCacheDispatcher: CacheDispatcher = { return entry; }, }; - -let currentCache: Map | null = null; - -export function setCurrentCache( - cache: Map | null, -): Map | null { - currentCache = cache; - return currentCache; -} - -export function getCurrentCache(): Map | null { - return currentCache; -} diff --git a/packages/react-server/src/forks/ReactFizzConfig.custom.js b/packages/react-server/src/forks/ReactFizzConfig.custom.js index f761eb392f3c0..4b44462d9d412 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.custom.js +++ b/packages/react-server/src/forks/ReactFizzConfig.custom.js @@ -23,6 +23,8 @@ // So `$$$config` looks like a global variable, but it's // really an argument to a top-level wrapping function. +import type {Request} from 'react-server/src/ReactFizzServer'; + declare var $$$config: any; export opaque type Destination = mixed; // eslint-disable-line no-undef export opaque type ResponseState = mixed; @@ -33,6 +35,9 @@ export opaque type SuspenseBoundaryID = mixed; export const isPrimaryRenderer = false; +export const supportsRequestStorage = false; +export const requestStorage: AsyncLocalStorage = (null: any); + export const getChildFormatContext = $$$config.getChildFormatContext; export const UNINITIALIZED_SUSPENSE_BOUNDARY_ID = $$$config.UNINITIALIZED_SUSPENSE_BOUNDARY_ID; @@ -68,8 +73,7 @@ export const writeCompletedBoundaryInstruction = $$$config.writeCompletedBoundaryInstruction; export const writeClientRenderBoundaryInstruction = $$$config.writeClientRenderBoundaryInstruction; -export const prepareToRender = $$$config.prepareToRender; -export const cleanupAfterRender = $$$config.cleanupAfterRender; +export const prepareHostDispatcher = $$$config.prepareHostDispatcher; // ------------------------- // Resources diff --git a/packages/react-server/src/forks/ReactFizzConfig.dom-browser.js b/packages/react-server/src/forks/ReactFizzConfig.dom-browser.js index f2cf57ab5942f..5f887770d211e 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.dom-browser.js +++ b/packages/react-server/src/forks/ReactFizzConfig.dom-browser.js @@ -6,5 +6,9 @@ * * @flow */ +import type {Request} from 'react-server/src/ReactFizzServer'; export * from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; + +export const supportsRequestStorage = false; +export const requestStorage: AsyncLocalStorage = (null: any); diff --git a/packages/react-server/src/forks/ReactFizzConfig.dom-bun.js b/packages/react-server/src/forks/ReactFizzConfig.dom-bun.js index f2cf57ab5942f..5f887770d211e 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.dom-bun.js +++ b/packages/react-server/src/forks/ReactFizzConfig.dom-bun.js @@ -6,5 +6,9 @@ * * @flow */ +import type {Request} from 'react-server/src/ReactFizzServer'; export * from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; + +export const supportsRequestStorage = false; +export const requestStorage: AsyncLocalStorage = (null: any); diff --git a/packages/react-server/src/forks/ReactFizzConfig.dom-edge-webpack.js b/packages/react-server/src/forks/ReactFizzConfig.dom-edge-webpack.js index f2cf57ab5942f..67c8d7c13a78c 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.dom-edge-webpack.js +++ b/packages/react-server/src/forks/ReactFizzConfig.dom-edge-webpack.js @@ -6,5 +6,12 @@ * * @flow */ +import type {Request} from 'react-server/src/ReactFizzServer'; export * from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; + +// For now, we get this from the global scope, but this will likely move to a module. +export const supportsRequestStorage = typeof AsyncLocalStorage === 'function'; +export const requestStorage: AsyncLocalStorage = supportsRequestStorage + ? new AsyncLocalStorage() + : (null: any); diff --git a/packages/react-server/src/forks/ReactFizzConfig.dom-legacy.js b/packages/react-server/src/forks/ReactFizzConfig.dom-legacy.js index 4760bb843ea89..903250ce22db2 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.dom-legacy.js +++ b/packages/react-server/src/forks/ReactFizzConfig.dom-legacy.js @@ -6,5 +6,9 @@ * * @flow */ +import type {Request} from 'react-server/src/ReactFizzServer'; export * from 'react-dom-bindings/src/server/ReactFizzConfigDOMLegacy'; + +export const supportsRequestStorage = false; +export const requestStorage: AsyncLocalStorage = (null: any); diff --git a/packages/react-server/src/forks/ReactFizzConfig.dom-node-webpack.js b/packages/react-server/src/forks/ReactFizzConfig.dom-node-webpack.js index f2cf57ab5942f..71c6ab5a5586c 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.dom-node-webpack.js +++ b/packages/react-server/src/forks/ReactFizzConfig.dom-node-webpack.js @@ -6,5 +6,12 @@ * * @flow */ +import {AsyncLocalStorage} from 'async_hooks'; + +import type {Request} from 'react-server/src/ReactFizzServer'; export * from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; + +export const supportsRequestStorage = true; +export const requestStorage: AsyncLocalStorage = + new AsyncLocalStorage(); diff --git a/packages/react-server/src/forks/ReactFizzConfig.dom-node.js b/packages/react-server/src/forks/ReactFizzConfig.dom-node.js index f2cf57ab5942f..99d0d74a7b76a 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.dom-node.js +++ b/packages/react-server/src/forks/ReactFizzConfig.dom-node.js @@ -7,4 +7,12 @@ * @flow */ +import {AsyncLocalStorage} from 'async_hooks'; + +import type {Request} from 'react-server/src/ReactFizzServer'; + export * from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; + +export const supportsRequestStorage = true; +export const requestStorage: AsyncLocalStorage = + new AsyncLocalStorage(); diff --git a/packages/react-server/src/forks/ReactFizzConfig.dom-relay.js b/packages/react-server/src/forks/ReactFizzConfig.dom-relay.js index f2cf57ab5942f..5f887770d211e 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.dom-relay.js +++ b/packages/react-server/src/forks/ReactFizzConfig.dom-relay.js @@ -6,5 +6,9 @@ * * @flow */ +import type {Request} from 'react-server/src/ReactFizzServer'; export * from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; + +export const supportsRequestStorage = false; +export const requestStorage: AsyncLocalStorage = (null: any); diff --git a/packages/react-server/src/forks/ReactFizzConfig.native-relay.js b/packages/react-server/src/forks/ReactFizzConfig.native-relay.js index c4981f9edf140..df90c935a9c92 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.native-relay.js +++ b/packages/react-server/src/forks/ReactFizzConfig.native-relay.js @@ -6,5 +6,9 @@ * * @flow */ +import type {Request} from 'react-server/src/ReactFizzServer'; export * from 'react-native-renderer/src/server/ReactFizzConfigNative'; + +export const supportsRequestStorage = false; +export const requestStorage: AsyncLocalStorage = (null: any); diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.custom.js b/packages/react-server/src/forks/ReactFlightServerConfig.custom.js index 28977b2357cc6..4590689e5f440 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.custom.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.custom.js @@ -6,8 +6,21 @@ * * @flow */ +import type {Request} from 'react-server/src/ReactFlightServer'; export * from '../ReactFlightServerConfigStream'; export * from '../ReactFlightServerConfigBundlerCustom'; +export type Hints = null; +export type HintModel = ''; + export const isPrimaryRenderer = false; + +export const prepareHostDispatcher = () => {}; + +export const supportsRequestStorage = false; +export const requestStorage: AsyncLocalStorage = (null: any); + +export function createHints(): null { + return null; +} diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-browser.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-browser.js index 5304ae8c21af8..a532e31b6c206 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-browser.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-browser.js @@ -7,6 +7,11 @@ * @flow */ +import type {Request} from 'react-server/src/ReactFlightServer'; + export * from '../ReactFlightServerConfigStream'; export * from 'react-server-dom-webpack/src/ReactFlightServerConfigWebpackBundler'; export * from 'react-dom-bindings/src/server/ReactFlightServerConfigDOM'; + +export const supportsRequestStorage = false; +export const requestStorage: AsyncLocalStorage = (null: any); diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-bun.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-bun.js index 3778ad89ee89c..31db7c12414eb 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-bun.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-bun.js @@ -7,6 +7,11 @@ * @flow */ +import type {Request} from 'react-server/src/ReactFlightServer'; + export * from '../ReactFlightServerConfigStream'; export * from '../ReactFlightServerConfigBundlerCustom'; export * from 'react-dom-bindings/src/server/ReactFlightServerConfigDOM'; + +export const supportsRequestStorage = false; +export const requestStorage: AsyncLocalStorage = (null: any); diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-webpack.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-webpack.js index 5304ae8c21af8..036c0b7dc3a38 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-webpack.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-webpack.js @@ -6,7 +6,14 @@ * * @flow */ +import type {Request} from 'react-server/src/ReactFlightServer'; export * from '../ReactFlightServerConfigStream'; export * from 'react-server-dom-webpack/src/ReactFlightServerConfigWebpackBundler'; export * from 'react-dom-bindings/src/server/ReactFlightServerConfigDOM'; + +// For now, we get this from the global scope, but this will likely move to a module. +export const supportsRequestStorage = typeof AsyncLocalStorage === 'function'; +export const requestStorage: AsyncLocalStorage = supportsRequestStorage + ? new AsyncLocalStorage() + : (null: any); diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js index 5304ae8c21af8..a532e31b6c206 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js @@ -7,6 +7,11 @@ * @flow */ +import type {Request} from 'react-server/src/ReactFlightServer'; + export * from '../ReactFlightServerConfigStream'; export * from 'react-server-dom-webpack/src/ReactFlightServerConfigWebpackBundler'; export * from 'react-dom-bindings/src/server/ReactFlightServerConfigDOM'; + +export const supportsRequestStorage = false; +export const requestStorage: AsyncLocalStorage = (null: any); diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-node-webpack.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-node-webpack.js index 5304ae8c21af8..62a70db4abe6f 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-node-webpack.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-node-webpack.js @@ -6,7 +6,14 @@ * * @flow */ +import {AsyncLocalStorage} from 'async_hooks'; + +import type {Request} from 'react-server/src/ReactFlightServer'; export * from '../ReactFlightServerConfigStream'; export * from 'react-server-dom-webpack/src/ReactFlightServerConfigWebpackBundler'; export * from 'react-dom-bindings/src/server/ReactFlightServerConfigDOM'; + +export const supportsRequestStorage = true; +export const requestStorage: AsyncLocalStorage = + new AsyncLocalStorage(); diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-node.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-node.js index 5304ae8c21af8..ccbacc8c1c3a0 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-node.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-node.js @@ -7,6 +7,14 @@ * @flow */ +import {AsyncLocalStorage} from 'async_hooks'; + +import type {Request} from 'react-server/src/ReactFlightServer'; + export * from '../ReactFlightServerConfigStream'; export * from 'react-server-dom-webpack/src/ReactFlightServerConfigWebpackBundler'; export * from 'react-dom-bindings/src/server/ReactFlightServerConfigDOM'; + +export const supportsRequestStorage = true; +export const requestStorage: AsyncLocalStorage = + new AsyncLocalStorage(); diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.custom.js b/packages/react-server/src/forks/ReactServerStreamConfig.custom.js index ed00afa434e7e..f62c2a54035ba 100644 --- a/packages/react-server/src/forks/ReactServerStreamConfig.custom.js +++ b/packages/react-server/src/forks/ReactServerStreamConfig.custom.js @@ -35,8 +35,6 @@ export const writeChunk = $$$config.writeChunk; export const writeChunkAndReturn = $$$config.writeChunkAndReturn; export const completeWriting = $$$config.completeWriting; export const flushBuffered = $$$config.flushBuffered; -export const supportsRequestStorage = $$$config.supportsRequestStorage; -export const requestStorage = $$$config.requestStorage; export const close = $$$config.close; export const closeWithError = $$$config.closeWithError; export const stringToChunk = $$$config.stringToChunk; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index a2080dc38ed8f..50579ff47a039 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -461,5 +461,6 @@ "473": "React doesn't accept base64 encoded file uploads because we don't except form data passed from a browser to ever encode data that way. If that's the wrong assumption, we can easily fix it.", "474": "Suspense Exception: This is not a real error, and should not leak into userspace. If you're seeing this, it's likely a bug in React.", "475": "Internal React Error: suspendedState null when it was expected to exists. Please report this as a React bug.", - "476": "Expected the form instance to be a HostComponent. This is a bug in React." + "476": "Expected the form instance to be a HostComponent. This is a bug in React.", + "477": "React Internal Error: processHintChunk is not implemented for Native-Relay. The fact that this method was called means there is a bug in React." } diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index f29fe5e17a433..23b779ddf8eb6 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -386,7 +386,7 @@ const bundles = [ global: 'ReactServerDOMClient', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, - externals: ['react'], + externals: ['react', 'react-dom'], }, { bundleTypes: [NODE_DEV, NODE_PROD], @@ -395,7 +395,7 @@ const bundles = [ global: 'ReactServerDOMClient', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, - externals: ['react', 'util'], + externals: ['react', 'react-dom', 'util'], }, { bundleTypes: [NODE_DEV, NODE_PROD], @@ -404,7 +404,7 @@ const bundles = [ global: 'ReactServerDOMClient', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, - externals: ['react', 'util'], + externals: ['react', 'react-dom', 'util'], }, { bundleTypes: [NODE_DEV, NODE_PROD], @@ -413,7 +413,7 @@ const bundles = [ global: 'ReactServerDOMClient', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, - externals: ['react'], + externals: ['react', 'react-dom'], }, /******* React Server DOM Webpack Plugin *******/ From a21d1475ffd7225a463f2d0c0c9b732c8dd795eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sat, 22 Apr 2023 01:04:24 -0400 Subject: [PATCH 24/38] [Flight] Fix File Upload in Node.js (#26700) Use the Blob constructor + append with filename instead of File constructor. Node.js doesn't expose a global File constructor but does support it in this form. Queue fields until we get the 'end' event from the previous file. We rely on previous files being available by the time a field is resolved. However, since the 'end' event in Readable is fired after two micro-tasks, these are not resolved in order. I use a queue of the fields while we're still waiting on files to finish. This still doesn't resolve files and fields in order relative to each other but that doesn't matter for our usage. --- fixtures/flight/src/Form.js | 8 +++++++- fixtures/flight/src/actions.js | 9 ++++++++- .../src/ReactFlightDOMServerNode.js | 20 ++++++++++++++++++- .../src/ReactFlightReplyServer.js | 7 +++++-- 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/fixtures/flight/src/Form.js b/fixtures/flight/src/Form.js index 8c2ff2922da1f..a4b92366d45aa 100644 --- a/fixtures/flight/src/Form.js +++ b/fixtures/flight/src/Form.js @@ -20,8 +20,14 @@ export default function Form({action, children}) { React.startTransition(() => setIsPending(false)); } }}> - + + + {isPending ? 'Saving...' : null} ); diff --git a/fixtures/flight/src/actions.js b/fixtures/flight/src/actions.js index 7143c31a39d7c..87cba005e0b72 100644 --- a/fixtures/flight/src/actions.js +++ b/fixtures/flight/src/actions.js @@ -5,5 +5,12 @@ export async function like() { } export async function greet(formData) { - return 'Hi ' + formData.get('name') + '!'; + const name = formData.get('name') || 'you'; + const file = formData.get('file'); + if (file) { + return `Ok, ${name}, here is ${file.name}: + ${(await file.text()).toUpperCase()} + `; + } + return 'Hi ' + name + '!'; } diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js index b1cda7e1042a1..f23959b2f8bee 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js @@ -88,8 +88,17 @@ function decodeReplyFromBusboy( webpackMap: ServerManifest, ): Thenable { const response = createResponse(webpackMap, ''); + let pendingFiles = 0; + const queuedFields: Array = []; busboyStream.on('field', (name, value) => { - resolveField(response, name, value); + if (pendingFiles > 0) { + // Because the 'end' event fires two microtasks after the next 'field' + // we would resolve files and fields out of order. To handle this properly + // we queue any fields we receive until the previous file is done. + queuedFields.push(name, value); + } else { + resolveField(response, name, value); + } }); busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => { if (encoding.toLowerCase() === 'base64') { @@ -99,12 +108,21 @@ function decodeReplyFromBusboy( 'the wrong assumption, we can easily fix it.', ); } + pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); value.on('data', chunk => { resolveFileChunk(response, file, chunk); }); value.on('end', () => { resolveFileComplete(response, name, file); + pendingFiles--; + if (pendingFiles === 0) { + // Release any queued fields + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } + queuedFields.length = 0; + } }); }); busboyStream.on('finish', () => { diff --git a/packages/react-server/src/ReactFlightReplyServer.js b/packages/react-server/src/ReactFlightReplyServer.js index 88b846f283506..078f76f11f5e5 100644 --- a/packages/react-server/src/ReactFlightReplyServer.js +++ b/packages/react-server/src/ReactFlightReplyServer.js @@ -564,8 +564,11 @@ export function resolveFileComplete( handle: FileHandle, ): void { // Add this file to the backing store. - const file = new File(handle.chunks, handle.filename, {type: handle.mime}); - response._formData.append(key, file); + // Node.js doesn't expose a global File constructor so we need to use + // the append() form that takes the file name as the third argument, + // to create a File object. + const blob = new Blob(handle.chunks, {type: handle.mime}); + response._formData.append(key, blob, handle.filename); } export function close(response: Response): void { From 7ce765ec321a6f213019b56b36f9dccb2a8a7d5c Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Sun, 23 Apr 2023 14:50:17 -0400 Subject: [PATCH 25/38] Clean up enableUseHook flag (#26707) This has been statically enabled everywhere for months. --- .../src/__tests__/ReactFlight-test.js | 2 - .../react-debug-tools/src/ReactDebugHooks.js | 8 +++ .../src/__tests__/ReactDOMFizzServer-test.js | 6 -- .../react-reconciler/src/ReactFiberHooks.js | 71 ++++++------------- .../src/ReactInternalTypes.js | 2 +- .../src/__tests__/ReactIsomorphicAct-test.js | 3 - .../src/__tests__/ReactUse-test.js | 20 ------ .../src/__tests__/ReactFlightDOM-test.js | 19 ----- .../__tests__/ReactFlightDOMBrowser-test.js | 8 --- .../src/__tests__/ReactFlightDOMEdge-test.js | 1 - .../src/__tests__/ReactFlightDOMNode-test.js | 1 - packages/react-server/src/ReactFizzHooks.js | 5 +- packages/react-server/src/ReactFlightHooks.js | 3 +- .../react/src/__tests__/ReactFetch-test.js | 2 - packages/shared/ReactFeatureFlags.js | 2 - .../forks/ReactFeatureFlags.native-fb.js | 1 - .../forks/ReactFeatureFlags.native-oss.js | 1 - .../forks/ReactFeatureFlags.test-renderer.js | 1 - .../ReactFeatureFlags.test-renderer.native.js | 1 - .../ReactFeatureFlags.test-renderer.www.js | 1 - .../shared/forks/ReactFeatureFlags.www.js | 1 - 21 files changed, 31 insertions(+), 128 deletions(-) diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index cb240482273d1..23a44a41ed803 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -530,7 +530,6 @@ describe('ReactFlight', () => { expect(ReactNoop).toMatchRenderedOutput(
I am client
); }); - // @gate enableUseHook it('should error if a non-serializable value is passed to a host component', async () => { function ClientImpl({children}) { return children; @@ -641,7 +640,6 @@ describe('ReactFlight', () => { }); }); - // @gate enableUseHook it('should trigger the inner most error boundary inside a Client Component', async () => { function ServerComponent() { throw new Error('This was thrown in the Server Component.'); diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index bed4c2fead5eb..362e5f36d2bb1 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -106,6 +106,13 @@ function readContext(context: ReactContext): T { return context._currentValue; } +function use(): T { + // TODO: What should this do if it receives an unresolved promise? + throw new Error( + 'Support for `use` not yet implemented in react-debug-tools.', + ); +} + function useContext(context: ReactContext): T { hookLog.push({ primitive: 'Context', @@ -327,6 +334,7 @@ function useId(): string { } const Dispatcher: DispatcherType = { + use, readContext, useCacheRefresh, useCallback, diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 1f8746c0e96cc..40d996194cb94 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -5212,7 +5212,6 @@ describe('ReactDOMFizzServer', () => { }); }); - // @gate enableUseHook it('basic use(promise)', async () => { const promiseA = Promise.resolve('A'); const promiseB = Promise.resolve('B'); @@ -5258,7 +5257,6 @@ describe('ReactDOMFizzServer', () => { expect(getVisibleChildren(container)).toEqual('ABC'); }); - // @gate enableUseHook it('basic use(context)', async () => { const ContextA = React.createContext('default'); const ContextB = React.createContext('B'); @@ -5303,7 +5301,6 @@ describe('ReactDOMFizzServer', () => { expect(getVisibleChildren(container)).toEqual(['AB', 'C']); }); - // @gate enableUseHook it('use(promise) in multiple components', async () => { const promiseA = Promise.resolve('A'); const promiseB = Promise.resolve('B'); @@ -5357,7 +5354,6 @@ describe('ReactDOMFizzServer', () => { expect(getVisibleChildren(container)).toEqual('ABCD'); }); - // @gate enableUseHook it('using a rejected promise will throw', async () => { const promiseA = Promise.resolve('A'); const promiseB = Promise.reject(new Error('Oops!')); @@ -5443,7 +5439,6 @@ describe('ReactDOMFizzServer', () => { } }); - // @gate enableUseHook it("use a promise that's already been instrumented and resolved", async () => { const thenable = { status: 'fulfilled', @@ -5467,7 +5462,6 @@ describe('ReactDOMFizzServer', () => { expect(getVisibleChildren(container)).toEqual('Hi'); }); - // @gate enableUseHook it('unwraps thenable that fulfills synchronously without suspending', async () => { function App() { const thenable = { diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index fa0f155bf8048..b4b193233b401 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -37,7 +37,6 @@ import { enableLazyContextPropagation, enableUseMutableSource, enableTransitionTracing, - enableUseHook, enableUseMemoCacheHook, enableUseEffectEventHook, enableLegacyCache, @@ -2944,6 +2943,7 @@ function markUpdateInDevTools
(fiber: Fiber, lane: Lane, action: A): void { export const ContextOnlyDispatcher: Dispatcher = { readContext, + use, useCallback: throwInvalidHookError, useContext: throwInvalidHookError, useEffect: throwInvalidHookError, @@ -2964,9 +2964,6 @@ export const ContextOnlyDispatcher: Dispatcher = { if (enableCache) { (ContextOnlyDispatcher: Dispatcher).useCacheRefresh = throwInvalidHookError; } -if (enableUseHook) { - (ContextOnlyDispatcher: Dispatcher).use = throwInvalidHookError; -} if (enableUseMemoCacheHook) { (ContextOnlyDispatcher: Dispatcher).useMemoCache = throwInvalidHookError; } @@ -2977,6 +2974,7 @@ if (enableUseEffectEventHook) { const HooksDispatcherOnMount: Dispatcher = { readContext, + use, useCallback: mountCallback, useContext: readContext, useEffect: mountEffect, @@ -2997,9 +2995,6 @@ const HooksDispatcherOnMount: Dispatcher = { if (enableCache) { (HooksDispatcherOnMount: Dispatcher).useCacheRefresh = mountRefresh; } -if (enableUseHook) { - (HooksDispatcherOnMount: Dispatcher).use = use; -} if (enableUseMemoCacheHook) { (HooksDispatcherOnMount: Dispatcher).useMemoCache = useMemoCache; } @@ -3009,6 +3004,7 @@ if (enableUseEffectEventHook) { const HooksDispatcherOnUpdate: Dispatcher = { readContext, + use, useCallback: updateCallback, useContext: readContext, useEffect: updateEffect, @@ -3032,9 +3028,6 @@ if (enableCache) { if (enableUseMemoCacheHook) { (HooksDispatcherOnUpdate: Dispatcher).useMemoCache = useMemoCache; } -if (enableUseHook) { - (HooksDispatcherOnUpdate: Dispatcher).use = use; -} if (enableUseEffectEventHook) { (HooksDispatcherOnUpdate: Dispatcher).useEffectEvent = updateEvent; } @@ -3042,6 +3035,7 @@ if (enableUseEffectEventHook) { const HooksDispatcherOnRerender: Dispatcher = { readContext, + use, useCallback: updateCallback, useContext: readContext, useEffect: updateEffect, @@ -3062,9 +3056,6 @@ const HooksDispatcherOnRerender: Dispatcher = { if (enableCache) { (HooksDispatcherOnRerender: Dispatcher).useCacheRefresh = updateRefresh; } -if (enableUseHook) { - (HooksDispatcherOnRerender: Dispatcher).use = use; -} if (enableUseMemoCacheHook) { (HooksDispatcherOnRerender: Dispatcher).useMemoCache = useMemoCache; } @@ -3103,6 +3094,7 @@ if (__DEV__) { readContext(context: ReactContext): T { return readContext(context); }, + use, useCallback(callback: T, deps: Array | void | null): T { currentHookNameInDev = 'useCallback'; mountHookTypesDev(); @@ -3243,9 +3235,6 @@ if (__DEV__) { return mountRefresh(); }; } - if (enableUseHook) { - (HooksDispatcherOnMountInDEV: Dispatcher).use = use; - } if (enableUseMemoCacheHook) { (HooksDispatcherOnMountInDEV: Dispatcher).useMemoCache = useMemoCache; } @@ -3264,6 +3253,7 @@ if (__DEV__) { readContext(context: ReactContext): T { return readContext(context); }, + use, useCallback(callback: T, deps: Array | void | null): T { currentHookNameInDev = 'useCallback'; updateHookTypesDev(); @@ -3398,9 +3388,6 @@ if (__DEV__) { return mountRefresh(); }; } - if (enableUseHook) { - (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).use = use; - } if (enableUseMemoCacheHook) { (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useMemoCache = useMemoCache; @@ -3420,6 +3407,7 @@ if (__DEV__) { readContext(context: ReactContext): T { return readContext(context); }, + use, useCallback(callback: T, deps: Array | void | null): T { currentHookNameInDev = 'useCallback'; updateHookTypesDev(); @@ -3557,9 +3545,6 @@ if (__DEV__) { return updateRefresh(); }; } - if (enableUseHook) { - (HooksDispatcherOnUpdateInDEV: Dispatcher).use = use; - } if (enableUseMemoCacheHook) { (HooksDispatcherOnUpdateInDEV: Dispatcher).useMemoCache = useMemoCache; } @@ -3578,7 +3563,7 @@ if (__DEV__) { readContext(context: ReactContext): T { return readContext(context); }, - + use, useCallback(callback: T, deps: Array | void | null): T { currentHookNameInDev = 'useCallback'; updateHookTypesDev(); @@ -3716,9 +3701,6 @@ if (__DEV__) { return updateRefresh(); }; } - if (enableUseHook) { - (HooksDispatcherOnRerenderInDEV: Dispatcher).use = use; - } if (enableUseMemoCacheHook) { (HooksDispatcherOnRerenderInDEV: Dispatcher).useMemoCache = useMemoCache; } @@ -3738,6 +3720,10 @@ if (__DEV__) { warnInvalidContextAccess(); return readContext(context); }, + use(usable: Usable): T { + warnInvalidHookAccess(); + return use(usable); + }, useCallback(callback: T, deps: Array | void | null): T { currentHookNameInDev = 'useCallback'; warnInvalidHookAccess(); @@ -3888,14 +3874,6 @@ if (__DEV__) { return mountRefresh(); }; } - if (enableUseHook) { - (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).use = function ( - usable: Usable, - ): T { - warnInvalidHookAccess(); - return use(usable); - }; - } if (enableUseMemoCacheHook) { (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useMemoCache = function (size: number): Array { @@ -3920,6 +3898,10 @@ if (__DEV__) { warnInvalidContextAccess(); return readContext(context); }, + use(usable: Usable): T { + warnInvalidHookAccess(); + return use(usable); + }, useCallback(callback: T, deps: Array | void | null): T { currentHookNameInDev = 'useCallback'; warnInvalidHookAccess(); @@ -4073,14 +4055,6 @@ if (__DEV__) { return updateRefresh(); }; } - if (enableUseHook) { - (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).use = function ( - usable: Usable, - ): T { - warnInvalidHookAccess(); - return use(usable); - }; - } if (enableUseMemoCacheHook) { (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useMemoCache = function (size: number): Array { @@ -4105,7 +4079,10 @@ if (__DEV__) { warnInvalidContextAccess(); return readContext(context); }, - + use(usable: Usable): T { + warnInvalidHookAccess(); + return use(usable); + }, useCallback(callback: T, deps: Array | void | null): T { currentHookNameInDev = 'useCallback'; warnInvalidHookAccess(); @@ -4259,14 +4236,6 @@ if (__DEV__) { return updateRefresh(); }; } - if (enableUseHook) { - (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).use = function < - T, - >(usable: Usable): T { - warnInvalidHookAccess(); - return use(usable); - }; - } if (enableUseMemoCacheHook) { (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useMemoCache = function (size: number): Array { diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 281186c53de0d..1421181cb8ea9 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -372,7 +372,7 @@ type BasicStateAction = (S => S) | S; type Dispatch = A => void; export type Dispatcher = { - use?: (Usable) => T, + use: (Usable) => T, readContext(context: ReactContext): T, useState(initialState: (() => S) | S): [S, Dispatch>], useReducer( diff --git a/packages/react-reconciler/src/__tests__/ReactIsomorphicAct-test.js b/packages/react-reconciler/src/__tests__/ReactIsomorphicAct-test.js index 8615d4ab2667e..28b5333e4cf4c 100644 --- a/packages/react-reconciler/src/__tests__/ReactIsomorphicAct-test.js +++ b/packages/react-reconciler/src/__tests__/ReactIsomorphicAct-test.js @@ -206,7 +206,6 @@ describe('isomorphic act()', () => { }); // @gate __DEV__ - // @gate enableUseHook test('unwraps promises by yielding to microtasks (async act scope)', async () => { const promise = Promise.resolve('Async'); @@ -232,7 +231,6 @@ describe('isomorphic act()', () => { }); // @gate __DEV__ - // @gate enableUseHook test('unwraps promises by yielding to microtasks (non-async act scope)', async () => { const promise = Promise.resolve('Async'); @@ -260,7 +258,6 @@ describe('isomorphic act()', () => { }); // @gate __DEV__ - // @gate enableUseHook test('warns if a promise is used in a non-awaited `act` scope', async () => { const promise = new Promise(() => {}); diff --git a/packages/react-reconciler/src/__tests__/ReactUse-test.js b/packages/react-reconciler/src/__tests__/ReactUse-test.js index ec3b86acb1cda..1057dc2718ec7 100644 --- a/packages/react-reconciler/src/__tests__/ReactUse-test.js +++ b/packages/react-reconciler/src/__tests__/ReactUse-test.js @@ -184,7 +184,6 @@ describe('ReactUse', () => { expect(root).toMatchRenderedOutput('Loading...'); }); - // @gate enableUseHook test('basic use(promise)', async () => { const promiseA = Promise.resolve('A'); const promiseB = Promise.resolve('B'); @@ -213,7 +212,6 @@ describe('ReactUse', () => { expect(root).toMatchRenderedOutput('ABC'); }); - // @gate enableUseHook test("using a promise that's not cached between attempts", async () => { function Async() { const text = @@ -241,7 +239,6 @@ describe('ReactUse', () => { expect(root).toMatchRenderedOutput('ABC'); }); - // @gate enableUseHook test('using a rejected promise will throw', async () => { class ErrorBoundary extends React.Component { state = {error: null}; @@ -286,7 +283,6 @@ describe('ReactUse', () => { assertLog(['Oops!', 'Oops!']); }); - // @gate enableUseHook test('use(promise) in multiple components', async () => { // This tests that the state for tracking promises is reset per component. const promiseA = Promise.resolve('A'); @@ -320,7 +316,6 @@ describe('ReactUse', () => { expect(root).toMatchRenderedOutput('ABCD'); }); - // @gate enableUseHook test('use(promise) in multiple sibling components', async () => { // This tests that the state for tracking promises is reset per component. @@ -356,7 +351,6 @@ describe('ReactUse', () => { expect(root).toMatchRenderedOutput('Loading...'); }); - // @gate enableUseHook test('erroring in the same component as an uncached promise does not result in an infinite loop', async () => { class ErrorBoundary extends React.Component { state = {error: null}; @@ -434,7 +428,6 @@ describe('ReactUse', () => { expect(root).toMatchRenderedOutput('Caught an error: Oops!'); }); - // @gate enableUseHook test('basic use(context)', async () => { const ContextA = React.createContext(''); const ContextB = React.createContext('B'); @@ -458,7 +451,6 @@ describe('ReactUse', () => { expect(root).toMatchRenderedOutput('AB'); }); - // @gate enableUseHook test('interrupting while yielded should reset contexts', async () => { let resolve; const promise = new Promise(r => { @@ -505,7 +497,6 @@ describe('ReactUse', () => { expect(root).toMatchRenderedOutput(
Hello world!
); }); - // @gate enableUseHook || !__DEV__ test('warns if use(promise) is wrapped with try/catch block', async () => { function Async() { try { @@ -541,7 +532,6 @@ describe('ReactUse', () => { } }); - // @gate enableUseHook test('during a transition, can unwrap async operations even if nothing is cached', async () => { function App() { return ; @@ -577,7 +567,6 @@ describe('ReactUse', () => { expect(root).toMatchRenderedOutput('Async'); }); - // @gate enableUseHook test("does not prevent a Suspense fallback from showing if it's a new boundary, even during a transition", async () => { function App() { return ; @@ -620,7 +609,6 @@ describe('ReactUse', () => { expect(root).toMatchRenderedOutput('Async'); }); - // @gate enableUseHook test('when waiting for data to resolve, a fresh update will trigger a restart', async () => { function App() { return ; @@ -652,7 +640,6 @@ describe('ReactUse', () => { assertLog(['Something different']); }); - // @gate enableUseHook test('when waiting for data to resolve, an update on a different root does not cause work to be dropped', async () => { const getCachedAsyncText = cache(getAsyncText); @@ -693,7 +680,6 @@ describe('ReactUse', () => { expect(root1).toMatchRenderedOutput('Hi'); }); - // @gate enableUseHook test('while suspended, hooks cannot be called (i.e. current dispatcher is unset correctly)', async () => { function App() { return ; @@ -722,7 +708,6 @@ describe('ReactUse', () => { ); }); - // @gate enableUseHook test('unwraps thenable that fulfills synchronously without suspending', async () => { function App() { const thenable = { @@ -750,7 +735,6 @@ describe('ReactUse', () => { expect(root).toMatchRenderedOutput('Hi'); }); - // @gate enableUseHook test('does not suspend indefinitely if an interleaved update was skipped', async () => { function Child({childShouldSuspend}) { return ( @@ -979,7 +963,6 @@ describe('ReactUse', () => { expect(root).toMatchRenderedOutput('aguacate avocat'); }); - // @gate enableUseHook test( 'wrap an async function with useMemo to skip running the function ' + 'twice when loading new data', @@ -1012,7 +995,6 @@ describe('ReactUse', () => { }, ); - // @gate enableUseHook test('load multiple nested Suspense boundaries', async () => { const getCachedAsyncText = cache(getAsyncText); @@ -1056,7 +1038,6 @@ describe('ReactUse', () => { expect(root).toMatchRenderedOutput('ABC'); }); - // @gate enableUseHook test('load multiple nested Suspense boundaries (uncached requests)', async () => { // This the same as the previous test, except the requests are not cached. // The tree should still eventually resolve, despite the @@ -1139,7 +1120,6 @@ describe('ReactUse', () => { expect(root).toMatchRenderedOutput('ABC'); }); - // @gate enableUseHook test('use() combined with render phase updates', async () => { function Async() { const a = use(Promise.resolve('A')); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js index fb99875425279..32224491e4aff 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -130,7 +130,6 @@ describe('ReactFlightDOM', () => { }); }); - // @gate enableUseHook it('should resolve the root', async () => { // Model function Text({children}) { @@ -180,7 +179,6 @@ describe('ReactFlightDOM', () => { ); }); - // @gate enableUseHook it('should not get confused by $', async () => { // Model function RootModel() { @@ -215,7 +213,6 @@ describe('ReactFlightDOM', () => { expect(container.innerHTML).toBe('

$1

'); }); - // @gate enableUseHook it('should not get confused by @', async () => { // Model function RootModel() { @@ -250,7 +247,6 @@ describe('ReactFlightDOM', () => { expect(container.innerHTML).toBe('

@div

'); }); - // @gate enableUseHook it('should be able to esm compat test module references', async () => { const ESMCompatModule = { __esModule: true, @@ -300,7 +296,6 @@ describe('ReactFlightDOM', () => { expect(container.innerHTML).toBe('

Hello World

'); }); - // @gate enableUseHook it('should be able to render a named component export', async () => { const Module = { Component: function ({greeting}) { @@ -338,7 +333,6 @@ describe('ReactFlightDOM', () => { expect(container.innerHTML).toBe('

Hello World

'); }); - // @gate enableUseHook it('should be able to render a module split named component export', async () => { const Module = { // This gets split into a separate module from the original one. @@ -377,7 +371,6 @@ describe('ReactFlightDOM', () => { expect(container.innerHTML).toBe('

Hello World

'); }); - // @gate enableUseHook it('should unwrap async module references', async () => { const AsyncModule = Promise.resolve(function AsyncModule({text}) { return 'Async: ' + text; @@ -418,7 +411,6 @@ describe('ReactFlightDOM', () => { expect(container.innerHTML).toBe('

Async: Module

'); }); - // @gate enableUseHook it('should unwrap async module references using use', async () => { const AsyncModule = Promise.resolve('Async Text'); @@ -457,7 +449,6 @@ describe('ReactFlightDOM', () => { expect(container.innerHTML).toBe('

Async Text

'); }); - // @gate enableUseHook it('should be able to import a name called "then"', async () => { const thenExports = { then: function then() { @@ -531,7 +522,6 @@ describe('ReactFlightDOM', () => { ); }); - // @gate enableUseHook it('should progressively reveal server components', async () => { let reportedErrors = []; @@ -728,7 +718,6 @@ describe('ReactFlightDOM', () => { expect(reportedErrors).toEqual([]); }); - // @gate enableUseHook it('should preserve state of client components on refetch', async () => { // Client @@ -814,7 +803,6 @@ describe('ReactFlightDOM', () => { expect(inputB.value).toBe('goodbye'); }); - // @gate enableUseHook it('should be able to complete after aborting and throw the reason client-side', async () => { const reportedErrors = []; @@ -873,7 +861,6 @@ describe('ReactFlightDOM', () => { expect(reportedErrors).toEqual(['for reasons']); }); - // @gate enableUseHook it('should be able to recover from a direct reference erroring client-side', async () => { const reportedErrors = []; @@ -919,7 +906,6 @@ describe('ReactFlightDOM', () => { expect(reportedErrors).toEqual([]); }); - // @gate enableUseHook it('should be able to recover from a direct reference erroring client-side async', async () => { const reportedErrors = []; @@ -977,7 +963,6 @@ describe('ReactFlightDOM', () => { expect(reportedErrors).toEqual([]); }); - // @gate enableUseHook it('should be able to recover from a direct reference erroring server-side', async () => { const reportedErrors = []; @@ -1044,7 +1029,6 @@ describe('ReactFlightDOM', () => { expect(reportedErrors).toEqual(['bug in the bundler']); }); - // @gate enableUseHook it('should pass a Promise through props and be able use() it on the client', async () => { async function getData() { return 'async hello'; @@ -1090,7 +1074,6 @@ describe('ReactFlightDOM', () => { expect(container.innerHTML).toBe('

async hello

'); }); - // @gate enableUseHook it('should throw on the client if a passed promise eventually rejects', async () => { const reportedErrors = []; const theError = new Error('Server throw'); @@ -1158,7 +1141,6 @@ describe('ReactFlightDOM', () => { expect(reportedErrors).toEqual([theError]); }); - // @gate enableUseHook it('should support ReactDOM.preload when rendering in Fiber', async () => { function Component() { return

hello world

; @@ -1214,7 +1196,6 @@ describe('ReactFlightDOM', () => { expect(container.innerHTML).toBe('

hello world

'); }); - // @gate enableUseHook it('should support ReactDOM.preload when rendering in Fizz', async () => { function Component() { return

hello world

; diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index c846493de8430..b1e41f2a232f7 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -182,7 +182,6 @@ describe('ReactFlightDOMBrowser', () => { }); }); - // @gate enableUseHook it('should progressively reveal server components', async () => { let reportedErrors = []; @@ -492,7 +491,6 @@ describe('ReactFlightDOMBrowser', () => { expect(isDone).toBeTruthy(); }); - // @gate enableUseHook it('should be able to complete after aborting and throw the reason client-side', async () => { const reportedErrors = []; @@ -576,7 +574,6 @@ describe('ReactFlightDOMBrowser', () => { expect(reportedErrors).toEqual(['for reasons']); }); - // @gate enableUseHook it('basic use(promise)', async () => { function Server() { return ( @@ -605,7 +602,6 @@ describe('ReactFlightDOMBrowser', () => { expect(container.innerHTML).toBe('ABC'); }); - // @gate enableUseHook it('basic use(context)', async () => { const ContextA = React.createServerContext('ContextA', ''); const ContextB = React.createServerContext('ContextB', 'B'); @@ -639,7 +635,6 @@ describe('ReactFlightDOMBrowser', () => { expect(container.innerHTML).toBe('AB'); }); - // @gate enableUseHook it('use(promise) in multiple components', async () => { function Child({prefix}) { return prefix + use(Promise.resolve('C')) + use(Promise.resolve('D')); @@ -670,7 +665,6 @@ describe('ReactFlightDOMBrowser', () => { expect(container.innerHTML).toBe('ABCD'); }); - // @gate enableUseHook it('using a rejected promise will throw', async () => { const promiseA = Promise.resolve('A'); const promiseB = Promise.reject(new Error('Oops!')); @@ -732,7 +726,6 @@ describe('ReactFlightDOMBrowser', () => { expect(reportedErrors[0].message).toBe('Oops!'); }); - // @gate enableUseHook it("use a promise that's already been instrumented and resolved", async () => { const thenable = { status: 'fulfilled', @@ -760,7 +753,6 @@ describe('ReactFlightDOMBrowser', () => { expect(container.innerHTML).toBe('Hi'); }); - // @gate enableUseHook it('unwraps thenable that fulfills synchronously without suspending', async () => { function Server() { const thenable = { diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index e090add747cea..ee110d483d711 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -54,7 +54,6 @@ describe('ReactFlightDOMEdge', () => { } } - // @gate enableUseHook it('should allow an alternative module mapping to be used for SSR', async () => { function ClientComponent() { return Client Component; diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index 17b65bcddf786..c720b4434cdf9 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -56,7 +56,6 @@ describe('ReactFlightDOMNode', () => { }); } - // @gate enableUseHook it('should allow an alternative module mapping to be used for SSR', async () => { function ClientComponent() { return Client Component; diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index a389c092618ed..62e4586f74590 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -31,7 +31,6 @@ import {makeId} from './ReactFizzConfig'; import { enableCache, - enableUseHook, enableUseEffectEventHook, enableUseMemoCacheHook, } from 'shared/ReactFeatureFlags'; @@ -610,6 +609,7 @@ function noop(): void {} export const HooksDispatcher: Dispatcher = { readContext, + use, useContext, useMemo, useReducer, @@ -641,9 +641,6 @@ if (enableUseEffectEventHook) { if (enableUseMemoCacheHook) { HooksDispatcher.useMemoCache = useMemoCache; } -if (enableUseHook) { - HooksDispatcher.use = use; -} export let currentResponseState: null | ResponseState = (null: any); export function setCurrentResponseState( diff --git a/packages/react-server/src/ReactFlightHooks.js b/packages/react-server/src/ReactFlightHooks.js index 270e1f24007b2..190292515efb3 100644 --- a/packages/react-server/src/ReactFlightHooks.js +++ b/packages/react-server/src/ReactFlightHooks.js @@ -16,7 +16,6 @@ import { REACT_MEMO_CACHE_SENTINEL, } from 'shared/ReactSymbols'; import {readContext as readContextImpl} from './ReactFlightNewContext'; -import {enableUseHook} from 'shared/ReactFeatureFlags'; import {createThenableState, trackUsedThenable} from './ReactFlightThenable'; import {isClientReference} from './ReactFlightServerConfig'; @@ -100,7 +99,7 @@ export const HooksDispatcher: Dispatcher = { } return data; }, - use: enableUseHook ? use : (unsupportedHook: any), + use, }; function unsupportedHook(): void { diff --git a/packages/react/src/__tests__/ReactFetch-test.js b/packages/react/src/__tests__/ReactFetch-test.js index 2993ed564cdbd..b3f8843b8856e 100644 --- a/packages/react/src/__tests__/ReactFetch-test.js +++ b/packages/react/src/__tests__/ReactFetch-test.js @@ -135,7 +135,6 @@ describe('ReactFetch', () => { expect(fetchCount).toBe(1); }); - // @gate enableUseHook it('can opt-out of deduping fetches inside of render with custom signal', async () => { const controller = new AbortController(); function useCustomHook() { @@ -154,7 +153,6 @@ describe('ReactFetch', () => { expect(fetchCount).not.toBe(1); }); - // @gate enableUseHook it('opts out of deduping for POST requests', async () => { function useCustomHook() { return use( diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 6129bb1476f59..791ed622a0ff6 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -106,8 +106,6 @@ export const enableHostSingletons = true; export const enableFloat = true; -export const enableUseHook = true; - // Enables unstable_useMemoCache hook, intended as a compilation target for // auto-memoization. export const enableUseMemoCacheHook = __EXPERIMENTAL__; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 9345b0bb85ff3..239228901600d 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -53,7 +53,6 @@ export const disableModulePatternComponents = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = true; -export const enableUseHook = true; export const enableUseMemoCacheHook = true; export const enableUseEffectEventHook = false; export const enableClientRenderFallbackOnTextMismatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index c5888b2837c50..326f7d5385b46 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -39,7 +39,6 @@ export const disableModulePatternComponents = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; -export const enableUseHook = true; export const enableUseMemoCacheHook = false; export const enableUseEffectEventHook = false; export const enableClientRenderFallbackOnTextMismatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 63f3c9f6eb206..f3d296f257c92 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -39,7 +39,6 @@ export const disableModulePatternComponents = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; -export const enableUseHook = true; export const enableUseMemoCacheHook = false; export const enableUseEffectEventHook = false; export const enableClientRenderFallbackOnTextMismatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js index 771362d6bfecb..751695f8eb0b1 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js @@ -43,7 +43,6 @@ export const enableGetInspectorDataForInstanceInProduction = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; -export const enableUseHook = true; export const enableUseMemoCacheHook = false; export const enableUseEffectEventHook = false; export const enableClientRenderFallbackOnTextMismatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 433d18d918247..31d2292dcf4e8 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -39,7 +39,6 @@ export const disableModulePatternComponents = true; export const enableSuspenseAvoidThisFallback = true; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; -export const enableUseHook = true; export const enableUseMemoCacheHook = false; export const enableUseEffectEventHook = false; export const enableClientRenderFallbackOnTextMismatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index a4a64c0d93e81..949811e68148e 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -51,7 +51,6 @@ export const enableSuspenseAvoidThisFallbackFizz = false; export const disableSchedulerTimeoutInWorkLoop = false; export const enableCPUSuspense = true; export const enableFloat = true; -export const enableUseHook = true; export const enableUseMemoCacheHook = true; export const enableUseEffectEventHook = true; export const enableHostSingletons = true; From 2fa632381839c8732dad9107b90911163b7f2b7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sun, 23 Apr 2023 17:44:28 -0400 Subject: [PATCH 26/38] Restore server controlled form fields to whatever they should be (#26708) Fizz can emit whatever it wants for the SSR version of these fields when it's a function action so they might not align with what is in the previous props. Therefore we need to force them to update if we're updating to a non-function where they might be relevant again. --- .../src/client/ReactDOMComponent.js | 145 ++++++++++++--- .../src/client/ReactDOMInput.js | 1 - .../src/__tests__/ReactDOMFizzForm-test.js | 165 ++++++++++++++++++ 3 files changed, 283 insertions(+), 28 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index 0a198d718b172..8d2402e26ba9b 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -483,6 +483,68 @@ function setProp( case 'action': case 'formAction': { // TODO: Consider moving these special cases to the form, input and button tags. + if (enableFormActions) { + if (typeof value === 'function') { + // Set a javascript URL that doesn't do anything. We don't expect this to be invoked + // because we'll preventDefault, but it can happen if a form is manually submitted or + // if someone calls stopPropagation before React gets the event. + // If CSP is used to block javascript: URLs that's fine too. It just won't show this + // error message but the URL will be logged. + domElement.setAttribute( + key, + // eslint-disable-next-line no-script-url + "javascript:throw new Error('" + + 'A React form was unexpectedly submitted. If you called form.submit() manually, ' + + "consider using form.requestSubmit() instead. If you're trying to use " + + 'event.stopPropagation() in a submit event handler, consider also calling ' + + 'event.preventDefault().' + + "')", + ); + break; + } else if (typeof prevValue === 'function') { + // When we're switching off a Server Action that was originally hydrated. + // The server control these fields during SSR that are now trailing. + // The regular diffing doesn't apply since we compare against the previous props. + // Instead, we need to force them to be set to whatever they should be now. + // This would be a lot cleaner if we did this whole fork in the per-tag approach. + if (key === 'formAction') { + if (tag !== 'input') { + // Setting the name here isn't completely safe for inputs if this is switching + // to become a radio button. In that case we let the tag based override take + // control. + setProp(domElement, tag, 'name', props.name, props, null); + } + setProp( + domElement, + tag, + 'formEncType', + props.formEncType, + props, + null, + ); + setProp( + domElement, + tag, + 'formMethod', + props.formMethod, + props, + null, + ); + setProp( + domElement, + tag, + 'formTarget', + props.formTarget, + props, + null, + ); + } else { + setProp(domElement, tag, 'encType', props.encType, props, null); + setProp(domElement, tag, 'method', props.method, props, null); + setProp(domElement, tag, 'target', props.target, props, null); + } + } + } if ( value == null || (!enableFormActions && typeof value === 'function') || @@ -495,24 +557,6 @@ function setProp( if (__DEV__) { validateFormActionInDevelopment(tag, key, value, props); } - if (enableFormActions && typeof value === 'function') { - // Set a javascript URL that doesn't do anything. We don't expect this to be invoked - // because we'll preventDefault, but it can happen if a form is manually submitted or - // if someone calls stopPropagation before React gets the event. - // If CSP is used to block javascript: URLs that's fine too. It just won't show this - // error message but the URL will be logged. - domElement.setAttribute( - key, - // eslint-disable-next-line no-script-url - "javascript:throw new Error('" + - 'A React form was unexpectedly submitted. If you called form.submit() manually, ' + - "consider using form.requestSubmit() instead. If you're trying to use " + - 'event.stopPropagation() in a submit event handler, consider also calling ' + - 'event.preventDefault().' + - "')", - ); - break; - } // `setAttribute` with objects becomes only `[object]` in IE8/9, // ('' + value) makes it output the correct toString()-value. if (__DEV__) { @@ -1138,7 +1182,7 @@ export function setInitialProperties( break; } default: { - setProp(domElement, tag, propKey, propValue, props); + setProp(domElement, tag, propKey, propValue, props, null); } } } @@ -1169,7 +1213,7 @@ export function setInitialProperties( break; } default: { - setProp(domElement, tag, propKey, propValue, props); + setProp(domElement, tag, propKey, propValue, props, null); } } } @@ -1935,7 +1979,14 @@ export function updatePropertiesWithDiff( break; } default: { - setProp(domElement, tag, propKey, propValue, nextProps, null); + setProp( + domElement, + tag, + propKey, + propValue, + nextProps, + lastProps[propKey], + ); } } } @@ -2010,7 +2061,14 @@ export function updatePropertiesWithDiff( } // defaultValue are ignored by setProp default: { - setProp(domElement, tag, propKey, propValue, nextProps, null); + setProp( + domElement, + tag, + propKey, + propValue, + nextProps, + lastProps[propKey], + ); } } } @@ -2045,7 +2103,14 @@ export function updatePropertiesWithDiff( } // defaultValue is ignored by setProp default: { - setProp(domElement, tag, propKey, propValue, nextProps, null); + setProp( + domElement, + tag, + propKey, + propValue, + nextProps, + lastProps[propKey], + ); } } } @@ -2066,7 +2131,14 @@ export function updatePropertiesWithDiff( break; } default: { - setProp(domElement, tag, propKey, propValue, nextProps, null); + setProp( + domElement, + tag, + propKey, + propValue, + nextProps, + lastProps[propKey], + ); } } } @@ -2105,7 +2177,14 @@ export function updatePropertiesWithDiff( } // defaultChecked and defaultValue are ignored by setProp default: { - setProp(domElement, tag, propKey, propValue, nextProps, null); + setProp( + domElement, + tag, + propKey, + propValue, + nextProps, + lastProps[propKey], + ); } } } @@ -2122,7 +2201,7 @@ export function updatePropertiesWithDiff( propKey, propValue, nextProps, - null, + lastProps[propKey], ); } return; @@ -2134,7 +2213,7 @@ export function updatePropertiesWithDiff( for (let i = 0; i < updatePayload.length; i += 2) { const propKey = updatePayload[i]; const propValue = updatePayload[i + 1]; - setProp(domElement, tag, propKey, propValue, nextProps, null); + setProp(domElement, tag, propKey, propValue, nextProps, lastProps[propKey]); } } @@ -2709,6 +2788,18 @@ function diffHydratedGenericElement( const hasFormActionURL = serverValue === EXPECTED_FORM_ACTION_URL; if (typeof value === 'function') { extraAttributes.delete(propKey.toLowerCase()); + // The server can set these extra properties to implement actions. + // So we remove them from the extra attributes warnings. + if (propKey === 'formAction') { + extraAttributes.delete('name'); + extraAttributes.delete('formenctype'); + extraAttributes.delete('formmethod'); + extraAttributes.delete('formtarget'); + } else { + extraAttributes.delete('enctype'); + extraAttributes.delete('method'); + extraAttributes.delete('target'); + } if (hasFormActionURL) { // Expected continue; diff --git a/packages/react-dom-bindings/src/client/ReactDOMInput.js b/packages/react-dom-bindings/src/client/ReactDOMInput.js index 2096238d762f6..30f06dacd18e1 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMInput.js +++ b/packages/react-dom-bindings/src/client/ReactDOMInput.js @@ -131,7 +131,6 @@ export function updateInput( // Submit/reset inputs need the attribute removed completely to avoid // blank-text buttons. node.removeAttribute('value'); - return; } if (disableInputAttributeSyncing) { diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js index a73c29f0abe2c..efb3a1de960e8 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js @@ -195,4 +195,169 @@ describe('ReactDOMFizzForm', () => { 'Prop `action` did not match. Server: "action" Client: "function action(formData) {}"', ); }); + + // @gate enableFormActions || !__DEV__ + it('should reset form fields after you update away from hydrated function', async () => { + const formRef = React.createRef(); + const inputRef = React.createRef(); + const buttonRef = React.createRef(); + function action(formData) {} + function App({isUpdate}) { + return ( +
+ +
, ); }); @@ -503,10 +506,70 @@ describe('ReactDOMForm', () => { } }); - await submit(ref.current); + await submit(inputRef.current); expect(button).toBe('delete'); expect(title).toBe(null); + + await submit(buttonRef.current); + + expect(button).toBe('edit'); + expect(title).toBe('hello'); + + // Ensure that the type field got correctly restored + expect(inputRef.current.getAttribute('type')).toBe('submit'); + expect(buttonRef.current.getAttribute('type')).toBe(null); + }); + + // @gate enableFormActions + it('excludes the submitter name when the submitter is a function action', async () => { + const inputRef = React.createRef(); + const buttonRef = React.createRef(); + let button; + + function action(formData) { + // A function action cannot control the name since it might be controlled by the server + // so we need to make sure it doesn't get into the FormData. + button = formData.get('button'); + } + + const root = ReactDOMClient.createRoot(container); + await expect(async () => { + await act(async () => { + root.render( +
+ + +
, + ); + }); + }).toErrorDev([ + 'Cannot specify a "name" prop for a button that specifies a function as a formAction.', + ]); + + await submit(inputRef.current); + + expect(button).toBe(null); + + await submit(buttonRef.current); + + expect(button).toBe(null); + + // Ensure that the type field got correctly restored + expect(inputRef.current.getAttribute('type')).toBe('submit'); + expect(buttonRef.current.getAttribute('type')).toBe(null); }); // @gate enableFormActions || !__DEV__ From 9ece58ebaa46f8b5e90a6ad71be4919e1dc9c563 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 24 Apr 2023 19:26:50 -0400 Subject: [PATCH 30/38] Go through the toString path for booleanish strings and .name property (#26720) This is consistent with what we used to do but not what we want to do. --- packages/react-dom-bindings/src/client/ReactDOMComponent.js | 5 ++++- packages/react-dom-bindings/src/client/ReactDOMInput.js | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index bd7dfc0d53738..2603d8db35770 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -685,7 +685,10 @@ function setProp( if (__DEV__) { checkAttributeStringCoercion(value, key); } - domElement.setAttribute(key, (value: any)); + domElement.setAttribute( + key, + enableTrustedTypesIntegration ? (value: any) : '' + (value: any), + ); } else { domElement.removeAttribute(key); } diff --git a/packages/react-dom-bindings/src/client/ReactDOMInput.js b/packages/react-dom-bindings/src/client/ReactDOMInput.js index 6b16d479532cd..409bf1e907650 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMInput.js +++ b/packages/react-dom-bindings/src/client/ReactDOMInput.js @@ -188,7 +188,7 @@ export function updateInput( if (__DEV__) { checkAttributeStringCoercion(name, 'name'); } - node.name = name; + node.name = toString(getToStringValue(name)); } else { node.removeAttribute('name'); } From 919620b2935ca6bb8dfc96204a7cf8754c006240 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 24 Apr 2023 20:18:34 -0400 Subject: [PATCH 31/38] Add stub for experimental_useFormStatus (#26719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This wires up, but does not yet implement, an experimental hook called useFormStatus. The hook is imported from React DOM, not React, because it represents DOM-specific state — its return type includes FormData as one of its fields. Other renderers that implement similar methods would use their own renderer-specific types. The API is prefixed and only available in the experimental channel. It can only be used from client (browser, SSR) components, not Server Components. --- packages/react-dom/index.classic.fb.js | 1 + packages/react-dom/index.experimental.js | 1 + packages/react-dom/index.js | 1 + packages/react-dom/index.modern.fb.js | 1 + packages/react-dom/src/ReactDOMFormActions.js | 50 +++++++++++++++++++ .../src/__tests__/ReactDOMFizzForm-test.js | 18 +++++++ .../src/__tests__/ReactDOMForm-test.js | 18 +++++++ packages/react-dom/src/client/ReactDOM.js | 1 + 8 files changed, 91 insertions(+) create mode 100644 packages/react-dom/src/ReactDOMFormActions.js diff --git a/packages/react-dom/index.classic.fb.js b/packages/react-dom/index.classic.fb.js index a9efae8208b94..e093e3ff08499 100644 --- a/packages/react-dom/index.classic.fb.js +++ b/packages/react-dom/index.classic.fb.js @@ -31,6 +31,7 @@ export { unstable_createEventHandle, unstable_renderSubtreeIntoContainer, unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority. + useFormStatus as experimental_useFormStatus, prefetchDNS, preconnect, preload, diff --git a/packages/react-dom/index.experimental.js b/packages/react-dom/index.experimental.js index 5e905c19a6f57..32d66c66b3626 100644 --- a/packages/react-dom/index.experimental.js +++ b/packages/react-dom/index.experimental.js @@ -20,6 +20,7 @@ export { unstable_batchedUpdates, unstable_renderSubtreeIntoContainer, unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority. + useFormStatus as experimental_useFormStatus, prefetchDNS, preconnect, preload, diff --git a/packages/react-dom/index.js b/packages/react-dom/index.js index 169db31142d9b..d9290ae6099f2 100644 --- a/packages/react-dom/index.js +++ b/packages/react-dom/index.js @@ -23,6 +23,7 @@ export { unstable_createEventHandle, unstable_renderSubtreeIntoContainer, unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority. + useFormStatus as experimental_useFormStatus, prefetchDNS, preconnect, preload, diff --git a/packages/react-dom/index.modern.fb.js b/packages/react-dom/index.modern.fb.js index c41519668d5a5..6ea394c8179d5 100644 --- a/packages/react-dom/index.modern.fb.js +++ b/packages/react-dom/index.modern.fb.js @@ -16,6 +16,7 @@ export { unstable_batchedUpdates, unstable_createEventHandle, unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority. + useFormStatus as experimental_useFormStatus, prefetchDNS, preconnect, preload, diff --git a/packages/react-dom/src/ReactDOMFormActions.js b/packages/react-dom/src/ReactDOMFormActions.js new file mode 100644 index 0000000000000..b5a763976d03f --- /dev/null +++ b/packages/react-dom/src/ReactDOMFormActions.js @@ -0,0 +1,50 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import {enableAsyncActions, enableFormActions} from 'shared/ReactFeatureFlags'; + +type FormStatusNotPending = {| + pending: false, + data: null, + method: null, + action: null, +|}; + +type FormStatusPending = {| + pending: true, + data: FormData, + method: string, + action: string | (FormData => void | Promise), +|}; + +export type FormStatus = FormStatusPending | FormStatusNotPending; + +// Since the "not pending" value is always the same, we can reuse the +// same object across all transitions. +const sharedNotPendingObject = { + pending: false, + data: null, + method: null, + action: null, +}; + +const NotPending: FormStatus = __DEV__ + ? Object.freeze(sharedNotPendingObject) + : sharedNotPendingObject; + +export function useFormStatus(): FormStatus { + if (!(enableFormActions && enableAsyncActions)) { + throw new Error('Not implemented.'); + } else { + // TODO: This isn't fully implemented yet but we return a correctly typed + // value so we can test that the API is exposed and gated correctly. The + // real implementation will access the status via the dispatcher. + return NotPending; + } +} diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js index efb3a1de960e8..900f270b9fba5 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js @@ -19,6 +19,7 @@ let container; let React; let ReactDOMServer; let ReactDOMClient; +let useFormStatus; describe('ReactDOMFizzForm', () => { beforeEach(() => { @@ -26,6 +27,7 @@ describe('ReactDOMFizzForm', () => { React = require('react'); ReactDOMServer = require('react-dom/server.browser'); ReactDOMClient = require('react-dom/client'); + useFormStatus = require('react-dom').experimental_useFormStatus; act = require('internal-test-utils').act; container = document.createElement('div'); document.body.appendChild(container); @@ -360,4 +362,20 @@ describe('ReactDOMFizzForm', () => { expect(buttonRef.current.hasAttribute('formMethod')).toBe(false); expect(buttonRef.current.hasAttribute('formTarget')).toBe(false); }); + + // @gate enableFormActions + // @gate enableAsyncActions + it('useFormStatus is not pending during server render', async () => { + function App() { + const {pending} = useFormStatus(); + return 'Pending: ' + pending; + } + + const stream = await ReactDOMServer.renderToReadableStream(); + await readIntoContainer(stream); + expect(container.textContent).toBe('Pending: false'); + + await act(() => ReactDOMClient.hydrateRoot(container, )); + expect(container.textContent).toBe('Pending: false'); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMForm-test.js b/packages/react-dom/src/__tests__/ReactDOMForm-test.js index 267d19410bdd2..ba312e9dd66d9 100644 --- a/packages/react-dom/src/__tests__/ReactDOMForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js @@ -39,6 +39,7 @@ describe('ReactDOMForm', () => { let Suspense; let startTransition; let textCache; + let useFormStatus; beforeEach(() => { jest.resetModules(); @@ -51,6 +52,7 @@ describe('ReactDOMForm', () => { useState = React.useState; Suspense = React.Suspense; startTransition = React.startTransition; + useFormStatus = ReactDOM.experimental_useFormStatus; container = document.createElement('div'); document.body.appendChild(container); @@ -846,4 +848,20 @@ describe('ReactDOMForm', () => { assertLog(['Oh no!', 'Oh no!']); expect(container.textContent).toBe('Oh no!'); }); + + // @gate enableFormActions + // @gate enableAsyncActions + it('useFormStatus exists', async () => { + // This API isn't fully implemented yet. This just tests that it's wired + // up correctly. + + function App() { + const {pending} = useFormStatus(); + return 'Pending: ' + pending; + } + + const root = ReactDOMClient.createRoot(container); + await act(() => root.render()); + expect(container.textContent).toBe('Pending: false'); + }); }); diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index d6ef4261246de..f31dcee5bae1e 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -56,6 +56,7 @@ import { import Internals from '../ReactDOMSharedInternals'; export {prefetchDNS, preconnect, preload, preinit} from '../ReactDOMFloat'; +export {useFormStatus} from '../ReactDOMFormActions'; if (__DEV__) { if ( From 64d6be71224c4241ba8f9d80747d53c0fd6224e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 25 Apr 2023 09:12:16 -0400 Subject: [PATCH 32/38] Use require() to implement script src in tests (#26717) We currently use rollup to make an adhoc bundle from the file system when we're testing an import of an external file. This doesn't follow all the interception rules that we use in jest and in our actual builds. This switches to just using jest require() to load these. This means that they effectively have to load into the global document so this only works with global document tests which is all we have now anyway. --- .../src/__tests__/ReactDOMFizzServer-test.js | 6 +- .../src/__tests__/ReactDOMFloat-test.js | 8 +- .../react-dom/src/test-utils/FizzTestUtils.js | 79 +++---------------- .../ReactDOMServerIntegrationEnvironment.js | 2 + 4 files changed, 24 insertions(+), 71 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 40d996194cb94..8d30c29ed74bd 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -64,7 +64,11 @@ describe('ReactDOMFizzServer', () => { }); streamingContainer = null; global.window = jsdom.window; - global.document = jsdom.window.document; + global.document = global.window.document; + global.navigator = global.window.navigator; + global.Node = global.window.Node; + global.addEventListener = global.window.addEventListener; + global.MutationObserver = global.window.MutationObserver; container = document.getElementById('container'); Scheduler = require('scheduler'); diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 0aeef3357bd83..81ed890e040a2 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -59,7 +59,11 @@ describe('ReactDOMFloat', () => { }); streamingContainer = null; global.window = jsdom.window; - global.document = jsdom.window.document; + global.document = global.window.document; + global.navigator = global.window.navigator; + global.Node = global.window.Node; + global.addEventListener = global.window.addEventListener; + global.MutationObserver = global.window.MutationObserver; container = document.getElementById('container'); React = require('react'); @@ -95,7 +99,7 @@ describe('ReactDOMFloat', () => { renderOptions = {}; if (gate(flags => flags.enableFizzExternalRuntime)) { renderOptions.unstable_externalRuntimeSrc = - 'react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js'; + 'react-dom/unstable_server-external-runtime'; } }); diff --git a/packages/react-dom/src/test-utils/FizzTestUtils.js b/packages/react-dom/src/test-utils/FizzTestUtils.js index 18ae445307b70..1407504fd2b7f 100644 --- a/packages/react-dom/src/test-utils/FizzTestUtils.js +++ b/packages/react-dom/src/test-utils/FizzTestUtils.js @@ -8,68 +8,6 @@ */ 'use strict'; -import * as tmp from 'tmp'; -import * as fs from 'fs'; -import replace from '@rollup/plugin-replace'; -import resolve from '@rollup/plugin-node-resolve'; -import {rollup} from 'rollup'; -import path from 'path'; - -const rollupCache: Map = new Map(); - -// Utility function to read and bundle a standalone browser script -async function getRollupResult(scriptSrc: string): Promise { - const cachedResult = rollupCache.get(scriptSrc); - if (cachedResult !== undefined) { - return cachedResult; - } - let tmpFile; - try { - tmpFile = tmp.fileSync(); - const rollupConfig = { - input: require.resolve(scriptSrc), - onwarn: console.warn, - plugins: [ - replace({__DEV__: 'true', preventAssignment: true}), - resolve({ - rootDir: path.join(__dirname, '..', '..', '..'), - }), - ], - output: { - externalLiveBindings: false, - freeze: false, - interop: false, - esModule: false, - }, - }; - const outputConfig = { - file: tmpFile.name, - format: 'iife', - }; - const bundle = await rollup(rollupConfig); - await bundle.write(outputConfig); - const bundleBuffer = Buffer.alloc(4096); - let bundleStr = ''; - while (true) { - // $FlowFixMe[incompatible-call] - const bytes = fs.readSync(tmpFile.fd, bundleBuffer); - if (bytes <= 0) { - break; - } - bundleStr += bundleBuffer.slice(0, bytes).toString(); - } - rollupCache.set(scriptSrc, bundleStr); - return bundleStr; - } catch (e) { - rollupCache.set(scriptSrc, null); - return null; - } finally { - if (tmpFile) { - tmpFile.removeCallback(); - } - } -} - async function insertNodesAndExecuteScripts( source: Document | Element, target: Node, @@ -150,12 +88,17 @@ async function executeScript(script: Element) { const parent = script.parentNode; const scriptSrc = script.getAttribute('src'); if (scriptSrc) { - const rollupOutput = await getRollupResult(scriptSrc); - if (rollupOutput) { - const transientScript = ownerDocument.createElement('script'); - transientScript.textContent = rollupOutput; - parent.appendChild(transientScript); - parent.removeChild(transientScript); + if (document !== ownerDocument) { + throw new Error( + 'You must set the current document to the global document to use script src in tests', + ); + } + try { + // $FlowFixMe + require(scriptSrc); + } catch (x) { + const event = new window.ErrorEvent('error', {error: x}); + window.dispatchEvent(event); } } else { const newScript = ownerDocument.createElement('script'); diff --git a/scripts/jest/ReactDOMServerIntegrationEnvironment.js b/scripts/jest/ReactDOMServerIntegrationEnvironment.js index 388b217a1f479..1d92807ee8ddd 100644 --- a/scripts/jest/ReactDOMServerIntegrationEnvironment.js +++ b/scripts/jest/ReactDOMServerIntegrationEnvironment.js @@ -16,6 +16,8 @@ class ReactDOMServerIntegrationEnvironment extends NodeEnvironment { this.global.document = this.global.window.document; this.global.navigator = this.global.window.navigator; this.global.Node = this.global.window.Node; + this.global.addEventListener = this.global.window.addEventListener; + this.global.MutationObserver = this.global.window.MutationObserver; } async setup() { From bf449ee74e5e98af3d08c87bb6a9f22a021f3522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 25 Apr 2023 10:22:20 -0400 Subject: [PATCH 33/38] Replay Client Actions After Hydration (#26716) We used to have Event Replaying for any kind of Discrete event where we'd track any event after hydrateRoot and before the async code/data has loaded in to hydrate the target. However, this didn't really work out because code inside event handlers are expected to be able to synchronously read the state of the world at the time they're invoked. If we replay discrete events later, the mutable state around them like selection or form state etc. may have changed. This limitation doesn't apply to Client Actions: - They're expected to be async functions that themselves work asynchronously. They're conceptually also in the "navigation" events that happen after the "submit" events so they're already not synchronously even before the first `await`. - They're expected to operate mostly on the FormData as input which we can snapshot at the time of the event. This PR adds a bit of inline script to the Fizz runtime (or external runtime) to track any early submit events on the page - but only if the action URL is our placeholder `javascript:` URL. We track a queue of these on `document.$$reactFormReplay`. Then we replay them in order as they get hydrated and we get a handle on the Client Action function. I add the runtime to the `bootstrapScripts` phase in Fizz which is really technically a little too late, because on a large page, it might take a while to get to that script even if you have displayed the form. However, that's also true for external runtime. So there's a very short window we might miss an event but it's good enough and better than risking blocking display on this script. The main thing that makes the replaying difficult to reason about is that we can have multiple instance of React using this same queue. This would be very usual but you could have two different Reacts SSR:ing different parts of the tree and using around the same version. We don't have any coordinating ids for this. We could stash something on the form perhaps but given our current structure it's more difficult to get to the form instance in the commit phase and a naive solution wouldn't preserve ordering between forms. This solution isn't 100% guaranteed to preserve ordering between different React instances neither but should be in order within one instance which is the common case. The hard part is that we don't know what instance something will belong to until it hydrates. So to solve that I keep everything in the original queue while we wait, so that ordering is preserved until we know which instance it'll go into. I ended up doing a bunch of clever tricks to make this work. These could use a lot more tests than I have right now. Another thing that's tricky is that you can update the action before it's replayed but we actually want to invoke the old action if that happens. So we have to extract it even if we can't invoke it right now just so we get the one that was there during hydration. --- .../src/events/ReactDOMEventListener.js | 14 +- .../src/events/ReactDOMEventReplaying.js | 139 +++++++++++++++++- .../events/plugins/FormActionEventPlugin.js | 8 + .../src/server/ReactFizzConfigDOM.js | 65 ++++++-- .../ReactDOMFizzInlineFormReplaying.js | 8 + ...actDOMFizzInstructionSetExternalRuntime.js | 7 + ...tDOMFizzInstructionSetInlineCodeStrings.js | 2 + .../ReactDOMFizzInstructionSetShared.js | 81 ++++++++++ .../src/__tests__/ReactDOMFizzForm-test.js | 77 +++++++++- scripts/rollup/externs/closure-externs.js | 9 ++ .../rollup/generate-inline-fizz-runtime.js | 5 + 11 files changed, 396 insertions(+), 19 deletions(-) create mode 100644 packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineFormReplaying.js create mode 100644 scripts/rollup/externs/closure-externs.js diff --git a/packages/react-dom-bindings/src/events/ReactDOMEventListener.js b/packages/react-dom-bindings/src/events/ReactDOMEventListener.js index 70cec7fa0e608..237cde231176b 100644 --- a/packages/react-dom-bindings/src/events/ReactDOMEventListener.js +++ b/packages/react-dom-bindings/src/events/ReactDOMEventListener.js @@ -225,19 +225,25 @@ export function dispatchEvent( ); } +export function findInstanceBlockingEvent( + nativeEvent: AnyNativeEvent, +): null | Container | SuspenseInstance { + const nativeEventTarget = getEventTarget(nativeEvent); + return findInstanceBlockingTarget(nativeEventTarget); +} + export let return_targetInst: null | Fiber = null; // Returns a SuspenseInstance or Container if it's blocked. // The return_targetInst field above is conceptually part of the return value. -export function findInstanceBlockingEvent( - nativeEvent: AnyNativeEvent, +export function findInstanceBlockingTarget( + targetNode: Node, ): null | Container | SuspenseInstance { // TODO: Warn if _enabled is false. return_targetInst = null; - const nativeEventTarget = getEventTarget(nativeEvent); - let targetInst = getClosestInstanceFromNode(nativeEventTarget); + let targetInst = getClosestInstanceFromNode(targetNode); if (targetInst !== null) { const nearestMounted = getNearestMountedFiber(targetInst); diff --git a/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js b/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js index 59fc3a8a61811..7960e6eced30d 100644 --- a/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js @@ -23,15 +23,20 @@ import { getContainerFromFiber, getSuspenseInstanceFromFiber, } from 'react-reconciler/src/ReactFiberTreeReflection'; -import {findInstanceBlockingEvent} from './ReactDOMEventListener'; +import { + findInstanceBlockingEvent, + findInstanceBlockingTarget, +} from './ReactDOMEventListener'; import {setReplayingEvent, resetReplayingEvent} from './CurrentReplayingEvent'; import { getInstanceFromNode, getClosestInstanceFromNode, + getFiberCurrentPropsFromNode, } from '../client/ReactDOMComponentTree'; import {HostRoot, SuspenseComponent} from 'react-reconciler/src/ReactWorkTags'; import {isHigherEventPriority} from 'react-reconciler/src/ReactEventPriorities'; import {isRootDehydrated} from 'react-reconciler/src/ReactFiberShellHydration'; +import {dispatchReplayedFormAction} from './plugins/FormActionEventPlugin'; import { attemptContinuousHydration, @@ -41,6 +46,7 @@ import { runWithPriority as attemptHydrationAtPriority, getCurrentUpdatePriority, } from 'react-reconciler/src/ReactEventPriorities'; +import {enableFormActions} from 'shared/ReactFeatureFlags'; // TODO: Upgrade this definition once we're on a newer version of Flow that // has this definition built-in. @@ -105,7 +111,7 @@ const discreteReplayableEvents: Array = [ 'change', 'contextmenu', 'reset', - 'submit', + // 'submit', // stopPropagation blocks the replay mechanism ]; export function isDiscreteEventThatRequiresHydration( @@ -430,6 +436,67 @@ function scheduleCallbackIfUnblocked( } } +type FormAction = FormData => void | Promise; + +type FormReplayingQueue = Array; // [form, submitter or action, formData...] + +let lastScheduledReplayQueue: null | FormReplayingQueue = null; + +function replayUnblockedFormActions(formReplayingQueue: FormReplayingQueue) { + if (lastScheduledReplayQueue === formReplayingQueue) { + lastScheduledReplayQueue = null; + } + for (let i = 0; i < formReplayingQueue.length; i += 3) { + const form: HTMLFormElement = formReplayingQueue[i]; + const submitterOrAction: + | null + | HTMLInputElement + | HTMLButtonElement + | FormAction = formReplayingQueue[i + 1]; + const formData: FormData = formReplayingQueue[i + 2]; + if (typeof submitterOrAction !== 'function') { + // This action is not hydrated yet. This might be because it's blocked on + // a different React instance or higher up our tree. + const blockedOn = findInstanceBlockingTarget(submitterOrAction || form); + if (blockedOn === null) { + // We're not blocked but we don't have an action. This must mean that + // this is in another React instance. We'll just skip past it. + continue; + } else { + // We're blocked on something in this React instance. We'll retry later. + break; + } + } + const formInst = getInstanceFromNode(form); + if (formInst !== null) { + // This is part of our instance. + // We're ready to replay this. Let's delete it from the queue. + formReplayingQueue.splice(i, 3); + i -= 3; + dispatchReplayedFormAction(formInst, submitterOrAction, formData); + // Continue without incrementing the index. + continue; + } + // This form must've been part of a different React instance. + // If we want to preserve ordering between React instances on the same root + // we'd need some way for the other instance to ping us when it's done. + // We'll just skip this and let the other instance execute it. + } +} + +function scheduleReplayQueueIfNeeded(formReplayingQueue: FormReplayingQueue) { + // Schedule a callback to execute any unblocked form actions in. + // We only keep track of the last queue which means that if multiple React oscillate + // commits, we could schedule more callbacks than necessary but it's not a big deal + // and we only really except one instance. + if (lastScheduledReplayQueue !== formReplayingQueue) { + lastScheduledReplayQueue = formReplayingQueue; + scheduleCallback(NormalPriority, () => + replayUnblockedFormActions(formReplayingQueue), + ); + } +} + export function retryIfBlockedOn( unblocked: Container | SuspenseInstance, ): void { @@ -467,4 +534,72 @@ export function retryIfBlockedOn( } } } + + if (enableFormActions) { + // Check the document if there are any queued form actions. + const root = unblocked.getRootNode(); + const formReplayingQueue: void | FormReplayingQueue = (root: any) + .$$reactFormReplay; + if (formReplayingQueue != null) { + for (let i = 0; i < formReplayingQueue.length; i += 3) { + const form: HTMLFormElement = formReplayingQueue[i]; + const submitterOrAction: + | null + | HTMLInputElement + | HTMLButtonElement + | FormAction = formReplayingQueue[i + 1]; + const formProps = getFiberCurrentPropsFromNode(form); + if (typeof submitterOrAction === 'function') { + // This action has already resolved. We're just waiting to dispatch it. + if (!formProps) { + // This was not part of this React instance. It might have been recently + // unblocking us from dispatching our events. So let's make sure we schedule + // a retry. + scheduleReplayQueueIfNeeded(formReplayingQueue); + } + continue; + } + let target: Node = form; + if (formProps) { + // This form belongs to this React instance but the submitter might + // not be done yet. + let action: null | FormAction = null; + const submitter = submitterOrAction; + if (submitter && submitter.hasAttribute('formAction')) { + // The submitter is the one that is responsible for the action. + target = submitter; + const submitterProps = getFiberCurrentPropsFromNode(submitter); + if (submitterProps) { + // The submitter is part of this instance. + action = (submitterProps: any).formAction; + } else { + const blockedOn = findInstanceBlockingTarget(target); + if (blockedOn !== null) { + // The submitter is not hydrated yet. We'll wait for it. + continue; + } + // The submitter must have been a part of a different React instance. + // Except the form isn't. We don't dispatch actions in this scenario. + } + } else { + action = (formProps: any).action; + } + if (typeof action === 'function') { + formReplayingQueue[i + 1] = action; + } else { + // Something went wrong so let's just delete this action. + formReplayingQueue.splice(i, 3); + i -= 3; + } + // Schedule a replay in case this unblocked something. + scheduleReplayQueueIfNeeded(formReplayingQueue); + continue; + } + // Something above this target is still blocked so we can't continue yet. + // We're not sure if this target is actually part of this React instance + // yet. It could be a different React as a child but at least some parent is. + // We must continue for any further queued actions. + } + } + } } diff --git a/packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js b/packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js index 1fc0091b1c1cd..f2800af5e12a5 100644 --- a/packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js +++ b/packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js @@ -114,3 +114,11 @@ function extractEvents( } export {extractEvents}; + +export function dispatchReplayedFormAction( + formInst: Fiber, + action: FormData => void | Promise, + formData: FormData, +): void { + startHostTransition(formInst, action, formData); +} diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 1b03652ef8ce7..279eef15764f0 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -65,6 +65,7 @@ import { completeBoundary as completeBoundaryFunction, completeBoundaryWithStyles as styleInsertionFunction, completeSegment as completeSegmentFunction, + formReplaying as formReplayingRuntime, } from './fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings'; import { @@ -104,11 +105,12 @@ const ScriptStreamingFormat: StreamingFormat = 0; const DataStreamingFormat: StreamingFormat = 1; export type InstructionState = number; -const NothingSent /* */ = 0b0000; -const SentCompleteSegmentFunction /* */ = 0b0001; -const SentCompleteBoundaryFunction /* */ = 0b0010; -const SentClientRenderFunction /* */ = 0b0100; -const SentStyleInsertionFunction /* */ = 0b1000; +const NothingSent /* */ = 0b00000; +const SentCompleteSegmentFunction /* */ = 0b00001; +const SentCompleteBoundaryFunction /* */ = 0b00010; +const SentClientRenderFunction /* */ = 0b00100; +const SentStyleInsertionFunction /* */ = 0b01000; +const SentFormReplayingRuntime /* */ = 0b10000; // Per response, global state that is not contextual to the rendering subtree. export type ResponseState = { @@ -637,6 +639,7 @@ const actionJavaScriptURL = stringToPrecomputedChunk( function pushFormActionAttribute( target: Array, + responseState: ResponseState, formAction: any, formEncType: any, formMethod: any, @@ -683,6 +686,7 @@ function pushFormActionAttribute( actionJavaScriptURL, attributeEnd, ); + injectFormReplayingRuntime(responseState); } else { // Plain form actions support all the properties, so we have to emit them. if (name !== null) { @@ -1256,9 +1260,30 @@ function pushStartOption( return children; } +const formReplayingRuntimeScript = + stringToPrecomputedChunk(formReplayingRuntime); + +function injectFormReplayingRuntime(responseState: ResponseState): void { + // If we haven't sent it yet, inject the runtime that tracks submitted JS actions + // for later replaying by Fiber. If we use an external runtime, we don't need + // to emit anything. It's always used. + if ( + (responseState.instructions & SentFormReplayingRuntime) === NothingSent && + (!enableFizzExternalRuntime || !responseState.externalRuntimeConfig) + ) { + responseState.instructions |= SentFormReplayingRuntime; + responseState.bootstrapChunks.unshift( + responseState.startInlineScript, + formReplayingRuntimeScript, + endInlineScript, + ); + } +} + function pushStartForm( target: Array, props: Object, + responseState: ResponseState, ): ReactNodeList { target.push(startChunkForTag('form')); @@ -1335,6 +1360,7 @@ function pushStartForm( actionJavaScriptURL, attributeEnd, ); + injectFormReplayingRuntime(responseState); } else { // Plain form actions support all the properties, so we have to emit them. if (formAction !== null) { @@ -1365,6 +1391,7 @@ function pushStartForm( function pushInput( target: Array, props: Object, + responseState: ResponseState, ): ReactNodeList { if (__DEV__) { checkControlledValueProps('input', props); @@ -1445,6 +1472,7 @@ function pushInput( pushFormActionAttribute( target, + responseState, formAction, formEncType, formMethod, @@ -1499,6 +1527,7 @@ function pushInput( function pushStartButton( target: Array, props: Object, + responseState: ResponseState, ): ReactNodeList { target.push(startChunkForTag('button')); @@ -1561,6 +1590,7 @@ function pushStartButton( pushFormActionAttribute( target, + responseState, formAction, formEncType, formMethod, @@ -2947,11 +2977,11 @@ export function pushStartInstance( case 'textarea': return pushStartTextArea(target, props); case 'input': - return pushInput(target, props); + return pushInput(target, props, responseState); case 'button': - return pushStartButton(target, props); + return pushStartButton(target, props, responseState); case 'form': - return pushStartForm(target, props); + return pushStartForm(target, props, responseState); case 'menuitem': return pushStartMenuItem(target, props); case 'title': @@ -3127,7 +3157,7 @@ export function pushEndInstance( target.push(endTag1, stringToChunk(type), endTag2); } -export function writeCompletedRoot( +function writeBootstrap( destination: Destination, responseState: ResponseState, ): boolean { @@ -3137,11 +3167,20 @@ export function writeCompletedRoot( writeChunk(destination, bootstrapChunks[i]); } if (i < bootstrapChunks.length) { - return writeChunkAndReturn(destination, bootstrapChunks[i]); + const lastChunk = bootstrapChunks[i]; + bootstrapChunks.length = 0; + return writeChunkAndReturn(destination, lastChunk); } return true; } +export function writeCompletedRoot( + destination: Destination, + responseState: ResponseState, +): boolean { + return writeBootstrap(destination, responseState); +} + // Structural Nodes // A placeholder is a node inside a hidden partial tree that can be filled in later, but before @@ -3599,11 +3638,13 @@ export function writeCompletedBoundaryInstruction( writeChunk(destination, completeBoundaryScript3b); } } + let writeMore; if (scriptFormat) { - return writeChunkAndReturn(destination, completeBoundaryScriptEnd); + writeMore = writeChunkAndReturn(destination, completeBoundaryScriptEnd); } else { - return writeChunkAndReturn(destination, completeBoundaryDataEnd); + writeMore = writeChunkAndReturn(destination, completeBoundaryDataEnd); } + return writeBootstrap(destination, responseState) && writeMore; } const clientRenderScript1Full = stringToPrecomputedChunk( diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineFormReplaying.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineFormReplaying.js new file mode 100644 index 0000000000000..2dcaf926adcb0 --- /dev/null +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineFormReplaying.js @@ -0,0 +1,8 @@ +import {listenToFormSubmissionsForReplaying} from './ReactDOMFizzInstructionSetShared'; + +// TODO: Export a helper function that throws the error from javascript URLs instead. +// We can do that here since we mess with globals anyway and we can guarantee it has loaded. +// It makes less sense in the external runtime since it's async loaded and doesn't expose globals +// so we might have to have two different URLs. + +listenToFormSubmissionsForReplaying(); diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js index 5600b1940f7ef..dc5232c8eeda1 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js @@ -6,8 +6,11 @@ import { clientRenderBoundary, completeBoundary, completeSegment, + listenToFormSubmissionsForReplaying, } from './ReactDOMFizzInstructionSetShared'; +import {enableFormActions} from 'shared/ReactFeatureFlags'; + export {clientRenderBoundary, completeBoundary, completeSegment}; const resourceMap = new Map(); @@ -136,3 +139,7 @@ export function completeBoundaryWithStyles( ), ); } + +if (enableFormActions) { + listenToFormSubmissionsForReplaying(); +} diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js index a7247fe0c5d60..d7195d43ca063 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js @@ -9,3 +9,5 @@ export const completeBoundaryWithStyles = '$RM=new Map;\n$RR=function(r,t,w){for(var u=$RC,n=$RM,p=new Map,q=document,g,b,h=q.querySelectorAll("link[data-precedence],style[data-precedence]"),v=[],k=0;b=h[k++];)"not all"===b.getAttribute("media")?v.push(b):("LINK"===b.tagName&&n.set(b.getAttribute("href"),b),p.set(b.dataset.precedence,g=b));b=0;h=[];var l,a;for(k=!0;;){if(k){var f=w[b++];if(!f){k=!1;b=0;continue}var c=!1,m=0;var d=f[m++];if(a=n.get(d)){var e=a._p;c=!0}else{a=q.createElement("link");a.href=d;a.rel="stylesheet";for(a.dataset.precedence=\nl=f[m++];e=f[m++];)a.setAttribute(e,f[m++]);e=a._p=new Promise(function(x,y){a.onload=x;a.onerror=y});n.set(d,a)}d=a.getAttribute("media");!e||"l"===e.s||d&&!matchMedia(d).matches||h.push(e);if(c)continue}else{a=v[b++];if(!a)break;l=a.getAttribute("data-precedence");a.removeAttribute("media")}c=p.get(l)||g;c===g&&(g=a);p.set(l,a);c?c.parentNode.insertBefore(a,c.nextSibling):(c=q.head,c.insertBefore(a,c.firstChild))}Promise.all(h).then(u.bind(null,r,t,""),u.bind(null,r,t,"Resource failed to load"))};'; export const completeSegment = '$RS=function(a,b){a=document.getElementById(a);b=document.getElementById(b);for(a.parentNode.removeChild(a);a.firstChild;)b.parentNode.insertBefore(a.firstChild,b);b.parentNode.removeChild(b)};'; +export const formReplaying = + 'addEventListener("submit",function(a){if(!a.defaultPrevented){var c=a.target,d=a.submitter,e=c.action,b=d;if(d){var f=d.getAttribute("formAction");null!=f&&(e=f,b=null)}"javascript:throw new Error(\'A React form was unexpectedly submitted.\')"===e&&(a.preventDefault(),b?(a=document.createElement("input"),a.name=b.name,a.value=b.value,b.parentNode.insertBefore(a,b),b=new FormData(c),a.parentNode.removeChild(a)):b=new FormData(c),a=c.getRootNode(),(a.$$reactFormReplay=a.$$reactFormReplay||[]).push(c,\nd,b))}});'; diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js index 6058eddaad7eb..4d826753dc097 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js @@ -125,3 +125,84 @@ export function completeSegment(containerID, placeholderID) { } placeholderNode.parentNode.removeChild(placeholderNode); } + +// This is the exact URL string we expect that Fizz renders if we provide a function action. +// We use this for hydration warnings. It needs to be in sync with Fizz. Maybe makes sense +// as a shared module for that reason. +const EXPECTED_FORM_ACTION_URL = + // eslint-disable-next-line no-script-url + "javascript:throw new Error('A React form was unexpectedly submitted.')"; + +export function listenToFormSubmissionsForReplaying() { + // A global replay queue ensures actions are replayed in order. + // This event listener should be above the React one. That way when + // we preventDefault in React's handling we also prevent this event + // from queing it. Since React listens to the root and the top most + // container you can use is the document, the window is fine. + // eslint-disable-next-line no-restricted-globals + addEventListener('submit', event => { + if (event.defaultPrevented) { + // We let earlier events to prevent the action from submitting. + return; + } + const form = event.target; + const submitter = event['submitter']; + let action = form.action; + let formDataSubmitter = submitter; + if (submitter) { + const submitterAction = submitter.getAttribute('formAction'); + if (submitterAction != null) { + // The submitter overrides the action. + action = submitterAction; + // If the submitter overrides the action, and it passes the test below, + // that means that it was a function action which conceptually has no name. + // Therefore, we exclude the submitter from the formdata. + formDataSubmitter = null; + } + } + if (action !== EXPECTED_FORM_ACTION_URL) { + // The form is a regular form action, we can bail. + return; + } + + // Prevent native navigation. + // This will also prevent other React's on the same page from listening. + event.preventDefault(); + + // Take a snapshot of the FormData at the time of the event. + let formData; + if (formDataSubmitter) { + // The submitter's value should be included in the FormData. + // It should be in the document order in the form. + // Since the FormData constructor invokes the formdata event it also + // needs to be available before that happens so after construction it's too + // late. We use a temporary fake node for the duration of this event. + // TODO: FormData takes a second argument that it's the submitter but this + // is fairly new so not all browsers support it yet. Switch to that technique + // when available. + const temp = document.createElement('input'); + temp.name = formDataSubmitter.name; + temp.value = formDataSubmitter.value; + formDataSubmitter.parentNode.insertBefore(temp, formDataSubmitter); + formData = new FormData(form); + temp.parentNode.removeChild(temp); + } else { + formData = new FormData(form); + } + + // Queue for replaying later. This field could potentially be shared with multiple + // Reacts on the same page since each one will preventDefault for the next one. + // This means that this protocol is shared with any React version that shares the same + // javascript: URL placeholder value. So we might not be the first to declare it. + // We attach it to the form's root node, which is the shared environment context + // where we preserve sequencing and where we'll pick it up from during hydration. + // In practice, this is just the same as document but we might support shadow trees + // in the future. + const root = form.getRootNode(); + (root['$$reactFormReplay'] = root['$$reactFormReplay'] || []).push( + form, + submitter, + formData, + ); + }); +} diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js index 900f270b9fba5..efd151995d08a 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js @@ -9,6 +9,8 @@ 'use strict'; +import {insertNodesAndExecuteScripts} from '../test-utils/FizzTestUtils'; + // Polyfills for test environment global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; @@ -65,7 +67,9 @@ describe('ReactDOMFizzForm', () => { } result += Buffer.from(value).toString('utf8'); } - container.innerHTML = result; + const temp = document.createElement('div'); + temp.innerHTML = result; + insertNodesAndExecuteScripts(temp, container, null); } // @gate enableFormActions @@ -378,4 +382,75 @@ describe('ReactDOMFizzForm', () => { await act(() => ReactDOMClient.hydrateRoot(container, )); expect(container.textContent).toBe('Pending: false'); }); + + // @gate enableFormActions + it('should replay a form action after hydration', async () => { + let foo; + function action(formData) { + foo = formData.get('foo'); + } + function App() { + return ( +
+ +
+ ); + } + + const stream = await ReactDOMServer.renderToReadableStream(); + await readIntoContainer(stream); + + // Dispatch an event before hydration + submit(container.getElementsByTagName('form')[0]); + + await act(async () => { + ReactDOMClient.hydrateRoot(container, ); + }); + + // It should've now been replayed + expect(foo).toBe('bar'); + }); + + // @gate enableFormActions + it('should replay input/button formAction', async () => { + let rootActionCalled = false; + let savedTitle = null; + let deletedTitle = null; + + function action(formData) { + rootActionCalled = true; + } + + function saveItem(formData) { + savedTitle = formData.get('title'); + } + + function deleteItem(formData) { + deletedTitle = formData.get('title'); + } + + function App() { + return ( +
+ + + +
+ ); + } + + const stream = await ReactDOMServer.renderToReadableStream(); + await readIntoContainer(stream); + + submit(container.getElementsByTagName('input')[1]); + submit(container.getElementsByTagName('button')[0]); + + await act(async () => { + ReactDOMClient.hydrateRoot(container, ); + }); + + expect(savedTitle).toBe('Hello'); + expect(deletedTitle).toBe('Hello'); + expect(rootActionCalled).toBe(false); + }); }); diff --git a/scripts/rollup/externs/closure-externs.js b/scripts/rollup/externs/closure-externs.js new file mode 100644 index 0000000000000..f8eeb0e368eb4 --- /dev/null +++ b/scripts/rollup/externs/closure-externs.js @@ -0,0 +1,9 @@ +/** + * @externs + */ +/* eslint-disable */ + +'use strict'; + +/** @type {function} */ +var addEventListener; diff --git a/scripts/rollup/generate-inline-fizz-runtime.js b/scripts/rollup/generate-inline-fizz-runtime.js index a84f721b5612d..941bbeec69785 100644 --- a/scripts/rollup/generate-inline-fizz-runtime.js +++ b/scripts/rollup/generate-inline-fizz-runtime.js @@ -29,6 +29,10 @@ const config = [ entry: 'ReactDOMFizzInlineCompleteSegment.js', exportName: 'completeSegment', }, + { + entry: 'ReactDOMFizzInlineFormReplaying.js', + exportName: 'formReplaying', + }, ]; const prettierConfig = require('../../.prettierrc.js'); @@ -40,6 +44,7 @@ async function main() { const compiler = new ClosureCompiler({ entry_point: fullEntryPath, js: [ + require.resolve('./externs/closure-externs.js'), fullEntryPath, instructionDir + '/ReactDOMFizzInstructionSetInlineSource.js', instructionDir + '/ReactDOMFizzInstructionSetShared.js', From ed545ae3d3478cd82aa498494025cb3a15188e4c Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Tue, 25 Apr 2023 12:01:34 -0400 Subject: [PATCH 34/38] Turn off enableFormActions in Meta build (#26721) This is enabled in the canary channels, but because it's relatively untested, we'll disable it at Meta until they're ready to start trying it out. It can change some behavior even if you don't intentionally start using the API. The reason it's not a dynamic flag is that it affects the external Fizz runtime, which currently can't read flags at runtime. --- packages/shared/forks/ReactFeatureFlags.www.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 949811e68148e..de0d73cd82685 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -73,7 +73,7 @@ export const enableLegacyCache = true; export const enableCacheElement = true; export const enableFetchInstrumentation = false; -export const enableFormActions = true; +export const enableFormActions = false; export const disableJavaScriptURLs = true; From 25b99efe0c9c9d593c86829386c86740d409fa8c Mon Sep 17 00:00:00 2001 From: lauren Date: Tue, 25 Apr 2023 09:19:25 -0700 Subject: [PATCH 35/38] [DevTools] Add support for useMemoCache (#26696) useMemoCache wasn't previously supported in the DevTools, so any attempt to inspect a component using the hook would result in a `dispatcher.useMemoCache is not a function (it is undefined)` error. --- .../react-debug-tools/src/ReactDebugHooks.js | 47 +++++++++++++++++++ .../ReactHooksInspectionIntegration-test.js | 29 ++++++++++++ .../forks/ReactFeatureFlags.test-renderer.js | 2 +- 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 362e5f36d2bb1..42fc7fbe16288 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -51,9 +51,19 @@ type Dispatch
= A => void; let primitiveStackCache: null | Map> = null; +type MemoCache = { + data: Array>, + index: number, +}; + +type FunctionComponentUpdateQueue = { + memoCache?: MemoCache | null, +}; + type Hook = { memoizedState: any, next: Hook | null, + updateQueue: FunctionComponentUpdateQueue | null, }; function getPrimitiveStackCache(): Map> { @@ -79,6 +89,10 @@ function getPrimitiveStackCache(): Map> { Dispatcher.useDebugValue(null); Dispatcher.useCallback(() => {}); Dispatcher.useMemo(() => null); + if (typeof Dispatcher.useMemoCache === 'function') { + // This type check is for Flow only. + Dispatcher.useMemoCache(0); + } } finally { readHookLog = hookLog; hookLog = []; @@ -333,6 +347,38 @@ function useId(): string { return id; } +function useMemoCache(size: number): Array { + const hook = nextHook(); + let memoCache: MemoCache; + if ( + hook !== null && + hook.updateQueue !== null && + hook.updateQueue.memoCache != null + ) { + memoCache = hook.updateQueue.memoCache; + } else { + memoCache = { + data: [], + index: 0, + }; + } + + let data = memoCache.data[memoCache.index]; + if (data === undefined) { + const MEMO_CACHE_SENTINEL = Symbol.for('react.memo_cache_sentinel'); + data = new Array(size); + for (let i = 0; i < size; i++) { + data[i] = MEMO_CACHE_SENTINEL; + } + } + hookLog.push({ + primitive: 'MemoCache', + stackError: new Error(), + value: data, + }); + return data; +} + const Dispatcher: DispatcherType = { use, readContext, @@ -345,6 +391,7 @@ const Dispatcher: DispatcherType = { useLayoutEffect, useInsertionEffect, useMemo, + useMemoCache, useReducer, useRef, useState, diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js index 5e4860ce7045b..7b27b57f63ad9 100644 --- a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js +++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js @@ -14,6 +14,7 @@ let React; let ReactTestRenderer; let ReactDebugTools; let act; +let useMemoCache; describe('ReactHooksInspectionIntegration', () => { beforeEach(() => { @@ -22,6 +23,7 @@ describe('ReactHooksInspectionIntegration', () => { ReactTestRenderer = require('react-test-renderer'); act = require('internal-test-utils').act; ReactDebugTools = require('react-debug-tools'); + useMemoCache = React.unstable_useMemoCache; }); it('should inspect the current state of useState hooks', async () => { @@ -633,6 +635,33 @@ describe('ReactHooksInspectionIntegration', () => { }); }); + // @gate enableUseMemoCacheHook + it('should support useMemoCache hook', () => { + function Foo() { + const $ = useMemoCache(1); + let t0; + + if ($[0] === Symbol.for('react.memo_cache_sentinel')) { + t0 =
{1}
; + $[0] = t0; + } else { + t0 = $[0]; + } + + return t0; + } + + const renderer = ReactTestRenderer.create(); + const childFiber = renderer.root.findByType(Foo)._currentFiber(); + const tree = ReactDebugTools.inspectHooksOfFiber(childFiber); + + expect(tree.length).toEqual(1); + expect(tree[0].isStateEditable).toBe(false); + expect(tree[0].name).toBe('MemoCache'); + expect(tree[0].value).toHaveLength(1); + expect(tree[0].value[0]).toEqual(
{1}
); + }); + describe('useDebugValue', () => { it('should support inspectable values for multiple custom hooks', () => { function useLabeledValue(label) { diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index f3d296f257c92..bbc988448c543 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -39,7 +39,7 @@ export const disableModulePatternComponents = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; -export const enableUseMemoCacheHook = false; +export const enableUseMemoCacheHook = true; export const enableUseEffectEventHook = false; export const enableClientRenderFallbackOnTextMismatch = true; export const enableComponentStackLocations = true; From f87e97a0a67fa7cfd7e6f2ec985621c0e825cb23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Tue, 25 Apr 2023 18:26:34 +0200 Subject: [PATCH 36/38] Handle line endings correctly on Windows in build script for RN (#26727) ## Summary We added some post-processing in the build for RN in #26616 that broke for users on Windows due to how line endings were handled to the regular expression to insert some directives in the docblock. This fixes that problem, reported in #26697 as well. ## How did you test this change? Verified files are still built correctly on Mac/Linux. Will ask for help to test on Windows. --- scripts/rollup/packaging.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/rollup/packaging.js b/scripts/rollup/packaging.js index b49472c1bea91..dba7e835cddab 100644 --- a/scripts/rollup/packaging.js +++ b/scripts/rollup/packaging.js @@ -148,9 +148,9 @@ function processGenerated(directory) { const originalContents = readFileSync(file, 'utf8'); const contents = originalContents // Replace {@}format with {@}noformat - .replace(/(\n\s*\*\s*)@format\b.*(\n)/, '$1@noformat$2') + .replace(/(\r?\n\s*\*\s*)@format\b.*(\n)/, '$1@noformat$2') // Add {@}nolint and {@}generated - .replace(' */\n', ` * @nolint\n * ${getSigningToken()}\n */\n`); + .replace(/(\r?\n\s*\*)\//, `$1 @nolint$1 ${getSigningToken()}$1/`); const signedContents = signFile(contents); writeFileSync(file, signedContents, 'utf8'); }); From ec5e9c2a75749b0a470b7148738cb85bbb035958 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 25 Apr 2023 15:10:34 -0700 Subject: [PATCH 37/38] Fix double preload (#26729) I found a couple scenarios where preloads were issued too aggressively 1. During SSR, if you render a new stylesheet after the preamble flushed it will flush a preload even if the resource was already preloaded 2. During Client render, if you call `ReactDOM.preload()` it will only check if a preload exists in the Document before inserting a new one. It should check for an underlying resource such as a stylesheet link or script if the preload is for a recognized asset type --- .../src/client/ReactFiberConfigDOM.js | 23 ++- .../src/server/ReactFizzConfigDOM.js | 22 ++- .../src/__tests__/ReactDOMFloat-test.js | 160 ++++++++++++++++++ 3 files changed, 194 insertions(+), 11 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 455074f62625a..6886b0156203d 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -2138,8 +2138,12 @@ function preload(href: string, options: PreloadOptions) { const as = options.as; const limitedEscapedHref = escapeSelectorAttributeValueInsideDoubleQuotes(href); - const preloadKey = `link[rel="preload"][as="${as}"][href="${limitedEscapedHref}"]`; - let key = preloadKey; + const preloadSelector = `link[rel="preload"][as="${as}"][href="${limitedEscapedHref}"]`; + + // Some preloads are keyed under their selector. This happens when the preload is for + // an arbitrary type. Other preloads are keyed under the resource key they represent a preload for. + // Here we figure out which key to use to determine if we have a preload already. + let key = preloadSelector; switch (as) { case 'style': key = getStyleKey(href); @@ -2152,7 +2156,20 @@ function preload(href: string, options: PreloadOptions) { const preloadProps = preloadPropsFromPreloadOptions(href, as, options); preloadPropsMap.set(key, preloadProps); - if (null === ownerDocument.querySelector(preloadKey)) { + if (null === ownerDocument.querySelector(preloadSelector)) { + if ( + as === 'style' && + ownerDocument.querySelector(getStylesheetSelectorFromKey(key)) + ) { + // We already have a stylesheet for this key. We don't need to preload it. + return; + } else if ( + as === 'script' && + ownerDocument.querySelector(getScriptSelectorFromKey(key)) + ) { + // We already have a stylesheet for this key. We don't need to preload it. + return; + } const instance = ownerDocument.createElement('link'); setInitialProperties(instance, 'link', preloadProps); markNodeAsHoistable(instance); diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 279eef15764f0..10a49b447d9e0 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -1900,6 +1900,7 @@ function pushLink( if (!resource) { const resourceProps = stylesheetPropsFromRawProps(props); const preloadResource = resources.preloadsMap.get(key); + let state = NoState; if (preloadResource) { // If we already had a preload we don't want that resource to flush directly. // We let the newly created resource govern flushing. @@ -1908,11 +1909,14 @@ function pushLink( resourceProps, preloadResource.props, ); + if (preloadResource.state & Flushed) { + state = PreloadFlushed; + } } resource = { type: 'stylesheet', chunks: ([]: Array), - state: NoState, + state, props: resourceProps, }; resources.stylesMap.set(key, resource); @@ -4004,12 +4008,9 @@ function flushAllStylesInPreamble( } function preloadLateStyle(this: Destination, resource: StyleResource) { - if (__DEV__) { - if (resource.state & PreloadFlushed) { - console.error( - 'React encountered a Stylesheet Resource that already flushed a Preload when it was not expected to. This is a bug in React.', - ); - } + if (resource.state & PreloadFlushed) { + // This resource has already had a preload flushed + return; } if (resource.type === 'style') { @@ -5209,10 +5210,15 @@ function preinit(href: string, options: PreinitOptions): void { } } if (!resource) { + let state = NoState; + const preloadResource = resources.preloadsMap.get(key); + if (preloadResource && preloadResource.state & Flushed) { + state = PreloadFlushed; + } resource = { type: 'stylesheet', chunks: ([]: Array), - state: NoState, + state, props: stylesheetPropsFromPreinitOptions(href, precedence, options), }; resources.stylesMap.set(key, resource); diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 81ed890e040a2..48603d89d2252 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -3391,6 +3391,166 @@ body { ); }); + it('will not flush a preload for a new rendered Stylesheet Resource if one was already flushed', async () => { + function Component() { + ReactDOM.preload('foo', {as: 'style'}); + return ( +
+ + + + hello + + +
+ ); + } + await act(() => { + renderToPipeableStream( + + + + + , + ).pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + +
loading...
+ + , + ); + await act(() => { + resolveText('blocked'); + }); + await act(loadStylesheets); + assertLog(['load stylesheet: foo']); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + +
hello
+ + , + ); + }); + + it('will not flush a preload for a new preinitialized Stylesheet Resource if one was already flushed', async () => { + function Component() { + ReactDOM.preload('foo', {as: 'style'}); + return ( +
+ + + + hello + + +
+ ); + } + + function Preinit() { + ReactDOM.preinit('foo', {as: 'style'}); + } + await act(() => { + renderToPipeableStream( + + + + + , + ).pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + +
loading...
+ + , + ); + await act(() => { + resolveText('blocked'); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + +
hello
+ + , + ); + }); + + it('will not insert a preload if the underlying resource already exists in the Document', async () => { + await act(() => { + renderToPipeableStream( + + + +