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}
:
Loading
}
+

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;