Skip to content

Commit 800c9db

Browse files
authored
ViewTransitions in Navigation (facebook#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.
1 parent 98418e8 commit 800c9db

File tree

7 files changed

+149
-40
lines changed

7 files changed

+149
-40
lines changed

fixtures/view-transition/server/render.js

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,24 +20,27 @@ export default function render(url, res) {
2020
console.error('Fatal', error);
2121
});
2222
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+
);
4144
// Abandon and switch to client rendering after 5 seconds.
4245
// Try lowering this to see the client recover.
4346
setTimeout(abort, 5000);
Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,79 @@
1-
import React from 'react';
1+
import React, {
2+
startTransition,
3+
useInsertionEffect,
4+
useEffect,
5+
useState,
6+
} from 'react';
27

38
import Chrome from './Chrome';
49
import Page from './Page';
510

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]);
774
return (
875
<Chrome title="Hello World" assets={assets}>
9-
<Page />
76+
<Page url={routerState.url} navigate={navigate} />
1077
</Chrome>
1178
);
1279
}

fixtures/view-transition/src/components/Page.js

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
import React, {
22
unstable_ViewTransition as ViewTransition,
3-
startTransition,
4-
useEffect,
5-
useState,
63
unstable_Activity as Activity,
74
} from 'react';
85

@@ -37,13 +34,8 @@ function Component() {
3734
);
3835
}
3936

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';
4739
const exclamation = (
4840
<ViewTransition name="exclamation">
4941
<span>!</span>
@@ -53,9 +45,7 @@ export default function Page() {
5345
<div>
5446
<button
5547
onClick={() => {
56-
startTransition(() => {
57-
setShow(show => !show);
58-
});
48+
navigate(show ? '/?a' : '/?b');
5949
}}>
6050
{show ? 'A' : 'B'}
6151
</button>
@@ -75,6 +65,15 @@ export default function Page() {
7565
<ViewTransition>
7666
{show ? <div>hello{exclamation}</div> : <section>Loading</section>}
7767
</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>
7877
{show ? null : (
7978
<ViewTransition>
8079
<div>world{exclamation}</div>

fixtures/view-transition/src/index.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,10 @@ import {hydrateRoot} from 'react-dom/client';
33

44
import App from './components/App';
55

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

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1205,22 +1205,48 @@ export function startViewTransition(
12051205
layoutCallback: () => void,
12061206
passiveCallback: () => mixed,
12071207
): boolean {
1208-
const ownerDocument =
1208+
const ownerDocument: Document =
12091209
rootContainer.nodeType === DOCUMENT_NODE
1210-
? rootContainer
1210+
? (rootContainer: any)
12111211
: rootContainer.ownerDocument;
12121212
try {
12131213
// $FlowFixMe[prop-missing]
12141214
const transition = ownerDocument.startViewTransition({
12151215
update() {
12161216
mutationCallback();
12171217
// 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+
}
12191229
},
12201230
types: null, // TODO: Provide types.
12211231
});
12221232
// $FlowFixMe[prop-missing]
12231233
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+
}
12241250
transition.ready.then(layoutCallback, layoutCallback);
12251251
transition.finished.then(() => {
12261252
// $FlowFixMe[prop-missing]

packages/react-reconciler/src/ReactFiberLane.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,10 @@ export function getNextLanesToFlushSync(
350350
//
351351
// The main use case is updates scheduled by popstate events, which are
352352
// 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.
353357
const lanesToFlush = SyncUpdateLanes | extraLanesToForceSync;
354358

355359
// Early bailout if there's no pending work left.

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3507,7 +3507,12 @@ function flushMutationEffects(): void {
35073507
}
35083508

35093509
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+
) {
35113516
return;
35123517
}
35133518
pendingEffectsStatus = NO_PENDING_EFFECTS;
@@ -3790,7 +3795,6 @@ export function flushPendingEffects(wasDelayedCommit?: boolean): boolean {
37903795
// Returns whether passive effects were flushed.
37913796
flushMutationEffects();
37923797
flushLayoutEffects();
3793-
flushAfterMutationEffects();
37943798
return flushPassiveEffects(wasDelayedCommit);
37953799
}
37963800

0 commit comments

Comments
 (0)