mirror of
https://github.com/zebrajr/react.git
synced 2025-12-06 12:20:20 +01:00
This will provide the opt-in for using [View Transitions](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) in React. View Transitions only trigger for async updates like `startTransition`, `useDeferredValue`, Actions or `<Suspense>` revealing from fallback to content. Synchronous updates provide an opt-out but also guarantee that they commit immediately which View Transitions can't. There's no need to opt-in to View Transitions at the "cause" side like event handlers or actions. They don't know what UI will change and whether that has an animated transition described. Conceptually the `<ViewTransition>` component is like a DOM fragment that transitions its children in its own isolate/snapshot. The API works by wrapping a DOM node or inner component: ```js import {ViewTransition} from 'react'; <ViewTransition><Component /></ViewTransition> ``` The default is `name="auto"` which will automatically assign a `view-transition-name` to the inner DOM node. That way you can add a View Transition to a Component without controlling its DOM nodes styling otherwise. A difference between this and the browser's built-in `view-transition-name: auto` is that switching the DOM nodes within the `<ViewTransition>` component preserves the same name so this example cross-fades between the DOM nodes instead of causing an exit and enter: ```js <ViewTransition>{condition ? <ComponentA /> : <ComponentB />}</ViewTransition> ``` This becomes especially useful with `<Suspense>` as this example cross-fades between Skeleton and Content: ```js <ViewTransition> <Suspense fallback={<Skeleton />}> <Content /> </Suspense> </ViewTransition> ``` Where as this example triggers an exit of the Skeleton and an enter of the Content: ```js <Suspense fallback={<ViewTransition><Skeleton /></ViewTransition>}> <ViewTransition><Content /></ViewTransition> </Suspense> ``` Managing instances and keys becomes extra important. You can also specify an explicit `name` property for example for animating the same conceptual item from one page onto another. However, best practices is to property namespace these since they can easily collide. It's also useful to add an `id` to it if available. ```js <ViewTransition name="my-shared-view"> ``` The model in general is the same as plain `view-transition-name` except React manages a set of heuristics for when to apply it. A problem with the naive View Transitions model is that it overly opts in every boundary that *might* transition into transitioning. This is leads to unfortunate effects like things floating around when unrelated updates happen. This leads the whole document to animate which means that nothing is clickable in the meantime. It makes it not useful for smaller and more local transitions. Best practice is to add `view-transition-name` only right before you're about to need to animate the thing. This is tricky to manage globally on complex apps and is not compositional. Instead we let React manage when a `<ViewTransition>` "activates" and add/remove the `view-transition-name`. This is also when React calls `startViewTransition` behind the scenes while it mutates the DOM. I've come up with a number of heuristics that I think will make a lot easier to coordinate this. The principle is that only if something that updates that particular boundary do we activate it. I hope that one day maybe browsers will have something like these built-in and we can remove our implementation. A `<ViewTransition>` only activates if: - If a mounted Component renders a `<ViewTransition>` within it outside the first DOM node, and it is within the viewport, then that ViewTransition activates as an "enter" animation. This avoids inner "enter" animations trigger when the parent mounts. - If an unmounted Component had a `<ViewTransition>` within it outside the first DOM node, and it was within the viewport, then that ViewTransition activates as an "exit" animation. This avoids inner "exit" animations triggering when the parent unmounts. - If an explicitly named `<ViewTransition name="...">` is deep within an unmounted tree and one with the same name appears in a mounted tree at the same time, then both are activated as a pair, but only if they're both in the viewport. This avoids these triggering "enter" or "exit" animations when going between parents that don't have a pair. - If an already mounted `<ViewTransition>` is visible and a DOM mutation, that might affect how it's painted, happens within its children but outside any nested `<ViewTransition>`. This allows it to "cross-fade" between its updates. - If an already mounted `<ViewTransition>` resizes or moves as the result of direct DOM nodes siblings changing or moving around. This allows insertion, deletion and reorders into a list to animate all children. It is only within one DOM node though, to avoid unrelated changes in the parent to trigger this. If an item is outside the viewport before and after, then it's skipped to avoid things flying across the screen. - If a `<ViewTransition>` boundary changes size, due to a DOM mutation within it, then the parent activates (or the root document if there are no more parents). This ensures that the container can cross-fade to avoid abrupt relayout. This can be avoided by using absolutely positioned children. When this can avoid bubbling to the root document, whatever is not animating is still responsive to clicks during the transition. Conceptually each DOM node has its own default that activates the parent `<ViewTransition>` or no transition if the parent is the root. That means that if you add a DOM node like `<div><ViewTransition><Component /></ViewTransition></div>` this won't trigger an "enter" animation since it was the div that was added, not the ViewTransition. Instead, it might cause a cross-fade of the parent ViewTransition or no transition if it had no parent. This ensures that only explicit boundaries perform coarse animations instead of every single node which is really the benefit of the View Transitions model. This ends up working out well for simple cases like switching between two pages immediately while transitioning one floating item that appears on both pages. Because only the floating item transitions by default. Note that it's possible to add manual `view-transition-name` with CSS or `style={{ viewTransitionName: 'auto' }}` that always transitions as long as something else has a `<ViewTransition>` that activates. For example a `<ViewTransition>` can wrap a whole page for a cross-fade but inside of it an explicit name can be added to something to ensure it animates as a move when something relates else changes its layout. Instead of just cross-fading it along with the Page which would be the default. There's more PRs coming with some optimizations, fixes and expanded APIs. This first PR explores the above core heuristic. --------- Co-authored-by: Sebastian "Sebbie" Silbermann <silbermann.sebastian@gmail.com>
353 lines
13 KiB
JavaScript
353 lines
13 KiB
JavaScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @flow
|
|
*/
|
|
|
|
import type {LazyComponent} from 'react/src/ReactLazy';
|
|
|
|
import {
|
|
REACT_SUSPENSE_TYPE,
|
|
REACT_SUSPENSE_LIST_TYPE,
|
|
REACT_FORWARD_REF_TYPE,
|
|
REACT_MEMO_TYPE,
|
|
REACT_LAZY_TYPE,
|
|
REACT_VIEW_TRANSITION_TYPE,
|
|
} from 'shared/ReactSymbols';
|
|
|
|
import {disableLogs, reenableLogs} from 'shared/ConsolePatchingDev';
|
|
|
|
import ReactSharedInternals from 'shared/ReactSharedInternals';
|
|
|
|
import DefaultPrepareStackTrace from 'shared/DefaultPrepareStackTrace';
|
|
|
|
import {enableViewTransition} from 'shared/ReactFeatureFlags';
|
|
|
|
let prefix;
|
|
let suffix;
|
|
export function describeBuiltInComponentFrame(name: string): string {
|
|
if (prefix === undefined) {
|
|
// Extract the VM specific prefix used by each line.
|
|
try {
|
|
throw Error();
|
|
} catch (x) {
|
|
const match = x.stack.trim().match(/\n( *(at )?)/);
|
|
prefix = (match && match[1]) || '';
|
|
suffix =
|
|
x.stack.indexOf('\n at') > -1
|
|
? // V8
|
|
' (<anonymous>)'
|
|
: // JSC/Spidermonkey
|
|
x.stack.indexOf('@') > -1
|
|
? '@unknown:0:0'
|
|
: // Other
|
|
'';
|
|
}
|
|
}
|
|
// We use the prefix to ensure our stacks line up with native stack frames.
|
|
return '\n' + prefix + name + suffix;
|
|
}
|
|
|
|
export function describeDebugInfoFrame(name: string, env: ?string): string {
|
|
return describeBuiltInComponentFrame(name + (env ? ' [' + env + ']' : ''));
|
|
}
|
|
|
|
let reentry = false;
|
|
let componentFrameCache;
|
|
if (__DEV__) {
|
|
const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
|
|
componentFrameCache = new PossiblyWeakMap<Function, string>();
|
|
}
|
|
|
|
/**
|
|
* Leverages native browser/VM stack frames to get proper details (e.g.
|
|
* filename, line + col number) for a single component in a component stack. We
|
|
* do this by:
|
|
* (1) throwing and catching an error in the function - this will be our
|
|
* control error.
|
|
* (2) calling the component which will eventually throw an error that we'll
|
|
* catch - this will be our sample error.
|
|
* (3) diffing the control and sample error stacks to find the stack frame
|
|
* which represents our component.
|
|
*/
|
|
export function describeNativeComponentFrame(
|
|
fn: Function,
|
|
construct: boolean,
|
|
): string {
|
|
// If something asked for a stack inside a fake render, it should get ignored.
|
|
if (!fn || reentry) {
|
|
return '';
|
|
}
|
|
|
|
if (__DEV__) {
|
|
const frame = componentFrameCache.get(fn);
|
|
if (frame !== undefined) {
|
|
return frame;
|
|
}
|
|
}
|
|
|
|
reentry = true;
|
|
const previousPrepareStackTrace = Error.prepareStackTrace;
|
|
Error.prepareStackTrace = DefaultPrepareStackTrace;
|
|
let previousDispatcher = null;
|
|
|
|
if (__DEV__) {
|
|
previousDispatcher = ReactSharedInternals.H;
|
|
// Set the dispatcher in DEV because this might be call in the render function
|
|
// for warnings.
|
|
ReactSharedInternals.H = null;
|
|
disableLogs();
|
|
}
|
|
try {
|
|
/**
|
|
* Finding a common stack frame between sample and control errors can be
|
|
* tricky given the different types and levels of stack trace truncation from
|
|
* different JS VMs. So instead we'll attempt to control what that common
|
|
* frame should be through this object method:
|
|
* Having both the sample and control errors be in the function under the
|
|
* `DescribeNativeComponentFrameRoot` property, + setting the `name` and
|
|
* `displayName` properties of the function ensures that a stack
|
|
* frame exists that has the method name `DescribeNativeComponentFrameRoot` in
|
|
* it for both control and sample stacks.
|
|
*/
|
|
const RunInRootFrame = {
|
|
DetermineComponentFrameRoot(): [?string, ?string] {
|
|
let control;
|
|
try {
|
|
// This should throw.
|
|
if (construct) {
|
|
// Something should be setting the props in the constructor.
|
|
const Fake = function () {
|
|
throw Error();
|
|
};
|
|
// $FlowFixMe[prop-missing]
|
|
Object.defineProperty(Fake.prototype, 'props', {
|
|
set: function () {
|
|
// We use a throwing setter instead of frozen or non-writable props
|
|
// because that won't throw in a non-strict mode function.
|
|
throw Error();
|
|
},
|
|
});
|
|
if (typeof Reflect === 'object' && Reflect.construct) {
|
|
// We construct a different control for this case to include any extra
|
|
// frames added by the construct call.
|
|
try {
|
|
Reflect.construct(Fake, []);
|
|
} catch (x) {
|
|
control = x;
|
|
}
|
|
Reflect.construct(fn, [], Fake);
|
|
} else {
|
|
try {
|
|
Fake.call();
|
|
} catch (x) {
|
|
control = x;
|
|
}
|
|
// $FlowFixMe[prop-missing] found when upgrading Flow
|
|
fn.call(Fake.prototype);
|
|
}
|
|
} else {
|
|
try {
|
|
throw Error();
|
|
} catch (x) {
|
|
control = x;
|
|
}
|
|
// TODO(luna): This will currently only throw if the function component
|
|
// tries to access React/ReactDOM/props. We should probably make this throw
|
|
// in simple components too
|
|
const maybePromise = fn();
|
|
|
|
// If the function component returns a promise, it's likely an async
|
|
// component, which we don't yet support. Attach a noop catch handler to
|
|
// silence the error.
|
|
// TODO: Implement component stacks for async client components?
|
|
if (maybePromise && typeof maybePromise.catch === 'function') {
|
|
maybePromise.catch(() => {});
|
|
}
|
|
}
|
|
} catch (sample) {
|
|
// This is inlined manually because closure doesn't do it for us.
|
|
if (sample && control && typeof sample.stack === 'string') {
|
|
return [sample.stack, control.stack];
|
|
}
|
|
}
|
|
return [null, null];
|
|
},
|
|
};
|
|
// $FlowFixMe[prop-missing]
|
|
RunInRootFrame.DetermineComponentFrameRoot.displayName =
|
|
'DetermineComponentFrameRoot';
|
|
const namePropDescriptor = Object.getOwnPropertyDescriptor(
|
|
RunInRootFrame.DetermineComponentFrameRoot,
|
|
'name',
|
|
);
|
|
// Before ES6, the `name` property was not configurable.
|
|
if (namePropDescriptor && namePropDescriptor.configurable) {
|
|
// V8 utilizes a function's `name` property when generating a stack trace.
|
|
Object.defineProperty(
|
|
RunInRootFrame.DetermineComponentFrameRoot,
|
|
// Configurable properties can be updated even if its writable descriptor
|
|
// is set to `false`.
|
|
// $FlowFixMe[cannot-write]
|
|
'name',
|
|
{value: 'DetermineComponentFrameRoot'},
|
|
);
|
|
}
|
|
|
|
const [sampleStack, controlStack] =
|
|
RunInRootFrame.DetermineComponentFrameRoot();
|
|
if (sampleStack && controlStack) {
|
|
// This extracts the first frame from the sample that isn't also in the control.
|
|
// Skipping one frame that we assume is the frame that calls the two.
|
|
const sampleLines = sampleStack.split('\n');
|
|
const controlLines = controlStack.split('\n');
|
|
let s = 0;
|
|
let c = 0;
|
|
while (
|
|
s < sampleLines.length &&
|
|
!sampleLines[s].includes('DetermineComponentFrameRoot')
|
|
) {
|
|
s++;
|
|
}
|
|
while (
|
|
c < controlLines.length &&
|
|
!controlLines[c].includes('DetermineComponentFrameRoot')
|
|
) {
|
|
c++;
|
|
}
|
|
// We couldn't find our intentionally injected common root frame, attempt
|
|
// to find another common root frame by search from the bottom of the
|
|
// control stack...
|
|
if (s === sampleLines.length || c === controlLines.length) {
|
|
s = sampleLines.length - 1;
|
|
c = controlLines.length - 1;
|
|
while (s >= 1 && c >= 0 && sampleLines[s] !== controlLines[c]) {
|
|
// We expect at least one stack frame to be shared.
|
|
// Typically this will be the root most one. However, stack frames may be
|
|
// cut off due to maximum stack limits. In this case, one maybe cut off
|
|
// earlier than the other. We assume that the sample is longer or the same
|
|
// and there for cut off earlier. So we should find the root most frame in
|
|
// the sample somewhere in the control.
|
|
c--;
|
|
}
|
|
}
|
|
for (; s >= 1 && c >= 0; s--, c--) {
|
|
// Next we find the first one that isn't the same which should be the
|
|
// frame that called our sample function and the control.
|
|
if (sampleLines[s] !== controlLines[c]) {
|
|
// In V8, the first line is describing the message but other VMs don't.
|
|
// If we're about to return the first line, and the control is also on the same
|
|
// line, that's a pretty good indicator that our sample threw at same line as
|
|
// the control. I.e. before we entered the sample frame. So we ignore this result.
|
|
// This can happen if you passed a class to function component, or non-function.
|
|
if (s !== 1 || c !== 1) {
|
|
do {
|
|
s--;
|
|
c--;
|
|
// We may still have similar intermediate frames from the construct call.
|
|
// The next one that isn't the same should be our match though.
|
|
if (c < 0 || sampleLines[s] !== controlLines[c]) {
|
|
// V8 adds a "new" prefix for native classes. Let's remove it to make it prettier.
|
|
let frame = '\n' + sampleLines[s].replace(' at new ', ' at ');
|
|
|
|
// If our component frame is labeled "<anonymous>"
|
|
// but we have a user-provided "displayName"
|
|
// splice it in to make the stack more readable.
|
|
if (fn.displayName && frame.includes('<anonymous>')) {
|
|
frame = frame.replace('<anonymous>', fn.displayName);
|
|
}
|
|
|
|
if (__DEV__) {
|
|
if (typeof fn === 'function') {
|
|
componentFrameCache.set(fn, frame);
|
|
}
|
|
}
|
|
// Return the line we found.
|
|
return frame;
|
|
}
|
|
} while (s >= 1 && c >= 0);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
reentry = false;
|
|
if (__DEV__) {
|
|
ReactSharedInternals.H = previousDispatcher;
|
|
reenableLogs();
|
|
}
|
|
Error.prepareStackTrace = previousPrepareStackTrace;
|
|
}
|
|
// Fallback to just using the name if we couldn't make it throw.
|
|
const name = fn ? fn.displayName || fn.name : '';
|
|
const syntheticFrame = name ? describeBuiltInComponentFrame(name) : '';
|
|
if (__DEV__) {
|
|
if (typeof fn === 'function') {
|
|
componentFrameCache.set(fn, syntheticFrame);
|
|
}
|
|
}
|
|
return syntheticFrame;
|
|
}
|
|
|
|
export function describeClassComponentFrame(ctor: Function): string {
|
|
return describeNativeComponentFrame(ctor, true);
|
|
}
|
|
|
|
export function describeFunctionComponentFrame(fn: Function): string {
|
|
return describeNativeComponentFrame(fn, false);
|
|
}
|
|
|
|
function shouldConstruct(Component: Function) {
|
|
const prototype = Component.prototype;
|
|
return !!(prototype && prototype.isReactComponent);
|
|
}
|
|
|
|
// TODO: Delete this once the key warning no longer uses it. I.e. when enableOwnerStacks ship.
|
|
export function describeUnknownElementTypeFrameInDEV(type: any): string {
|
|
if (!__DEV__) {
|
|
return '';
|
|
}
|
|
if (type == null) {
|
|
return '';
|
|
}
|
|
if (typeof type === 'function') {
|
|
return describeNativeComponentFrame(type, shouldConstruct(type));
|
|
}
|
|
if (typeof type === 'string') {
|
|
return describeBuiltInComponentFrame(type);
|
|
}
|
|
switch (type) {
|
|
case REACT_SUSPENSE_TYPE:
|
|
return describeBuiltInComponentFrame('Suspense');
|
|
case REACT_SUSPENSE_LIST_TYPE:
|
|
return describeBuiltInComponentFrame('SuspenseList');
|
|
case REACT_VIEW_TRANSITION_TYPE:
|
|
if (enableViewTransition) {
|
|
return describeBuiltInComponentFrame('ViewTransition');
|
|
}
|
|
}
|
|
if (typeof type === 'object') {
|
|
switch (type.$$typeof) {
|
|
case REACT_FORWARD_REF_TYPE:
|
|
return describeFunctionComponentFrame(type.render);
|
|
case REACT_MEMO_TYPE:
|
|
// Memo may contain any component type so we recursively resolve it.
|
|
return describeUnknownElementTypeFrameInDEV(type.type);
|
|
case REACT_LAZY_TYPE: {
|
|
const lazyComponent: LazyComponent<any, any> = (type: any);
|
|
const payload = lazyComponent._payload;
|
|
const init = lazyComponent._init;
|
|
try {
|
|
// Lazy may contain any component type so we recursively resolve it.
|
|
return describeUnknownElementTypeFrameInDEV(init(payload));
|
|
} catch (x) {}
|
|
}
|
|
}
|
|
}
|
|
return '';
|
|
}
|