From 243ba4d79c3c6e7b4dad0cc08d029b60b84d71f2 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 10 Jun 2025 15:32:10 -0400 Subject: [PATCH 1/3] Add CustomTimeline protocol This basically lets a custom implementation drive the Animation we start on pseudo-elements. --- .eslintrc.js | 1 + .../src/client/ReactFiberConfigDOM.js | 82 ++++++++++++++----- 2 files changed, 61 insertions(+), 22 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 2736a5d6d3e57..f7f748516d9ab 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -622,6 +622,7 @@ module.exports = { ScrollTimeline: 'readonly', EventListenerOptionsOrUseCapture: 'readonly', FocusOptions: 'readonly', + OptionalEffectTiming: 'readonly', spyOnDev: 'readonly', spyOnDevAndProd: 'readonly', diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 317d13f6c5366..b891b227e88b2 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -2213,7 +2213,8 @@ function animateGesture( keyframes: any, targetElement: Element, pseudoElement: string, - timeline: AnimationTimeline, + timeline: GestureTimeline, + customTimelineCleanup: Array<() => void>, rangeStart: number, rangeEnd: number, moveFirstFrameIntoViewport: boolean, @@ -2274,24 +2275,49 @@ function animateGesture( } // TODO: Reverse the reverse if the original direction is reverse. const reverse = rangeStart > rangeEnd; - targetElement.animate(keyframes, { - pseudoElement: pseudoElement, - // Set the timeline to the current gesture timeline to drive the updates. - timeline: timeline, - // We reset all easing functions to linear so that it feels like you - // have direct impact on the transition and to avoid double bouncing - // from scroll bouncing. - easing: 'linear', - // We fill in both direction for overscroll. - fill: 'both', // TODO: Should we preserve the fill instead? - // We play all gestures in reverse, except if we're in reverse direction - // in which case we need to play it in reverse of the reverse. - direction: reverse ? 'normal' : 'reverse', - // Range start needs to be higher than range end. If it goes in reverse - // we reverse the whole animation below. - rangeStart: (reverse ? rangeEnd : rangeStart) + '%', - rangeEnd: (reverse ? rangeStart : rangeEnd) + '%', - }); + if (timeline instanceof AnimationTimeline) { + // Native Timeline + targetElement.animate(keyframes, { + pseudoElement: pseudoElement, + // Set the timeline to the current gesture timeline to drive the updates. + timeline: timeline, + // We reset all easing functions to linear so that it feels like you + // have direct impact on the transition and to avoid double bouncing + // from scroll bouncing. + easing: 'linear', + // We fill in both direction for overscroll. + fill: 'both', // TODO: Should we preserve the fill instead? + // We play all gestures in reverse, except if we're in reverse direction + // in which case we need to play it in reverse of the reverse. + direction: reverse ? 'normal' : 'reverse', + // Range start needs to be higher than range end. If it goes in reverse + // we reverse the whole animation below. + rangeStart: (reverse ? rangeEnd : rangeStart) + '%', + rangeEnd: (reverse ? rangeStart : rangeEnd) + '%', + }); + } else { + // Custom Timeline + const animation = targetElement.animate(keyframes, { + pseudoElement: pseudoElement, + // We reset all easing functions to linear so that it feels like you + // have direct impact on the transition and to avoid double bouncing + // from scroll bouncing. + easing: 'linear', + // We fill in both direction for overscroll. + fill: 'both', // TODO: Should we preserve the fill instead? + // We play all gestures in reverse, except if we're in reverse direction + // in which case we need to play it in reverse of the reverse. + direction: reverse ? 'normal' : 'reverse', + // We set the delay and duration to represent the span of the range. + delay: reverse ? rangeEnd : rangeStart, + duration: reverse ? rangeStart - rangeEnd : rangeEnd - rangeStart, + }); + // Let the custom timeline take control of driving the animation. + const cleanup = timeline.animate(animation); + if (cleanup) { + customTimelineCleanup.push(cleanup); + } + } } export function startGestureTransition( @@ -2320,6 +2346,7 @@ export function startGestureTransition( }); // $FlowFixMe[prop-missing] ownerDocument.__reactViewTransition = transition; + const customTimelineCleanup: Array<() => void> = []; // Cleanup Animations started in a CustomTimeline const readyCallback = () => { const documentElement: Element = (ownerDocument.documentElement: any); // Loop through all View Transition Animations. @@ -2419,6 +2446,7 @@ export function startGestureTransition( effect.target, pseudoElement, timeline, + customTimelineCleanup, adjustedRangeStart, adjustedRangeEnd, isGeneratedGroupAnim, @@ -2445,6 +2473,7 @@ export function startGestureTransition( effect.target, pseudoElementName, timeline, + customTimelineCleanup, rangeStart, rangeEnd, false, @@ -2494,6 +2523,10 @@ export function startGestureTransition( transition.ready.then(readyForAnimations, handleError); transition.finished.finally(() => { cancelAllViewTransitionAnimations((ownerDocument.documentElement: any)); + for (let i = 0; i < customTimelineCleanup.length; i++) { + const cleanup = customTimelineCleanup[i]; + cleanup(); + } // $FlowFixMe[prop-missing] if (ownerDocument.__reactViewTransition === transition) { // $FlowFixMe[prop-missing] @@ -2597,10 +2630,15 @@ export function createViewTransitionInstance( }; } -export type GestureTimeline = AnimationTimeline; // TODO: More provider types. +interface CustomTimeline { + currentTime: number; + animate(animation: Animation): void | (() => void); +} + +export type GestureTimeline = AnimationTimeline | CustomTimeline; -export function getCurrentGestureOffset(provider: GestureTimeline): number { - const time = provider.currentTime; +export function getCurrentGestureOffset(timeline: GestureTimeline): number { + const time = timeline.currentTime; if (time === null) { throw new Error( 'Cannot start a gesture with a disconnected AnimationTimeline.', From 6baadf7dd17dc05a0d8815b7b298ef14933fa4e4 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 12 Jun 2025 13:59:24 -0400 Subject: [PATCH 2/3] Support ESM in the fixture server --- fixtures/view-transition/loader/package.json | 3 ++ fixtures/view-transition/loader/server.js | 54 +++++++++++++++++++ fixtures/view-transition/package.json | 7 +-- fixtures/view-transition/server/index.js | 12 +++-- fixtures/view-transition/server/render.js | 2 +- .../view-transition/src/components/App.js | 4 +- .../view-transition/src/components/Page.js | 4 +- fixtures/view-transition/src/index.js | 2 +- 8 files changed, 74 insertions(+), 14 deletions(-) create mode 100644 fixtures/view-transition/loader/package.json create mode 100644 fixtures/view-transition/loader/server.js diff --git a/fixtures/view-transition/loader/package.json b/fixtures/view-transition/loader/package.json new file mode 100644 index 0000000000000..3dbc1ca591c05 --- /dev/null +++ b/fixtures/view-transition/loader/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/fixtures/view-transition/loader/server.js b/fixtures/view-transition/loader/server.js new file mode 100644 index 0000000000000..f56ac9fd039b5 --- /dev/null +++ b/fixtures/view-transition/loader/server.js @@ -0,0 +1,54 @@ +import babel from '@babel/core'; + +const babelOptions = { + babelrc: false, + ignore: [/\/(build|node_modules)\//], + plugins: [ + '@babel/plugin-syntax-import-meta', + '@babel/plugin-transform-react-jsx', + ], +}; + +export async function load(url, context, defaultLoad) { + if (url.endsWith('.css')) { + return {source: 'export default {}', format: 'module', shortCircuit: true}; + } + const {format} = context; + const result = await defaultLoad(url, context, defaultLoad); + if (result.format === 'module') { + const opt = Object.assign({filename: url}, babelOptions); + const newResult = await babel.transformAsync(result.source, opt); + if (!newResult) { + if (typeof result.source === 'string') { + return result; + } + return { + source: Buffer.from(result.source).toString('utf8'), + format: 'module', + }; + } + return {source: newResult.code, format: 'module'}; + } + return defaultLoad(url, context, defaultLoad); +} + +async function babelTransformSource(source, context, defaultTransformSource) { + const {format} = context; + if (format === 'module') { + const opt = Object.assign({filename: context.url}, babelOptions); + const newResult = await babel.transformAsync(source, opt); + if (!newResult) { + if (typeof source === 'string') { + return {source}; + } + return { + source: Buffer.from(source).toString('utf8'), + }; + } + return {source: newResult.code}; + } + return defaultTransformSource(source, context, defaultTransformSource); +} + +export const transformSource = + process.version < 'v16' ? babelTransformSource : undefined; diff --git a/fixtures/view-transition/package.json b/fixtures/view-transition/package.json index 8d222b29d3c07..21964094ebb4e 100644 --- a/fixtures/view-transition/package.json +++ b/fixtures/view-transition/package.json @@ -13,7 +13,8 @@ "express": "^4.14.0", "ignore-styles": "^5.0.1", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "animation-timelines": "^0.0.1" }, "eslintConfig": { "extends": [ @@ -27,8 +28,8 @@ "prebuild": "cp -r ../../build/oss-experimental/* ./node_modules/ && rm -rf node_modules/.cache;", "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"", "dev:client": "BROWSER=none PORT=3001 react-scripts start", - "dev:server": "NODE_ENV=development node server", - "start": "react-scripts build && NODE_ENV=production node server", + "dev:server": "NODE_ENV=development node --experimental-loader ./loader/server.js server", + "start": "react-scripts build && NODE_ENV=production node --experimental-loader ./loader/server.js server", "build": "react-scripts build", "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject" diff --git a/fixtures/view-transition/server/index.js b/fixtures/view-transition/server/index.js index 3f542b8f6e67d..e13d4706b9ef9 100644 --- a/fixtures/view-transition/server/index.js +++ b/fixtures/view-transition/server/index.js @@ -20,13 +20,15 @@ if (process.env.NODE_ENV === 'development') { for (var key in require.cache) { delete require.cache[key]; } - const render = require('./render').default; - render(req.url, res); + import('./render.js').then(({default: render}) => { + render(req.url, res); + }); }); } else { - const render = require('./render').default; - app.get('/', function (req, res) { - render(req.url, res); + import('./render.js').then(({default: render}) => { + app.get('/', function (req, res) { + render(req.url, res); + }); }); } diff --git a/fixtures/view-transition/server/render.js b/fixtures/view-transition/server/render.js index 11d352eabdd72..08224a57c4da2 100644 --- a/fixtures/view-transition/server/render.js +++ b/fixtures/view-transition/server/render.js @@ -1,7 +1,7 @@ import React from 'react'; import {renderToPipeableStream} from 'react-dom/server'; -import App from '../src/components/App'; +import App from '../src/components/App.js'; let assets; if (process.env.NODE_ENV === 'development') { diff --git a/fixtures/view-transition/src/components/App.js b/fixtures/view-transition/src/components/App.js index dd8dcb73a2ef2..bf7ff5c8916a4 100644 --- a/fixtures/view-transition/src/components/App.js +++ b/fixtures/view-transition/src/components/App.js @@ -7,8 +7,8 @@ import React, { use, } from 'react'; -import Chrome from './Chrome'; -import Page from './Page'; +import Chrome from './Chrome.js'; +import Page from './Page.js'; const enableNavigationAPI = typeof navigation === 'object'; diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index c0d6f7a0a24ca..ef1a855320634 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -13,12 +13,12 @@ import React, { import {createPortal} from 'react-dom'; -import SwipeRecognizer from './SwipeRecognizer'; +import SwipeRecognizer from './SwipeRecognizer.js'; import './Page.css'; import transitions from './Transitions.module.css'; -import NestedReveal from './NestedReveal'; +import NestedReveal from './NestedReveal.js'; async function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); diff --git a/fixtures/view-transition/src/index.js b/fixtures/view-transition/src/index.js index 8c2fac3e67ada..29b53bf037928 100644 --- a/fixtures/view-transition/src/index.js +++ b/fixtures/view-transition/src/index.js @@ -1,7 +1,7 @@ import React from 'react'; import {hydrateRoot} from 'react-dom/client'; -import App from './components/App'; +import App from './components/App.js'; hydrateRoot( document, From e4bb93cda84c87d4e98418024da4bd268499ce90 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 10 Jun 2025 11:55:27 -0400 Subject: [PATCH 3/3] Use polyfill from "animation-timelines" package --- fixtures/view-transition/package.json | 2 +- .../src/components/SwipeRecognizer.js | 22 +++++++++++++------ fixtures/view-transition/yarn.lock | 5 +++++ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/fixtures/view-transition/package.json b/fixtures/view-transition/package.json index 21964094ebb4e..44a8ff0bfa541 100644 --- a/fixtures/view-transition/package.json +++ b/fixtures/view-transition/package.json @@ -14,7 +14,7 @@ "ignore-styles": "^5.0.1", "react": "^19.0.0", "react-dom": "^19.0.0", - "animation-timelines": "^0.0.1" + "animation-timelines": "^0.0.4" }, "eslintConfig": { "extends": [ diff --git a/fixtures/view-transition/src/components/SwipeRecognizer.js b/fixtures/view-transition/src/components/SwipeRecognizer.js index 7e7176d194d83..81b544dd1fcba 100644 --- a/fixtures/view-transition/src/components/SwipeRecognizer.js +++ b/fixtures/view-transition/src/components/SwipeRecognizer.js @@ -5,6 +5,8 @@ import React, { unstable_startGestureTransition as startGestureTransition, } from 'react'; +import ScrollTimelinePolyfill from 'animation-timelines/scroll-timeline'; + // Example of a Component that can recognize swipe gestures using a ScrollTimeline // without scrolling its own content. Allowing it to be used as an inert gesture // recognizer to drive a View Transition. @@ -25,14 +27,20 @@ export default function SwipeRecognizer({ if (activeGesture.current !== null) { return; } - if (typeof ScrollTimeline !== 'function') { - return; + + let scrollTimeline; + if (typeof ScrollTimeline === 'function') { + // eslint-disable-next-line no-undef + scrollTimeline = new ScrollTimeline({ + source: scrollRef.current, + axis: axis, + }); + } else { + scrollTimeline = new ScrollTimelinePolyfill({ + source: scrollRef.current, + axis: axis, + }); } - // eslint-disable-next-line no-undef - const scrollTimeline = new ScrollTimeline({ - source: scrollRef.current, - axis: axis, - }); activeGesture.current = startGestureTransition( scrollTimeline, () => { diff --git a/fixtures/view-transition/yarn.lock b/fixtures/view-transition/yarn.lock index 76a6af00ca2ef..3efb208f1ec1a 100644 --- a/fixtures/view-transition/yarn.lock +++ b/fixtures/view-transition/yarn.lock @@ -2427,6 +2427,11 @@ ajv@^8.0.0, ajv@^8.6.0, ajv@^8.9.0: json-schema-traverse "^1.0.0" require-from-string "^2.0.2" +animation-timelines@^0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/animation-timelines/-/animation-timelines-0.0.4.tgz#7ac4614bae73c4d1ea2ff18d5d87a518793258af" + integrity sha512-HwCE3m1nM8ZdLbwDwD1j5ZNKmY+3J2CliXJNIsf3y1Si927SIaWpfxkycTg5nWLJSHgjsYxrmOy2Jbo4JR1e9A== + ansi-escapes@^4.2.1, ansi-escapes@^4.3.1: version "4.3.2" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"