Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 21 additions & 18 deletions fixtures/view-transition/server/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,27 @@ export default function render(url, res) {
console.error('Fatal', error);
});
let didError = false;
const {pipe, abort} = renderToPipeableStream(<App assets={assets} />, {
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('<!doctype><p>Error</p>');
},
onError(x) {
didError = true;
console.error(x);
},
});
const {pipe, abort} = renderToPipeableStream(
<App assets={assets} initialURL={url} />,
{
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('<!doctype><p>Error</p>');
},
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);
Expand Down
73 changes: 70 additions & 3 deletions fixtures/view-transition/src/components/App.js
Original file line number Diff line number Diff line change
@@ -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 (
<Chrome title="Hello World" assets={assets}>
<Page />
<Page url={routerState.url} navigate={navigate} />
</Chrome>
);
}
25 changes: 12 additions & 13 deletions fixtures/view-transition/src/components/Page.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import React, {
unstable_ViewTransition as ViewTransition,
startTransition,
useEffect,
useState,
unstable_Activity as Activity,
} from 'react';

Expand Down Expand Up @@ -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 = (
<ViewTransition name="exclamation">
<span>!</span>
Expand All @@ -53,9 +45,7 @@ export default function Page() {
<div>
<button
onClick={() => {
startTransition(() => {
setShow(show => !show);
});
navigate(show ? '/?a' : '/?b');
}}>
{show ? 'A' : 'B'}
</button>
Expand All @@ -75,6 +65,15 @@ export default function Page() {
<ViewTransition>
{show ? <div>hello{exclamation}</div> : <section>Loading</section>}
</ViewTransition>
<p>scroll me</p>
<p></p>
<p></p>
<p></p>
<p></p>
<p></p>
<p></p>
<p></p>
<p></p>
{show ? null : (
<ViewTransition>
<div>world{exclamation}</div>
Expand Down
8 changes: 7 additions & 1 deletion fixtures/view-transition/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,10 @@ import {hydrateRoot} from 'react-dom/client';

import App from './components/App';

hydrateRoot(document, <App assets={window.assetManifest} />);
hydrateRoot(
document,
<App
assets={window.assetManifest}
initialURL={document.location.pathname + document.location.search}
/>
);
41 changes: 36 additions & 5 deletions packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion packages/react-native-renderer/src/ReactFiberConfigNative.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions packages/react-reconciler/src/ReactFiberLane.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
51 changes: 34 additions & 17 deletions packages/react-reconciler/src/ReactFiberWorkLoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
}

Expand Down
Loading
Loading