diff --git a/fixtures/view-transition/server/render.js b/fixtures/view-transition/server/render.js
index 0d956fd66caf7..11d352eabdd72 100644
--- a/fixtures/view-transition/server/render.js
+++ b/fixtures/view-transition/server/render.js
@@ -20,24 +20,27 @@ export default function render(url, res) {
console.error('Fatal', error);
});
let didError = false;
- const {pipe, abort} = renderToPipeableStream(, {
- bootstrapScripts: [assets['main.js']],
- onShellReady() {
- // If something errored before we started streaming, we set the error code appropriately.
- res.statusCode = didError ? 500 : 200;
- res.setHeader('Content-type', 'text/html');
- pipe(res);
- },
- onShellError(x) {
- // Something errored before we could complete the shell so we emit an alternative shell.
- res.statusCode = 500;
- res.send('
Error
');
- },
- onError(x) {
- didError = true;
- console.error(x);
- },
- });
+ const {pipe, abort} = renderToPipeableStream(
+ ,
+ {
+ bootstrapScripts: [assets['main.js']],
+ onShellReady() {
+ // If something errored before we started streaming, we set the error code appropriately.
+ res.statusCode = didError ? 500 : 200;
+ res.setHeader('Content-type', 'text/html');
+ pipe(res);
+ },
+ onShellError(x) {
+ // Something errored before we could complete the shell so we emit an alternative shell.
+ res.statusCode = 500;
+ res.send('Error
');
+ },
+ onError(x) {
+ didError = true;
+ console.error(x);
+ },
+ }
+ );
// Abandon and switch to client rendering after 5 seconds.
// Try lowering this to see the client recover.
setTimeout(abort, 5000);
diff --git a/fixtures/view-transition/src/components/App.js b/fixtures/view-transition/src/components/App.js
index 6867b29d4c5a5..028f511107c8b 100644
--- a/fixtures/view-transition/src/components/App.js
+++ b/fixtures/view-transition/src/components/App.js
@@ -1,12 +1,79 @@
-import React from 'react';
+import React, {
+ startTransition,
+ useLayoutEffect,
+ useEffect,
+ useState,
+} from 'react';
import Chrome from './Chrome';
import Page from './Page';
-export default function App({assets}) {
+const enableNavigationAPI = typeof navigation === 'object';
+
+export default function App({assets, initialURL}) {
+ const [routerState, setRouterState] = useState({
+ pendingNav: () => {},
+ url: initialURL,
+ });
+ function navigate(url) {
+ if (enableNavigationAPI) {
+ window.navigation.navigate(url);
+ } else {
+ startTransition(() => {
+ setRouterState({
+ url,
+ pendingNav() {
+ window.history.pushState({}, '', url);
+ },
+ });
+ });
+ }
+ }
+ useEffect(() => {
+ if (enableNavigationAPI) {
+ window.navigation.addEventListener('navigate', event => {
+ if (!event.canIntercept) {
+ return;
+ }
+ const newURL = new URL(event.destination.url);
+ event.intercept({
+ handler() {
+ let promise;
+ startTransition(() => {
+ promise = new Promise(resolve => {
+ setRouterState({
+ url: newURL.pathname + newURL.search,
+ pendingNav: resolve,
+ });
+ });
+ });
+ return promise;
+ },
+ commit: 'after-transition', // plz ship this, browsers
+ });
+ });
+ } else {
+ window.addEventListener('popstate', () => {
+ // This should not animate because restoration has to be synchronous.
+ // Even though it's a transition.
+ startTransition(() => {
+ setRouterState({
+ url: document.location.pathname + document.location.search,
+ pendingNav() {
+ // Noop. URL has already updated.
+ },
+ });
+ });
+ });
+ }
+ }, []);
+ const pendingNav = routerState.pendingNav;
+ useLayoutEffect(() => {
+ pendingNav();
+ }, [pendingNav]);
return (
-
+
);
}
diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js
index 0ebc83bc91bac..ab40790b1647b 100644
--- a/fixtures/view-transition/src/components/Page.js
+++ b/fixtures/view-transition/src/components/Page.js
@@ -1,8 +1,5 @@
import React, {
unstable_ViewTransition as ViewTransition,
- startTransition,
- useEffect,
- useState,
unstable_Activity as Activity,
} from 'react';
@@ -37,13 +34,8 @@ function Component() {
);
}
-export default function Page() {
- const [show, setShow] = useState(false);
- useEffect(() => {
- startTransition(() => {
- setShow(true);
- });
- }, []);
+export default function Page({url, navigate}) {
+ const show = url === '/?b';
const exclamation = (
!
@@ -53,9 +45,7 @@ export default function Page() {
@@ -75,6 +65,15 @@ export default function Page() {
{show ? hello{exclamation}
: }
+
scroll me
+
+
+
+
+
+
+
+
{show ? null : (
world{exclamation}
diff --git a/fixtures/view-transition/src/index.js b/fixtures/view-transition/src/index.js
index f6457ce570674..8c2fac3e67ada 100644
--- a/fixtures/view-transition/src/index.js
+++ b/fixtures/view-transition/src/index.js
@@ -3,4 +3,10 @@ import {hydrateRoot} from 'react-dom/client';
import App from './components/App';
-hydrateRoot(document, );
+hydrateRoot(
+ document,
+
+);
diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
index 93b80f13479f2..8b17db1dc38a1 100644
--- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
@@ -1201,27 +1201,58 @@ export function hasInstanceAffectedParent(
export function startViewTransition(
rootContainer: Container,
mutationCallback: () => void,
- afterMutationCallback: () => void,
layoutCallback: () => void,
+ afterMutationCallback: () => void,
+ spawnedWorkCallback: () => void,
passiveCallback: () => mixed,
): boolean {
- const ownerDocument =
+ const ownerDocument: Document =
rootContainer.nodeType === DOCUMENT_NODE
- ? rootContainer
+ ? (rootContainer: any)
: rootContainer.ownerDocument;
try {
// $FlowFixMe[prop-missing]
const transition = ownerDocument.startViewTransition({
update() {
+ // Note: We read the existence of a pending navigation before we apply the
+ // mutations. That way we're not waiting on a navigation that we spawned
+ // from this update. Only navigations that started before this commit.
+ const ownerWindow = ownerDocument.defaultView;
+ const pendingNavigation =
+ ownerWindow.navigation && ownerWindow.navigation.transition;
mutationCallback();
// TODO: Wait for fonts.
- afterMutationCallback();
+ layoutCallback();
+ if (pendingNavigation) {
+ return pendingNavigation.finished.then(
+ afterMutationCallback,
+ afterMutationCallback,
+ );
+ } else {
+ afterMutationCallback();
+ }
},
types: null, // TODO: Provide types.
});
// $FlowFixMe[prop-missing]
ownerDocument.__reactViewTransition = transition;
- transition.ready.then(layoutCallback, layoutCallback);
+ if (__DEV__) {
+ transition.ready.then(undefined, (reason: mixed) => {
+ if (
+ typeof reason === 'object' &&
+ reason !== null &&
+ reason.name === 'TimeoutError'
+ ) {
+ console.error(
+ 'A ViewTransition timed out because a Navigation stalled. ' +
+ 'This can happen if a Navigation is blocked on React itself. ' +
+ "Such as if it's resolved inside useEffect. " +
+ 'This can be solved by moving the resolution to useLayoutEffect.',
+ );
+ }
+ });
+ }
+ transition.ready.then(spawnedWorkCallback, spawnedWorkCallback);
transition.finished.then(() => {
// $FlowFixMe[prop-missing]
ownerDocument.__reactViewTransition = null;
diff --git a/packages/react-native-renderer/src/ReactFiberConfigNative.js b/packages/react-native-renderer/src/ReactFiberConfigNative.js
index 5687e60637d11..55699f0973b76 100644
--- a/packages/react-native-renderer/src/ReactFiberConfigNative.js
+++ b/packages/react-native-renderer/src/ReactFiberConfigNative.js
@@ -583,8 +583,9 @@ export function hasInstanceAffectedParent(
export function startViewTransition(
rootContainer: Container,
mutationCallback: () => void,
- afterMutationCallback: () => void,
layoutCallback: () => void,
+ afterMutationCallback: () => void,
+ spawnedWorkCallback: () => void,
passiveCallback: () => mixed,
): boolean {
return false;
diff --git a/packages/react-reconciler/src/ReactFiberLane.js b/packages/react-reconciler/src/ReactFiberLane.js
index 11037d5f24053..922fe2f977d45 100644
--- a/packages/react-reconciler/src/ReactFiberLane.js
+++ b/packages/react-reconciler/src/ReactFiberLane.js
@@ -350,6 +350,10 @@ export function getNextLanesToFlushSync(
//
// The main use case is updates scheduled by popstate events, which are
// flushed synchronously even though they are transitions.
+ // Note that we intentionally treat this as a sync flush to include any
+ // sync updates in a single pass but also intentionally disables View Transitions
+ // inside popstate. Because they can start synchronously before scroll restoration
+ // happens.
const lanesToFlush = SyncUpdateLanes | extraLanesToForceSync;
// Early bailout if there's no pending work left.
diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js
index d73e4f7002bd2..c79d6d541c271 100644
--- a/packages/react-reconciler/src/ReactFiberWorkLoop.js
+++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js
@@ -637,10 +637,11 @@ const THROTTLED_COMMIT = 2;
const NO_PENDING_EFFECTS = 0;
const PENDING_MUTATION_PHASE = 1;
-const PENDING_AFTER_MUTATION_PHASE = 2;
-const PENDING_LAYOUT_PHASE = 3;
-const PENDING_PASSIVE_PHASE = 4;
-let pendingEffectsStatus: 0 | 1 | 2 | 3 | 4 = 0;
+const PENDING_LAYOUT_PHASE = 2;
+const PENDING_AFTER_MUTATION_PHASE = 3;
+const PENDING_SPAWNED_WORK = 4;
+const PENDING_PASSIVE_PHASE = 5;
+let pendingEffectsStatus: 0 | 1 | 2 | 3 | 4 | 5 = 0;
let pendingEffectsRoot: FiberRoot = (null: any);
let pendingFinishedWork: Fiber = (null: any);
let pendingEffectsLanes: Lanes = NoLanes;
@@ -3432,19 +3433,17 @@ function commitRoot(
startViewTransition(
root.containerInfo,
flushMutationEffects,
- flushAfterMutationEffects,
flushLayoutEffects,
- // TODO: This flushes passive effects at the end of the transition but
- // we also schedule work to flush them separately which we really shouldn't.
- // We use flushPendingEffects instead of
+ flushAfterMutationEffects,
+ flushSpawnedWork,
flushPassiveEffects,
);
if (!startedViewTransition) {
// Flush synchronously.
flushMutationEffects();
- // Skip flushAfterMutationEffects
- pendingEffectsStatus = PENDING_LAYOUT_PHASE;
flushLayoutEffects();
+ // Skip flushAfterMutationEffects
+ flushSpawnedWork();
}
}
@@ -3457,7 +3456,7 @@ function flushAfterMutationEffects(): void {
const finishedWork = pendingFinishedWork;
const lanes = pendingEffectsLanes;
commitAfterMutationEffects(root, finishedWork, lanes);
- pendingEffectsStatus = PENDING_LAYOUT_PHASE;
+ pendingEffectsStatus = PENDING_SPAWNED_WORK;
}
function flushMutationEffects(): void {
@@ -3503,7 +3502,7 @@ function flushMutationEffects(): void {
// componentWillUnmount, but before the layout phase, so that the finished
// work is current during componentDidMount/Update.
root.current = finishedWork;
- pendingEffectsStatus = PENDING_AFTER_MUTATION_PHASE;
+ pendingEffectsStatus = PENDING_LAYOUT_PHASE;
}
function flushLayoutEffects(): void {
@@ -3515,10 +3514,6 @@ function flushLayoutEffects(): void {
const root = pendingEffectsRoot;
const finishedWork = pendingFinishedWork;
const lanes = pendingEffectsLanes;
- const completedRenderEndTime = pendingEffectsRenderEndTime;
- const recoverableErrors = pendingRecoverableErrors;
- const didIncludeRenderPhaseUpdate = pendingDidIncludeRenderPhaseUpdate;
- const suspendedCommitReason = pendingSuspendedCommitReason;
const subtreeHasLayoutEffects =
(finishedWork.subtreeFlags & LayoutMask) !== NoFlags;
@@ -3549,11 +3544,32 @@ function flushLayoutEffects(): void {
ReactSharedInternals.T = prevTransition;
}
}
+ pendingEffectsStatus = PENDING_AFTER_MUTATION_PHASE;
+}
+
+function flushSpawnedWork(): void {
+ if (
+ pendingEffectsStatus !== PENDING_SPAWNED_WORK &&
+ // If a startViewTransition times out, we might flush this earlier than
+ // after mutation phase. In that case, we just skip the after mutation phase.
+ pendingEffectsStatus !== PENDING_AFTER_MUTATION_PHASE
+ ) {
+ return;
+ }
+ pendingEffectsStatus = NO_PENDING_EFFECTS;
// Tell Scheduler to yield at the end of the frame, so the browser has an
// opportunity to paint.
requestPaint();
+ const root = pendingEffectsRoot;
+ const finishedWork = pendingFinishedWork;
+ const lanes = pendingEffectsLanes;
+ const completedRenderEndTime = pendingEffectsRenderEndTime;
+ const recoverableErrors = pendingRecoverableErrors;
+ const didIncludeRenderPhaseUpdate = pendingDidIncludeRenderPhaseUpdate;
+ const suspendedCommitReason = pendingSuspendedCommitReason;
+
if (enableProfilerTimer && enableComponentPerformanceTrack) {
recordCommitEndTime();
logCommitPhase(
@@ -3790,7 +3806,8 @@ export function flushPendingEffects(wasDelayedCommit?: boolean): boolean {
// Returns whether passive effects were flushed.
flushMutationEffects();
flushLayoutEffects();
- flushAfterMutationEffects();
+ // Skip flushAfterMutation if we're forcing this early.
+ flushSpawnedWork();
return flushPassiveEffects(wasDelayedCommit);
}
diff --git a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js
index 15db19c3c96da..c05f59af160f0 100644
--- a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js
+++ b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js
@@ -365,8 +365,9 @@ export function hasInstanceAffectedParent(
export function startViewTransition(
rootContainer: Container,
mutationCallback: () => void,
- afterMutationCallback: () => void,
layoutCallback: () => void,
+ afterMutationCallback: () => void,
+ spawnedWorkCallback: () => void,
passiveCallback: () => mixed,
): boolean {
return false;