Skip to content

Add Postpone API #27238

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Aug 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 77 additions & 3 deletions packages/react-client/src/ReactFlightClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import type {HintModel} from 'react-server/src/ReactFlightServerConfig';

import type {CallServerCallback} from './ReactFlightReplyClient';

import {enableBinaryFlight} from 'shared/ReactFeatureFlags';
import type {Postpone} from 'react/src/ReactPostpone';

import {enableBinaryFlight, enablePostpone} from 'shared/ReactFeatureFlags';

import {
resolveClientReference,
Expand All @@ -39,7 +41,11 @@ import {
knownServerReferences,
} from './ReactFlightReplyClient';

import {REACT_LAZY_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
import {
REACT_LAZY_TYPE,
REACT_ELEMENT_TYPE,
REACT_POSTPONE_TYPE,
} from 'shared/ReactSymbols';

import {getOrCreateServerContext} from 'shared/ReactServerContextRegistry';

Expand Down Expand Up @@ -226,7 +232,7 @@ function createBlockedChunk<T>(response: Response): BlockedChunk<T> {

function createErrorChunk<T>(
response: Response,
error: ErrorWithDigest,
error: Error | Postpone,
): ErroredChunk<T> {
// $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
return new Chunk(ERRORED, null, error, response);
Expand Down Expand Up @@ -867,6 +873,57 @@ function resolveErrorDev(
}
}

function resolvePostponeProd(response: Response, id: number): void {
if (__DEV__) {
// These errors should never make it into a build so we don't need to encode them in codes.json
// eslint-disable-next-line react-internal/prod-error-codes
throw new Error(
'resolvePostponeProd should never be called in development mode. Use resolvePostponeDev instead. This is a bug in React.',
);
}
const error = new Error(
'A Server Component was postponed. The reason is omitted in production' +
' builds to avoid leaking sensitive details.',
);
const postponeInstance: Postpone = (error: any);
postponeInstance.$$typeof = REACT_POSTPONE_TYPE;
postponeInstance.stack = 'Error: ' + error.message;
const chunks = response._chunks;
const chunk = chunks.get(id);
if (!chunk) {
chunks.set(id, createErrorChunk(response, postponeInstance));
} else {
triggerErrorOnChunk(chunk, postponeInstance);
}
}

function resolvePostponeDev(
response: Response,
id: number,
reason: string,
stack: string,
): void {
if (!__DEV__) {
// These errors should never make it into a build so we don't need to encode them in codes.json
// eslint-disable-next-line react-internal/prod-error-codes
throw new Error(
'resolvePostponeDev should never be called in production mode. Use resolvePostponeProd instead. This is a bug in React.',
);
}
// eslint-disable-next-line react-internal/prod-error-codes
const error = new Error(reason || '');
const postponeInstance: Postpone = (error: any);
postponeInstance.$$typeof = REACT_POSTPONE_TYPE;
postponeInstance.stack = stack;
const chunks = response._chunks;
const chunk = chunks.get(id);
if (!chunk) {
chunks.set(id, createErrorChunk(response, postponeInstance));
} else {
triggerErrorOnChunk(chunk, postponeInstance);
}
}

function resolveHint(
response: Response,
code: string,
Expand Down Expand Up @@ -1019,6 +1076,23 @@ function processFullRow(
resolveText(response, id, row);
return;
}
case 80 /* "P" */: {
if (enablePostpone) {
if (__DEV__) {
const postponeInfo = JSON.parse(row);
resolvePostponeDev(
response,
id,
postponeInfo.reason,
postponeInfo.stack,
);
} else {
resolvePostponeProd(response, id);
}
return;
}
}
// Fallthrough
default: /* """ "{" "[" "t" "f" "n" "0" - "9" */ {
// We assume anything else is JSON.
resolveModel(response, id, row);
Expand Down
91 changes: 91 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6040,4 +6040,95 @@ describe('ReactDOMFizzServer', () => {
console.error = originalConsoleError;
}
});

// @gate enablePostpone
it('client renders postponed boundaries without erroring', async () => {
function Postponed({isClient}) {
if (!isClient) {
React.unstable_postpone('testing postpone');
}
return 'client only';
}

function App({isClient}) {
return (
<div>
<Suspense fallback={'loading...'}>
<Postponed isClient={isClient} />
</Suspense>
</div>
);
}

const errors = [];

await act(() => {
const {pipe} = renderToPipeableStream(<App isClient={false} />, {
onError(error) {
errors.push(error.message);
},
});
pipe(writable);
});

expect(getVisibleChildren(container)).toEqual(<div>loading...</div>);

ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
onRecoverableError(error) {
errors.push(error.message);
},
});
await waitForAll([]);
// Postponing should not be logged as a recoverable error since it's intentional.
expect(errors).toEqual([]);
expect(getVisibleChildren(container)).toEqual(<div>client only</div>);
});

// @gate enablePostpone
it('errors if trying to postpone outside a Suspense boundary', async () => {
function Postponed() {
React.unstable_postpone('testing postpone');
return 'client only';
}

function App() {
return (
<div>
<Postponed />
</div>
);
}

const errors = [];
const fatalErrors = [];
const postponed = [];
let written = false;

const testWritable = new Stream.Writable();
testWritable._write = (chunk, encoding, next) => {
written = true;
};

await act(() => {
const {pipe} = renderToPipeableStream(<App />, {
onPostpone(reason) {
postponed.push(reason);
},
onError(error) {
errors.push(error.message);
},
onShellError(error) {
fatalErrors.push(error.message);
},
});
pipe(testWritable);
});

expect(written).toBe(false);
// Postponing is not logged as an error but as a postponed reason.
expect(errors).toEqual([]);
expect(postponed).toEqual(['testing postpone']);
// However, it does error the shell.
expect(fatalErrors).toEqual(['testing postpone']);
});
});
39 changes: 39 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -503,4 +503,43 @@ describe('ReactDOMFizzServerBrowser', () => {
`"<link rel="preload" href="init.js" as="script" fetchPriority="low" nonce="R4nd0m"/><link rel="modulepreload" href="init.mjs" fetchPriority="low" nonce="R4nd0m"/><div>hello world</div><script nonce="${nonce}">INIT();</script><script src="init.js" nonce="${nonce}" async=""></script><script type="module" src="init.mjs" nonce="${nonce}" async=""></script>"`,
);
});

// @gate enablePostpone
it('errors if trying to postpone outside a Suspense boundary', async () => {
function Postponed() {
React.unstable_postpone('testing postpone');
return 'client only';
}

function App() {
return (
<div>
<Postponed />
</div>
);
}

const errors = [];
const postponed = [];

let caughtError = null;
try {
await ReactDOMFizzServer.renderToReadableStream(<App />, {
onError(error) {
errors.push(error.message);
},
onPostpone(reason) {
postponed.push(reason);
},
});
} catch (error) {
caughtError = error;
}

// Postponing is not logged as an error but as a postponed reason.
expect(errors).toEqual([]);
expect(postponed).toEqual(['testing postpone']);
// However, it does error the shell.
expect(caughtError.message).toEqual('testing postpone');
});
});
2 changes: 2 additions & 0 deletions packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type Options = {
progressiveChunkSize?: number,
signal?: AbortSignal,
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
};

Expand Down Expand Up @@ -100,6 +101,7 @@ function renderToReadableStream(
onShellReady,
onShellError,
onFatalError,
options ? options.onPostpone : undefined,
);
if (options && options.signal) {
const signal = options.signal;
Expand Down
2 changes: 2 additions & 0 deletions packages/react-dom/src/server/ReactDOMFizzServerBun.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type Options = {
progressiveChunkSize?: number,
signal?: AbortSignal,
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
};

Expand Down Expand Up @@ -101,6 +102,7 @@ function renderToReadableStream(
onShellReady,
onShellError,
onFatalError,
options ? options.onPostpone : undefined,
);
if (options && options.signal) {
const signal = options.signal;
Expand Down
2 changes: 2 additions & 0 deletions packages/react-dom/src/server/ReactDOMFizzServerEdge.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type Options = {
progressiveChunkSize?: number,
signal?: AbortSignal,
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
};

Expand Down Expand Up @@ -100,6 +101,7 @@ function renderToReadableStream(
onShellReady,
onShellError,
onFatalError,
options ? options.onPostpone : undefined,
);
if (options && options.signal) {
const signal = options.signal;
Expand Down
2 changes: 2 additions & 0 deletions packages/react-dom/src/server/ReactDOMFizzServerNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ type Options = {
onShellError?: (error: mixed) => void,
onAllReady?: () => void,
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
};

Expand Down Expand Up @@ -80,6 +81,7 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
options ? options.onShellReady : undefined,
options ? options.onShellError : undefined,
undefined,
options ? options.onPostpone : undefined,
);
}

Expand Down
2 changes: 2 additions & 0 deletions packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type Options = {
progressiveChunkSize?: number,
signal?: AbortSignal,
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
};

Expand Down Expand Up @@ -85,6 +86,7 @@ function prerender(
undefined,
undefined,
onFatalError,
options ? options.onPostpone : undefined,
);
if (options && options.signal) {
const signal = options.signal;
Expand Down
2 changes: 2 additions & 0 deletions packages/react-dom/src/server/ReactDOMFizzStaticEdge.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type Options = {
progressiveChunkSize?: number,
signal?: AbortSignal,
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
};

Expand Down Expand Up @@ -85,6 +86,7 @@ function prerender(
undefined,
undefined,
onFatalError,
options ? options.onPostpone : undefined,
);
if (options && options.signal) {
const signal = options.signal;
Expand Down
2 changes: 2 additions & 0 deletions packages/react-dom/src/server/ReactDOMFizzStaticNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type Options = {
progressiveChunkSize?: number,
signal?: AbortSignal,
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
};

Expand Down Expand Up @@ -99,6 +100,7 @@ function prerenderToNodeStreams(
undefined,
undefined,
onFatalError,
options ? options.onPostpone : undefined,
);
if (options && options.signal) {
const signal = options.signal;
Expand Down
1 change: 1 addition & 0 deletions packages/react-dom/src/server/ReactDOMLegacyServerImpl.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ function renderToStringImpl(
onShellReady,
undefined,
undefined,
undefined,
);
startWork(request);
// If anything suspended and is still pending, we'll abort it before writing.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ function renderToNodeStreamImpl(
onAllReady,
undefined,
undefined,
undefined,
);
destination.request = request;
startWork(request);
Expand Down
Loading