@samitouri / QOS-React / commits / 800c9db22e

ViewTransitions in Navigation (#32028)

This adds navigation support to the View Transition fixture using both `history.pushState/popstate` and the Navigation API models. Because `popstate` does scroll restoration synchronously at the end of the event, but `startViewTransition` cannot start synchronously, it would observe the "old" state as after applying scroll restoration. This leads to weird artifacts. So we intentionally do not support View Transitions in `popstate`. If it suspends anyway for some other reason, then scroll restoration is broken anyway and then it is supported. We don't have to do anything here because this is already how things worked because the sync `popstate` special case already included the sync lane which opts it out of View Transitions. For the Navigation API, scroll restoration can be blocked. The best way to do this is to resolve the Navigation API promise after React has applied its mutation. We can detect if there's currently any pending navigation and wait to resolve the `startViewTransition` until it finishes and any scroll restoration has been applied. https://github.com/user-attachments/assets/f53b3282-6315-4513-b3d6-b8981d66964e There is a subtle thing here. If we read the viewport metrics before scroll restoration has been applied, then we might assume something is or isn't going to be within the viewport incorrectly. This is evident on the "Slide In from Left" example. When we're going forward to that page we shift the scroll position such that it's going to appear in the viewport. If we did this before applying scroll restoration, it would not animate because it wasn't in the viewport then. Therefore, we need to run the after mutation phase after scroll restoration. A consequence of this is that you have to resolve Navigation in `useInsertionEffect` as otherwise it leads to a deadlock (which eventually gets broken by `startViewTransition`'s timeout of 10 seconds). Another consequence is that now `useLayoutEffect` observes the restored state. However, I think what we'll likely do is move the layout phase to before the after mutation phase which also ensures that auto-scrolling inside `useLayoutEffect` are considered in the viewport measurements as well.

Sebastian Markbåge committed Jan 8, 2025 at 18:57 UTC 800c9db22e69680f17e238724478537282215f89
7 files changed +149 -40
fixtures/view-transition/server/render.js
+21 -18
@@ -20,24 +20,27 @@ export default function render(url, res) {
20 console.error('Fatal', error);
21 });
22 let didError = false;
23 - const {pipe, abort} = renderToPipeableStream(<App assets={assets} />, {
24 - bootstrapScripts: [assets['main.js']],
25 - onShellReady() {
26 - // If something errored before we started streaming, we set the error code appropriately.
27 - res.statusCode = didError ? 500 : 200;
28 - res.setHeader('Content-type', 'text/html');
29 - pipe(res);
30 - },
31 - onShellError(x) {
32 - // Something errored before we could complete the shell so we emit an alternative shell.
33 - res.statusCode = 500;
34 - res.send('<!doctype><p>Error</p>');
35 - },
36 - onError(x) {
37 - didError = true;
38 - console.error(x);
39 - },
40 - });
23 + const {pipe, abort} = renderToPipeableStream(
24 + <App assets={assets} initialURL={url} />,
25 + {
26 + bootstrapScripts: [assets['main.js']],
27 + onShellReady() {
28 + // If something errored before we started streaming, we set the error code appropriately.
29 + res.statusCode = didError ? 500 : 200;
30 + res.setHeader('Content-type', 'text/html');
31 + pipe(res);
32 + },
33 + onShellError(x) {
34 + // Something errored before we could complete the shell so we emit an alternative shell.
35 + res.statusCode = 500;
36 + res.send('<!doctype><p>Error</p>');
37 + },
38 + onError(x) {
39 + didError = true;
40 + console.error(x);
41 + },
42 + }
43 + );
44 // Abandon and switch to client rendering after 5 seconds.
45 // Try lowering this to see the client recover.
46 setTimeout(abort, 5000);
fixtures/view-transition/src/components/App.js
+70 -3
@@ -1,12 +1,79 @@
1 -import React from 'react';
1 +import React, {
2 + startTransition,
3 + useInsertionEffect,
4 + useEffect,
5 + useState,
6 +} from 'react';
7
8 import Chrome from './Chrome';
9 import Page from './Page';
10
6 -export default function App({assets}) {
11 +const enableNavigationAPI = typeof navigation === 'object';
12 +
13 +export default function App({assets, initialURL}) {
14 + const [routerState, setRouterState] = useState({
15 + pendingNav: () => {},
16 + url: initialURL,
17 + });
18 + function navigate(url) {
19 + if (enableNavigationAPI) {
20 + window.navigation.navigate(url);
21 + } else {
22 + startTransition(() => {
23 + setRouterState({
24 + url,
25 + pendingNav() {
26 + window.history.pushState({}, '', url);
27 + },
28 + });
29 + });
30 + }
31 + }
32 + useEffect(() => {
33 + if (enableNavigationAPI) {
34 + window.navigation.addEventListener('navigate', event => {
35 + if (!event.canIntercept) {
36 + return;
37 + }
38 + const newURL = new URL(event.destination.url);
39 + event.intercept({
40 + handler() {
41 + let promise;
42 + startTransition(() => {
43 + promise = new Promise(resolve => {
44 + setRouterState({
45 + url: newURL.pathname + newURL.search,
46 + pendingNav: resolve,
47 + });
48 + });
49 + });
50 + return promise;
51 + },
52 + commit: 'after-transition', // plz ship this, browsers
53 + });
54 + });
55 + } else {
56 + window.addEventListener('popstate', () => {
57 + // This should not animate because restoration has to be synchronous.
58 + // Even though it's a transition.
59 + startTransition(() => {
60 + setRouterState({
61 + url: document.location.pathname + document.location.search,
62 + pendingNav() {
63 + // Noop. URL has already updated.
64 + },
65 + });
66 + });
67 + });
68 + }
69 + }, []);
70 + const pendingNav = routerState.pendingNav;
71 + useInsertionEffect(() => {
72 + pendingNav();
73 + }, [pendingNav]);
74 return (
75 <Chrome title="Hello World" assets={assets}>
9 - <Page />
76 + <Page url={routerState.url} navigate={navigate} />
77 </Chrome>
78 );
79 }
fixtures/view-transition/src/components/Page.js
+12 -13
@@ -1,8 +1,5 @@
1 import React, {
2 unstable_ViewTransition as ViewTransition,
3 - startTransition,
4 - useEffect,
5 - useState,
3 unstable_Activity as Activity,
4 } from 'react';
5
@@ -37,13 +34,8 @@ function Component() {
34 );
35 }
36
40 -export default function Page() {
41 - const [show, setShow] = useState(false);
42 - useEffect(() => {
43 - startTransition(() => {
44 - setShow(true);
45 - });
46 - }, []);
37 +export default function Page({url, navigate}) {
38 + const show = url === '/?b';
39 const exclamation = (
40 <ViewTransition name="exclamation">
41 <span>!</span>
@@ -53,9 +45,7 @@ export default function Page() {
45 <div>
46 <button
47 onClick={() => {
56 - startTransition(() => {
57 - setShow(show => !show);
58 - });
48 + navigate(show ? '/?a' : '/?b');
49 }}>
50 {show ? 'A' : 'B'}
51 </button>
@@ -75,6 +65,15 @@ export default function Page() {
65 <ViewTransition>
66 {show ? <div>hello{exclamation}</div> : <section>Loading</section>}
67 </ViewTransition>
68 + <p>scroll me</p>
69 + <p></p>
70 + <p></p>
71 + <p></p>
72 + <p></p>
73 + <p></p>
74 + <p></p>
75 + <p></p>
76 + <p></p>
77 {show ? null : (
78 <ViewTransition>
79 <div>world{exclamation}</div>
fixtures/view-transition/src/index.js
+7 -1
@@ -3,4 +3,10 @@ import {hydrateRoot} from 'react-dom/client';
3
4 import App from './components/App';
5
6 -hydrateRoot(document, <App assets={window.assetManifest} />);
6 +hydrateRoot(
7 + document,
8 + <App
9 + assets={window.assetManifest}
10 + initialURL={document.location.pathname + document.location.search}
11 + />
12 +);
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+29 -3
@@ -1205,9 +1205,9 @@ export function startViewTransition(
1205 layoutCallback: () => void,
1206 passiveCallback: () => mixed,
1207 ): boolean {
1208 - const ownerDocument =
1208 + const ownerDocument: Document =
1209 rootContainer.nodeType === DOCUMENT_NODE
1210 - ? rootContainer
1210 + ? (rootContainer: any)
1211 : rootContainer.ownerDocument;
1212 try {
1213 // $FlowFixMe[prop-missing]
@@ -1215,12 +1215,38 @@ export function startViewTransition(
1215 update() {
1216 mutationCallback();
1217 // TODO: Wait for fonts.
1218 - afterMutationCallback();
1218 + const ownerWindow = ownerDocument.defaultView;
1219 + const pendingNavigation =
1220 + ownerWindow.navigation && ownerWindow.navigation.transition;
1221 + if (pendingNavigation) {
1222 + return pendingNavigation.finished.then(
1223 + afterMutationCallback,
1224 + afterMutationCallback,
1225 + );
1226 + } else {
1227 + afterMutationCallback();
1228 + }
1229 },
1230 types: null, // TODO: Provide types.
1231 });
1232 // $FlowFixMe[prop-missing]
1233 ownerDocument.__reactViewTransition = transition;
1234 + if (__DEV__) {
1235 + transition.ready.then(undefined, (reason: mixed) => {
1236 + if (
1237 + typeof reason === 'object' &&
1238 + reason !== null &&
1239 + reason.name === 'TimeoutError'
1240 + ) {
1241 + console.error(
1242 + 'A ViewTransition timed out because a Navigation stalled. ' +
1243 + 'This can happen if a Navigation is blocked on React itself. ' +
1244 + "Such as if it's resolved inside useLayoutEffect. " +
1245 + 'This can be solved by moving the resolution to useInsertionEffect.',
1246 + );
1247 + }
1248 + });
1249 + }
1250 transition.ready.then(layoutCallback, layoutCallback);
1251 transition.finished.then(() => {
1252 // $FlowFixMe[prop-missing]
packages/react-reconciler/src/ReactFiberLane.js
+4
@@ -350,6 +350,10 @@ export function getNextLanesToFlushSync(
350 //
351 // The main use case is updates scheduled by popstate events, which are
352 // flushed synchronously even though they are transitions.
353 + // Note that we intentionally treat this as a sync flush to include any
354 + // sync updates in a single pass but also intentionally disables View Transitions
355 + // inside popstate. Because they can start synchronously before scroll restoration
356 + // happens.
357 const lanesToFlush = SyncUpdateLanes | extraLanesToForceSync;
358
359 // Early bailout if there's no pending work left.
packages/react-reconciler/src/ReactFiberWorkLoop.js
+6 -2
@@ -3507,7 +3507,12 @@ function flushMutationEffects(): void {
3507 }
3508
3509 function flushLayoutEffects(): void {
3510 - if (pendingEffectsStatus !== PENDING_LAYOUT_PHASE) {
3510 + if (
3511 + pendingEffectsStatus !== PENDING_LAYOUT_PHASE &&
3512 + // If a startViewTransition times out, we might flush this earlier than
3513 + // after mutation phase. In that case, we just skip the after mutation phase.
3514 + pendingEffectsStatus !== PENDING_AFTER_MUTATION_PHASE
3515 + ) {
3516 return;
3517 }
3518 pendingEffectsStatus = NO_PENDING_EFFECTS;
@@ -3790,7 +3795,6 @@ export function flushPendingEffects(wasDelayedCommit?: boolean): boolean {
3795 // Returns whether passive effects were flushed.
3796 flushMutationEffects();
3797 flushLayoutEffects();
3793 - flushAfterMutationEffects();
3798 return flushPassiveEffects(wasDelayedCommit);
3799 }
3800