',
+ {
+ runScripts: 'dangerously',
+ },
+ );
+ document = jsdom.window.document;
+ container = document.getElementById('container');
+ global.window = jsdom.window;
+ // The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it.
+ global.requestAnimationFrame = global.window.requestAnimationFrame = cb =>
+ setTimeout(cb);
+
+ buffer = '';
+ hasErrored = false;
+
+ writable = new Stream.PassThrough();
+ writable.setEncoding('utf8');
+ writable.on('data', chunk => {
+ buffer += chunk;
+ });
+ writable.on('error', error => {
+ hasErrored = true;
+ fatalError = error;
+ });
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ async function serverAct(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;
+ }
+ // 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;
+ buffer = '';
+ const temp = document.createElement('body');
+ temp.innerHTML = bufferedContent;
+ await insertNodesAndExecuteScripts(temp, container, null);
+ jest.runAllTimers();
+ }
+
+ function Text(props) {
+ Scheduler.log(props.text);
+ return
;
+ }
+
+ function createAsyncText(text) {
+ let resolved = false;
+ const Component = function () {
+ if (!resolved) {
+ Scheduler.log('Suspend! [' + text + ']');
+ throw promise;
+ }
+ return
;
+ };
+ const promise = new Promise(resolve => {
+ Component.resolve = function () {
+ resolved = true;
+ return resolve();
+ };
+ });
+ return Component;
+ }
+
+ // @gate enableSuspenseList
+ it('shows content independently by default', async () => {
+ const A = createAsyncText('A');
+ const B = createAsyncText('B');
+ const C = createAsyncText('C');
+
+ function Foo() {
+ return (
+
+ );
+ }
+
+ await A.resolve();
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
);
+ pipe(writable);
+ });
+
+ assertLog(['A', 'Suspend! [B]', 'Suspend! [C]', 'Loading B', 'Loading C']);
+
+ expect(getVisibleChildren(container)).toEqual(
+
,
+ );
+
+ await serverAct(() => C.resolve());
+ assertLog(['C']);
+
+ expect(getVisibleChildren(container)).toEqual(
+
,
+ );
+
+ await serverAct(() => B.resolve());
+ assertLog(['B']);
+
+ expect(getVisibleChildren(container)).toEqual(
+
,
+ );
+ });
+
+ // @gate enableSuspenseList
+ it('displays each items in "forwards" order', async () => {
+ const A = createAsyncText('A');
+ const B = createAsyncText('B');
+ const C = createAsyncText('C');
+
+ function Foo() {
+ return (
+
+ );
+ }
+
+ await C.resolve();
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
);
+ pipe(writable);
+ });
+
+ assertLog([
+ 'Suspend! [A]',
+ 'Suspend! [B]', // TODO: Defer rendering the content after fallback if previous suspended,
+ 'C',
+ 'Loading A',
+ 'Loading B',
+ 'Loading C',
+ ]);
+
+ expect(getVisibleChildren(container)).toEqual(
+
,
+ );
+
+ await serverAct(() => A.resolve());
+ assertLog(['A']);
+
+ expect(getVisibleChildren(container)).toEqual(
+
,
+ );
+
+ await serverAct(() => B.resolve());
+ assertLog(['B']);
+
+ expect(getVisibleChildren(container)).toEqual(
+
,
+ );
+ });
+
+ // @gate enableSuspenseList
+ it('displays each items in "backwards" order', async () => {
+ const A = createAsyncText('A');
+ const B = createAsyncText('B');
+ const C = createAsyncText('C');
+
+ function Foo() {
+ return (
+
+ );
+ }
+
+ await A.resolve();
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
);
+ pipe(writable);
+ });
+
+ assertLog([
+ 'Suspend! [C]',
+ 'Suspend! [B]', // TODO: Defer rendering the content after fallback if previous suspended,
+ 'A',
+ 'Loading C',
+ 'Loading B',
+ 'Loading A',
+ ]);
+
+ expect(getVisibleChildren(container)).toEqual(
+
,
+ );
+
+ await serverAct(() => C.resolve());
+ assertLog(['C']);
+
+ expect(getVisibleChildren(container)).toEqual(
+
,
+ );
+
+ await serverAct(() => B.resolve());
+ assertLog(['B']);
+
+ expect(getVisibleChildren(container)).toEqual(
+
,
+ );
+ });
+});
diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzViewTransition-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzViewTransition-test.js
new file mode 100644
index 0000000000..3077fb5b3f
--- /dev/null
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzViewTransition-test.js
@@ -0,0 +1,335 @@
+/**
+ * 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
+ * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
+ */
+
+'use strict';
+import {
+ insertNodesAndExecuteScripts,
+ getVisibleChildren,
+} from '../test-utils/FizzTestUtils';
+
+let JSDOM;
+let React;
+let Suspense;
+let ViewTransition;
+let ReactDOMClient;
+let clientAct;
+let ReactDOMFizzServer;
+let Stream;
+let document;
+let writable;
+let container;
+let buffer = '';
+let hasErrored = false;
+let fatalError = undefined;
+
+describe('ReactDOMFizzViewTransition', () => {
+ beforeEach(() => {
+ jest.resetModules();
+ JSDOM = require('jsdom').JSDOM;
+ React = require('react');
+ ReactDOMClient = require('react-dom/client');
+ clientAct = require('internal-test-utils').act;
+ ReactDOMFizzServer = require('react-dom/server');
+ Stream = require('stream');
+
+ Suspense = React.Suspense;
+ ViewTransition = React.unstable_ViewTransition;
+
+ // Test Environment
+ const jsdom = new JSDOM(
+ '
',
+ {
+ runScripts: 'dangerously',
+ },
+ );
+ document = jsdom.window.document;
+ container = document.getElementById('container');
+ global.window = jsdom.window;
+ // The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it.
+ global.requestAnimationFrame = global.window.requestAnimationFrame = cb =>
+ setTimeout(cb);
+
+ buffer = '';
+ hasErrored = false;
+
+ writable = new Stream.PassThrough();
+ writable.setEncoding('utf8');
+ writable.on('data', chunk => {
+ buffer += chunk;
+ });
+ writable.on('error', error => {
+ hasErrored = true;
+ fatalError = error;
+ });
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ async function serverAct(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;
+ }
+ // 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;
+ buffer = '';
+ const temp = document.createElement('body');
+ temp.innerHTML = bufferedContent;
+ await insertNodesAndExecuteScripts(temp, container, null);
+ jest.runAllTimers();
+ }
+
+ // @gate enableViewTransition
+ it('emits annotations for view transitions', async () => {
+ function App() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
);
+ pipe(writable);
+ });
+
+ expect(getVisibleChildren(container)).toEqual(
+
,
+ );
+
+ // Hydration should not yield any errors.
+ await clientAct(async () => {
+ ReactDOMClient.hydrateRoot(container,
);
+ });
+ });
+
+ // @gate enableViewTransition
+ it('emits enter/exit annotations for view transitions inside Suspense', async () => {
+ let resolve;
+ const promise = new Promise(r => (resolve = r));
+ function Suspend() {
+ return React.use(promise);
+ }
+ function App() {
+ const fallback = (
+
+
+
+ Loading
+
+
+
+ );
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
);
+ pipe(writable);
+ });
+
+ expect(getVisibleChildren(container)).toEqual(
+
,
+ );
+
+ await serverAct(async () => {
+ await resolve(
+
+
+ Content
+
+
,
+ );
+ });
+
+ expect(getVisibleChildren(container)).toEqual(
+
,
+ );
+
+ // Hydration should not yield any errors.
+ await clientAct(async () => {
+ ReactDOMClient.hydrateRoot(container,
);
+ });
+ });
+
+ // @gate enableViewTransition
+ it('can emit both enter and exit on the same node', async () => {
+ let resolve;
+ const promise = new Promise(r => (resolve = r));
+ function Suspend() {
+ return React.use(promise);
+ }
+ function App() {
+ const fallback = (
+
+
+
+
+ Loading
+
+
+
+
+ );
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
);
+ pipe(writable);
+ });
+
+ expect(getVisibleChildren(container)).toEqual(
+
,
+ );
+
+ await serverAct(async () => {
+ await resolve(
+
+
+ Content
+
+
,
+ );
+ });
+
+ expect(getVisibleChildren(container)).toEqual(
+
,
+ );
+
+ // Hydration should not yield any errors.
+ await clientAct(async () => {
+ ReactDOMClient.hydrateRoot(container,
);
+ });
+ });
+
+ // @gate enableViewTransition
+ it('emits annotations for view transitions outside Suspense', async () => {
+ let resolve;
+ const promise = new Promise(r => (resolve = r));
+ function Suspend() {
+ return React.use(promise);
+ }
+ function App() {
+ const fallback = (
+
+
+ Loading
+
+
+ );
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
);
+ pipe(writable);
+ });
+
+ expect(getVisibleChildren(container)).toEqual(
+
,
+ );
+
+ await serverAct(async () => {
+ await resolve(
+
+
+ Content
+
+
,
+ );
+ });
+
+ expect(getVisibleChildren(container)).toEqual(
+
,
+ );
+
+ // Hydration should not yield any errors.
+ await clientAct(async () => {
+ ReactDOMClient.hydrateRoot(container,
);
+ });
+ });
+});
diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
index b7f42bcf8d..2d3f1f0b8b 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
@@ -704,8 +704,14 @@ describe('ReactDOMFloat', () => {
(gate(flags => flags.shouldUseFizzExternalRuntime)
? ''
: '') +
- '
foo ' +
- 'bar
',
+ (gate(flags => flags.enableFizzBlockingRender)
+ ? '
'
+ : '') +
+ '
foo ' +
+ 'bar' +
+ (gate(flags => flags.enableFizzBlockingRender)
+ ? '
'
+ : ''),
'',
]);
});
diff --git a/packages/react-dom/src/__tests__/ReactDOMLegacyFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMLegacyFloat-test.js
index f2cabafc9f..f6d868c841 100644
--- a/packages/react-dom/src/__tests__/ReactDOMLegacyFloat-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMLegacyFloat-test.js
@@ -34,8 +34,15 @@ describe('ReactDOMFloat', () => {
);
expect(result).toEqual(
- '
' +
- '
title ',
+ '
' +
+ (gate(flags => flags.enableFizzBlockingRender)
+ ? '
'
+ : '') +
+ '
title ' +
+ (gate(flags => flags.enableFizzBlockingRender)
+ ? '
'
+ : '') +
+ '',
);
});
});
diff --git a/packages/react-dom/src/__tests__/ReactRenderDocument-test.js b/packages/react-dom/src/__tests__/ReactRenderDocument-test.js
index 2b54bc9009..3501f1bd92 100644
--- a/packages/react-dom/src/__tests__/ReactRenderDocument-test.js
+++ b/packages/react-dom/src/__tests__/ReactRenderDocument-test.js
@@ -78,14 +78,20 @@ describe('rendering React components at document', () => {
root = ReactDOMClient.hydrateRoot(testDocument,
);
});
expect(testDocument.body.innerHTML).toBe(
- 'Hello world' + '
',
+ 'Hello world' +
+ (gate(flags => flags.enableFizzBlockingRender)
+ ? '
'
+ : ''),
);
await act(() => {
root.render(
);
});
expect(testDocument.body.innerHTML).toBe(
- 'Hello moon' + '
',
+ 'Hello moon' +
+ (gate(flags => flags.enableFizzBlockingRender)
+ ? '
'
+ : ''),
);
expect(body === testDocument.body).toBe(true);
@@ -112,7 +118,10 @@ describe('rendering React components at document', () => {
root = ReactDOMClient.hydrateRoot(testDocument,
);
});
expect(testDocument.body.innerHTML).toBe(
- 'Hello world' + '
',
+ 'Hello world' +
+ (gate(flags => flags.enableFizzBlockingRender)
+ ? '
'
+ : ''),
);
const originalDocEl = testDocument.documentElement;
@@ -124,9 +133,15 @@ describe('rendering React components at document', () => {
expect(testDocument.firstChild).toBe(originalDocEl);
expect(testDocument.head).toBe(originalHead);
expect(testDocument.body).toBe(originalBody);
- expect(originalBody.innerHTML).toBe('
');
+ expect(originalBody.innerHTML).toBe(
+ gate(flags => flags.enableFizzBlockingRender)
+ ? '
'
+ : '',
+ );
expect(originalHead.innerHTML).toBe(
- '
',
+ gate(flags => flags.enableFizzBlockingRender)
+ ? '
'
+ : '',
);
});
@@ -166,7 +181,10 @@ describe('rendering React components at document', () => {
});
expect(testDocument.body.innerHTML).toBe(
- 'Hello world' + '
',
+ 'Hello world' +
+ (gate(flags => flags.enableFizzBlockingRender)
+ ? '
'
+ : ''),
);
await act(() => {
@@ -174,7 +192,9 @@ describe('rendering React components at document', () => {
});
expect(testDocument.body.innerHTML).toBe(
- '
' + 'Goodbye world',
+ (gate(flags => flags.enableFizzBlockingRender)
+ ? '
'
+ : '') + 'Goodbye world',
);
});
@@ -205,7 +225,10 @@ describe('rendering React components at document', () => {
});
expect(testDocument.body.innerHTML).toBe(
- 'Hello world' + '
',
+ 'Hello world' +
+ (gate(flags => flags.enableFizzBlockingRender)
+ ? '
'
+ : ''),
);
});
@@ -341,7 +364,10 @@ describe('rendering React components at document', () => {
expect(testDocument.body.innerHTML).toBe(
favorSafetyOverHydrationPerf
? 'Hello world'
- : 'Goodbye world
',
+ : 'Goodbye world' +
+ (gate(flags => flags.enableFizzBlockingRender)
+ ? '
'
+ : ''),
);
});
diff --git a/packages/react-dom/src/client/ReactDOMDefaultTransitionIndicator.js b/packages/react-dom/src/client/ReactDOMDefaultTransitionIndicator.js
new file mode 100644
index 0000000000..8f1a32d826
--- /dev/null
+++ b/packages/react-dom/src/client/ReactDOMDefaultTransitionIndicator.js
@@ -0,0 +1,89 @@
+/**
+ * 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
+ */
+
+export function defaultOnDefaultTransitionIndicator(): void | (() => void) {
+ if (typeof navigation !== 'object') {
+ // If the Navigation API is not available, then this is a noop.
+ return;
+ }
+
+ let isCancelled = false;
+ let pendingResolve: null | (() => void) = null;
+
+ function handleNavigate(event: NavigateEvent) {
+ if (event.canIntercept && event.info === 'react-transition') {
+ event.intercept({
+ handler() {
+ return new Promise(resolve => (pendingResolve = resolve));
+ },
+ focusReset: 'manual',
+ scroll: 'manual',
+ });
+ }
+ }
+
+ function handleNavigateComplete() {
+ if (pendingResolve !== null) {
+ // If this was not our navigation completing, we were probably cancelled.
+ // We'll start a new one below.
+ pendingResolve();
+ pendingResolve = null;
+ }
+ if (!isCancelled) {
+ // Some other navigation completed but we should still be running.
+ // Start another fake one to keep the loading indicator going.
+ startFakeNavigation();
+ }
+ }
+
+ // $FlowFixMe
+ navigation.addEventListener('navigate', handleNavigate);
+ // $FlowFixMe
+ navigation.addEventListener('navigatesuccess', handleNavigateComplete);
+ // $FlowFixMe
+ navigation.addEventListener('navigateerror', handleNavigateComplete);
+
+ function startFakeNavigation() {
+ if (isCancelled) {
+ // We already stopped this Transition.
+ return;
+ }
+ if (navigation.transition) {
+ // There is an on-going Navigation already happening. Let's wait for it to
+ // finish before starting our fake one.
+ return;
+ }
+ // Trigger a fake navigation to the same page
+ const currentEntry = navigation.currentEntry;
+ if (currentEntry && currentEntry.url != null) {
+ navigation.navigate(currentEntry.url, {
+ state: currentEntry.getState(),
+ info: 'react-transition', // indicator to routers to ignore this navigation
+ history: 'replace',
+ });
+ }
+ }
+
+ // Delay the start a bit in case this is a fast navigation.
+ setTimeout(startFakeNavigation, 100);
+
+ return function () {
+ isCancelled = true;
+ // $FlowFixMe
+ navigation.removeEventListener('navigate', handleNavigate);
+ // $FlowFixMe
+ navigation.removeEventListener('navigatesuccess', handleNavigateComplete);
+ // $FlowFixMe
+ navigation.removeEventListener('navigateerror', handleNavigateComplete);
+ if (pendingResolve !== null) {
+ pendingResolve();
+ pendingResolve = null;
+ }
+ };
+}
diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js
index ef2c9ddf19..97f4c83515 100644
--- a/packages/react-dom/src/client/ReactDOMRoot.js
+++ b/packages/react-dom/src/client/ReactDOMRoot.js
@@ -95,13 +95,9 @@ import {
defaultOnCaughtError,
defaultOnRecoverableError,
} from 'react-reconciler/src/ReactFiberReconciler';
+import {defaultOnDefaultTransitionIndicator} from './ReactDOMDefaultTransitionIndicator';
import {ConcurrentRoot} from 'react-reconciler/src/ReactRootTags';
-function defaultOnDefaultTransitionIndicator(): void | (() => void) {
- // TODO: Implement the default
- return function () {};
-}
-
// $FlowFixMe[missing-this-annot]
function ReactDOMRoot(internalRoot: FiberRoot) {
this._internalRoot = internalRoot;
diff --git a/packages/react-markup/src/ReactFizzConfigMarkup.js b/packages/react-markup/src/ReactFizzConfigMarkup.js
index 7d14ccd628..bbca0d4ddf 100644
--- a/packages/react-markup/src/ReactFizzConfigMarkup.js
+++ b/packages/react-markup/src/ReactFizzConfigMarkup.js
@@ -52,6 +52,8 @@ export type {
export {
getChildFormatContext,
+ getSuspenseFallbackFormatContext,
+ getSuspenseContentFormatContext,
makeId,
pushEndInstance,
pushFormStateMarkerIsMatching,
@@ -86,6 +88,20 @@ export {
import escapeTextForBrowser from 'react-dom-bindings/src/server/escapeTextForBrowser';
+export function getViewTransitionFormatContext(
+ resumableState: ResumableState,
+ parentContext: FormatContext,
+ update: void | null | 'none' | 'auto' | string,
+ enter: void | null | 'none' | 'auto' | string,
+ exit: void | null | 'none' | 'auto' | string,
+ share: void | null | 'none' | 'auto' | string,
+ name: void | null | 'auto' | string,
+ autoName: string, // name or an autogenerated unique name
+): FormatContext {
+ // ViewTransition reveals are not supported in markup renders.
+ return parentContext;
+}
+
export function pushStartInstance(
target: Array
,
type: string,
@@ -96,7 +112,6 @@ export function pushStartInstance(
hoistableState: null | HoistableState,
formatContext: FormatContext,
textEmbedded: boolean,
- isFallback: boolean,
): ReactNodeList {
for (const propKey in props) {
if (hasOwnProperty.call(props, propKey)) {
@@ -127,7 +142,6 @@ export function pushStartInstance(
hoistableState,
formatContext,
textEmbedded,
- isFallback,
);
}
diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js
index 119c9885db..0244e3dfab 100644
--- a/packages/react-noop-renderer/src/ReactNoopServer.js
+++ b/packages/react-noop-renderer/src/ReactNoopServer.js
@@ -104,6 +104,16 @@ const ReactNoopServer = ReactFizzServer({
getChildFormatContext(): null {
return null;
},
+ getSuspenseFallbackFormatContext(): null {
+ return null;
+ },
+ getSuspenseContentFormatContext(): null {
+ return null;
+ },
+
+ getViewTransitionFormatContext(): null {
+ return null;
+ },
resetResumableState(): void {},
completeResumableState(): void {},
diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js
index dd5173cd83..6fbad9adac 100644
--- a/packages/react-noop-renderer/src/createReactNoop.js
+++ b/packages/react-noop-renderer/src/createReactNoop.js
@@ -1142,9 +1142,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
// TODO: Turn this on once tests are fixed
// console.error(error);
}
- function onDefaultTransitionIndicator(): void | (() => void) {
- // TODO: Allow this as an option.
- }
+ function onDefaultTransitionIndicator(): void | (() => void) {}
let idCounter = 0;
diff --git a/packages/react-reconciler/src/ReactFiberAsyncAction.js b/packages/react-reconciler/src/ReactFiberAsyncAction.js
index f57d7597d6..9d1194874d 100644
--- a/packages/react-reconciler/src/ReactFiberAsyncAction.js
+++ b/packages/react-reconciler/src/ReactFiberAsyncAction.js
@@ -15,7 +15,10 @@ import type {
import type {Lane} from './ReactFiberLane';
import type {Transition} from 'react/src/ReactStartTransition';
-import {requestTransitionLane} from './ReactFiberRootScheduler';
+import {
+ requestTransitionLane,
+ ensureScheduleIsScheduled,
+} from './ReactFiberRootScheduler';
import {NoLane} from './ReactFiberLane';
import {
hasScheduledTransitionWork,
@@ -24,9 +27,13 @@ import {
import {
enableComponentPerformanceTrack,
enableProfilerTimer,
+ enableDefaultTransitionIndicator,
} from 'shared/ReactFeatureFlags';
import {clearEntangledAsyncTransitionTypes} from './ReactFiberTransitionTypes';
+import noop from 'shared/noop';
+import reportGlobalError from 'shared/reportGlobalError';
+
// If there are multiple, concurrent async actions, they are entangled. All
// transition updates that occur while the async action is still in progress
// are treated as part of the action.
@@ -46,6 +53,21 @@ let currentEntangledLane: Lane = NoLane;
// until the async action scope has completed.
let currentEntangledActionThenable: Thenable | null = null;
+// Track the default indicator for every root. undefined means we haven't
+// had any roots registered yet. null means there's more than one callback.
+// If there's more than one callback we bailout to not supporting isomorphic
+// default indicators.
+let isomorphicDefaultTransitionIndicator:
+ | void
+ | null
+ | (() => void | (() => void)) = undefined;
+// The clean up function for the currently running indicator.
+let pendingIsomorphicIndicator: null | (() => void) = null;
+// The number of roots that have pending Transitions that depend on the
+// started isomorphic indicator.
+let pendingEntangledRoots: number = 0;
+let needsIsomorphicIndicator: boolean = false;
+
export function entangleAsyncAction(
transition: Transition,
thenable: Thenable,
@@ -66,6 +88,12 @@ export function entangleAsyncAction(
},
};
currentEntangledActionThenable = entangledThenable;
+ if (enableDefaultTransitionIndicator) {
+ needsIsomorphicIndicator = true;
+ // We'll check if we need a default indicator in a microtask. Ensure
+ // we have this scheduled even if no root is scheduled.
+ ensureScheduleIsScheduled();
+ }
}
currentEntangledPendingCount++;
thenable.then(pingEngtangledActionScope, pingEngtangledActionScope);
@@ -86,6 +114,9 @@ function pingEngtangledActionScope() {
}
}
clearEntangledAsyncTransitionTypes();
+ if (pendingEntangledRoots === 0) {
+ stopIsomorphicDefaultIndicator();
+ }
if (currentEntangledListeners !== null) {
// All the actions have finished. Close the entangled async action scope
// and notify all the listeners.
@@ -98,6 +129,7 @@ function pingEngtangledActionScope() {
currentEntangledListeners = null;
currentEntangledLane = NoLane;
currentEntangledActionThenable = null;
+ needsIsomorphicIndicator = false;
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i];
listener();
@@ -161,3 +193,71 @@ export function peekEntangledActionLane(): Lane {
export function peekEntangledActionThenable(): Thenable | null {
return currentEntangledActionThenable;
}
+
+export function registerDefaultIndicator(
+ onDefaultTransitionIndicator: () => void | (() => void),
+): void {
+ if (!enableDefaultTransitionIndicator) {
+ return;
+ }
+ if (isomorphicDefaultTransitionIndicator === undefined) {
+ isomorphicDefaultTransitionIndicator = onDefaultTransitionIndicator;
+ } else if (
+ isomorphicDefaultTransitionIndicator !== onDefaultTransitionIndicator
+ ) {
+ isomorphicDefaultTransitionIndicator = null;
+ // Stop any on-going indicator since it's now ambiguous.
+ stopIsomorphicDefaultIndicator();
+ }
+}
+
+export function startIsomorphicDefaultIndicatorIfNeeded() {
+ if (!enableDefaultTransitionIndicator) {
+ return;
+ }
+ if (!needsIsomorphicIndicator) {
+ return;
+ }
+ if (
+ isomorphicDefaultTransitionIndicator != null &&
+ pendingIsomorphicIndicator === null
+ ) {
+ try {
+ pendingIsomorphicIndicator =
+ isomorphicDefaultTransitionIndicator() || noop;
+ } catch (x) {
+ pendingIsomorphicIndicator = noop;
+ reportGlobalError(x);
+ }
+ }
+}
+
+function stopIsomorphicDefaultIndicator() {
+ if (!enableDefaultTransitionIndicator) {
+ return;
+ }
+ if (pendingIsomorphicIndicator !== null) {
+ const cleanup = pendingIsomorphicIndicator;
+ pendingIsomorphicIndicator = null;
+ cleanup();
+ }
+}
+
+function releaseIsomorphicIndicator() {
+ if (--pendingEntangledRoots === 0) {
+ stopIsomorphicDefaultIndicator();
+ }
+}
+
+export function hasOngoingIsomorphicIndicator(): boolean {
+ return pendingIsomorphicIndicator !== null;
+}
+
+export function retainIsomorphicIndicator(): () => void {
+ pendingEntangledRoots++;
+ return releaseIsomorphicIndicator;
+}
+
+export function markIsomorphicIndicatorHandled(): void {
+ needsIsomorphicIndicator = false;
+}
diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js
index 975313a99f..69bc84038d 100644
--- a/packages/react-reconciler/src/ReactFiberBeginWork.js
+++ b/packages/react-reconciler/src/ReactFiberBeginWork.js
@@ -14,6 +14,9 @@ import type {
ViewTransitionProps,
ActivityProps,
SuspenseProps,
+ SuspenseListProps,
+ SuspenseListRevealOrder,
+ SuspenseListTailMode,
TracingMarkerProps,
CacheProps,
ProfilerProps,
@@ -26,7 +29,6 @@ import type {ActivityState} from './ReactFiberActivityComponent';
import type {
SuspenseState,
SuspenseListRenderState,
- SuspenseListTailMode,
} from './ReactFiberSuspenseComponent';
import type {SuspenseContext} from './ReactFiberSuspenseContext';
import type {
@@ -3222,8 +3224,6 @@ function findLastContentRow(firstChild: null | Fiber): null | Fiber {
return lastContentRow;
}
-type SuspenseListRevealOrder = 'forwards' | 'backwards' | 'together' | void;
-
function validateRevealOrder(revealOrder: SuspenseListRevealOrder) {
if (__DEV__) {
if (
@@ -3410,7 +3410,7 @@ function updateSuspenseListComponent(
workInProgress: Fiber,
renderLanes: Lanes,
) {
- const nextProps = workInProgress.pendingProps;
+ const nextProps: SuspenseListProps = workInProgress.pendingProps;
const revealOrder: SuspenseListRevealOrder = nextProps.revealOrder;
const tailMode: SuspenseListTailMode = nextProps.tail;
const newChildren = nextProps.children;
@@ -3543,6 +3543,12 @@ function updateViewTransition(
current === null
? ViewTransitionNamedMount | ViewTransitionNamedStatic
: ViewTransitionNamedStatic;
+ } else {
+ // The server may have used useId to auto-assign a generated name for this boundary.
+ // We push a materialization to ensure child ids line up with the server.
+ if (getIsHydrating()) {
+ pushMaterializedTreeId(workInProgress);
+ }
}
if (__DEV__) {
// $FlowFixMe[prop-missing]
diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js
index 93f2a476bd..e21271f3b3 100644
--- a/packages/react-reconciler/src/ReactFiberCommitWork.js
+++ b/packages/react-reconciler/src/ReactFiberCommitWork.js
@@ -20,6 +20,7 @@ import type {
import type {Fiber, FiberRoot} from './ReactInternalTypes';
import type {Lanes} from './ReactFiberLane';
import {
+ includesLoadingIndicatorLanes,
includesOnlySuspenseyCommitEligibleLanes,
includesOnlyViewTransitionEligibleLanes,
} from './ReactFiberLane';
@@ -59,6 +60,8 @@ import {
enableComponentPerformanceTrack,
enableViewTransition,
enableFragmentRefs,
+ enableEagerAlternateStateNodeCleanup,
+ enableDefaultTransitionIndicator,
} from 'shared/ReactFeatureFlags';
import {
FunctionComponent,
@@ -207,6 +210,7 @@ import {
TransitionRoot,
TransitionTracingMarker,
} from './ReactFiberTracingMarkerComponent';
+import {getViewTransitionClassName} from './ReactFiberViewTransitionComponent';
import {
commitHookLayoutEffects,
commitHookLayoutUnmountEffects,
@@ -267,13 +271,16 @@ import {
} from './ReactFiberCommitViewTransitions';
import {
viewTransitionMutationContext,
+ pushRootMutationContext,
pushMutationContext,
popMutationContext,
+ rootMutationContext,
} from './ReactFiberMutationTracking';
import {
trackNamedViewTransition,
untrackNamedViewTransition,
} from './ReactFiberDuplicateViewTransitions';
+import {markIndicatorHandled} from './ReactFiberRootScheduler';
// Used during the commit phase to track the state of the Offscreen component stack.
// Allows us to avoid traversing the return path to find the nearest Offscreen ancestor.
@@ -297,6 +304,7 @@ export let shouldFireAfterActiveInstanceBlur: boolean = false;
// Used during the commit phase to track whether a parent ViewTransition component
// might have been affected by any mutations / relayouts below.
let viewTransitionContextChanged: boolean = false;
+let inUpdateViewTransition: boolean = false;
let rootViewTransitionAffected: boolean = false;
function isHydratingParent(current: Fiber, finishedWork: Fiber): boolean {
@@ -1931,6 +1939,7 @@ export function commitMutationEffects(
inProgressRoot = root;
rootViewTransitionAffected = false;
+ inUpdateViewTransition = false;
resetComponentEffectTimers();
@@ -2170,6 +2179,20 @@ function commitMutationEffectsOnFiber(
}
}
}
+ } else {
+ if (enableEagerAlternateStateNodeCleanup) {
+ if (supportsPersistence) {
+ if (finishedWork.alternate !== null) {
+ // `finishedWork.alternate.stateNode` is pointing to a stale shadow
+ // node at this point, retaining it and its subtree. To reclaim
+ // memory, point `alternate.stateNode` to new shadow node. This
+ // prevents shadow node from staying in memory longer than it
+ // needs to. The correct behaviour of this is checked by test in
+ // React Native: ShadowNodeReferenceCounter-itest.js#L150
+ finishedWork.alternate.stateNode = finishedWork.stateNode;
+ }
+ }
+ }
}
break;
}
@@ -2201,6 +2224,7 @@ function commitMutationEffectsOnFiber(
case HostRoot: {
const prevProfilerEffectDuration = pushNestedEffectDurations();
+ pushRootMutationContext();
if (supportsResources) {
prepareToCommitHoistables();
@@ -2250,6 +2274,18 @@ function commitMutationEffectsOnFiber(
);
}
+ popMutationContext(false);
+
+ if (
+ enableDefaultTransitionIndicator &&
+ rootMutationContext &&
+ includesLoadingIndicatorLanes(lanes)
+ ) {
+ // This root had a mutation. Mark this root as having rendered a manual
+ // loading state.
+ markIndicatorHandled(root);
+ }
+
break;
}
case HostPortal: {
@@ -2266,7 +2302,7 @@ function commitMutationEffectsOnFiber(
recursivelyTraverseMutationEffects(root, finishedWork, lanes);
commitReconciliationEffects(finishedWork, lanes);
}
- if (viewTransitionMutationContext) {
+ if (viewTransitionMutationContext && inUpdateViewTransition) {
// A Portal doesn't necessarily exist within the context of this subtree.
// Ideally we would track which React ViewTransition component nests the container
// but that's costly. Instead, we treat each Portal as if it's a new React root.
@@ -2501,11 +2537,16 @@ function commitMutationEffectsOnFiber(
}
}
const prevMutationContext = pushMutationContext();
- recursivelyTraverseMutationEffects(root, finishedWork, lanes);
- commitReconciliationEffects(finishedWork, lanes);
+ const prevUpdate = inUpdateViewTransition;
const isViewTransitionEligible =
enableViewTransition &&
includesOnlyViewTransitionEligibleLanes(lanes);
+ const props = finishedWork.memoizedProps;
+ inUpdateViewTransition =
+ isViewTransitionEligible &&
+ getViewTransitionClassName(props.default, props.update) !== 'none';
+ recursivelyTraverseMutationEffects(root, finishedWork, lanes);
+ commitReconciliationEffects(finishedWork, lanes);
if (isViewTransitionEligible) {
if (current === null) {
// This is a new mount. We should have handled this as part of the
@@ -2518,6 +2559,7 @@ function commitMutationEffectsOnFiber(
finishedWork.flags |= Update;
}
}
+ inUpdateViewTransition = prevUpdate;
popMutationContext(prevMutationContext);
break;
}
@@ -2730,6 +2772,8 @@ function commitAfterMutationEffectsOnFiber(
// Ideally we would track which React ViewTransition component nests the container
// but that's costly. Instead, we treat each Portal as if it's a new React root.
// Therefore any leaked resize of a child could affect the root so the root should animate.
+ // We only do this if the Portal is inside a ViewTransition and it is not disabled
+ // with update="none". Otherwise the Portal is considered not animating.
rootViewTransitionAffected = true;
}
viewTransitionContextChanged = prevContextChanged;
diff --git a/packages/react-reconciler/src/ReactFiberLane.js b/packages/react-reconciler/src/ReactFiberLane.js
index fdada6b065..bd7f3267ef 100644
--- a/packages/react-reconciler/src/ReactFiberLane.js
+++ b/packages/react-reconciler/src/ReactFiberLane.js
@@ -27,6 +27,7 @@ import {
transitionLaneExpirationMs,
retryLaneExpirationMs,
disableLegacyMode,
+ enableDefaultTransitionIndicator,
} from 'shared/ReactFeatureFlags';
import {isDevToolsPresent} from './ReactFiberDevToolsHook';
import {clz32} from './clz32';
@@ -640,6 +641,10 @@ export function includesOnlySuspenseyCommitEligibleLanes(
);
}
+export function includesLoadingIndicatorLanes(lanes: Lanes): boolean {
+ return (lanes & (SyncLane | DefaultLane)) !== NoLanes;
+}
+
export function includesBlockingLane(lanes: Lanes): boolean {
const SyncDefaultLanes =
InputContinuousHydrationLane |
@@ -766,6 +771,10 @@ export function createLaneMap(initial: T): LaneMap {
export function markRootUpdated(root: FiberRoot, updateLane: Lane) {
root.pendingLanes |= updateLane;
+ if (enableDefaultTransitionIndicator) {
+ // Mark that this lane might need a loading indicator to be shown.
+ root.indicatorLanes |= updateLane & TransitionLanes;
+ }
// If there are any suspended transitions, it's possible this new update
// could unblock them. Clear the suspended lanes so that we can try rendering
@@ -847,6 +856,10 @@ export function markRootFinished(
root.pingedLanes = NoLanes;
root.warmLanes = NoLanes;
+ if (enableDefaultTransitionIndicator) {
+ root.indicatorLanes &= remainingLanes;
+ }
+
root.expiredLanes &= remainingLanes;
root.entangledLanes &= remainingLanes;
diff --git a/packages/react-reconciler/src/ReactFiberMutationTracking.js b/packages/react-reconciler/src/ReactFiberMutationTracking.js
index 164ec2c6ed..cb439bd68f 100644
--- a/packages/react-reconciler/src/ReactFiberMutationTracking.js
+++ b/packages/react-reconciler/src/ReactFiberMutationTracking.js
@@ -7,10 +7,23 @@
* @flow
*/
-import {enableViewTransition} from 'shared/ReactFeatureFlags';
+import {
+ enableDefaultTransitionIndicator,
+ enableViewTransition,
+} from 'shared/ReactFeatureFlags';
+export let rootMutationContext: boolean = false;
export let viewTransitionMutationContext: boolean = false;
+export function pushRootMutationContext(): void {
+ if (enableDefaultTransitionIndicator) {
+ rootMutationContext = false;
+ }
+ if (enableViewTransition) {
+ viewTransitionMutationContext = false;
+ }
+}
+
export function pushMutationContext(): boolean {
if (!enableViewTransition) {
return false;
@@ -22,12 +35,21 @@ export function pushMutationContext(): boolean {
export function popMutationContext(prev: boolean): void {
if (enableViewTransition) {
+ if (viewTransitionMutationContext) {
+ rootMutationContext = true;
+ }
viewTransitionMutationContext = prev;
}
}
export function trackHostMutation(): void {
+ // This is extremely hot function that must be inlined. Don't add more stuff.
if (enableViewTransition) {
viewTransitionMutationContext = true;
+ } else if (enableDefaultTransitionIndicator) {
+ // We only set this if enableViewTransition is not on. Otherwise we track
+ // it on the viewTransitionMutationContext and collect it when we pop
+ // to avoid more than a single operation in this hot path.
+ rootMutationContext = true;
}
}
diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js
index dbba2329cf..ab7b1fcdd1 100644
--- a/packages/react-reconciler/src/ReactFiberReconciler.js
+++ b/packages/react-reconciler/src/ReactFiberReconciler.js
@@ -125,6 +125,7 @@ export {
defaultOnRecoverableError,
} from './ReactFiberErrorLogger';
import {getLabelForLane, TotalLanes} from 'react-reconciler/src/ReactFiberLane';
+import {registerDefaultIndicator} from './ReactFiberAsyncAction';
type OpaqueRoot = FiberRoot;
@@ -259,7 +260,7 @@ export function createContainer(
): OpaqueRoot {
const hydrate = false;
const initialChildren = null;
- return createFiberRoot(
+ const root = createFiberRoot(
containerInfo,
tag,
hydrate,
@@ -274,6 +275,8 @@ export function createContainer(
onDefaultTransitionIndicator,
transitionCallbacks,
);
+ registerDefaultIndicator(onDefaultTransitionIndicator);
+ return root;
}
export function createHydrationContainer(
@@ -323,6 +326,8 @@ export function createHydrationContainer(
transitionCallbacks,
);
+ registerDefaultIndicator(onDefaultTransitionIndicator);
+
// TODO: Move this to FiberRoot constructor
root.context = getContextForSubtree(null);
diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js
index cc2a528010..e9d107bcb8 100644
--- a/packages/react-reconciler/src/ReactFiberRoot.js
+++ b/packages/react-reconciler/src/ReactFiberRoot.js
@@ -79,6 +79,9 @@ function FiberRootNode(
this.pingedLanes = NoLanes;
this.warmLanes = NoLanes;
this.expiredLanes = NoLanes;
+ if (enableDefaultTransitionIndicator) {
+ this.indicatorLanes = NoLanes;
+ }
this.errorRecoveryDisabledLanes = NoLanes;
this.shellSuspendCounter = 0;
@@ -94,6 +97,7 @@ function FiberRootNode(
if (enableDefaultTransitionIndicator) {
this.onDefaultTransitionIndicator = onDefaultTransitionIndicator;
+ this.pendingIndicator = null;
}
this.pooledCache = null;
diff --git a/packages/react-reconciler/src/ReactFiberRootScheduler.js b/packages/react-reconciler/src/ReactFiberRootScheduler.js
index f7c26580bb..142812dab3 100644
--- a/packages/react-reconciler/src/ReactFiberRootScheduler.js
+++ b/packages/react-reconciler/src/ReactFiberRootScheduler.js
@@ -20,11 +20,13 @@ import {
enableComponentPerformanceTrack,
enableYieldingBeforePassive,
enableGestureTransition,
+ enableDefaultTransitionIndicator,
} from 'shared/ReactFeatureFlags';
import {
NoLane,
NoLanes,
SyncLane,
+ DefaultLane,
getHighestPriorityLane,
getNextLanes,
includesSyncLane,
@@ -78,6 +80,17 @@ import {
resetNestedUpdateFlag,
syncNestedUpdateFlag,
} from './ReactProfilerTimer';
+import {peekEntangledActionLane} from './ReactFiberAsyncAction';
+
+import noop from 'shared/noop';
+import reportGlobalError from 'shared/reportGlobalError';
+
+import {
+ startIsomorphicDefaultIndicatorIfNeeded,
+ hasOngoingIsomorphicIndicator,
+ retainIsomorphicIndicator,
+ markIsomorphicIndicatorHandled,
+} from './ReactFiberAsyncAction';
// A linked list of all the roots with pending work. In an idiomatic app,
// there's only a single root, but we do support multi root apps, hence this
@@ -124,6 +137,20 @@ export function ensureRootIsScheduled(root: FiberRoot): void {
// without consulting the schedule.
mightHavePendingSyncWork = true;
+ ensureScheduleIsScheduled();
+
+ if (
+ __DEV__ &&
+ !disableLegacyMode &&
+ ReactSharedInternals.isBatchingLegacy &&
+ root.tag === LegacyRoot
+ ) {
+ // Special `act` case: Record whenever a legacy update is scheduled.
+ ReactSharedInternals.didScheduleLegacyUpdate = true;
+ }
+}
+
+export function ensureScheduleIsScheduled(): void {
// At the end of the current event, go through each of the roots and ensure
// there's a task scheduled for each one at the correct priority.
if (__DEV__ && ReactSharedInternals.actQueue !== null) {
@@ -138,16 +165,6 @@ export function ensureRootIsScheduled(root: FiberRoot): void {
scheduleImmediateRootScheduleTask();
}
}
-
- if (
- __DEV__ &&
- !disableLegacyMode &&
- ReactSharedInternals.isBatchingLegacy &&
- root.tag === LegacyRoot
- ) {
- // Special `act` case: Record whenever a legacy update is scheduled.
- ReactSharedInternals.didScheduleLegacyUpdate = true;
- }
}
export function flushSyncWorkOnAllRoots() {
@@ -256,8 +273,14 @@ function processRootScheduleInMicrotask() {
// render it synchronously anyway. We do this during a popstate event to
// preserve the scroll position of the previous page.
syncTransitionLanes = currentEventTransitionLane;
+ } else if (enableDefaultTransitionIndicator) {
+ // If we have a Transition scheduled by this event it might be paired
+ // with Default lane scheduled loading indicators. To unbatch it from
+ // other events later on, flush it early to determine whether it
+ // rendered an indicator. This ensures that setState in default priority
+ // event doesn't trigger onDefaultTransitionIndicator.
+ syncTransitionLanes = DefaultLane;
}
- currentEventTransitionLane = NoLane;
}
const currentTime = now();
@@ -315,6 +338,46 @@ function processRootScheduleInMicrotask() {
if (!hasPendingCommitEffects()) {
flushSyncWorkAcrossRoots_impl(syncTransitionLanes, false);
}
+
+ if (currentEventTransitionLane !== NoLane) {
+ // Reset Event Transition Lane so that we allocate a new one next time.
+ currentEventTransitionLane = NoLane;
+ startDefaultTransitionIndicatorIfNeeded();
+ }
+}
+
+function startDefaultTransitionIndicatorIfNeeded() {
+ if (!enableDefaultTransitionIndicator) {
+ return;
+ }
+ // Check if we need to start an isomorphic indicator like if an async action
+ // was started.
+ startIsomorphicDefaultIndicatorIfNeeded();
+ // Check all the roots if there are any new indicators needed.
+ let root = firstScheduledRoot;
+ while (root !== null) {
+ if (root.indicatorLanes !== NoLanes && root.pendingIndicator === null) {
+ // We have new indicator lanes that requires a loading state. Start the
+ // default transition indicator.
+ if (hasOngoingIsomorphicIndicator()) {
+ // We already have an isomorphic indicator going which means it has to
+ // also apply to this root since it implies all roots have the same one.
+ // We retain this indicator so that it keeps going until we commit this
+ // root.
+ root.pendingIndicator = retainIsomorphicIndicator();
+ } else {
+ try {
+ const onDefaultTransitionIndicator =
+ root.onDefaultTransitionIndicator;
+ root.pendingIndicator = onDefaultTransitionIndicator() || noop;
+ } catch (x) {
+ root.pendingIndicator = noop;
+ reportGlobalError(x);
+ }
+ }
+ }
+ root = root.next;
+ }
}
function scheduleTaskForRootDuringMicrotask(
@@ -645,7 +708,15 @@ export function requestTransitionLane(
// 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();
+ const actionScopeLane = peekEntangledActionLane();
+ currentEventTransitionLane =
+ actionScopeLane !== NoLane
+ ? // We're inside an async action scope. Reuse the same lane.
+ actionScopeLane
+ : // 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.
+ claimNextTransitionLane();
}
return currentEventTransitionLane;
}
@@ -653,3 +724,13 @@ export function requestTransitionLane(
export function didCurrentEventScheduleTransition(): boolean {
return currentEventTransitionLane !== NoLane;
}
+
+export function markIndicatorHandled(root: FiberRoot): void {
+ if (enableDefaultTransitionIndicator) {
+ // The current transition event rendered a synchronous loading state.
+ // Clear it from the indicator lanes. We don't need to show a separate
+ // loading state for this lane.
+ root.indicatorLanes &= ~currentEventTransitionLane;
+ markIsomorphicIndicatorHandled();
+ }
+}
diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js
index 07a7df2ec5..f7ae78ed6d 100644
--- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.js
+++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js
@@ -7,7 +7,7 @@
* @flow
*/
-import type {Wakeable} from 'shared/ReactTypes';
+import type {Wakeable, SuspenseListTailMode} from 'shared/ReactTypes';
import type {Fiber} from './ReactInternalTypes';
import type {SuspenseInstance} from './ReactFiberConfig';
import type {Lane} from './ReactFiberLane';
@@ -42,8 +42,6 @@ export type SuspenseState = {
hydrationErrors: Array> | null,
};
-export type SuspenseListTailMode = 'collapsed' | 'hidden' | void;
-
export type SuspenseListRenderState = {
isBackwards: boolean,
// The currently rendering tail row.
diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js
index 95285c936e..cd5c1c1468 100644
--- a/packages/react-reconciler/src/ReactFiberWorkLoop.js
+++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js
@@ -52,11 +52,14 @@ import {
enableThrottledScheduling,
enableViewTransition,
enableGestureTransition,
+ enableDefaultTransitionIndicator,
} from 'shared/ReactFeatureFlags';
import {resetOwnerStackLimit} from 'shared/ReactOwnerStackReset';
import ReactSharedInternals from 'shared/ReactSharedInternals';
import is from 'shared/objectIs';
+import reportGlobalError from 'shared/reportGlobalError';
+
import {
// Aliased because `act` will override and push to an internal queue
scheduleCallback as Scheduler_scheduleCallback,
@@ -356,7 +359,6 @@ import {
requestTransitionLane,
} from './ReactFiberRootScheduler';
import {getMaskedContext, getUnmaskedContext} from './ReactFiberContext';
-import {peekEntangledActionLane} from './ReactFiberAsyncAction';
import {logUncaughtError} from './ReactFiberErrorLogger';
import {
deleteScheduledGesture,
@@ -779,14 +781,7 @@ export function requestUpdateLane(fiber: Fiber): Lane {
transition._updatedFibers.add(fiber);
}
- const actionScopeLane = peekEntangledActionLane();
- return actionScopeLane !== NoLane
- ? // We're inside an async action scope. Reuse the same lane.
- actionScopeLane
- : // 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(transition);
+ return requestTransitionLane(transition);
}
return eventPriorityToLane(resolveUpdatePriority());
@@ -3601,6 +3596,33 @@ function flushLayoutEffects(): void {
const finishedWork = pendingFinishedWork;
const lanes = pendingEffectsLanes;
+ if (enableDefaultTransitionIndicator) {
+ const cleanUpIndicator = root.pendingIndicator;
+ if (cleanUpIndicator !== null && root.indicatorLanes === NoLanes) {
+ // We have now committed all Transitions that needed the default indicator
+ // so we can now run the clean up function. We do this in the layout phase
+ // so it has the same semantics as if you did it with a useLayoutEffect or
+ // if it was reset automatically with useOptimistic.
+ const prevTransition = ReactSharedInternals.T;
+ ReactSharedInternals.T = null;
+ const previousPriority = getCurrentUpdatePriority();
+ setCurrentUpdatePriority(DiscreteEventPriority);
+ const prevExecutionContext = executionContext;
+ executionContext |= CommitContext;
+ root.pendingIndicator = null;
+ try {
+ cleanUpIndicator();
+ } catch (x) {
+ reportGlobalError(x);
+ } finally {
+ // Reset the priority to the previous non-sync value.
+ executionContext = prevExecutionContext;
+ setCurrentUpdatePriority(previousPriority);
+ ReactSharedInternals.T = prevTransition;
+ }
+ }
+ }
+
const subtreeHasLayoutEffects =
(finishedWork.subtreeFlags & LayoutMask) !== NoFlags;
const rootHasLayoutEffect = (finishedWork.flags & LayoutMask) !== NoFlags;
diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js
index b364d4ec47..25840749a1 100644
--- a/packages/react-reconciler/src/ReactInternalTypes.js
+++ b/packages/react-reconciler/src/ReactInternalTypes.js
@@ -248,6 +248,7 @@ type BaseFiberRootProperties = {
pingedLanes: Lanes,
warmLanes: Lanes,
expiredLanes: Lanes,
+ indicatorLanes: Lanes, // enableDefaultTransitionIndicator only
errorRecoveryDisabledLanes: Lanes,
shellSuspendCounter: number,
@@ -280,7 +281,9 @@ type BaseFiberRootProperties = {
errorInfo: {+componentStack?: ?string},
) => void,
+ // enableDefaultTransitionIndicator only
onDefaultTransitionIndicator: () => void | (() => void),
+ pendingIndicator: null | (() => void),
formState: ReactFormState | null,
diff --git a/packages/react-reconciler/src/__tests__/ReactDefaultTransitionIndicator-test.js b/packages/react-reconciler/src/__tests__/ReactDefaultTransitionIndicator-test.js
new file mode 100644
index 0000000000..fb698e821a
--- /dev/null
+++ b/packages/react-reconciler/src/__tests__/ReactDefaultTransitionIndicator-test.js
@@ -0,0 +1,480 @@
+/**
+ * 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
+ * @jest-environment node
+ */
+
+'use strict';
+
+let React;
+let ReactNoop;
+let Scheduler;
+let act;
+let use;
+let useOptimistic;
+let useState;
+let useTransition;
+let useDeferredValue;
+let assertLog;
+let waitForPaint;
+
+describe('ReactDefaultTransitionIndicator', () => {
+ beforeEach(() => {
+ jest.resetModules();
+
+ React = require('react');
+ ReactNoop = require('react-noop-renderer');
+ Scheduler = require('scheduler');
+ const InternalTestUtils = require('internal-test-utils');
+ act = InternalTestUtils.act;
+ assertLog = InternalTestUtils.assertLog;
+ waitForPaint = InternalTestUtils.waitForPaint;
+ use = React.use;
+ useOptimistic = React.useOptimistic;
+ useState = React.useState;
+ useTransition = React.useTransition;
+ useDeferredValue = React.useDeferredValue;
+ });
+
+ // @gate enableDefaultTransitionIndicator
+ it('triggers the default indicator while a transition is on-going', async () => {
+ let resolve;
+ const promise = new Promise(r => (resolve = r));
+ function App() {
+ return use(promise);
+ }
+
+ const root = ReactNoop.createRoot({
+ onDefaultTransitionIndicator() {
+ Scheduler.log('start');
+ return () => {
+ Scheduler.log('stop');
+ };
+ },
+ });
+ await act(() => {
+ React.startTransition(() => {
+ root.render( );
+ });
+ });
+
+ assertLog(['start']);
+
+ await act(async () => {
+ await resolve('Hello');
+ });
+
+ assertLog(['stop']);
+
+ expect(root).toMatchRenderedOutput('Hello');
+ });
+
+ // @gate enableDefaultTransitionIndicator
+ it('does not trigger the default indicator if there is a sync mutation', async () => {
+ const promiseA = Promise.resolve('Hi');
+ let resolveB;
+ const promiseB = new Promise(r => (resolveB = r));
+ let update;
+ function App({children}) {
+ const [state, setState] = useState('');
+ update = setState;
+ return (
+
+ {state}
+ {children}
+
+ );
+ }
+
+ const root = ReactNoop.createRoot({
+ onDefaultTransitionIndicator() {
+ Scheduler.log('start');
+ return () => {
+ Scheduler.log('stop');
+ };
+ },
+ });
+ await act(() => {
+ React.startTransition(() => {
+ root.render({promiseA} );
+ });
+ });
+
+ assertLog(['start', 'stop']);
+
+ expect(root).toMatchRenderedOutput(Hi
);
+
+ await act(() => {
+ update('Loading...');
+ React.startTransition(() => {
+ update('');
+ root.render({promiseB} );
+ });
+ });
+
+ assertLog([]);
+
+ expect(root).toMatchRenderedOutput(Loading...Hi
);
+
+ await act(async () => {
+ await resolveB('Hello');
+ });
+
+ assertLog([]);
+
+ expect(root).toMatchRenderedOutput(Hello
);
+ });
+
+ // @gate enableDefaultTransitionIndicator
+ it('does not trigger the default indicator if there is an optimistic update', async () => {
+ const promiseA = Promise.resolve('Hi');
+ let resolveB;
+ const promiseB = new Promise(r => (resolveB = r));
+ let update;
+ function App({children}) {
+ const [state, setOptimistic] = useOptimistic('');
+ update = setOptimistic;
+ return (
+
+ {state}
+ {children}
+
+ );
+ }
+
+ const root = ReactNoop.createRoot({
+ onDefaultTransitionIndicator() {
+ Scheduler.log('start');
+ return () => {
+ Scheduler.log('stop');
+ };
+ },
+ });
+ await act(() => {
+ React.startTransition(() => {
+ root.render({promiseA} );
+ });
+ });
+
+ assertLog(['start', 'stop']);
+
+ expect(root).toMatchRenderedOutput(Hi
);
+
+ await act(() => {
+ React.startTransition(() => {
+ update('Loading...');
+ root.render({promiseB} );
+ });
+ });
+
+ assertLog([]);
+
+ expect(root).toMatchRenderedOutput(Loading...Hi
);
+
+ await act(async () => {
+ await resolveB('Hello');
+ });
+
+ assertLog([]);
+
+ expect(root).toMatchRenderedOutput(Hello
);
+ });
+
+ // @gate enableDefaultTransitionIndicator
+ it('does not trigger the default indicator if there is an isPending update', async () => {
+ const promiseA = Promise.resolve('Hi');
+ let resolveB;
+ const promiseB = new Promise(r => (resolveB = r));
+ let start;
+ function App({children}) {
+ const [isPending, startTransition] = useTransition();
+ start = startTransition;
+ return (
+
+ {isPending ? 'Loading...' : ''}
+ {children}
+
+ );
+ }
+
+ const root = ReactNoop.createRoot({
+ onDefaultTransitionIndicator() {
+ Scheduler.log('start');
+ return () => {
+ Scheduler.log('stop');
+ };
+ },
+ });
+ await act(() => {
+ React.startTransition(() => {
+ root.render({promiseA} );
+ });
+ });
+
+ assertLog(['start', 'stop']);
+
+ expect(root).toMatchRenderedOutput(Hi
);
+
+ await act(() => {
+ start(() => {
+ root.render({promiseB} );
+ });
+ });
+
+ assertLog([]);
+
+ expect(root).toMatchRenderedOutput(Loading...Hi
);
+
+ await act(async () => {
+ await resolveB('Hello');
+ });
+
+ assertLog([]);
+
+ expect(root).toMatchRenderedOutput(Hello
);
+ });
+
+ // @gate enableDefaultTransitionIndicator
+ it('triggers the default indicator while an async transition is ongoing', async () => {
+ let resolve;
+ const promise = new Promise(r => (resolve = r));
+ let start;
+ function App() {
+ const [, startTransition] = useTransition();
+ start = startTransition;
+ return 'Hi';
+ }
+
+ const root = ReactNoop.createRoot({
+ onDefaultTransitionIndicator() {
+ Scheduler.log('start');
+ return () => {
+ Scheduler.log('stop');
+ };
+ },
+ });
+ await act(() => {
+ root.render( );
+ });
+
+ assertLog([]);
+
+ await act(() => {
+ // Start an async action but we haven't called setState yet
+ start(() => promise);
+ });
+
+ assertLog(['start']);
+
+ await act(async () => {
+ await resolve('Hello');
+ });
+
+ assertLog(['stop']);
+
+ expect(root).toMatchRenderedOutput('Hi');
+ });
+
+ // @gate enableDefaultTransitionIndicator
+ it('triggers the default indicator while an async transition is ongoing (isomorphic)', async () => {
+ let resolve;
+ const promise = new Promise(r => (resolve = r));
+ function App() {
+ return 'Hi';
+ }
+
+ const root = ReactNoop.createRoot({
+ onDefaultTransitionIndicator() {
+ Scheduler.log('start');
+ return () => {
+ Scheduler.log('stop');
+ };
+ },
+ });
+ await act(() => {
+ root.render( );
+ });
+
+ assertLog([]);
+
+ await act(() => {
+ // Start an async action but we haven't called setState yet
+ React.startTransition(() => promise);
+ });
+
+ assertLog(['start']);
+
+ await act(async () => {
+ await resolve('Hello');
+ });
+
+ assertLog(['stop']);
+
+ expect(root).toMatchRenderedOutput('Hi');
+ });
+
+ it('does not triggers isomorphic async action default indicator if there are two different ones', async () => {
+ let resolve;
+ const promise = new Promise(r => (resolve = r));
+ function App() {
+ return 'Hi';
+ }
+
+ const root = ReactNoop.createRoot({
+ onDefaultTransitionIndicator() {
+ Scheduler.log('start');
+ return () => {
+ Scheduler.log('stop');
+ };
+ },
+ });
+ // Initialize second root. This is now ambiguous which indicator to use.
+ ReactNoop.createRoot({
+ onDefaultTransitionIndicator() {
+ Scheduler.log('start2');
+ return () => {
+ Scheduler.log('stop2');
+ };
+ },
+ });
+ await act(() => {
+ root.render( );
+ });
+
+ assertLog([]);
+
+ await act(() => {
+ // Start an async action but we haven't called setState yet
+ React.startTransition(() => promise);
+ });
+
+ assertLog([]);
+
+ await act(async () => {
+ await resolve('Hello');
+ });
+
+ assertLog([]);
+
+ expect(root).toMatchRenderedOutput('Hi');
+ });
+
+ it('does not triggers isomorphic async action default indicator if there is a loading state', async () => {
+ let resolve;
+ const promise = new Promise(r => (resolve = r));
+ let update;
+ function App() {
+ const [state, setState] = useState(false);
+ update = setState;
+ return state ? 'Loading' : 'Hi';
+ }
+
+ const root = ReactNoop.createRoot({
+ onDefaultTransitionIndicator() {
+ Scheduler.log('start');
+ return () => {
+ Scheduler.log('stop');
+ };
+ },
+ });
+ await act(() => {
+ root.render( );
+ });
+
+ assertLog([]);
+
+ await act(() => {
+ update(true);
+ React.startTransition(() => promise.then(() => update(false)));
+ });
+
+ assertLog([]);
+
+ expect(root).toMatchRenderedOutput('Loading');
+
+ await act(async () => {
+ await resolve('Hello');
+ });
+
+ assertLog([]);
+
+ expect(root).toMatchRenderedOutput('Hi');
+ });
+
+ it('should not trigger for useDeferredValue (sync)', async () => {
+ function Text({text}) {
+ Scheduler.log(text);
+ return text;
+ }
+ function App({value}) {
+ const deferredValue = useDeferredValue(value, 'Hi');
+ return ;
+ }
+
+ const root = ReactNoop.createRoot({
+ onDefaultTransitionIndicator() {
+ Scheduler.log('start');
+ return () => {
+ Scheduler.log('stop');
+ };
+ },
+ });
+ await act(async () => {
+ root.render( );
+ await waitForPaint(['Hi']);
+ expect(root).toMatchRenderedOutput('Hi');
+ });
+
+ assertLog(['Hello']);
+
+ expect(root).toMatchRenderedOutput('Hello');
+
+ assertLog([]);
+
+ await act(async () => {
+ root.render( );
+ await waitForPaint(['Hello']);
+ expect(root).toMatchRenderedOutput('Hello');
+ });
+
+ assertLog(['Bye']);
+
+ expect(root).toMatchRenderedOutput('Bye');
+ });
+
+ // @gate enableDefaultTransitionIndicator
+ it('should not trigger for useDeferredValue (transition)', async () => {
+ function Text({text}) {
+ Scheduler.log(text);
+ return text;
+ }
+ function App({value}) {
+ const deferredValue = useDeferredValue(value, 'Hi');
+ return ;
+ }
+
+ const root = ReactNoop.createRoot({
+ onDefaultTransitionIndicator() {
+ Scheduler.log('start');
+ return () => {
+ Scheduler.log('stop');
+ };
+ },
+ });
+ await act(async () => {
+ React.startTransition(() => {
+ root.render( );
+ });
+ await waitForPaint(['start', 'Hi', 'stop']);
+ expect(root).toMatchRenderedOutput('Hi');
+ });
+
+ assertLog(['Hello']);
+
+ expect(root).toMatchRenderedOutput('Hello');
+ });
+});
diff --git a/packages/react-server-dom-webpack/src/ReactFlightWebpackPlugin.js b/packages/react-server-dom-webpack/src/ReactFlightWebpackPlugin.js
index 816b9f2a73..59723c903f 100644
--- a/packages/react-server-dom-webpack/src/ReactFlightWebpackPlugin.js
+++ b/packages/react-server-dom-webpack/src/ReactFlightWebpackPlugin.js
@@ -277,8 +277,14 @@ export default class ReactFlightWebpackPlugin {
chunkGroup.chunks.forEach(function (c) {
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
for (const file of c.files) {
- if (!file.endsWith('.js')) return;
- if (file.endsWith('.hot-update.js')) return;
+ if (!(file.endsWith('.js') || file.endsWith('.mjs'))) {
+ return;
+ }
+ if (
+ file.endsWith('.hot-update.js') ||
+ file.endsWith('.hot-update.mjs')
+ )
+ return;
chunks.push(c.id, file);
break;
}
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 80562624eb..05a6a227c2 100644
--- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
+++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
@@ -1921,14 +1921,28 @@ describe('ReactFlightDOM', () => {
expect(content1).toEqual(
' ' +
' ' +
- ' ' +
- 'hello world
',
+ (gate(flags => flags.enableFizzBlockingRender)
+ ? ' '
+ : '') +
+ '' +
+ 'hello world
' +
+ (gate(flags => flags.enableFizzBlockingRender)
+ ? ' '
+ : '') +
+ '',
);
expect(content2).toEqual(
' ' +
' ' +
- ' ' +
- 'hello world
',
+ (gate(flags => flags.enableFizzBlockingRender)
+ ? ' '
+ : '') +
+ '' +
+ 'hello world
' +
+ (gate(flags => flags.enableFizzBlockingRender)
+ ? ' '
+ : '') +
+ '',
);
});
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 4313c379b7..9d668ea3c3 100644
--- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
@@ -1899,8 +1899,16 @@ describe('ReactFlightDOMBrowser', () => {
}
expect(content).toEqual(
- ' ' +
- 'hello world
',
+ '' +
+ (gate(flags => flags.enableFizzBlockingRender)
+ ? ' '
+ : '') +
+ '' +
+ 'hello world
' +
+ (gate(flags => flags.enableFizzBlockingRender)
+ ? ' '
+ : '') +
+ '',
);
});
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index f86dc8c9ce..b8f5c1be75 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -24,6 +24,8 @@ import type {
ViewTransitionProps,
ActivityProps,
SuspenseProps,
+ SuspenseListProps,
+ SuspenseListRevealOrder,
} from 'shared/ReactTypes';
import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
import type {
@@ -74,6 +76,9 @@ import {
pushEndInstance,
pushSegmentFinale,
getChildFormatContext,
+ getSuspenseFallbackFormatContext,
+ getSuspenseContentFormatContext,
+ getViewTransitionFormatContext,
writeHoistables,
writePreambleStart,
writePreambleEnd,
@@ -138,6 +143,10 @@ import {
callComponentInDEV,
callRenderInDEV,
} from './ReactFizzCallUserSpace';
+import {
+ getViewTransitionClassName,
+ getViewTransitionName,
+} from './ReactFizzViewTransitionComponent';
import {resetOwnerStackLimit} from 'shared/ReactOwnerStackReset';
import {
@@ -224,6 +233,12 @@ type LegacyContext = {
[key: string]: any,
};
+type SuspenseListRow = {
+ pendingTasks: number, // The number of tasks, previous rows and inner suspense boundaries blocking this row.
+ boundaries: null | Array, // The boundaries in this row waiting to be unblocked by the previous row. (null means this row is not blocked)
+ next: null | SuspenseListRow, // The next row blocked by this one.
+};
+
const CLIENT_RENDERED = 4; // if it errors or infinitely suspends
type SuspenseBoundary = {
@@ -231,6 +246,7 @@ type SuspenseBoundary = {
rootSegmentID: number,
parentFlushed: boolean,
pendingTasks: number, // when it reaches zero we can show this boundary's content
+ row: null | SuspenseListRow, // the row that this boundary blocks from completing.
completedSegments: Array, // completed but not yet flushed segments.
byteSize: number, // used to determine whether to inline children boundaries.
fallbackAbortableTasks: Set, // used to cancel task on the fallback if the boundary completes or gets canceled.
@@ -261,12 +277,12 @@ type RenderTask = {
formatContext: FormatContext, // the format's specific context (e.g. HTML/SVG/MathML)
context: ContextSnapshot, // the current new context that this task is executing in
treeContext: TreeContext, // the current tree context that this task is executing in
+ row: null | SuspenseListRow, // the current SuspenseList row that this is rendering inside
componentStack: null | ComponentStackNode, // stack frame description of the currently rendering component
thenableState: null | ThenableState,
- isFallback: boolean, // whether this task is rendering inside a fallback tree
legacyContext: LegacyContext, // the current legacy context that this task is executing in
debugTask: null | ConsoleTask, // DEV only
- // DON'T ANY MORE FIELDS. We at 16 already which otherwise requires converting to a constructor.
+ // DON'T ANY MORE FIELDS. We at 16 in prod already which otherwise requires converting to a constructor.
// Consider splitting into multiple objects or consolidating some fields.
};
@@ -292,13 +308,11 @@ type ReplayTask = {
formatContext: FormatContext, // the format's specific context (e.g. HTML/SVG/MathML)
context: ContextSnapshot, // the current new context that this task is executing in
treeContext: TreeContext, // the current tree context that this task is executing in
+ row: null | SuspenseListRow, // the current SuspenseList row that this is rendering inside
componentStack: null | ComponentStackNode, // stack frame description of the currently rendering component
thenableState: null | ThenableState,
- isFallback: boolean, // whether this task is rendering inside a fallback tree
legacyContext: LegacyContext, // the current legacy context that this task is executing in
debugTask: null | ConsoleTask, // DEV only
- // DON'T ANY MORE FIELDS. We at 16 already which otherwise requires converting to a constructor.
- // Consider splitting into multiple objects or consolidating some fields.
};
export type Task = RenderTask | ReplayTask;
@@ -537,7 +551,7 @@ export function createRequest(
rootContextSnapshot,
emptyTreeContext,
null,
- false,
+ null,
emptyContextObject,
null,
);
@@ -643,7 +657,7 @@ export function resumeRequest(
rootContextSnapshot,
emptyTreeContext,
null,
- false,
+ null,
emptyContextObject,
null,
);
@@ -671,7 +685,7 @@ export function resumeRequest(
rootContextSnapshot,
emptyTreeContext,
null,
- false,
+ null,
emptyContextObject,
null,
);
@@ -737,6 +751,7 @@ function pingTask(request: Request, task: Task): void {
function createSuspenseBoundary(
request: Request,
+ row: null | SuspenseListRow,
fallbackAbortableTasks: Set,
contentPreamble: null | Preamble,
fallbackPreamble: null | Preamble,
@@ -746,6 +761,7 @@ function createSuspenseBoundary(
rootSegmentID: -1,
parentFlushed: false,
pendingTasks: 0,
+ row: row,
completedSegments: [],
byteSize: 0,
fallbackAbortableTasks,
@@ -763,6 +779,17 @@ function createSuspenseBoundary(
boundary.errorStack = null;
boundary.errorComponentStack = null;
}
+ if (row !== null) {
+ // This boundary will block this row from completing.
+ row.pendingTasks++;
+ const blockedBoundaries = row.boundaries;
+ if (blockedBoundaries !== null) {
+ // Previous rows will block this boundary itself from completing.
+ request.allPendingTasks++;
+ boundary.pendingTasks++;
+ blockedBoundaries.push(boundary);
+ }
+ }
return boundary;
}
@@ -780,8 +807,8 @@ function createRenderTask(
formatContext: FormatContext,
context: ContextSnapshot,
treeContext: TreeContext,
+ row: null | SuspenseListRow,
componentStack: null | ComponentStackNode,
- isFallback: boolean,
legacyContext: LegacyContext,
debugTask: null | ConsoleTask,
): RenderTask {
@@ -791,6 +818,9 @@ function createRenderTask(
} else {
blockedBoundary.pendingTasks++;
}
+ if (row !== null) {
+ row.pendingTasks++;
+ }
const task: RenderTask = ({
replay: null,
node,
@@ -805,9 +835,9 @@ function createRenderTask(
formatContext,
context,
treeContext,
+ row,
componentStack,
thenableState,
- isFallback,
}: any);
if (!disableLegacyContext) {
task.legacyContext = legacyContext;
@@ -832,8 +862,8 @@ function createReplayTask(
formatContext: FormatContext,
context: ContextSnapshot,
treeContext: TreeContext,
+ row: null | SuspenseListRow,
componentStack: null | ComponentStackNode,
- isFallback: boolean,
legacyContext: LegacyContext,
debugTask: null | ConsoleTask,
): ReplayTask {
@@ -843,6 +873,9 @@ function createReplayTask(
} else {
blockedBoundary.pendingTasks++;
}
+ if (row !== null) {
+ row.pendingTasks++;
+ }
replay.pendingTasks++;
const task: ReplayTask = ({
replay,
@@ -858,9 +891,9 @@ function createReplayTask(
formatContext,
context,
treeContext,
+ row,
componentStack,
thenableState,
- isFallback,
}: any);
if (!disableLegacyContext) {
task.legacyContext = legacyContext;
@@ -1146,12 +1179,21 @@ function renderSuspenseBoundary(
// an already completed Suspense boundary. It's too late to do anything about it
// so we can just render through it.
const prevKeyPath = someTask.keyPath;
+ const prevContext = someTask.formatContext;
+ const prevRow = someTask.row;
someTask.keyPath = keyPath;
+ someTask.formatContext = getSuspenseContentFormatContext(
+ request.resumableState,
+ prevContext,
+ );
+ someTask.row = null;
const content: ReactNodeList = props.children;
try {
renderNode(request, someTask, content, -1);
} finally {
someTask.keyPath = prevKeyPath;
+ someTask.formatContext = prevContext;
+ someTask.row = prevRow;
}
return;
}
@@ -1159,6 +1201,8 @@ function renderSuspenseBoundary(
const task: RenderTask = someTask;
const prevKeyPath = task.keyPath;
+ const prevContext = task.formatContext;
+ const prevRow = task.row;
const parentBoundary = task.blockedBoundary;
const parentPreamble = task.blockedPreamble;
const parentHoistableState = task.hoistableState;
@@ -1176,12 +1220,19 @@ function renderSuspenseBoundary(
if (canHavePreamble(task.formatContext)) {
newBoundary = createSuspenseBoundary(
request,
+ task.row,
fallbackAbortSet,
createPreambleState(),
createPreambleState(),
);
} else {
- newBoundary = createSuspenseBoundary(request, fallbackAbortSet, null, null);
+ newBoundary = createSuspenseBoundary(
+ request,
+ task.row,
+ fallbackAbortSet,
+ null,
+ null,
+ );
}
if (request.trackedPostpones !== null) {
newBoundary.trackedContentKeyPath = keyPath;
@@ -1237,6 +1288,10 @@ function renderSuspenseBoundary(
task.blockedSegment = boundarySegment;
task.blockedPreamble = newBoundary.fallbackPreamble;
task.keyPath = fallbackKeyPath;
+ task.formatContext = getSuspenseFallbackFormatContext(
+ request.resumableState,
+ prevContext,
+ );
boundarySegment.status = RENDERING;
try {
renderNode(request, task, fallback, -1);
@@ -1259,6 +1314,7 @@ function renderSuspenseBoundary(
task.blockedSegment = parentSegment;
task.blockedPreamble = parentPreamble;
task.keyPath = prevKeyPath;
+ task.formatContext = prevContext;
}
// We create a suspended task for the primary content because we want to allow
@@ -1274,11 +1330,14 @@ function renderSuspenseBoundary(
newBoundary.contentState,
task.abortSet,
keyPath,
- task.formatContext,
+ getSuspenseContentFormatContext(
+ request.resumableState,
+ task.formatContext,
+ ),
task.context,
task.treeContext,
+ null, // The row gets reset inside the Suspense boundary.
task.componentStack,
- task.isFallback,
!disableLegacyContext ? task.legacyContext : emptyContextObject,
__DEV__ ? task.debugTask : null,
);
@@ -1302,6 +1361,11 @@ function renderSuspenseBoundary(
task.hoistableState = newBoundary.contentState;
task.blockedSegment = contentRootSegment;
task.keyPath = keyPath;
+ task.formatContext = getSuspenseContentFormatContext(
+ request.resumableState,
+ prevContext,
+ );
+ task.row = null;
contentRootSegment.status = RENDERING;
try {
@@ -1323,6 +1387,14 @@ function renderSuspenseBoundary(
// the fallback. However, if this boundary ended up big enough to be eligible for outlining
// we can't do that because we might still need the fallback if we outline it.
if (!isEligibleForOutlining(request, newBoundary)) {
+ if (prevRow !== null) {
+ // If we have synchronously completed the boundary and it's not eligible for outlining
+ // then we don't have to wait for it to be flushed before we unblock future rows.
+ // This lets us inline small rows in order.
+ if (--prevRow.pendingTasks === 0) {
+ finishSuspenseListRow(request, prevRow);
+ }
+ }
if (request.pendingRootTasks === 0 && task.blockedPreamble) {
// The root is complete and this boundary may contribute part of the preamble.
// We eagerly attempt to prepare the preamble here because we expect most requests
@@ -1388,6 +1460,8 @@ function renderSuspenseBoundary(
task.hoistableState = parentHoistableState;
task.blockedSegment = parentSegment;
task.keyPath = prevKeyPath;
+ task.formatContext = prevContext;
+ task.row = prevRow;
}
const fallbackKeyPath = [keyPath[0], 'Suspense Fallback', keyPath[2]];
@@ -1404,11 +1478,14 @@ function renderSuspenseBoundary(
newBoundary.fallbackState,
fallbackAbortSet,
fallbackKeyPath,
- task.formatContext,
+ getSuspenseFallbackFormatContext(
+ request.resumableState,
+ task.formatContext,
+ ),
task.context,
task.treeContext,
+ task.row,
task.componentStack,
- true,
!disableLegacyContext ? task.legacyContext : emptyContextObject,
__DEV__ ? task.debugTask : null,
);
@@ -1431,6 +1508,8 @@ function replaySuspenseBoundary(
fallbackSlots: ResumeSlots,
): void {
const prevKeyPath = task.keyPath;
+ const prevContext = task.formatContext;
+ const prevRow = task.row;
const previousReplaySet: ReplaySet = task.replay;
const parentBoundary = task.blockedBoundary;
@@ -1444,6 +1523,7 @@ function replaySuspenseBoundary(
if (canHavePreamble(task.formatContext)) {
resumedBoundary = createSuspenseBoundary(
request,
+ task.row,
fallbackAbortSet,
createPreambleState(),
createPreambleState(),
@@ -1451,6 +1531,7 @@ function replaySuspenseBoundary(
} else {
resumedBoundary = createSuspenseBoundary(
request,
+ task.row,
fallbackAbortSet,
null,
null,
@@ -1466,6 +1547,11 @@ function replaySuspenseBoundary(
task.blockedBoundary = resumedBoundary;
task.hoistableState = resumedBoundary.contentState;
task.keyPath = keyPath;
+ task.formatContext = getSuspenseContentFormatContext(
+ request.resumableState,
+ prevContext,
+ );
+ task.row = null;
task.replay = {nodes: childNodes, slots: childSlots, pendingTasks: 1};
try {
@@ -1541,6 +1627,8 @@ function replaySuspenseBoundary(
task.hoistableState = parentHoistableState;
task.replay = previousReplaySet;
task.keyPath = prevKeyPath;
+ task.formatContext = prevContext;
+ task.row = prevRow;
}
const fallbackKeyPath = [keyPath[0], 'Suspense Fallback', keyPath[2]];
@@ -1562,11 +1650,14 @@ function replaySuspenseBoundary(
resumedBoundary.fallbackState,
fallbackAbortSet,
fallbackKeyPath,
- task.formatContext,
+ getSuspenseFallbackFormatContext(
+ request.resumableState,
+ task.formatContext,
+ ),
task.context,
task.treeContext,
+ task.row,
task.componentStack,
- true,
!disableLegacyContext ? task.legacyContext : emptyContextObject,
__DEV__ ? task.debugTask : null,
);
@@ -1577,6 +1668,317 @@ function replaySuspenseBoundary(
request.pingedTasks.push(suspendedFallbackTask);
}
+function finishSuspenseListRow(request: Request, row: SuspenseListRow): void {
+ // This row finished. Now we have to unblock all the next rows that were blocked on this.
+ // We do this in a loop to avoid stack overflow for very long lists that get unblocked.
+ let unblockedRow = row.next;
+ while (unblockedRow !== null) {
+ // Unblocking the boundaries will decrement the count of this row but we keep it above
+ // zero so they never finish this row recursively.
+ const unblockedBoundaries = unblockedRow.boundaries;
+ if (unblockedBoundaries !== null) {
+ unblockedRow.boundaries = null;
+ for (let i = 0; i < unblockedBoundaries.length; i++) {
+ finishedTask(request, unblockedBoundaries[i], null, null);
+ }
+ }
+ // Instead we decrement at the end to keep it all in this loop.
+ unblockedRow.pendingTasks--;
+ if (unblockedRow.pendingTasks > 0) {
+ // Still blocked.
+ break;
+ }
+ unblockedRow = unblockedRow.next;
+ }
+}
+
+function createSuspenseListRow(
+ previousRow: null | SuspenseListRow,
+): SuspenseListRow {
+ const newRow: SuspenseListRow = {
+ pendingTasks: 1, // At first the row is blocked on attempting rendering itself.
+ boundaries: null,
+ next: null,
+ };
+ if (previousRow !== null && previousRow.pendingTasks > 0) {
+ // If the previous row is not done yet, we add ourselves to be blocked on it.
+ // When it finishes, we'll decrement our pending tasks.
+ newRow.pendingTasks++;
+ newRow.boundaries = [];
+ previousRow.next = newRow;
+ }
+ return newRow;
+}
+
+function renderSuspenseListRows(
+ request: Request,
+ task: Task,
+ keyPath: KeyNode,
+ rows: Array,
+ revealOrder: 'forwards' | 'backwards',
+): void {
+ // This is a fork of renderChildrenArray that's aware of tracking rows.
+ const prevKeyPath = task.keyPath;
+ const previousComponentStack = task.componentStack;
+ let previousDebugTask = null;
+ if (__DEV__) {
+ previousDebugTask = task.debugTask;
+ // We read debugInfo from task.node.props.children instead of rows because it
+ // might have been an unwrapped iterable so we read from the original node.
+ pushServerComponentStack(task, (task.node: any).props.children._debugInfo);
+ }
+
+ const prevTreeContext = task.treeContext;
+ const prevRow = task.row;
+ const totalChildren = rows.length;
+
+ if (task.replay !== null) {
+ // Replay
+ // First we need to check if we have any resume slots at this level.
+ const resumeSlots = task.replay.slots;
+ if (resumeSlots !== null && typeof resumeSlots === 'object') {
+ let previousSuspenseListRow: null | SuspenseListRow = null;
+ for (let n = 0; n < totalChildren; n++) {
+ // Since we are going to resume into a slot whose order was already
+ // determined by the prerender, we can safely resume it even in reverse
+ // render order.
+ const i = revealOrder !== 'backwards' ? n : totalChildren - 1 - n;
+ const node = rows[i];
+ task.row = previousSuspenseListRow = createSuspenseListRow(
+ previousSuspenseListRow,
+ );
+ task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i);
+ const resumeSegmentID = resumeSlots[i];
+ // TODO: If this errors we should still continue with the next sibling.
+ if (typeof resumeSegmentID === 'number') {
+ resumeNode(request, task, resumeSegmentID, node, i);
+ // We finished rendering this node, so now we can consume this
+ // slot. This must happen after in case we rerender this task.
+ delete resumeSlots[i];
+ } else {
+ renderNode(request, task, node, i);
+ }
+ if (--previousSuspenseListRow.pendingTasks === 0) {
+ finishSuspenseListRow(request, previousSuspenseListRow);
+ }
+ }
+ } else {
+ let previousSuspenseListRow: null | SuspenseListRow = null;
+ for (let n = 0; n < totalChildren; n++) {
+ // Since we are going to resume into a slot whose order was already
+ // determined by the prerender, we can safely resume it even in reverse
+ // render order.
+ const i = revealOrder !== 'backwards' ? n : totalChildren - 1 - n;
+ const node = rows[i];
+ if (__DEV__) {
+ warnForMissingKey(request, task, node);
+ }
+ task.row = previousSuspenseListRow = createSuspenseListRow(
+ previousSuspenseListRow,
+ );
+ task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i);
+ renderNode(request, task, node, i);
+ if (--previousSuspenseListRow.pendingTasks === 0) {
+ finishSuspenseListRow(request, previousSuspenseListRow);
+ }
+ }
+ }
+ } else {
+ task = ((task: any): RenderTask); // Refined
+ if (revealOrder !== 'backwards') {
+ // Forwards direction
+ let previousSuspenseListRow: null | SuspenseListRow = null;
+ for (let i = 0; i < totalChildren; i++) {
+ const node = rows[i];
+ if (__DEV__) {
+ warnForMissingKey(request, task, node);
+ }
+ task.row = previousSuspenseListRow = createSuspenseListRow(
+ previousSuspenseListRow,
+ );
+ task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i);
+ renderNode(request, task, node, i);
+ if (--previousSuspenseListRow.pendingTasks === 0) {
+ finishSuspenseListRow(request, previousSuspenseListRow);
+ }
+ }
+ } else {
+ // For backwards direction we need to do things a bit differently.
+ // We give each row its own segment so that we can render the content in
+ // reverse order but still emit it in the right order when we flush.
+ const parentSegment = task.blockedSegment;
+ const childIndex = parentSegment.children.length;
+ const insertionIndex = parentSegment.chunks.length;
+ let previousSuspenseListRow: null | SuspenseListRow = null;
+ for (let i = totalChildren - 1; i >= 0; i--) {
+ const node = rows[i];
+ task.row = previousSuspenseListRow = createSuspenseListRow(
+ previousSuspenseListRow,
+ );
+ task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i);
+ const newSegment = createPendingSegment(
+ request,
+ insertionIndex,
+ null,
+ task.formatContext,
+ // Assume we are text embedded at the trailing edges
+ i === 0 ? parentSegment.lastPushedText : true,
+ true,
+ );
+ // Insert in the beginning of the sequence, which will insert before any previous rows.
+ parentSegment.children.splice(childIndex, 0, newSegment);
+ task.blockedSegment = newSegment;
+ if (__DEV__) {
+ warnForMissingKey(request, task, node);
+ }
+ try {
+ renderNode(request, task, node, i);
+ pushSegmentFinale(
+ newSegment.chunks,
+ request.renderState,
+ newSegment.lastPushedText,
+ newSegment.textEmbedded,
+ );
+ newSegment.status = COMPLETED;
+ finishedSegment(request, task.blockedBoundary, newSegment);
+ if (--previousSuspenseListRow.pendingTasks === 0) {
+ finishSuspenseListRow(request, previousSuspenseListRow);
+ }
+ } catch (thrownValue: mixed) {
+ if (request.status === ABORTING) {
+ newSegment.status = ABORTED;
+ } else {
+ newSegment.status = ERRORED;
+ }
+ throw thrownValue;
+ }
+ }
+ task.blockedSegment = parentSegment;
+ // Reset lastPushedText for current Segment since the new Segments "consumed" it
+ parentSegment.lastPushedText = false;
+ }
+ }
+
+ // Because this context is always set right before rendering every child, we
+ // only need to reset it to the previous value at the very end.
+ task.treeContext = prevTreeContext;
+ task.row = prevRow;
+ task.keyPath = prevKeyPath;
+ if (__DEV__) {
+ task.componentStack = previousComponentStack;
+ task.debugTask = previousDebugTask;
+ }
+}
+
+function renderSuspenseList(
+ request: Request,
+ task: Task,
+ keyPath: KeyNode,
+ props: SuspenseListProps,
+): void {
+ const children: any = props.children;
+ const revealOrder: SuspenseListRevealOrder = props.revealOrder;
+ // TODO: Support tail hidden/collapsed modes.
+ // const tailMode: SuspenseListTailMode = props.tail;
+ if (revealOrder === 'forwards' || revealOrder === 'backwards') {
+ // For ordered reveal, we need to produce rows from the children.
+ if (isArray(children)) {
+ renderSuspenseListRows(request, task, keyPath, children, revealOrder);
+ return;
+ }
+ const iteratorFn = getIteratorFn(children);
+ if (iteratorFn) {
+ const iterator = iteratorFn.call(children);
+ if (iterator) {
+ if (__DEV__) {
+ validateIterable(task, children, -1, iterator, iteratorFn);
+ }
+ // TODO: We currently use the same id algorithm as regular nodes
+ // but we need a new algorithm for SuspenseList that doesn't require
+ // a full set to be loaded up front to support Async Iterable.
+ // When we have that, we shouldn't buffer anymore.
+ let step = iterator.next();
+ if (!step.done) {
+ const rows = [];
+ do {
+ rows.push(step.value);
+ step = iterator.next();
+ } while (!step.done);
+ renderSuspenseListRows(request, task, keyPath, children, revealOrder);
+ }
+ return;
+ }
+ }
+ if (
+ enableAsyncIterableChildren &&
+ typeof (children: any)[ASYNC_ITERATOR] === 'function'
+ ) {
+ const iterator: AsyncIterator = (children: any)[
+ ASYNC_ITERATOR
+ ]();
+ if (iterator) {
+ if (__DEV__) {
+ validateAsyncIterable(task, (children: any), -1, iterator);
+ }
+ // TODO: Update the task.children to be the iterator to avoid asking
+ // for new iterators, but we currently warn for rendering these
+ // so needs some refactoring to deal with the warning.
+
+ // Restore the thenable state before resuming.
+ const prevThenableState = task.thenableState;
+ task.thenableState = null;
+ prepareToUseThenableState(prevThenableState);
+
+ // We need to know how many total rows are in this set, so that we
+ // can allocate enough id slots to acommodate them. So we must exhaust
+ // the iterator before we start recursively rendering the rows.
+ // TODO: This is not great but I think it's inherent to the id
+ // generation algorithm.
+
+ const rows = [];
+
+ let done = false;
+
+ if (iterator === children) {
+ // If it's an iterator we need to continue reading where we left
+ // off. We can do that by reading the first few rows from the previous
+ // thenable state.
+ // $FlowFixMe
+ let step = readPreviousThenableFromState();
+ while (step !== undefined) {
+ if (step.done) {
+ done = true;
+ break;
+ }
+ rows.push(step.value);
+ step = readPreviousThenableFromState();
+ }
+ }
+
+ if (!done) {
+ let step = unwrapThenable(iterator.next());
+ while (!step.done) {
+ rows.push(step.value);
+ step = unwrapThenable(iterator.next());
+ }
+ }
+ renderSuspenseListRows(request, task, keyPath, rows, revealOrder);
+ return;
+ }
+ }
+ // This case will warn on the client. It's the same as independent revealOrder.
+ }
+
+ if (revealOrder === 'together') {
+ // TODO
+ }
+ // For other reveal order modes, we just render it as a fragment.
+ const prevKeyPath = task.keyPath;
+ task.keyPath = keyPath;
+ renderNodeDestructive(request, task, children, -1);
+ task.keyPath = prevKeyPath;
+}
+
function renderPreamble(
request: Request,
task: Task,
@@ -1607,8 +2009,8 @@ function renderPreamble(
task.formatContext,
task.context,
task.treeContext,
+ task.row,
task.componentStack,
- task.isFallback,
!disableLegacyContext ? task.legacyContext : emptyContextObject,
__DEV__ ? task.debugTask : null,
);
@@ -1653,7 +2055,6 @@ function renderHostElement(
task.hoistableState,
task.formatContext,
segment.lastPushedText,
- task.isFallback,
);
segment.lastPushedText = false;
const prevContext = task.formatContext;
@@ -2271,9 +2672,43 @@ function renderViewTransition(
keyPath: KeyNode,
props: ViewTransitionProps,
) {
+ const prevContext = task.formatContext;
const prevKeyPath = task.keyPath;
+ // Get the name off props or generate an auto-generated one in case we need it.
+ const autoName = getViewTransitionName(
+ props,
+ task.treeContext,
+ request.resumableState,
+ );
+ task.formatContext = getViewTransitionFormatContext(
+ request.resumableState,
+ prevContext,
+ getViewTransitionClassName(props.default, props.update),
+ getViewTransitionClassName(props.default, props.enter),
+ getViewTransitionClassName(props.default, props.exit),
+ getViewTransitionClassName(props.default, props.share),
+ props.name,
+ autoName,
+ );
task.keyPath = keyPath;
- renderNodeDestructive(request, task, props.children, -1);
+ if (props.name != null && props.name !== 'auto') {
+ renderNodeDestructive(request, task, props.children, -1);
+ } else {
+ // This will be auto-assigned a name which claims a "useId" slot.
+ // This component materialized an id. We treat this as its own level, with
+ // a single "child" slot.
+ const prevTreeContext = task.treeContext;
+ const totalChildren = 1;
+ const index = 0;
+ // Modify the id context. Because we'll need to reset this if something
+ // suspends or errors, we'll use the non-destructive render path.
+ task.treeContext = pushTreeContext(prevTreeContext, totalChildren, index);
+ renderNode(request, task, props.children, -1);
+ // Like the other contexts, this does not need to be in a finally block
+ // because renderNode takes care of unwinding the stack.
+ task.treeContext = prevTreeContext;
+ }
+ task.formatContext = prevContext;
task.keyPath = prevKeyPath;
}
@@ -2324,11 +2759,7 @@ function renderElement(
return;
}
case REACT_SUSPENSE_LIST_TYPE: {
- // TODO: SuspenseList should control the boundaries.
- const prevKeyPath = task.keyPath;
- task.keyPath = keyPath;
- renderNodeDestructive(request, task, props.children, -1);
- task.keyPath = prevKeyPath;
+ renderSuspenseList(request, task, keyPath, props);
return;
}
case REACT_VIEW_TRANSITION_TYPE: {
@@ -3478,8 +3909,8 @@ function spawnNewSuspendedReplayTask(
task.formatContext,
task.context,
task.treeContext,
+ task.row,
task.componentStack,
- task.isFallback,
!disableLegacyContext ? task.legacyContext : emptyContextObject,
__DEV__ ? task.debugTask : null,
);
@@ -3520,8 +3951,8 @@ function spawnNewSuspendedRenderTask(
task.formatContext,
task.context,
task.treeContext,
+ task.row,
task.componentStack,
- task.isFallback,
!disableLegacyContext ? task.legacyContext : emptyContextObject,
__DEV__ ? task.debugTask : null,
);
@@ -3829,10 +4260,19 @@ function erroredReplay(
function erroredTask(
request: Request,
boundary: Root | SuspenseBoundary,
+ row: null | SuspenseListRow,
error: mixed,
errorInfo: ThrownInfo,
debugTask: null | ConsoleTask,
) {
+ if (row !== null) {
+ if (--row.pendingTasks === 0) {
+ finishSuspenseListRow(request, row);
+ }
+ }
+
+ request.allPendingTasks--;
+
// Report the error to a global handler.
let errorDigest;
// We don't handle halts here because we only halt when prerendering and
@@ -3884,7 +4324,6 @@ function erroredTask(
}
}
- request.allPendingTasks--;
if (request.allPendingTasks === 0) {
completeAll(request);
}
@@ -3899,7 +4338,7 @@ function abortTaskSoft(this: Request, task: Task): void {
const segment = task.blockedSegment;
if (segment !== null) {
segment.status = ABORTED;
- finishedTask(request, boundary, segment);
+ finishedTask(request, boundary, task.row, segment);
}
}
@@ -3913,6 +4352,7 @@ function abortRemainingSuspenseBoundary(
): void {
const resumedBoundary = createSuspenseBoundary(
request,
+ null,
new Set(),
null,
null,
@@ -4012,6 +4452,13 @@ function abortTask(task: Task, request: Request, error: mixed): void {
segment.status = ABORTED;
}
+ const row = task.row;
+ if (row !== null) {
+ if (--row.pendingTasks === 0) {
+ finishSuspenseListRow(request, row);
+ }
+ }
+
const errorInfo = getThrownInfo(task.componentStack);
if (boundary === null) {
@@ -4034,7 +4481,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
// we just need to mark it as postponed.
logPostpone(request, postponeInstance.message, errorInfo, null);
trackPostpone(request, trackedPostpones, task, segment);
- finishedTask(request, null, segment);
+ finishedTask(request, null, row, segment);
} else {
const fatal = new Error(
'The render was aborted with postpone when the shell is incomplete. Reason: ' +
@@ -4053,7 +4500,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
// We log the error but we still resolve the prerender
logRecoverableError(request, error, errorInfo, null);
trackPostpone(request, trackedPostpones, task, segment);
- finishedTask(request, null, segment);
+ finishedTask(request, null, row, segment);
} else {
logRecoverableError(request, error, errorInfo, null);
fatalError(request, error, errorInfo, null);
@@ -4125,7 +4572,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
abortTask(fallbackTask, request, error),
);
boundary.fallbackAbortableTasks.clear();
- return finishedTask(request, boundary, segment);
+ return finishedTask(request, boundary, row, segment);
}
}
boundary.status = CLIENT_RENDERED;
@@ -4142,7 +4589,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
logPostpone(request, postponeInstance.message, errorInfo, null);
if (request.trackedPostpones !== null && segment !== null) {
trackPostpone(request, request.trackedPostpones, task, segment);
- finishedTask(request, task.blockedBoundary, segment);
+ finishedTask(request, task.blockedBoundary, row, segment);
// If this boundary was still pending then we haven't already cancelled its fallbacks.
// We'll need to abort the fallbacks, which will also error that parent boundary.
@@ -4298,8 +4745,15 @@ function finishedSegment(
function finishedTask(
request: Request,
boundary: Root | SuspenseBoundary,
+ row: null | SuspenseListRow,
segment: null | Segment,
) {
+ if (row !== null) {
+ if (--row.pendingTasks === 0) {
+ finishSuspenseListRow(request, row);
+ }
+ }
+ request.allPendingTasks--;
if (boundary === null) {
if (segment !== null && segment.parentFlushed) {
if (request.completedRootSegment !== null) {
@@ -4347,6 +4801,13 @@ function finishedTask(
if (!isEligibleForOutlining(request, boundary)) {
boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request);
boundary.fallbackAbortableTasks.clear();
+ const boundaryRow = boundary.row;
+ if (boundaryRow !== null) {
+ // If we aren't eligible for outlining, we don't have to wait until we flush it.
+ if (--boundaryRow.pendingTasks === 0) {
+ finishSuspenseListRow(request, boundaryRow);
+ }
+ }
}
if (
@@ -4382,7 +4843,6 @@ function finishedTask(
}
}
- request.allPendingTasks--;
if (request.allPendingTasks === 0) {
completeAll(request);
}
@@ -4446,7 +4906,7 @@ function retryRenderTask(
task.abortSet.delete(task);
segment.status = COMPLETED;
finishedSegment(request, task.blockedBoundary, segment);
- finishedTask(request, task.blockedBoundary, segment);
+ finishedTask(request, task.blockedBoundary, task.row, segment);
} catch (thrownValue: mixed) {
resetHooksState();
@@ -4499,7 +4959,7 @@ function retryRenderTask(
}
trackPostpone(request, trackedPostpones, task, segment);
- finishedTask(request, task.blockedBoundary, segment);
+ finishedTask(request, task.blockedBoundary, task.row, segment);
return;
}
@@ -4533,7 +4993,7 @@ function retryRenderTask(
__DEV__ ? task.debugTask : null,
);
trackPostpone(request, trackedPostpones, task, segment);
- finishedTask(request, task.blockedBoundary, segment);
+ finishedTask(request, task.blockedBoundary, task.row, segment);
return;
}
}
@@ -4545,6 +5005,7 @@ function retryRenderTask(
erroredTask(
request,
task.blockedBoundary,
+ task.row,
x,
errorInfo,
__DEV__ ? task.debugTask : null,
@@ -4592,7 +5053,7 @@ function retryReplayTask(request: Request, task: ReplayTask): void {
task.replay.pendingTasks--;
task.abortSet.delete(task);
- finishedTask(request, task.blockedBoundary, null);
+ finishedTask(request, task.blockedBoundary, task.row, null);
} catch (thrownValue) {
resetHooksState();
@@ -4904,6 +5365,16 @@ function flushSegment(
// Emit a client rendered suspense boundary wrapper.
// We never queue the inner boundary so we'll never emit its content or partial segments.
+ const row = boundary.row;
+ if (row !== null) {
+ // Since this boundary end up client rendered, we can unblock future suspense list rows.
+ // This means that they may appear out of order if the future rows succeed but this is
+ // a client rendered row.
+ if (--row.pendingTasks === 0) {
+ finishSuspenseListRow(request, row);
+ }
+ }
+
if (__DEV__) {
writeStartClientRenderedSuspenseBoundary(
destination,
@@ -4992,6 +5463,16 @@ function flushSegment(
if (hoistableState) {
hoistHoistables(hoistableState, boundary.contentState);
}
+
+ const row = boundary.row;
+ if (row !== null && isEligibleForOutlining(request, boundary)) {
+ // Once we have written the boundary, we can unblock the row and let future
+ // rows be written. This may schedule new completed boundaries.
+ if (--row.pendingTasks === 0) {
+ finishSuspenseListRow(request, row);
+ }
+ }
+
// We can inline this boundary's content as a complete boundary.
writeStartCompletedSuspenseBoundary(destination, request.renderState);
@@ -5070,6 +5551,15 @@ function flushCompletedBoundary(
}
completedSegments.length = 0;
+ const row = boundary.row;
+ if (row !== null && isEligibleForOutlining(request, boundary)) {
+ // Once we have written the boundary, we can unblock the row and let future
+ // rows be written. This may schedule new completed boundaries.
+ if (--row.pendingTasks === 0) {
+ finishSuspenseListRow(request, row);
+ }
+ }
+
writeHoistablesForBoundary(
destination,
boundary.contentState,
@@ -5262,6 +5752,7 @@ function flushCompletedQueues(
// Next we check the completed boundaries again. This may have had
// boundaries added to it in case they were too larged to be inlined.
+ // SuspenseListRows might have been unblocked as well.
// New ones might be added in this loop.
const largeBoundaries = request.completedBoundaries;
for (i = 0; i < largeBoundaries.length; i++) {
diff --git a/packages/react-server/src/ReactFizzViewTransitionComponent.js b/packages/react-server/src/ReactFizzViewTransitionComponent.js
new file mode 100644
index 0000000000..d2e6c0e120
--- /dev/null
+++ b/packages/react-server/src/ReactFizzViewTransitionComponent.js
@@ -0,0 +1,72 @@
+/**
+ * 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 {ViewTransitionProps, ViewTransitionClass} from 'shared/ReactTypes';
+import type {TreeContext} from './ReactFizzTreeContext';
+import type {ResumableState} from './ReactFizzConfig';
+
+import {getTreeId} from './ReactFizzTreeContext';
+import {makeId} from './ReactFizzConfig';
+
+export function getViewTransitionName(
+ props: ViewTransitionProps,
+ treeContext: TreeContext,
+ resumableState: ResumableState,
+): string {
+ if (props.name != null && props.name !== 'auto') {
+ return props.name;
+ }
+ const treeId = getTreeId(treeContext);
+ return makeId(resumableState, treeId, 0);
+}
+
+function getClassNameByType(classByType: ?ViewTransitionClass): ?string {
+ if (classByType == null || typeof classByType === 'string') {
+ return classByType;
+ }
+ let className: ?string = null;
+ const activeTypes = null; // TODO: Support passing active types.
+ if (activeTypes !== null) {
+ for (let i = 0; i < activeTypes.length; i++) {
+ const match = classByType[activeTypes[i]];
+ if (match != null) {
+ if (match === 'none') {
+ // If anything matches "none" that takes precedence over any other
+ // type that also matches.
+ return 'none';
+ }
+ if (className == null) {
+ className = match;
+ } else {
+ className += ' ' + match;
+ }
+ }
+ }
+ }
+ if (className == null) {
+ // We had no other matches. Match the default for this configuration.
+ return classByType.default;
+ }
+ return className;
+}
+
+export function getViewTransitionClassName(
+ defaultClass: ?ViewTransitionClass,
+ eventClass: ?ViewTransitionClass,
+): ?string {
+ const className: ?string = getClassNameByType(defaultClass);
+ const eventClassName: ?string = getClassNameByType(eventClass);
+ if (eventClassName == null) {
+ return className === 'auto' ? null : className;
+ }
+ if (eventClassName === 'auto') {
+ return null;
+ }
+ return eventClassName;
+}
diff --git a/packages/react-server/src/forks/ReactFizzConfig.custom.js b/packages/react-server/src/forks/ReactFizzConfig.custom.js
index ad33d1fc72..981e390ea4 100644
--- a/packages/react-server/src/forks/ReactFizzConfig.custom.js
+++ b/packages/react-server/src/forks/ReactFizzConfig.custom.js
@@ -48,6 +48,12 @@ export const bindToConsole = $$$config.bindToConsole;
export const resetResumableState = $$$config.resetResumableState;
export const completeResumableState = $$$config.completeResumableState;
export const getChildFormatContext = $$$config.getChildFormatContext;
+export const getSuspenseFallbackFormatContext =
+ $$$config.getSuspenseFallbackFormatContext;
+export const getSuspenseContentFormatContext =
+ $$$config.getSuspenseContentFormatContext;
+export const getViewTransitionFormatContext =
+ $$$config.getViewTransitionFormatContext;
export const makeId = $$$config.makeId;
export const pushTextInstance = $$$config.pushTextInstance;
export const pushStartInstance = $$$config.pushStartInstance;
diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js
index 217b095a29..216b6d668a 100644
--- a/packages/shared/ReactFeatureFlags.js
+++ b/packages/shared/ReactFeatureFlags.js
@@ -98,6 +98,8 @@ export const enableScrollEndPolyfill = __EXPERIMENTAL__;
export const enableSuspenseyImages = false;
+export const enableFizzBlockingRender = __EXPERIMENTAL__; // rel="expect"
+
export const enableSrcObject = __EXPERIMENTAL__;
export const enableHydrationChangeEvent = __EXPERIMENTAL__;
@@ -141,6 +143,8 @@ export const enablePersistedModeClonedFlag = false;
export const enableShallowPropDiffing = false;
+export const enableEagerAlternateStateNodeCleanup = true;
+
/**
* Enables an expiration time for retry lanes to avoid starvation.
*/
diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js
index 4c8ca77bb1..9bd64cd81d 100644
--- a/packages/shared/ReactTypes.js
+++ b/packages/shared/ReactTypes.js
@@ -290,6 +290,30 @@ export type SuspenseProps = {
name?: string,
};
+export type SuspenseListRevealOrder =
+ | 'forwards'
+ | 'backwards'
+ | 'together'
+ | void;
+
+export type SuspenseListTailMode = 'collapsed' | 'hidden' | void;
+
+type DirectionalSuspenseListProps = {
+ children?: ReactNodeList,
+ revealOrder: 'forwards' | 'backwards',
+ tail?: SuspenseListTailMode,
+};
+
+type NonDirectionalSuspenseListProps = {
+ children?: ReactNodeList,
+ revealOrder?: 'together' | void,
+ tail?: void,
+};
+
+export type SuspenseListProps =
+ | DirectionalSuspenseListProps
+ | NonDirectionalSuspenseListProps;
+
export type TracingMarkerProps = {
name: string,
children?: ReactNodeList,
diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js
index 29a49ae366..aa413984e8 100644
--- a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js
+++ b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js
@@ -22,6 +22,7 @@ export const enableObjectFiber = __VARIANT__;
export const enableHiddenSubtreeInsertionEffectCleanup = __VARIANT__;
export const enablePersistedModeClonedFlag = __VARIANT__;
export const enableShallowPropDiffing = __VARIANT__;
+export const enableEagerAlternateStateNodeCleanup = __VARIANT__;
export const passChildrenWhenCloningPersistedNodes = __VARIANT__;
export const enableFastAddPropertiesInDiffing = __VARIANT__;
export const enableLazyPublicInstanceInFabric = __VARIANT__;
diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js
index 8383c0dc35..4298a267a3 100644
--- a/packages/shared/forks/ReactFeatureFlags.native-fb.js
+++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js
@@ -24,6 +24,7 @@ export const {
enableObjectFiber,
enablePersistedModeClonedFlag,
enableShallowPropDiffing,
+ enableEagerAlternateStateNodeCleanup,
passChildrenWhenCloningPersistedNodes,
enableFastAddPropertiesInDiffing,
enableLazyPublicInstanceInFabric,
@@ -82,6 +83,7 @@ export const enableViewTransition = false;
export const enableGestureTransition = false;
export const enableScrollEndPolyfill = true;
export const enableSuspenseyImages = false;
+export const enableFizzBlockingRender = true;
export const enableSrcObject = false;
export const enableHydrationChangeEvent = true;
export const enableDefaultTransitionIndicator = false;
diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js
index acc04dfb05..8a7a59ebb2 100644
--- a/packages/shared/forks/ReactFeatureFlags.native-oss.js
+++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js
@@ -49,6 +49,7 @@ export const enableSchedulingProfiler = __PROFILE__;
export const enableComponentPerformanceTrack = false;
export const enableScopeAPI = false;
export const enableShallowPropDiffing = false;
+export const enableEagerAlternateStateNodeCleanup = false;
export const enableSuspenseAvoidThisFallback = false;
export const enableSuspenseCallback = false;
export const enableTaint = true;
@@ -72,6 +73,7 @@ export const enableFastAddPropertiesInDiffing = false;
export const enableLazyPublicInstanceInFabric = false;
export const enableScrollEndPolyfill = true;
export const enableSuspenseyImages = false;
+export const enableFizzBlockingRender = true;
export const enableSrcObject = false;
export const enableHydrationChangeEvent = false;
export const enableDefaultTransitionIndicator = false;
diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js
index 4e51e7260e..6de2578838 100644
--- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js
+++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js
@@ -62,6 +62,7 @@ export const enableInfiniteRenderLoopDetection = false;
export const renameElementSymbol = true;
export const enableShallowPropDiffing = false;
+export const enableEagerAlternateStateNodeCleanup = false;
export const enableYieldingBeforePassive = true;
@@ -72,6 +73,7 @@ export const enableFastAddPropertiesInDiffing = true;
export const enableLazyPublicInstanceInFabric = false;
export const enableScrollEndPolyfill = true;
export const enableSuspenseyImages = false;
+export const enableFizzBlockingRender = true;
export const enableSrcObject = false;
export const enableHydrationChangeEvent = false;
export const enableDefaultTransitionIndicator = false;
diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
index c9bd058f1f..643626d6f9 100644
--- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
+++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
@@ -47,6 +47,7 @@ export const enableSchedulingProfiler = __PROFILE__;
export const enableComponentPerformanceTrack = false;
export const enableScopeAPI = false;
export const enableShallowPropDiffing = false;
+export const enableEagerAlternateStateNodeCleanup = false;
export const enableSuspenseAvoidThisFallback = false;
export const enableSuspenseCallback = false;
export const enableTaint = true;
@@ -69,6 +70,7 @@ export const enableFastAddPropertiesInDiffing = false;
export const enableLazyPublicInstanceInFabric = false;
export const enableScrollEndPolyfill = true;
export const enableSuspenseyImages = false;
+export const enableFizzBlockingRender = true;
export const enableSrcObject = false;
export const enableHydrationChangeEvent = false;
export const enableDefaultTransitionIndicator = false;
diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
index baceb1e6b7..d22d5cadd7 100644
--- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
@@ -71,6 +71,7 @@ export const renameElementSymbol = false;
export const enableObjectFiber = false;
export const enableShallowPropDiffing = false;
+export const enableEagerAlternateStateNodeCleanup = false;
export const enableHydrationLaneScheduling = true;
@@ -83,6 +84,7 @@ export const enableFastAddPropertiesInDiffing = false;
export const enableLazyPublicInstanceInFabric = false;
export const enableScrollEndPolyfill = true;
export const enableSuspenseyImages = false;
+export const enableFizzBlockingRender = true;
export const enableSrcObject = false;
export const enableHydrationChangeEvent = false;
export const enableDefaultTransitionIndicator = false;
diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js
index 5ecd0073ed..839248e2e7 100644
--- a/packages/shared/forks/ReactFeatureFlags.www.js
+++ b/packages/shared/forks/ReactFeatureFlags.www.js
@@ -107,11 +107,14 @@ export const disableLegacyMode = true;
export const enableShallowPropDiffing = false;
+export const enableEagerAlternateStateNodeCleanup = false;
+
export const enableLazyPublicInstanceInFabric = false;
export const enableGestureTransition = false;
export const enableSuspenseyImages = false;
+export const enableFizzBlockingRender = true;
export const enableSrcObject = false;
export const enableHydrationChangeEvent = false;
export const enableDefaultTransitionIndicator = false;
diff --git a/scripts/flow/environment.js b/scripts/flow/environment.js
index c3fe40eeef..d66ef65d9d 100644
--- a/scripts/flow/environment.js
+++ b/scripts/flow/environment.js
@@ -429,3 +429,127 @@ declare const Bun: {
input: string | $TypedArray | DataView | ArrayBuffer | SharedArrayBuffer,
): number,
};
+
+// Navigation API
+
+declare const navigation: Navigation;
+
+interface NavigationResult {
+ committed: Promise;
+ finished: Promise;
+}
+
+declare class Navigation extends EventTarget {
+ entries(): NavigationHistoryEntry[];
+ +currentEntry: NavigationHistoryEntry | null;
+ updateCurrentEntry(options: NavigationUpdateCurrentEntryOptions): void;
+ +transition: NavigationTransition | null;
+
+ +canGoBack: boolean;
+ +canGoForward: boolean;
+
+ navigate(url: string, options?: NavigationNavigateOptions): NavigationResult;
+ reload(options?: NavigationReloadOptions): NavigationResult;
+
+ traverseTo(key: string, options?: NavigationOptions): NavigationResult;
+ back(options?: NavigationOptions): NavigationResult;
+ forward(options?: NavigationOptions): NavigationResult;
+
+ onnavigate: ((this: Navigation, ev: NavigateEvent) => any) | null;
+ onnavigatesuccess: ((this: Navigation, ev: Event) => any) | null;
+ onnavigateerror: ((this: Navigation, ev: ErrorEvent) => any) | null;
+ oncurrententrychange:
+ | ((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any)
+ | null;
+
+ // TODO: Implement addEventListener overrides. Doesn't seem like Flow supports this.
+}
+
+declare class NavigationTransition {
+ +navigationType: NavigationTypeString;
+ +from: NavigationHistoryEntry;
+ +finished: Promise;
+}
+
+interface NavigationHistoryEntryEventMap {
+ dispose: Event;
+}
+
+interface NavigationHistoryEntry extends EventTarget {
+ +key: string;
+ +id: string;
+ +url: string | null;
+ +index: number;
+ +sameDocument: boolean;
+
+ getState(): mixed;
+
+ ondispose: ((this: NavigationHistoryEntry, ev: Event) => any) | null;
+
+ // TODO: Implement addEventListener overrides. Doesn't seem like Flow supports this.
+}
+
+declare var NavigationHistoryEntry: {
+ prototype: NavigationHistoryEntry,
+ new(): NavigationHistoryEntry,
+};
+
+type NavigationTypeString = 'reload' | 'push' | 'replace' | 'traverse';
+
+interface NavigationUpdateCurrentEntryOptions {
+ state: mixed;
+}
+
+interface NavigationOptions {
+ info?: mixed;
+}
+
+interface NavigationNavigateOptions extends NavigationOptions {
+ state?: mixed;
+ history?: 'auto' | 'push' | 'replace';
+}
+
+interface NavigationReloadOptions extends NavigationOptions {
+ state?: mixed;
+}
+
+declare class NavigationCurrentEntryChangeEvent extends Event {
+ constructor(type: string, eventInit?: any): void;
+
+ +navigationType: NavigationTypeString | null;
+ +from: NavigationHistoryEntry;
+}
+
+declare class NavigateEvent extends Event {
+ constructor(type: string, eventInit?: any): void;
+
+ +navigationType: NavigationTypeString;
+ +canIntercept: boolean;
+ +userInitiated: boolean;
+ +hashChange: boolean;
+ +hasUAVisualTransition: boolean;
+ +destination: NavigationDestination;
+ +signal: AbortSignal;
+ +formData: FormData | null;
+ +downloadRequest: string | null;
+ +info?: mixed;
+
+ intercept(options?: NavigationInterceptOptions): void;
+ scroll(): void;
+}
+
+interface NavigationInterceptOptions {
+ handler?: () => Promise;
+ focusReset?: 'after-transition' | 'manual';
+ scroll?: 'after-transition' | 'manual';
+}
+
+declare class NavigationDestination {
+ +url: string;
+ +key: string | null;
+ +id: string | null;
+ +index: number;
+ +sameDocument: boolean;
+
+ getState(): mixed;
+}
diff --git a/scripts/rollup/generate-inline-fizz-runtime.js b/scripts/rollup/generate-inline-fizz-runtime.js
index 3d097f63e2..c531240e0c 100644
--- a/scripts/rollup/generate-inline-fizz-runtime.js
+++ b/scripts/rollup/generate-inline-fizz-runtime.js
@@ -25,6 +25,10 @@ const config = [
entry: 'ReactDOMFizzInlineCompleteBoundary.js',
exportName: 'completeBoundary',
},
+ {
+ entry: 'ReactDOMFizzInlineCompleteBoundaryUpgradeToViewTransitions.js',
+ exportName: 'completeBoundaryUpgradeToViewTransitions',
+ },
{
entry: 'ReactDOMFizzInlineCompleteBoundaryWithStyles.js',
exportName: 'completeBoundaryWithStyles',
diff --git a/scripts/rollup/validate/eslintrc.cjs.js b/scripts/rollup/validate/eslintrc.cjs.js
index 88d17772d7..65fd612990 100644
--- a/scripts/rollup/validate/eslintrc.cjs.js
+++ b/scripts/rollup/validate/eslintrc.cjs.js
@@ -35,6 +35,7 @@ module.exports = {
FinalizationRegistry: 'readonly',
ScrollTimeline: 'readonly',
+ navigation: 'readonly',
// Vendor specific
MSApp: 'readonly',
diff --git a/scripts/rollup/validate/eslintrc.cjs2015.js b/scripts/rollup/validate/eslintrc.cjs2015.js
index 8e87c8dbe0..fa0b471330 100644
--- a/scripts/rollup/validate/eslintrc.cjs2015.js
+++ b/scripts/rollup/validate/eslintrc.cjs2015.js
@@ -33,6 +33,7 @@ module.exports = {
globalThis: 'readonly',
FinalizationRegistry: 'readonly',
ScrollTimeline: 'readonly',
+ navigation: 'readonly',
// Vendor specific
MSApp: 'readonly',
__REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly',
diff --git a/scripts/rollup/validate/eslintrc.esm.js b/scripts/rollup/validate/eslintrc.esm.js
index 8b4bba3579..a5ea7afb97 100644
--- a/scripts/rollup/validate/eslintrc.esm.js
+++ b/scripts/rollup/validate/eslintrc.esm.js
@@ -35,6 +35,7 @@ module.exports = {
FinalizationRegistry: 'readonly',
ScrollTimeline: 'readonly',
+ navigation: 'readonly',
// Vendor specific
MSApp: 'readonly',
diff --git a/scripts/rollup/validate/eslintrc.fb.js b/scripts/rollup/validate/eslintrc.fb.js
index f0602e79e5..afee2f1199 100644
--- a/scripts/rollup/validate/eslintrc.fb.js
+++ b/scripts/rollup/validate/eslintrc.fb.js
@@ -35,6 +35,7 @@ module.exports = {
FinalizationRegistry: 'readonly',
ScrollTimeline: 'readonly',
+ navigation: 'readonly',
// Vendor specific
MSApp: 'readonly',
diff --git a/scripts/rollup/validate/eslintrc.rn.js b/scripts/rollup/validate/eslintrc.rn.js
index 052edabdc0..2420898beb 100644
--- a/scripts/rollup/validate/eslintrc.rn.js
+++ b/scripts/rollup/validate/eslintrc.rn.js
@@ -35,6 +35,7 @@ module.exports = {
FinalizationRegistry: 'readonly',
ScrollTimeline: 'readonly',
+ navigation: 'readonly',
// Vendor specific
MSApp: 'readonly',