',
+ {
+ 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 b7f42bcf8dbef..2d3f1f0b8b621 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 f2cabafc9f575..f6d868c84135a 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 2b54bc90090e4..3501f1bd92af7 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 0000000000000..8f1a32d826c1a
--- /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 ef2c9ddf193eb..97f4c83515364 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 7d14ccd628de2..3ed4e61190a00 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,19 @@ export {
import escapeTextForBrowser from 'react-dom-bindings/src/server/escapeTextForBrowser';
+export function getViewTransitionFormatContext(
+ 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 +111,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 +141,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 119c9885db783..0244e3dfab5fd 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 dd5173cd83720..6fbad9adac195 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 f57d7597d640a..9d1194874d46f 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 975313a99f8b1..2e76e5188ec5f 100644
--- a/packages/react-reconciler/src/ReactFiberBeginWork.js
+++ b/packages/react-reconciler/src/ReactFiberBeginWork.js
@@ -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 93f2a476bd8bc..e21271f3b3eea 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 fdada6b065a29..bd7f3267efd2d 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 164ec2c6edd7d..cb439bd68fb36 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 dbba2329cfc54..ab7b1fcdd11e9 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 cc2a528010e77..e9d107bcb899c 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 f7c26580bb95a..142812dab3915 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/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js
index 95285c936eb93..cd5c1c1468523 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 b364d4ec47abb..25840749a1adf 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 0000000000000..fb698e821aa0d
--- /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 816b9f2a73185..59723c903f642 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 80562624eb173..05a6a227c2b50 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)
+ ? ''
+ : '') +
+ '