Merge 6e1e8fba1e into sapling-pr-archive-mofeiZ

This commit is contained in:
mofeiZ
2025-01-13 12:06:22 -05:00
committed by GitHub
4 changed files with 352 additions and 108 deletions
+12 -18
View File
@@ -1,8 +1,6 @@
import React, {
unstable_ViewTransition as ViewTransition,
unstable_Activity as Activity,
useRef,
useLayoutEffect,
} from 'react';
import './Page.css';
@@ -37,21 +35,17 @@ function Component() {
}
export default function Page({url, navigate}) {
const ref = useRef();
const show = url === '/?b';
useLayoutEffect(() => {
const viewTransition = ref.current;
requestAnimationFrame(() => {
const keyframes = [
{rotate: '0deg', transformOrigin: '30px 8px'},
{rotate: '360deg', transformOrigin: '30px 8px'},
];
viewTransition.old.animate(keyframes, 300);
viewTransition.new.animate(keyframes, 300);
});
}, [show]);
function onTransition(viewTransition) {
const keyframes = [
{rotate: '0deg', transformOrigin: '30px 8px'},
{rotate: '360deg', transformOrigin: '30px 8px'},
];
viewTransition.old.animate(keyframes, 250);
viewTransition.new.animate(keyframes, 250);
}
const exclamation = (
<ViewTransition name="exclamation">
<ViewTransition name="exclamation" onShare={onTransition}>
<span>!</span>
</ViewTransition>
);
@@ -63,7 +57,7 @@ export default function Page({url, navigate}) {
}}>
{show ? 'A' : 'B'}
</button>
<ViewTransition>
<ViewTransition className="none">
<div>
{show ? (
<div>
@@ -76,7 +70,7 @@ export default function Page({url, navigate}) {
{a}
</div>
)}
<ViewTransition ref={ref}>
<ViewTransition>
{show ? <div>hello{exclamation}</div> : <section>Loading</section>}
</ViewTransition>
<p>scroll me</p>
@@ -98,7 +92,7 @@ export default function Page({url, navigate}) {
<div>!!</div>
</ViewTransition>
</Activity>
{show ? <Component /> : <p>&nbsp;</p>}
{show ? <Component /> : null}
</div>
</ViewTransition>
</div>
+267 -87
View File
@@ -186,6 +186,7 @@ import {
addMarkerIncompleteCallbackToPendingTransition,
addMarkerCompleteCallbackToPendingTransition,
retryDehydratedSuspenseBoundary,
scheduleViewTransitionEvent,
} from './ReactFiberWorkLoop';
import {
HasEffect as HookHasEffect,
@@ -202,7 +203,10 @@ import {
OffscreenDetached,
OffscreenPassiveEffectsConnected,
} from './ReactFiberActivityComponent';
import {getViewTransitionName} from './ReactFiberViewTransitionComponent';
import {
getViewTransitionName,
getViewTransitionClassName,
} from './ReactFiberViewTransitionComponent';
import {
TransitionRoot,
TransitionTracingMarker,
@@ -503,7 +507,7 @@ function commitBeforeMutationEffectsOnFiber(
// We should just stash the parent ViewTransitionComponent and continue
// walking the tree until we find HostComponent but to do that we need
// to use a stack which requires refactoring this phase.
commitBeforeUpdateViewTransition(current);
commitBeforeUpdateViewTransition(current, finishedWork);
}
}
break;
@@ -649,6 +653,7 @@ function commitAppearingPairViewTransitions(placement: Fiber): void {
if (child.tag === OffscreenComponent && child.memoizedState === null) {
// This tree was already hidden so we skip it.
} else {
commitAppearingPairViewTransitions(child);
if (
child.tag === ViewTransitionComponent &&
(child.flags & ViewTransitionNamedStatic) !== NoFlags
@@ -661,28 +666,34 @@ function commitAppearingPairViewTransitions(placement: Fiber): void {
'Found a pair with an auto name. This is a bug in React.',
);
}
// We found a new appearing view transition with the same name as this deletion.
// We'll transition between them.
viewTransitionHostInstanceIdx = 0;
const inViewport = applyViewTransitionToHostInstances(
child.child,
props.name,
const name = props.name;
const className: ?string = getViewTransitionClassName(
props.className,
null,
false,
props.share,
);
if (!inViewport) {
// This boundary is exiting within the viewport but is going to leave the viewport.
// Instead, we treat this as an exit of the previous entry by reverting the new name.
// Ideally we could undo the old transition but it's now too late. It's also on its
// on snapshot. We have know was for it to paint onto the original group.
// TODO: This will lead to things unexpectedly having exit animations that normally
// wouldn't happen. Consider if we should just let this fly off the screen instead.
restoreViewTransitionOnHostInstances(child.child, false);
if (className !== 'none') {
// We found a new appearing view transition with the same name as this deletion.
// We'll transition between them.
viewTransitionHostInstanceIdx = 0;
const inViewport = applyViewTransitionToHostInstances(
child.child,
name,
className,
null,
false,
);
if (!inViewport) {
// This boundary is exiting within the viewport but is going to leave the viewport.
// Instead, we treat this as an exit of the previous entry by reverting the new name.
// Ideally we could undo the old transition but it's now too late. It's also on its
// on snapshot. We have know was for it to paint onto the original group.
// TODO: This will lead to things unexpectedly having exit animations that normally
// wouldn't happen. Consider if we should just let this fly off the screen instead.
restoreViewTransitionOnHostInstances(child.child, false);
}
}
}
}
commitAppearingPairViewTransitions(child);
}
child = child.sibling;
}
@@ -690,21 +701,35 @@ function commitAppearingPairViewTransitions(placement: Fiber): void {
function commitEnterViewTransitions(placement: Fiber): void {
if (placement.tag === ViewTransitionComponent) {
const state: ViewTransitionState = placement.stateNode;
const props: ViewTransitionProps = placement.memoizedProps;
const name = getViewTransitionName(props, placement.stateNode);
viewTransitionHostInstanceIdx = 0;
const inViewport = applyViewTransitionToHostInstances(
placement.child,
name,
const name = getViewTransitionName(props, state);
const className: ?string = getViewTransitionClassName(
props.className,
null,
false,
state.paired ? props.share : props.enter,
);
if (!inViewport) {
// Revert the transition names. This boundary is not in the viewport
// so we won't bother animating it.
restoreViewTransitionOnHostInstances(placement.child, false);
// TODO: Should we still visit the children in case a named one was in the viewport?
if (className !== 'none') {
viewTransitionHostInstanceIdx = 0;
const inViewport = applyViewTransitionToHostInstances(
placement.child,
name,
className,
null,
false,
);
if (!inViewport) {
// TODO: If this was part of a pair we will still run the onShare callback.
// Revert the transition names. This boundary is not in the viewport
// so we won't bother animating it.
restoreViewTransitionOnHostInstances(placement.child, false);
// TODO: Should we still visit the children in case a named one was in the viewport?
} else {
commitAppearingPairViewTransitions(placement);
if (!state.paired) {
scheduleViewTransitionEvent(placement, props.onEnter);
}
}
} else {
commitAppearingPairViewTransitions(placement);
}
@@ -745,25 +770,34 @@ function commitDeletedPairViewTransitions(
if (name != null && name !== 'auto') {
const pair = appearingViewTransitions.get(name);
if (pair !== undefined) {
// We found a new appearing view transition with the same name as this deletion.
viewTransitionHostInstanceIdx = 0;
const inViewport = applyViewTransitionToHostInstances(
child.child,
name,
const className: ?string = getViewTransitionClassName(
props.className,
null,
false,
props.share,
);
if (!inViewport) {
// This boundary is not in the viewport so we won't treat it as a matched pair.
// Revert the transition names. This avoids it flying onto the screen which can
// be disruptive and doesn't really preserve any continuity anyway.
restoreViewTransitionOnHostInstances(child.child, false);
} else {
// We'll transition between them.
const oldinstance: ViewTransitionState = child.stateNode;
const newInstance: ViewTransitionState = pair;
newInstance.paired = oldinstance;
if (className !== 'none') {
// We found a new appearing view transition with the same name as this deletion.
viewTransitionHostInstanceIdx = 0;
const inViewport = applyViewTransitionToHostInstances(
child.child,
name,
className,
null,
false,
);
if (!inViewport) {
// This boundary is not in the viewport so we won't treat it as a matched pair.
// Revert the transition names. This avoids it flying onto the screen which can
// be disruptive and doesn't really preserve any continuity anyway.
restoreViewTransitionOnHostInstances(child.child, false);
} else {
// We'll transition between them.
const oldinstance: ViewTransitionState = child.stateNode;
const newInstance: ViewTransitionState = pair;
newInstance.paired = oldinstance;
// Note: If the other side ends up outside the viewport, we'll still run this.
// Therefore it's possible for onShare to be called with only an old snapshot.
scheduleViewTransitionEvent(child, props.onShare);
}
}
// Delete the entry so that we know when we've found all of them
// and can stop searching (size reaches zero).
@@ -787,22 +821,29 @@ function commitExitViewTransitions(
if (deletion.tag === ViewTransitionComponent) {
const props: ViewTransitionProps = deletion.memoizedProps;
const name = getViewTransitionName(props, deletion.stateNode);
viewTransitionHostInstanceIdx = 0;
const inViewport = applyViewTransitionToHostInstances(
deletion.child,
name,
const pair =
appearingViewTransitions !== null
? appearingViewTransitions.get(name)
: undefined;
const className: ?string = getViewTransitionClassName(
props.className,
null,
false,
pair !== undefined ? props.share : props.exit,
);
if (!inViewport) {
// Revert the transition names. This boundary is not in the viewport
// so we won't bother animating it.
restoreViewTransitionOnHostInstances(deletion.child, false);
// TODO: Should we still visit the children in case a named one was in the viewport?
} else if (appearingViewTransitions !== null) {
const pair = appearingViewTransitions.get(name);
if (pair !== undefined) {
if (className !== 'none') {
viewTransitionHostInstanceIdx = 0;
const inViewport = applyViewTransitionToHostInstances(
deletion.child,
name,
className,
null,
false,
);
if (!inViewport) {
// Revert the transition names. This boundary is not in the viewport
// so we won't bother animating it.
restoreViewTransitionOnHostInstances(deletion.child, false);
// TODO: Should we still visit the children in case a named one was in the viewport?
} else if (pair !== undefined) {
// We found a new appearing view transition with the same name as this deletion.
// We'll transition between them instead of running the normal exit.
const oldinstance: ViewTransitionState = deletion.stateNode;
@@ -810,8 +851,16 @@ function commitExitViewTransitions(
newInstance.paired = oldinstance;
// Delete the entry so that we know when we've found all of them
// and can stop searching (size reaches zero).
// $FlowFixMe[incompatible-use]: Refined by the pair.
appearingViewTransitions.delete(name);
// Note: If the other side ends up outside the viewport, we'll still run this.
// Therefore it's possible for onShare to be called with only an old snapshot.
scheduleViewTransitionEvent(deletion, props.onShare);
} else {
scheduleViewTransitionEvent(deletion, props.onExit);
}
}
if (appearingViewTransitions !== null) {
// Look for more pairs deeper in the tree.
commitDeletedPairViewTransitions(deletion, appearingViewTransitions);
}
@@ -828,7 +877,10 @@ function commitExitViewTransitions(
}
}
function commitBeforeUpdateViewTransition(current: Fiber): void {
function commitBeforeUpdateViewTransition(
current: Fiber,
finishedWork: Fiber,
): void {
// The way we deal with multiple HostInstances as children of a View Transition in an
// update can get tricky. The important bit is that if you swap out n HostInstances
// from n HostInstances then they match up in order. Similarly, if you don't swap
@@ -845,13 +897,32 @@ function commitBeforeUpdateViewTransition(current: Fiber): void {
// be unexpected but it is in line with the semantics that the ViewTransition is its
// own layer that cross-fades its content when it updates. If you want to reorder then
// each child needs its own ViewTransition.
const props: ViewTransitionProps = current.memoizedProps;
const name = getViewTransitionName(props, current.stateNode);
const oldProps: ViewTransitionProps = current.memoizedProps;
const oldName = getViewTransitionName(oldProps, current.stateNode);
const newProps: ViewTransitionProps = finishedWork.memoizedProps;
// This className applies only if there are fewer child DOM nodes than
// before or if this update should've been cancelled but we ended up with
// a parent animating so we need to animate the child too.
// For example, if update="foo" layout="none" and it turns out this was
// a layout only change, then the "foo" class will be applied even though
// it was not actually an update. Which is a bug.
let className: ?string = getViewTransitionClassName(
newProps.className,
newProps.update,
);
if (className === 'none') {
className = getViewTransitionClassName(newProps.className, newProps.layout);
if (className === 'none') {
// If both update and layout are both "none" then we don't have to
// apply a name. Since we won't animate this boundary.
return;
}
}
viewTransitionHostInstanceIdx = 0;
applyViewTransitionToHostInstances(
current.child,
name,
props.className,
oldName,
className,
(current.memoizedState = []),
true,
);
@@ -865,14 +936,20 @@ function commitNestedViewTransitions(changedParent: Fiber): void {
// was an update through this component then the inner one wins.
const props: ViewTransitionProps = child.memoizedProps;
const name = getViewTransitionName(props, child.stateNode);
viewTransitionHostInstanceIdx = 0;
applyViewTransitionToHostInstances(
child.child,
name,
const className: ?string = getViewTransitionClassName(
props.className,
(child.memoizedState = []),
false,
props.layout,
);
if (className !== 'none') {
viewTransitionHostInstanceIdx = 0;
applyViewTransitionToHostInstances(
child.child,
name,
className,
(child.memoizedState = []),
false,
);
}
} else if ((child.subtreeFlags & ViewTransitionStatic) !== NoFlags) {
commitNestedViewTransitions(child);
}
@@ -962,10 +1039,58 @@ function restoreNestedViewTransitions(changedParent: Fiber): void {
}
}
function cancelViewTransitionHostInstances(
currentViewTransition: Fiber,
child: null | Fiber,
stopAtNestedViewTransitions: boolean,
): void {
if (!supportsMutation) {
return;
}
while (child !== null) {
if (child.tag === HostComponent) {
const instance: Instance = child.stateNode;
const oldName = getViewTransitionName(
currentViewTransition.memoizedProps,
currentViewTransition.stateNode,
);
if (viewTransitionCancelableChildren === null) {
viewTransitionCancelableChildren = [];
}
viewTransitionCancelableChildren.push(
instance,
oldName,
child.memoizedProps,
);
viewTransitionHostInstanceIdx++;
} else if (
child.tag === OffscreenComponent &&
child.memoizedState !== null
) {
// Skip any hidden subtrees. They were or are effectively not there.
} else if (
child.tag === ViewTransitionComponent &&
stopAtNestedViewTransitions
) {
// Skip any nested view transitions for updates since in that case the
// inner most one is the one that handles the update.
} else {
cancelViewTransitionHostInstances(
currentViewTransition,
child.child,
stopAtNestedViewTransitions,
);
}
child = child.sibling;
}
}
function measureViewTransitionHostInstances(
currentViewTransition: Fiber,
parentViewTransition: Fiber,
child: null | Fiber,
name: string,
className: ?string,
previousMeasurements: null | Array<InstanceMeasurement>,
stopAtNestedViewTransitions: boolean,
): boolean {
@@ -1011,20 +1136,15 @@ function measureViewTransitionHostInstances(
parentViewTransition.flags |= AffectedParentLayout;
}
if ((parentViewTransition.flags & Update) !== NoFlags) {
const props: ViewTransitionProps = parentViewTransition.memoizedProps;
// We might update this node so we need to apply its new name for the new state.
const newName = getViewTransitionName(
props,
parentViewTransition.stateNode,
);
applyViewTransitionName(
instance,
viewTransitionHostInstanceIdx === 0
? newName
? name
: // If we have multiple Host Instances below, we add a suffix to the name to give
// each one a unique name.
newName + '_' + viewTransitionHostInstanceIdx,
props.className,
name + '_' + viewTransitionHostInstanceIdx,
className,
);
}
if (!inViewport || (parentViewTransition.flags & Update) === NoFlags) {
@@ -1066,6 +1186,8 @@ function measureViewTransitionHostInstances(
currentViewTransition,
parentViewTransition,
child.child,
name,
className,
previousMeasurements,
stopAtNestedViewTransitions,
)
@@ -1082,6 +1204,42 @@ function measureUpdateViewTransition(
current: Fiber,
finishedWork: Fiber,
): boolean {
const props: ViewTransitionProps = finishedWork.memoizedProps;
const updateClassName: ?string = getViewTransitionClassName(
props.className,
props.update,
);
const layoutClassName: ?string = getViewTransitionClassName(
props.className,
props.update,
);
let className: ?string;
if (updateClassName === 'none') {
if (layoutClassName === 'none') {
// If both update and layout class name were none, then we didn't apply any
// names in the before update phase so we shouldn't now neither.
return false;
}
// We don't care if this is mutated or children layout changed, but we still
// measure each instance to see if it moved and therefore should apply layout.
finishedWork.flags &= ~Update;
className = layoutClassName;
} else if ((finishedWork.flags & Update) !== NoFlags) {
// It was updated and we have an appropriate class name to apply.
className = updateClassName;
} else {
if (layoutClassName === 'none') {
// If we did not update, then all changes are considered a layout. We'll
// attempt to cancel.
viewTransitionHostInstanceIdx = 0;
cancelViewTransitionHostInstances(current, finishedWork.child, true);
return false;
}
// We didn't update but we might still apply layout so we measure each
// instance to see if it moved or resized.
className = layoutClassName;
}
const name = getViewTransitionName(props, finishedWork.stateNode);
// If nothing changed due to a mutation, or children changing size
// and the measurements end up unchanged, we should restore it to not animate.
viewTransitionHostInstanceIdx = 0;
@@ -1090,6 +1248,8 @@ function measureUpdateViewTransition(
current,
finishedWork,
finishedWork.child,
name,
className,
previousMeasurements,
true,
);
@@ -1110,14 +1270,27 @@ function measureNestedViewTransitions(changedParent: Fiber): void {
if (child.tag === ViewTransitionComponent) {
const current = child.alternate;
if (current !== null) {
const props: ViewTransitionProps = child.memoizedProps;
const name = getViewTransitionName(props, child.stateNode);
const className: ?string = getViewTransitionClassName(
props.className,
props.layout,
);
viewTransitionHostInstanceIdx = 0;
measureViewTransitionHostInstances(
const inViewport = measureViewTransitionHostInstances(
current,
child,
child.child,
name,
className,
child.memoizedState,
false,
);
if ((child.flags & Update) === NoFlags || !inViewport) {
// Nothing changed.
} else {
scheduleViewTransitionEvent(child, props.onLayout);
}
}
} else if ((child.subtreeFlags & ViewTransitionStatic) !== NoFlags) {
measureNestedViewTransitions(child);
@@ -2991,11 +3164,6 @@ function recursivelyTraverseAfterMutationEffects(
// its size and position. We need to measure this and if not, restore it to
// not animate.
measureNestedViewTransitions(parentFiber);
if ((parentFiber.flags & AffectedParentLayout) !== NoFlags) {
// This boundary changed size in a way that may have caused its parent to
// relayout. We need to bubble this information up to the parent.
viewTransitionContextChanged = true;
}
}
}
@@ -3075,6 +3243,8 @@ function commitAfterMutationEffectsOnFiber(
(Placement | Update | ChildDeletion | ContentReset | Visibility)) !==
NoFlags
) {
const wasMutated = (finishedWork.flags & Update) !== NoFlags;
const prevContextChanged = viewTransitionContextChanged;
const prevCancelableChildren = viewTransitionCancelableChildren;
viewTransitionContextChanged = false;
@@ -3103,7 +3273,17 @@ function commitAfterMutationEffectsOnFiber(
);
viewTransitionCancelableChildren = prevCancelableChildren;
}
// TODO: If this doesn't end up canceled, because a parent animates,
// then we should probably issue an event since this instance is part of it.
} else {
const props: ViewTransitionProps = finishedWork.memoizedProps;
scheduleViewTransitionEvent(
finishedWork,
wasMutated || viewTransitionContextChanged
? props.onUpdate
: props.onLayout,
);
// If this boundary did update, we cannot cancel its children so those are dropped.
viewTransitionCancelableChildren = prevCancelableChildren;
}
@@ -19,8 +19,18 @@ import {getTreeId} from './ReactFiberTreeContext';
export type ViewTransitionProps = {
name?: string,
className?: string,
children?: ReactNodeList,
className?: 'none' | string,
enter?: 'none' | string,
exit?: 'none' | string,
layout?: 'none' | string,
share?: 'none' | string,
update?: 'none' | string,
onEnter?: (instance: ViewTransitionInstance) => void,
onExit?: (instance: ViewTransitionInstance) => void,
onLayout?: (instance: ViewTransitionInstance) => void,
onShare?: (instance: ViewTransitionInstance) => void,
onUpdate?: (instance: ViewTransitionInstance) => void,
};
export type ViewTransitionState = {
@@ -71,3 +81,19 @@ export function getViewTransitionName(
// We should have assigned a name by now.
return (instance.autoName: any);
}
export function getViewTransitionClassName(
className: ?string,
eventClassName: ?string,
): ?string {
if (eventClassName == null) {
return className;
}
if (eventClassName === 'none') {
return eventClassName;
}
if (className != null) {
return className + ' ' + eventClassName;
}
return eventClassName;
}
+46 -2
View File
@@ -21,9 +21,12 @@ import type {
TransitionAbort,
} from './ReactFiberTracingMarkerComponent';
import type {OffscreenInstance} from './ReactFiberActivityComponent';
import type {Resource} from './ReactFiberConfig';
import type {Resource, ViewTransitionInstance} from './ReactFiberConfig';
import type {RootState} from './ReactFiberRoot';
import type {ViewTransitionState} from './ReactFiberViewTransitionComponent';
import {
getViewTransitionName,
type ViewTransitionState,
} from './ReactFiberViewTransitionComponent';
import {
enableCreateEventHandleAPI,
@@ -95,6 +98,7 @@ import {
resolveUpdatePriority,
trackSchedulerEvent,
startViewTransition,
createViewTransitionInstance,
} from './ReactFiberConfig';
import {createWorkInProgress, resetWorkInProgress} from './ReactFiber';
@@ -649,6 +653,7 @@ let pendingEffectsRemainingLanes: Lanes = NoLanes;
let pendingEffectsRenderEndTime: number = -0; // Profiling-only
let pendingPassiveTransitions: Array<Transition> | null = null;
let pendingRecoverableErrors: null | Array<CapturedValue<mixed>> = null;
let pendingViewTransitionEvents: Array<() => void> | null = null;
let pendingDidIncludeRenderPhaseUpdate: boolean = false;
let pendingSuspendedCommitReason: SuspendedCommitReason = IMMEDIATE_COMMIT; // Profiling-only
@@ -797,6 +802,27 @@ export function requestDeferredLane(): Lane {
return workInProgressDeferredLane;
}
export function scheduleViewTransitionEvent(
fiber: Fiber,
callback: ?(instance: ViewTransitionInstance) => void,
): void {
if (enableViewTransition) {
if (callback != null) {
const state: ViewTransitionState = fiber.stateNode;
let instance = state.ref;
if (instance === null) {
instance = state.ref = createViewTransitionInstance(
getViewTransitionName(fiber.memoizedProps, state),
);
}
if (pendingViewTransitionEvents === null) {
pendingViewTransitionEvents = [];
}
pendingViewTransitionEvents.push(callback.bind(null, instance));
}
}
}
export function peekDeferredLane(): Lane {
return workInProgressDeferredLane;
}
@@ -3322,6 +3348,9 @@ function commitRoot(
pendingEffectsRemainingLanes = remainingLanes;
pendingPassiveTransitions = transitions;
pendingRecoverableErrors = recoverableErrors;
if (enableViewTransition) {
pendingViewTransitionEvents = null;
}
pendingDidIncludeRenderPhaseUpdate = didIncludeRenderPhaseUpdate;
if (enableProfilerTimer) {
pendingEffectsRenderEndTime = completedRenderEndTime;
@@ -3673,6 +3702,21 @@ function flushSpawnedWork(): void {
}
}
if (enableViewTransition) {
// We should now be after the startViewTransition's .ready call which is late enough
// to start animating any pseudo-elements. We do this before flushing any passive
// effects or spawned sync work since this is still part of the previous commit.
// Even though conceptually it's like its own task between layout effets and passive.
const pendingEvents = pendingViewTransitionEvents;
if (pendingEvents !== null) {
pendingViewTransitionEvents = null;
for (let i = 0; i < pendingEvents.length; i++) {
const viewTransitionEvent = pendingEvents[i];
viewTransitionEvent();
}
}
}
// If the passive effects are the result of a discrete render, flush them
// synchronously at the end of the current task so that the result is
// immediately observable. Otherwise, we assume that they are not