diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js
index 658ed686293c7..d411bb2453069 100644
--- a/fixtures/view-transition/src/components/Page.js
+++ b/fixtures/view-transition/src/components/Page.js
@@ -238,8 +238,8 @@ export default function Page({url, navigate}) {
+ {show ? : null}
- {show ? : null}
diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js
index 6e96f2f0378ec..701d9df33cdd9 100644
--- a/packages/react-client/src/ReactFlightClient.js
+++ b/packages/react-client/src/ReactFlightClient.js
@@ -499,10 +499,44 @@ function createErrorChunk(
return new ReactPromise(ERRORED, null, error);
}
+function moveDebugInfoFromChunkToInnerValue(
+ chunk: InitializedChunk,
+ value: T,
+): void {
+ // Remove the debug info from the initialized chunk, and add it to the inner
+ // value instead. This can be a React element, an array, or an uninitialized
+ // Lazy.
+ const resolvedValue = resolveLazy(value);
+ if (
+ typeof resolvedValue === 'object' &&
+ resolvedValue !== null &&
+ (isArray(resolvedValue) ||
+ typeof resolvedValue[ASYNC_ITERATOR] === 'function' ||
+ resolvedValue.$$typeof === REACT_ELEMENT_TYPE ||
+ resolvedValue.$$typeof === REACT_LAZY_TYPE)
+ ) {
+ const debugInfo = chunk._debugInfo.splice(0);
+ if (isArray(resolvedValue._debugInfo)) {
+ // $FlowFixMe[method-unbinding]
+ resolvedValue._debugInfo.unshift.apply(
+ resolvedValue._debugInfo,
+ debugInfo,
+ );
+ } else {
+ Object.defineProperty((resolvedValue: any), '_debugInfo', {
+ configurable: false,
+ enumerable: false,
+ writable: true,
+ value: debugInfo,
+ });
+ }
+ }
+}
+
function wakeChunk(
listeners: Array mixed)>,
value: T,
- chunk: SomeChunk,
+ chunk: InitializedChunk,
): void {
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i];
@@ -512,6 +546,10 @@ function wakeChunk(
fulfillReference(listener, value, chunk);
}
}
+
+ if (__DEV__) {
+ moveDebugInfoFromChunkToInnerValue(chunk, value);
+ }
}
function rejectChunk(
@@ -649,7 +687,6 @@ function triggerErrorOnChunk(
}
try {
initializeDebugChunk(response, chunk);
- chunk._debugChunk = null;
if (initializingHandler !== null) {
if (initializingHandler.errored) {
// Ignore error parsing debug info, we'll report the original error instead.
@@ -932,9 +969,9 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void {
}
if (__DEV__) {
- // Lazily initialize any debug info and block the initializing chunk on any unresolved entries.
+ // Initialize any debug info and block the initializing chunk on any
+ // unresolved entries.
initializeDebugChunk(response, chunk);
- chunk._debugChunk = null;
}
try {
@@ -946,7 +983,14 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void {
if (resolveListeners !== null) {
cyclicChunk.value = null;
cyclicChunk.reason = null;
- wakeChunk(resolveListeners, value, cyclicChunk);
+ for (let i = 0; i < resolveListeners.length; i++) {
+ const listener = resolveListeners[i];
+ if (typeof listener === 'function') {
+ listener(value);
+ } else {
+ fulfillReference(listener, value, cyclicChunk);
+ }
+ }
}
if (initializingHandler !== null) {
if (initializingHandler.errored) {
@@ -963,6 +1007,10 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void {
const initializedChunk: InitializedChunk = (chunk: any);
initializedChunk.status = INITIALIZED;
initializedChunk.value = value;
+
+ if (__DEV__) {
+ moveDebugInfoFromChunkToInnerValue(initializedChunk, value);
+ }
} catch (error) {
const erroredChunk: ErroredChunk = (chunk: any);
erroredChunk.status = ERRORED;
@@ -1079,7 +1127,7 @@ function getTaskName(type: mixed): string {
function initializeElement(
response: Response,
element: any,
- lazyType: null | LazyComponent<
+ lazyNode: null | LazyComponent<
React$Element,
SomeChunk>,
>,
@@ -1151,15 +1199,33 @@ function initializeElement(
initializeFakeStack(response, owner);
}
- // In case the JSX runtime has validated the lazy type as a static child, we
- // need to transfer this information to the element.
- if (
- lazyType &&
- lazyType._store &&
- lazyType._store.validated &&
- !element._store.validated
- ) {
- element._store.validated = lazyType._store.validated;
+ if (lazyNode !== null) {
+ // In case the JSX runtime has validated the lazy type as a static child, we
+ // need to transfer this information to the element.
+ if (
+ lazyNode._store &&
+ lazyNode._store.validated &&
+ !element._store.validated
+ ) {
+ element._store.validated = lazyNode._store.validated;
+ }
+
+ // If the lazy node is initialized, we move its debug info to the inner
+ // value.
+ if (lazyNode._payload.status === INITIALIZED && lazyNode._debugInfo) {
+ const debugInfo = lazyNode._debugInfo.splice(0);
+ if (element._debugInfo) {
+ // $FlowFixMe[method-unbinding]
+ element._debugInfo.unshift.apply(element._debugInfo, debugInfo);
+ } else {
+ Object.defineProperty(element, '_debugInfo', {
+ configurable: false,
+ enumerable: false,
+ writable: true,
+ value: debugInfo,
+ });
+ }
+ }
}
// TODO: We should be freezing the element but currently, we might write into
@@ -1279,13 +1345,13 @@ function createElement(
createBlockedChunk(response);
handler.value = element;
handler.chunk = blockedChunk;
- const lazyType = createLazyChunkWrapper(blockedChunk, validated);
+ const lazyNode = createLazyChunkWrapper(blockedChunk, validated);
if (__DEV__) {
// After we have initialized any blocked references, initialize stack etc.
- const init = initializeElement.bind(null, response, element, lazyType);
+ const init = initializeElement.bind(null, response, element, lazyNode);
blockedChunk.then(init, init);
}
- return lazyType;
+ return lazyNode;
}
}
if (__DEV__) {
@@ -1466,7 +1532,7 @@ function fulfillReference(
const element: any = handler.value;
switch (key) {
case '3':
- transferReferencedDebugInfo(handler.chunk, fulfilledChunk, mappedValue);
+ transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
element.props = mappedValue;
break;
case '4':
@@ -1482,11 +1548,11 @@ function fulfillReference(
}
break;
default:
- transferReferencedDebugInfo(handler.chunk, fulfilledChunk, mappedValue);
+ transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
break;
}
} else if (__DEV__ && !reference.isDebug) {
- transferReferencedDebugInfo(handler.chunk, fulfilledChunk, mappedValue);
+ transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
}
handler.deps--;
@@ -1808,47 +1874,34 @@ function loadServerReference, T>(
return (null: any);
}
+function resolveLazy(value: any): mixed {
+ while (
+ typeof value === 'object' &&
+ value !== null &&
+ value.$$typeof === REACT_LAZY_TYPE
+ ) {
+ const payload: SomeChunk = value._payload;
+ if (payload.status === INITIALIZED) {
+ value = payload.value;
+ continue;
+ }
+ break;
+ }
+
+ return value;
+}
+
function transferReferencedDebugInfo(
parentChunk: null | SomeChunk,
referencedChunk: SomeChunk,
- referencedValue: mixed,
): void {
if (__DEV__) {
- const referencedDebugInfo = referencedChunk._debugInfo;
- // If we have a direct reference to an object that was rendered by a synchronous
- // server component, it might have some debug info about how it was rendered.
- // We forward this to the underlying object. This might be a React Element or
- // an Array fragment.
- // If this was a string / number return value we lose the debug info. We choose
- // that tradeoff to allow sync server components to return plain values and not
- // use them as React Nodes necessarily. We could otherwise wrap them in a Lazy.
- if (
- typeof referencedValue === 'object' &&
- referencedValue !== null &&
- (isArray(referencedValue) ||
- typeof referencedValue[ASYNC_ITERATOR] === 'function' ||
- referencedValue.$$typeof === REACT_ELEMENT_TYPE)
- ) {
- // We should maybe use a unique symbol for arrays but this is a React owned array.
- // $FlowFixMe[prop-missing]: This should be added to elements.
- const existingDebugInfo: ?ReactDebugInfo =
- (referencedValue._debugInfo: any);
- if (existingDebugInfo == null) {
- Object.defineProperty((referencedValue: any), '_debugInfo', {
- configurable: false,
- enumerable: false,
- writable: true,
- value: referencedDebugInfo.slice(0), // Clone so that pushing later isn't going into the original
- });
- } else {
- // $FlowFixMe[method-unbinding]
- existingDebugInfo.push.apply(existingDebugInfo, referencedDebugInfo);
- }
- }
- // We also add the debug info to the initializing chunk since the resolution of that promise is
- // also blocked by the referenced debug info. By adding it to both we can track it even if the array/element
- // is extracted, or if the root is rendered as is.
+ // We add the debug info to the initializing chunk since the resolution of
+ // that promise is also blocked by the referenced debug info. By adding it
+ // to both we can track it even if the array/element/lazy is extracted, or
+ // if the root is rendered as is.
if (parentChunk !== null) {
+ const referencedDebugInfo = referencedChunk._debugInfo;
const parentDebugInfo = parentChunk._debugInfo;
for (let i = 0; i < referencedDebugInfo.length; ++i) {
const debugInfoEntry = referencedDebugInfo[i];
@@ -1999,7 +2052,7 @@ function getOutlinedModel(
// If we're resolving the "owner" or "stack" slot of an Element array, we don't call
// transferReferencedDebugInfo because this reference is to a debug chunk.
} else {
- transferReferencedDebugInfo(initializingChunk, chunk, chunkValue);
+ transferReferencedDebugInfo(initializingChunk, chunk);
}
return chunkValue;
case PENDING:
@@ -2709,14 +2762,47 @@ function incrementChunkDebugInfo(
}
}
+function addDebugInfo(chunk: SomeChunk, debugInfo: ReactDebugInfo): void {
+ const value = resolveLazy(chunk.value);
+ if (
+ typeof value === 'object' &&
+ value !== null &&
+ (isArray(value) ||
+ typeof value[ASYNC_ITERATOR] === 'function' ||
+ value.$$typeof === REACT_ELEMENT_TYPE ||
+ value.$$typeof === REACT_LAZY_TYPE)
+ ) {
+ if (isArray(value._debugInfo)) {
+ // $FlowFixMe[method-unbinding]
+ value._debugInfo.push.apply(value._debugInfo, debugInfo);
+ } else {
+ Object.defineProperty((value: any), '_debugInfo', {
+ configurable: false,
+ enumerable: false,
+ writable: true,
+ value: debugInfo,
+ });
+ }
+ } else {
+ // $FlowFixMe[method-unbinding]
+ chunk._debugInfo.push.apply(chunk._debugInfo, debugInfo);
+ }
+}
+
function resolveChunkDebugInfo(
streamState: StreamState,
chunk: SomeChunk,
): void {
if (__DEV__ && enableAsyncDebugInfo) {
- // Push the currently resolving chunk's debug info representing the stream on the Promise
- // that was waiting on the stream.
- chunk._debugInfo.push({awaited: streamState._debugInfo});
+ // Add the currently resolving chunk's debug info representing the stream
+ // to the Promise that was waiting on the stream, or its underlying value.
+ const debugInfo: ReactDebugInfo = [{awaited: streamState._debugInfo}];
+ if (chunk.status === PENDING || chunk.status === BLOCKED) {
+ const boundAddDebugInfo = addDebugInfo.bind(null, chunk, debugInfo);
+ chunk.then(boundAddDebugInfo, boundAddDebugInfo);
+ } else {
+ addDebugInfo(chunk, debugInfo);
+ }
}
}
@@ -2909,7 +2995,8 @@ function resolveStream>(
const resolveListeners = chunk.value;
if (__DEV__) {
- // Lazily initialize any debug info and block the initializing chunk on any unresolved entries.
+ // Initialize any debug info and block the initializing chunk on any
+ // unresolved entries.
if (chunk._debugChunk != null) {
const prevHandler = initializingHandler;
const prevChunk = initializingChunk;
@@ -2923,7 +3010,6 @@ function resolveStream>(
}
try {
initializeDebugChunk(response, chunk);
- chunk._debugChunk = null;
if (initializingHandler !== null) {
if (initializingHandler.errored) {
// Ignore error parsing debug info, we'll report the original error instead.
@@ -2947,7 +3033,7 @@ function resolveStream>(
resolvedChunk.value = stream;
resolvedChunk.reason = controller;
if (resolveListeners !== null) {
- wakeChunk(resolveListeners, chunk.value, chunk);
+ wakeChunk(resolveListeners, chunk.value, (chunk: any));
}
}
diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js
index 0baee5a1f5098..b0f539bf2572c 100644
--- a/packages/react-client/src/__tests__/ReactFlight-test.js
+++ b/packages/react-client/src/__tests__/ReactFlight-test.js
@@ -327,8 +327,8 @@ describe('ReactFlight', () => {
const transport = ReactNoopFlightServer.render(root);
await act(async () => {
- const promise = ReactNoopFlightClient.read(transport);
- expect(getDebugInfo(promise)).toEqual(
+ const result = await ReactNoopFlightClient.read(transport);
+ expect(getDebugInfo(result)).toEqual(
__DEV__
? [
{time: 12},
@@ -346,7 +346,7 @@ describe('ReactFlight', () => {
]
: undefined,
);
- ReactNoop.render(await promise);
+ ReactNoop.render(result);
});
expect(ReactNoop).toMatchRenderedOutput(Hello, Seb Smith);
@@ -1378,9 +1378,7 @@ describe('ReactFlight', () => {
environmentName: 'Server',
},
],
- findSourceMapURLCalls: [
- [__filename, 'Server'],
- [__filename, 'Server'],
+ findSourceMapURLCalls: expect.arrayContaining([
// TODO: What should we request here? The outer () or the inner (inspected-page.html)?
['inspected-page.html:29:11), ', 'Server'],
[
@@ -1389,8 +1387,7 @@ describe('ReactFlight', () => {
],
['file:///testing.js', 'Server'],
['', 'Server'],
- [__filename, 'Server'],
- ],
+ ]),
});
} else {
expect(errors.map(getErrorForJestMatcher)).toEqual([
@@ -2785,8 +2782,8 @@ describe('ReactFlight', () => {
);
await act(async () => {
- const promise = ReactNoopFlightClient.read(transport);
- expect(getDebugInfo(promise)).toEqual(
+ const result = await ReactNoopFlightClient.read(transport);
+ expect(getDebugInfo(result)).toEqual(
__DEV__
? [
{time: gate(flags => flags.enableAsyncDebugInfo) ? 22 : 20},
@@ -2803,11 +2800,10 @@ describe('ReactFlight', () => {
]
: undefined,
);
- const result = await promise;
const thirdPartyChildren = await result.props.children[1];
// We expect the debug info to be transferred from the inner stream to the outer.
- expect(getDebugInfo(thirdPartyChildren[0])).toEqual(
+ expect(getDebugInfo(await thirdPartyChildren[0])).toEqual(
__DEV__
? [
{time: gate(flags => flags.enableAsyncDebugInfo) ? 54 : 22}, // Clamped to the start
@@ -2910,8 +2906,8 @@ describe('ReactFlight', () => {
);
await act(async () => {
- const promise = ReactNoopFlightClient.read(transport);
- expect(getDebugInfo(promise)).toEqual(
+ const result = await ReactNoopFlightClient.read(transport);
+ expect(getDebugInfo(result)).toEqual(
__DEV__
? [
{time: 16},
@@ -2924,17 +2920,10 @@ describe('ReactFlight', () => {
transport: expect.arrayContaining([]),
},
},
- {
- time: 16,
- },
- {
- time: 16,
- },
{time: 31},
]
: undefined,
);
- const result = await promise;
const thirdPartyFragment = await result.props.children;
expect(getDebugInfo(thirdPartyFragment)).toEqual(
__DEV__
@@ -2949,15 +2938,7 @@ describe('ReactFlight', () => {
children: {},
},
},
- {
- time: 33,
- },
- {
- time: 33,
- },
- {
- time: 33,
- },
+ {time: 33},
]
: undefined,
);
@@ -3013,8 +2994,8 @@ describe('ReactFlight', () => {
);
await act(async () => {
- const promise = ReactNoopFlightClient.read(transport);
- expect(getDebugInfo(promise)).toEqual(
+ const result = await ReactNoopFlightClient.read(transport);
+ expect(getDebugInfo(result)).toEqual(
__DEV__
? [
{time: 16},
@@ -3040,7 +3021,6 @@ describe('ReactFlight', () => {
]
: undefined,
);
- const result = await promise;
ReactNoop.render(result);
});
@@ -3891,15 +3871,6 @@ describe('ReactFlight', () => {
{
time: 13,
},
- {
- time: 14,
- },
- {
- time: 15,
- },
- {
- time: 16,
- },
]);
} else {
expect(root._debugInfo).toBe(undefined);
diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
index eb8c2786152d8..a93c32a947f10 100644
--- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
@@ -782,13 +782,14 @@ const HTML_COLGROUP_MODE = 9;
type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
-const NO_SCOPE = /* */ 0b000000;
-const NOSCRIPT_SCOPE = /* */ 0b000001;
-const PICTURE_SCOPE = /* */ 0b000010;
-const FALLBACK_SCOPE = /* */ 0b000100;
-const EXIT_SCOPE = /* */ 0b001000; // A direct Instance below a Suspense fallback is the only thing that can "exit"
-const ENTER_SCOPE = /* */ 0b010000; // A direct Instance below Suspense content is the only thing that can "enter"
-const UPDATE_SCOPE = /* */ 0b100000; // Inside a scope that applies "update" ViewTransitions if anything mutates here.
+const NO_SCOPE = /* */ 0b0000000;
+const NOSCRIPT_SCOPE = /* */ 0b0000001;
+const PICTURE_SCOPE = /* */ 0b0000010;
+const FALLBACK_SCOPE = /* */ 0b0000100;
+const EXIT_SCOPE = /* */ 0b0001000; // A direct Instance below a Suspense fallback is the only thing that can "exit"
+const ENTER_SCOPE = /* */ 0b0010000; // A direct Instance below Suspense content is the only thing that can "enter"
+const UPDATE_SCOPE = /* */ 0b0100000; // Inside a scope that applies "update" ViewTransitions if anything mutates here.
+const APPEARING_SCOPE = /* */ 0b1000000; // Below Suspense content subtree which might appear in an "enter" animation or "shared" animation.
// Everything not listed here are tracked for the whole subtree as opposed to just
// until the next Instance.
@@ -987,11 +988,20 @@ export function getSuspenseContentFormatContext(
resumableState: ResumableState,
parentContext: FormatContext,
): FormatContext {
+ const viewTransition = getSuspenseViewTransition(
+ parentContext.viewTransition,
+ );
+ let subtreeScope = parentContext.tagScope | ENTER_SCOPE;
+ if (viewTransition !== null && viewTransition.share !== 'none') {
+ // If we have a ViewTransition wrapping Suspense then the appearing animation
+ // will be applied just like an "enter" below. Mark it as animating.
+ subtreeScope |= APPEARING_SCOPE;
+ }
return createFormatContext(
parentContext.insertionMode,
parentContext.selectedValue,
- parentContext.tagScope | ENTER_SCOPE,
- getSuspenseViewTransition(parentContext.viewTransition),
+ subtreeScope,
+ viewTransition,
);
}
@@ -1063,6 +1073,9 @@ export function getViewTransitionFormatContext(
} else {
subtreeScope &= ~UPDATE_SCOPE;
}
+ if (enter !== 'none') {
+ subtreeScope |= APPEARING_SCOPE;
+ }
return createFormatContext(
parentContext.insertionMode,
parentContext.selectedValue,
@@ -3289,6 +3302,7 @@ function pushImg(
props: Object,
resumableState: ResumableState,
renderState: RenderState,
+ hoistableState: null | HoistableState,
formatContext: FormatContext,
): null {
const pictureOrNoScriptTagInScope =
@@ -3321,6 +3335,19 @@ function pushImg(
) {
// We have a suspensey image and ought to preload it to optimize the loading of display blocking
// resumableState.
+
+ if (hoistableState !== null) {
+ // Mark this boundary's state as having suspensey images.
+ // Only do that if we have a ViewTransition that might trigger a parent Suspense boundary
+ // to animate its appearing. Since that's the only case we'd actually apply suspensey images
+ // for SSR reveals.
+ const isInSuspenseWithEnterViewTransition =
+ formatContext.tagScope & APPEARING_SCOPE;
+ if (isInSuspenseWithEnterViewTransition) {
+ hoistableState.suspenseyImages = true;
+ }
+ }
+
const sizes = typeof props.sizes === 'string' ? props.sizes : undefined;
const key = getImageResourceKey(src, srcSet, sizes);
@@ -4255,7 +4282,14 @@ export function pushStartInstance(
return pushStartPreformattedElement(target, props, type, formatContext);
}
case 'img': {
- return pushImg(target, props, resumableState, renderState, formatContext);
+ return pushImg(
+ target,
+ props,
+ resumableState,
+ renderState,
+ hoistableState,
+ formatContext,
+ );
}
// Omitted close tags
case 'base':
@@ -6125,6 +6159,7 @@ type StylesheetResource = {
export type HoistableState = {
styles: Set,
stylesheets: Set,
+ suspenseyImages: boolean,
};
export type StyleQueue = {
@@ -6138,6 +6173,7 @@ export function createHoistableState(): HoistableState {
return {
styles: new Set(),
stylesheets: new Set(),
+ suspenseyImages: false,
};
}
@@ -6995,6 +7031,18 @@ export function hoistHoistables(
): void {
childState.styles.forEach(hoistStyleQueueDependency, parentState);
childState.stylesheets.forEach(hoistStylesheetDependency, parentState);
+ if (childState.suspenseyImages) {
+ // If the child has suspensey images, the parent now does too if it's inlined.
+ // Similarly, if a SuspenseList row has a suspensey image then effectively
+ // the next row should be blocked on it as well since the next row can't show
+ // earlier. In practice, since the child will be outlined this transferring
+ // may never matter but is conceptually correct.
+ parentState.suspenseyImages = true;
+ }
+}
+
+export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
+ return hoistableState.stylesheets.size > 0 || hoistableState.suspenseyImages;
}
// This function is called at various times depending on whether we are rendering
diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
index 6ab54af00f7b7..d48e9a8dd932e 100644
--- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
+++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
@@ -10,6 +10,7 @@
import type {
RenderState as BaseRenderState,
ResumableState,
+ HoistableState,
StyleQueue,
Resource,
HeadersDescriptor,
@@ -325,5 +326,10 @@ export function writePreambleStart(
);
}
+export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
+ // Never outline.
+ return false;
+}
+
export type TransitionStatus = FormStatus;
export const NotPendingTransition: TransitionStatus = NotPending;
diff --git a/packages/react-dom-bindings/src/server/ReactFlightServerConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFlightServerConfigDOM.js
index 9cded881352af..75ed81bf6fe42 100644
--- a/packages/react-dom-bindings/src/server/ReactFlightServerConfigDOM.js
+++ b/packages/react-dom-bindings/src/server/ReactFlightServerConfigDOM.js
@@ -61,3 +61,17 @@ export type Hints = Set;
export function createHints(): Hints {
return new Set();
}
+
+export opaque type FormatContext = null;
+
+export function createRootFormatContext(): FormatContext {
+ return null;
+}
+
+export function getChildFormatContext(
+ parentContext: FormatContext,
+ type: string,
+ props: Object,
+): FormatContext {
+ return parentContext;
+}
diff --git a/packages/react-markup/src/ReactFizzConfigMarkup.js b/packages/react-markup/src/ReactFizzConfigMarkup.js
index fee02f320fcb5..7dbe5592f3372 100644
--- a/packages/react-markup/src/ReactFizzConfigMarkup.js
+++ b/packages/react-markup/src/ReactFizzConfigMarkup.js
@@ -242,5 +242,10 @@ export function writeCompletedRoot(
return true;
}
+export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
+ // Never outline.
+ return false;
+}
+
export type TransitionStatus = FormStatus;
export const NotPendingTransition: TransitionStatus = NotPending;
diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js
index 59f0fafa5d21f..1793180cc7658 100644
--- a/packages/react-noop-renderer/src/ReactNoopServer.js
+++ b/packages/react-noop-renderer/src/ReactNoopServer.js
@@ -324,6 +324,9 @@ const ReactNoopServer = ReactFizzServer({
writeHoistablesForBoundary() {},
writePostamble() {},
hoistHoistables(parent: HoistableState, child: HoistableState) {},
+ hasSuspenseyContent(hoistableState: HoistableState): boolean {
+ return false;
+ },
createHoistableState(): HoistableState {
return null;
},
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 a49e268ebf040..49cc28535e92d 100644
--- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
@@ -27,6 +27,7 @@ let webpackMap;
let webpackServerMap;
let act;
let serverAct;
+let getDebugInfo;
let React;
let ReactDOM;
let ReactDOMClient;
@@ -48,6 +49,10 @@ describe('ReactFlightDOMBrowser', () => {
ReactServerScheduler = require('scheduler');
patchMessageChannel(ReactServerScheduler);
serverAct = require('internal-test-utils').serverAct;
+ getDebugInfo = require('internal-test-utils').getDebugInfo.bind(null, {
+ ignoreProps: true,
+ useFixedTime: true,
+ });
// Simulate the condition resolution
@@ -1767,6 +1772,9 @@ describe('ReactFlightDOMBrowser', () => {
webpackMap,
),
);
+
+ // Snapshot updates change this formatting, so we let prettier ignore it.
+ // prettier-ignore
const response =
await ReactServerDOMClient.createFromReadableStream(stream);
@@ -2906,4 +2914,143 @@ describe('ReactFlightDOMBrowser', () => {
'HiSebbie
',
);
});
+
+ it('should fully resolve debug info when transported through a (slow) debug channel', async () => {
+ function Paragraph({children}) {
+ return ReactServer.createElement('p', null, children);
+ }
+
+ let debugReadableStreamController;
+
+ const debugReadableStream = new ReadableStream({
+ start(controller) {
+ debugReadableStreamController = controller;
+ },
+ });
+
+ const stream = await serverAct(() =>
+ ReactServerDOMServer.renderToReadableStream(
+ {
+ root: ReactServer.createElement(
+ ReactServer.Fragment,
+ null,
+ ReactServer.createElement(Paragraph, null, 'foo'),
+ ReactServer.createElement(Paragraph, null, 'bar'),
+ ),
+ },
+ webpackMap,
+ {
+ debugChannel: {
+ writable: new WritableStream({
+ write(chunk) {
+ debugReadableStreamController.enqueue(chunk);
+ },
+ close() {
+ debugReadableStreamController.close();
+ },
+ }),
+ },
+ },
+ ),
+ );
+
+ function ClientRoot({response}) {
+ const {root} = use(response);
+ return root;
+ }
+
+ const [slowDebugStream1, slowDebugStream2] =
+ createDelayedStream(debugReadableStream).tee();
+
+ const response = ReactServerDOMClient.createFromReadableStream(stream, {
+ debugChannel: {readable: slowDebugStream1},
+ });
+
+ const container = document.createElement('div');
+ const clientRoot = ReactDOMClient.createRoot(container);
+
+ await act(() => {
+ clientRoot.render();
+ });
+
+ if (__DEV__) {
+ const debugStreamReader = slowDebugStream2.getReader();
+ while (true) {
+ const {done} = await debugStreamReader.read();
+ if (done) {
+ break;
+ }
+ // Allow the client to process each debug chunk as it arrives.
+ await act(() => {});
+ }
+ }
+
+ expect(container.innerHTML).toBe('foo
bar
');
+
+ if (
+ __DEV__ &&
+ gate(
+ flags =>
+ flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo,
+ )
+ ) {
+ const result = await response;
+ const firstParagraph = result.root[0];
+
+ expect(getDebugInfo(firstParagraph)).toMatchInlineSnapshot(`
+ [
+ {
+ "time": 0,
+ },
+ {
+ "env": "Server",
+ "key": null,
+ "name": "Paragraph",
+ "props": {},
+ "stack": [
+ [
+ "",
+ "/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js",
+ 2937,
+ 27,
+ 2931,
+ 34,
+ ],
+ [
+ "serverAct",
+ "/packages/internal-test-utils/internalAct.js",
+ 270,
+ 19,
+ 231,
+ 1,
+ ],
+ [
+ "Object.",
+ "/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js",
+ 2931,
+ 18,
+ 2918,
+ 89,
+ ],
+ ],
+ },
+ {
+ "time": 0,
+ },
+ {
+ "awaited": {
+ "byteSize": 0,
+ "end": 0,
+ "name": "RSC stream",
+ "owner": null,
+ "start": 0,
+ "value": {
+ "value": "stream",
+ },
+ },
+ },
+ ]
+ `);
+ }
+ });
});
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 55b434ce3eeff..7aaf4150db087 100644
--- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
+++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
@@ -1240,7 +1240,7 @@ describe('ReactFlightDOMEdge', () => {
env: 'Server',
});
if (gate(flags => flags.enableAsyncDebugInfo)) {
- expect(lazyWrapper._debugInfo).toEqual([
+ expect(greeting._debugInfo).toEqual([
{time: 12},
greetInfo,
{time: 13},
@@ -1259,7 +1259,7 @@ describe('ReactFlightDOMEdge', () => {
}
// The owner that created the span was the outer server component.
// We expect the debug info to be referentially equal to the owner.
- expect(greeting._owner).toBe(lazyWrapper._debugInfo[1]);
+ expect(greeting._owner).toBe(greeting._debugInfo[1]);
} else {
expect(lazyWrapper._debugInfo).toBe(undefined);
expect(greeting._owner).toBe(undefined);
@@ -1930,11 +1930,19 @@ describe('ReactFlightDOMEdge', () => {
if (__DEV__) {
expect(normalizeCodeLocInfo(componentStack)).toBe(
- '\n in Component\n in Suspense\n in body\n in html\n in ClientRoot (at **)',
+ '\n in Component\n' +
+ ' in Suspense\n' +
+ ' in body\n' +
+ ' in html\n' +
+ ' in App (at **)\n' +
+ ' in ClientRoot (at **)',
);
} else {
expect(normalizeCodeLocInfo(componentStack)).toBe(
- '\n in Suspense\n in body\n in html\n in ClientRoot (at **)',
+ '\n in Suspense\n' +
+ ' in body\n' +
+ ' in html\n' +
+ ' in ClientRoot (at **)',
);
}
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 f069b23b293c0..59df3c24d6f40 100644
--- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js
+++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js
@@ -722,11 +722,19 @@ describe('ReactFlightDOMNode', () => {
if (__DEV__) {
expect(normalizeCodeLocInfo(componentStack)).toBe(
- '\n in Component (at **)\n in Suspense\n in body\n in html\n in ClientRoot (at **)',
+ '\n in Component (at **)\n' +
+ ' in Suspense\n' +
+ ' in body\n' +
+ ' in html\n' +
+ ' in App (at **)\n' +
+ ' in ClientRoot (at **)',
);
} else {
expect(normalizeCodeLocInfo(componentStack)).toBe(
- '\n in Suspense\n in body\n in html\n in ClientRoot (at **)',
+ '\n in Suspense\n' +
+ ' in body\n' +
+ ' in html\n' +
+ ' in ClientRoot (at **)',
);
}
@@ -861,11 +869,19 @@ describe('ReactFlightDOMNode', () => {
if (__DEV__) {
expect(normalizeCodeLocInfo(componentStack)).toBe(
- '\n in Component (at **)\n in Suspense\n in body\n in html\n in ClientRoot (at **)',
+ '\n in Component (at **)\n' +
+ ' in Suspense\n' +
+ ' in body\n' +
+ ' in html\n' +
+ ' in App (at **)\n' +
+ ' in ClientRoot (at **)',
);
} else {
expect(normalizeCodeLocInfo(componentStack)).toBe(
- '\n in Suspense\n in body\n in html\n in ClientRoot (at **)',
+ '\n in Suspense\n' +
+ ' in body\n' +
+ ' in html\n' +
+ ' in ClientRoot (at **)',
);
}
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index 7d54dff3d080a..b8184a1983708 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -99,6 +99,7 @@ import {
hoistPreambleState,
isPreambleReady,
isPreambleContext,
+ hasSuspenseyContent,
} from './ReactFizzConfig';
import {
constructClassInstance,
@@ -461,7 +462,7 @@ function isEligibleForOutlining(
// The larger this limit is, the more we can save on preparing fallbacks in case we end up
// outlining.
return (
- boundary.byteSize > 500 &&
+ (boundary.byteSize > 500 || hasSuspenseyContent(boundary.contentState)) &&
// For boundaries that can possibly contribute to the preamble we don't want to outline
// them regardless of their size since the fallbacks should only be emitted if we've
// errored the boundary.
@@ -5748,8 +5749,13 @@ function flushSegment(
return writeEndPendingSuspenseBoundary(destination, request.renderState);
} else if (
+ // We don't outline when we're emitting partially completed boundaries optimistically
+ // because it doesn't make sense to outline something if its parent is going to be
+ // blocked on something later in the stream anyway.
+ !flushingPartialBoundaries &&
isEligibleForOutlining(request, boundary) &&
- flushedByteSize + boundary.byteSize > request.progressiveChunkSize
+ (flushedByteSize + boundary.byteSize > request.progressiveChunkSize ||
+ hasSuspenseyContent(boundary.contentState))
) {
// Inlining this boundary would make the current sequence being written too large
// and block the parent for too long. Instead, it will be emitted separately so that we
@@ -5980,6 +5986,8 @@ function flushPartiallyCompletedSegment(
}
}
+let flushingPartialBoundaries = false;
+
function flushCompletedQueues(
request: Request,
destination: Destination,
@@ -6095,6 +6103,7 @@ function flushCompletedQueues(
// Next we emit any segments of any boundaries that are partially complete
// but not deeply complete.
+ flushingPartialBoundaries = true;
const partialBoundaries = request.partialBoundaries;
for (i = 0; i < partialBoundaries.length; i++) {
const boundary = partialBoundaries[i];
@@ -6106,6 +6115,7 @@ function flushCompletedQueues(
}
}
partialBoundaries.splice(0, i);
+ flushingPartialBoundaries = false;
// Next we check the completed boundaries again. This may have had
// boundaries added to it in case they were too larged to be inlined.
@@ -6123,6 +6133,7 @@ function flushCompletedQueues(
}
largeBoundaries.splice(0, i);
} finally {
+ flushingPartialBoundaries = false;
if (
request.allPendingTasks === 0 &&
request.clientRenderedBoundaries.length === 0 &&
diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js
index 66203af1fefdb..d170787dc7997 100644
--- a/packages/react-server/src/ReactFlightServer.js
+++ b/packages/react-server/src/ReactFlightServer.js
@@ -49,6 +49,7 @@ import type {
Hints,
HintCode,
HintModel,
+ FormatContext,
} from './ReactFlightServerConfig';
import type {ThenableState} from './ReactFlightThenable';
import type {
@@ -88,6 +89,8 @@ import {
supportsRequestStorage,
requestStorage,
createHints,
+ createRootFormatContext,
+ getChildFormatContext,
initAsyncDebugInfo,
markAsyncSequenceRootTask,
getCurrentAsyncSequence,
@@ -525,6 +528,7 @@ type Task = {
toJSON: (key: string, value: ReactClientValue) => ReactJSONValue,
keyPath: null | string, // parent server component keys
implicitSlot: boolean, // true if the root server component of this sequence had a null key
+ formatContext: FormatContext, // an approximate parent context from host components
thenableState: ThenableState | null,
timed: boolean, // Profiling-only. Whether we need to track the completion time of this task.
time: number, // Profiling-only. The last time stamp emitted for this task.
@@ -758,6 +762,7 @@ function RequestInstance(
model,
null,
false,
+ createRootFormatContext(),
abortSet,
timeOrigin,
null,
@@ -980,6 +985,7 @@ function serializeThenable(
(thenable: any), // will be replaced by the value before we retry. used for debug info.
task.keyPath, // the server component sequence continues through Promise-as-a-child.
task.implicitSlot,
+ task.formatContext,
request.abortableTasks,
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
@@ -1102,6 +1108,7 @@ function serializeReadableStream(
task.model,
task.keyPath,
task.implicitSlot,
+ task.formatContext,
request.abortableTasks,
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
@@ -1197,6 +1204,7 @@ function serializeAsyncIterable(
task.model,
task.keyPath,
task.implicitSlot,
+ task.formatContext,
request.abortableTasks,
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
@@ -2028,6 +2036,7 @@ function deferTask(request: Request, task: Task): ReactJSONValue {
task.model, // the currently rendering element
task.keyPath, // unlike outlineModel this one carries along context
task.implicitSlot,
+ task.formatContext,
request.abortableTasks,
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
@@ -2048,6 +2057,7 @@ function outlineTask(request: Request, task: Task): ReactJSONValue {
task.model, // the currently rendering element
task.keyPath, // unlike outlineModel this one carries along context
task.implicitSlot,
+ task.formatContext,
request.abortableTasks,
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
@@ -2214,6 +2224,22 @@ function renderElement(
}
}
}
+ } else if (typeof type === 'string') {
+ const parentFormatContext = task.formatContext;
+ const newFormatContext = getChildFormatContext(
+ parentFormatContext,
+ type,
+ props,
+ );
+ if (parentFormatContext !== newFormatContext && props.children != null) {
+ // We've entered a new context. We need to create another Task which has
+ // the new context set up since it's not safe to push/pop in the middle of
+ // a tree. Additionally this means that any deduping within this tree now
+ // assumes the new context even if it's reused outside in a different context.
+ // We'll rely on this to dedupe the value later as we discover it again
+ // inside the returned element's tree.
+ outlineModelWithFormatContext(request, props.children, newFormatContext);
+ }
}
// For anything else, try it on the client instead.
// We don't know if the client will support it or not. This might error on the
@@ -2530,6 +2556,7 @@ function createTask(
model: ReactClientValue,
keyPath: null | string,
implicitSlot: boolean,
+ formatContext: FormatContext,
abortSet: Set,
lastTimestamp: number, // Profiling-only
debugOwner: null | ReactComponentInfo, // DEV-only
@@ -2554,6 +2581,7 @@ function createTask(
model,
keyPath,
implicitSlot,
+ formatContext: formatContext,
ping: () => pingTask(request, task),
toJSON: function (
this:
@@ -2819,11 +2847,26 @@ function serializeDebugClientReference(
}
function outlineModel(request: Request, value: ReactClientValue): number {
+ return outlineModelWithFormatContext(
+ request,
+ value,
+ // For deduped values we don't know which context it will be reused in
+ // so we have to assume that it's the root context.
+ createRootFormatContext(),
+ );
+}
+
+function outlineModelWithFormatContext(
+ request: Request,
+ value: ReactClientValue,
+ formatContext: FormatContext,
+): number {
const newTask = createTask(
request,
value,
null, // The way we use outlining is for reusing an object.
false, // It makes no sense for that use case to be contextual.
+ formatContext, // Except for FormatContext we optimistically use it.
request.abortableTasks,
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
@@ -3071,6 +3114,7 @@ function serializeBlob(request: Request, blob: Blob): string {
model,
null,
false,
+ createRootFormatContext(),
request.abortableTasks,
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
@@ -3208,6 +3252,7 @@ function renderModel(
task.model,
task.keyPath,
task.implicitSlot,
+ task.formatContext,
request.abortableTasks,
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
diff --git a/packages/react-server/src/forks/ReactFizzConfig.custom.js b/packages/react-server/src/forks/ReactFizzConfig.custom.js
index 981e390ea4dc6..aa8ea94b57917 100644
--- a/packages/react-server/src/forks/ReactFizzConfig.custom.js
+++ b/packages/react-server/src/forks/ReactFizzConfig.custom.js
@@ -104,4 +104,5 @@ export const writeHoistablesForBoundary = $$$config.writeHoistablesForBoundary;
export const writePostamble = $$$config.writePostamble;
export const hoistHoistables = $$$config.hoistHoistables;
export const createHoistableState = $$$config.createHoistableState;
+export const hasSuspenseyContent = $$$config.hasSuspenseyContent;
export const emitEarlyPreloads = $$$config.emitEarlyPreloads;
diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.custom.js b/packages/react-server/src/forks/ReactFlightServerConfig.custom.js
index fbb0168eee631..a7f0ee3d991b0 100644
--- a/packages/react-server/src/forks/ReactFlightServerConfig.custom.js
+++ b/packages/react-server/src/forks/ReactFlightServerConfig.custom.js
@@ -32,3 +32,17 @@ export const componentStorage: AsyncLocalStorage =
export function createHints(): any {
return null;
}
+
+export type FormatContext = null;
+
+export function createRootFormatContext(): FormatContext {
+ return null;
+}
+
+export function getChildFormatContext(
+ parentContext: FormatContext,
+ type: string,
+ props: Object,
+): FormatContext {
+ return parentContext;
+}
diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js
index bc599ac0b4011..2b4d2e1e809ea 100644
--- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js
+++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js
@@ -32,3 +32,17 @@ export const componentStorage: AsyncLocalStorage =
export function createHints(): any {
return null;
}
+
+export type FormatContext = null;
+
+export function createRootFormatContext(): FormatContext {
+ return null;
+}
+
+export function getChildFormatContext(
+ parentContext: FormatContext,
+ type: string,
+ props: Object,
+): FormatContext {
+ return parentContext;
+}
diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.markup.js b/packages/react-server/src/forks/ReactFlightServerConfig.markup.js
index 59a1bac1eb491..ca8c4670834ff 100644
--- a/packages/react-server/src/forks/ReactFlightServerConfig.markup.js
+++ b/packages/react-server/src/forks/ReactFlightServerConfig.markup.js
@@ -19,6 +19,20 @@ export function createHints(): Hints {
return null;
}
+export type FormatContext = null;
+
+export function createRootFormatContext(): FormatContext {
+ return null;
+}
+
+export function getChildFormatContext(
+ parentContext: FormatContext,
+ type: string,
+ props: Object,
+): FormatContext {
+ return parentContext;
+}
+
export const supportsRequestStorage = false;
export const requestStorage: AsyncLocalStorage = (null: any);