@samitouri / QOS-React-2 / commits / fc41c24aa6

Add ScrollTimeline Polyfill for Swipe Recognizer using a new CustomTimeline protocol (#33501)

The React API is just that we now accept this protocol as an alternative to a native `AnimationTimeline` to be passed to `startGestureTransition`. This is specifically the DOM version. ```js interface CustomTimeline { currentTime: number; animate(animation: Animation): void | (() => void); } ``` Instead, of passing this to the `Animation` that we start to control the View Transition keyframes, we instead inverse the control and pass the `Animation` to this one. It lets any custom implementation drive the updates. It can do so by updating the time every frame or letting it run a time based animation (such as momentum scroll). In this case I added a basic polyfill for `ScrollTimeline` in the example but we'll need a better one.

Sebastian Markbåge committed Jul 2, 2025 at 16:07 UTC fc41c24aa6e674319aed5bd0b25ca6fb92c268df
12 files changed +155 -43
.eslintrc.js
+1
@@ -622,6 +622,7 @@ module.exports = {
622 ScrollTimeline: 'readonly',
623 EventListenerOptionsOrUseCapture: 'readonly',
624 FocusOptions: 'readonly',
625 + OptionalEffectTiming: 'readonly',
626
627 spyOnDev: 'readonly',
628 spyOnDevAndProd: 'readonly',
fixtures/view-transition/loader/package.json new
+3
@@ -0,0 +1,3 @@
1 +{
2 + "type": "module"
3 +}
fixtures/view-transition/loader/server.js new
+54
@@ -0,0 +1,54 @@
1 +import babel from '@babel/core';
2 +
3 +const babelOptions = {
4 + babelrc: false,
5 + ignore: [/\/(build|node_modules)\//],
6 + plugins: [
7 + '@babel/plugin-syntax-import-meta',
8 + '@babel/plugin-transform-react-jsx',
9 + ],
10 +};
11 +
12 +export async function load(url, context, defaultLoad) {
13 + if (url.endsWith('.css')) {
14 + return {source: 'export default {}', format: 'module', shortCircuit: true};
15 + }
16 + const {format} = context;
17 + const result = await defaultLoad(url, context, defaultLoad);
18 + if (result.format === 'module') {
19 + const opt = Object.assign({filename: url}, babelOptions);
20 + const newResult = await babel.transformAsync(result.source, opt);
21 + if (!newResult) {
22 + if (typeof result.source === 'string') {
23 + return result;
24 + }
25 + return {
26 + source: Buffer.from(result.source).toString('utf8'),
27 + format: 'module',
28 + };
29 + }
30 + return {source: newResult.code, format: 'module'};
31 + }
32 + return defaultLoad(url, context, defaultLoad);
33 +}
34 +
35 +async function babelTransformSource(source, context, defaultTransformSource) {
36 + const {format} = context;
37 + if (format === 'module') {
38 + const opt = Object.assign({filename: context.url}, babelOptions);
39 + const newResult = await babel.transformAsync(source, opt);
40 + if (!newResult) {
41 + if (typeof source === 'string') {
42 + return {source};
43 + }
44 + return {
45 + source: Buffer.from(source).toString('utf8'),
46 + };
47 + }
48 + return {source: newResult.code};
49 + }
50 + return defaultTransformSource(source, context, defaultTransformSource);
51 +}
52 +
53 +export const transformSource =
54 + process.version < 'v16' ? babelTransformSource : undefined;
fixtures/view-transition/package.json
+4 -3
@@ -13,7 +13,8 @@
13 "express": "^4.14.0",
14 "ignore-styles": "^5.0.1",
15 "react": "^19.0.0",
16 - "react-dom": "^19.0.0"
16 + "react-dom": "^19.0.0",
17 + "animation-timelines": "^0.0.4"
18 },
19 "eslintConfig": {
20 "extends": [
@@ -27,8 +28,8 @@
28 "prebuild": "cp -r ../../build/oss-experimental/* ./node_modules/ && rm -rf node_modules/.cache;",
29 "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
30 "dev:client": "BROWSER=none PORT=3001 react-scripts start",
30 - "dev:server": "NODE_ENV=development node server",
31 - "start": "react-scripts build && NODE_ENV=production node server",
31 + "dev:server": "NODE_ENV=development node --experimental-loader ./loader/server.js server",
32 + "start": "react-scripts build && NODE_ENV=production node --experimental-loader ./loader/server.js server",
33 "build": "react-scripts build",
34 "test": "react-scripts test --env=jsdom",
35 "eject": "react-scripts eject"
fixtures/view-transition/server/index.js
+7 -5
@@ -20,13 +20,15 @@ if (process.env.NODE_ENV === 'development') {
20 for (var key in require.cache) {
21 delete require.cache[key];
22 }
23 - const render = require('./render').default;
24 - render(req.url, res);
23 + import('./render.js').then(({default: render}) => {
24 + render(req.url, res);
25 + });
26 });
27 } else {
27 - const render = require('./render').default;
28 - app.get('/', function (req, res) {
29 - render(req.url, res);
28 + import('./render.js').then(({default: render}) => {
29 + app.get('/', function (req, res) {
30 + render(req.url, res);
31 + });
32 });
33 }
34
fixtures/view-transition/server/render.js
+1 -1
@@ -1,7 +1,7 @@
1 import React from 'react';
2 import {renderToPipeableStream} from 'react-dom/server';
3
4 -import App from '../src/components/App';
4 +import App from '../src/components/App.js';
5
6 let assets;
7 if (process.env.NODE_ENV === 'development') {
fixtures/view-transition/src/components/App.js
+2 -2
@@ -6,8 +6,8 @@ import React, {
6 unstable_addTransitionType as addTransitionType,
7 } from 'react';
8
9 -import Chrome from './Chrome';
10 -import Page from './Page';
9 +import Chrome from './Chrome.js';
10 +import Page from './Page.js';
11
12 const enableNavigationAPI = typeof navigation === 'object';
13
fixtures/view-transition/src/components/Page.js
+2 -2
@@ -13,12 +13,12 @@ import React, {
13
14 import {createPortal} from 'react-dom';
15
16 -import SwipeRecognizer from './SwipeRecognizer';
16 +import SwipeRecognizer from './SwipeRecognizer.js';
17
18 import './Page.css';
19
20 import transitions from './Transitions.module.css';
21 -import NestedReveal from './NestedReveal';
21 +import NestedReveal from './NestedReveal.js';
22
23 async function sleep(ms) {
24 return new Promise(resolve => setTimeout(resolve, ms));
fixtures/view-transition/src/components/SwipeRecognizer.js
+15 -7
@@ -5,6 +5,8 @@ import React, {
5 unstable_startGestureTransition as startGestureTransition,
6 } from 'react';
7
8 +import ScrollTimelinePolyfill from 'animation-timelines/scroll-timeline';
9 +
10 // Example of a Component that can recognize swipe gestures using a ScrollTimeline
11 // without scrolling its own content. Allowing it to be used as an inert gesture
12 // recognizer to drive a View Transition.
@@ -25,14 +27,20 @@ export default function SwipeRecognizer({
27 if (activeGesture.current !== null) {
28 return;
29 }
28 - if (typeof ScrollTimeline !== 'function') {
29 - return;
30 +
31 + let scrollTimeline;
32 + if (typeof ScrollTimeline === 'function') {
33 + // eslint-disable-next-line no-undef
34 + scrollTimeline = new ScrollTimeline({
35 + source: scrollRef.current,
36 + axis: axis,
37 + });
38 + } else {
39 + scrollTimeline = new ScrollTimelinePolyfill({
40 + source: scrollRef.current,
41 + axis: axis,
42 + });
43 }
31 - // eslint-disable-next-line no-undef
32 - const scrollTimeline = new ScrollTimeline({
33 - source: scrollRef.current,
34 - axis: axis,
35 - });
44 activeGesture.current = startGestureTransition(
45 scrollTimeline,
46 () => {
fixtures/view-transition/src/index.js
+1 -1
@@ -1,7 +1,7 @@
1 import React from 'react';
2 import {hydrateRoot} from 'react-dom/client';
3
4 -import App from './components/App';
4 +import App from './components/App.js';
5
6 hydrateRoot(
7 document,
fixtures/view-transition/yarn.lock
+5
@@ -2427,6 +2427,11 @@ ajv@^8.0.0, ajv@^8.6.0, ajv@^8.9.0:
2427 json-schema-traverse "^1.0.0"
2428 require-from-string "^2.0.2"
2429
2430 +animation-timelines@^0.0.4:
2431 + version "0.0.4"
2432 + resolved "https://registry.yarnpkg.com/animation-timelines/-/animation-timelines-0.0.4.tgz#7ac4614bae73c4d1ea2ff18d5d87a518793258af"
2433 + integrity sha512-HwCE3m1nM8ZdLbwDwD1j5ZNKmY+3J2CliXJNIsf3y1Si927SIaWpfxkycTg5nWLJSHgjsYxrmOy2Jbo4JR1e9A==
2434 +
2435 ansi-escapes@^4.2.1, ansi-escapes@^4.3.1:
2436 version "4.3.2"
2437 resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+60 -22
@@ -2213,7 +2213,8 @@ function animateGesture(
2213 keyframes: any,
2214 targetElement: Element,
2215 pseudoElement: string,
2216 - timeline: AnimationTimeline,
2216 + timeline: GestureTimeline,
2217 + customTimelineCleanup: Array<() => void>,
2218 rangeStart: number,
2219 rangeEnd: number,
2220 moveFirstFrameIntoViewport: boolean,
@@ -2274,24 +2275,49 @@ function animateGesture(
2275 }
2276 // TODO: Reverse the reverse if the original direction is reverse.
2277 const reverse = rangeStart > rangeEnd;
2277 - targetElement.animate(keyframes, {
2278 - pseudoElement: pseudoElement,
2279 - // Set the timeline to the current gesture timeline to drive the updates.
2280 - timeline: timeline,
2281 - // We reset all easing functions to linear so that it feels like you
2282 - // have direct impact on the transition and to avoid double bouncing
2283 - // from scroll bouncing.
2284 - easing: 'linear',
2285 - // We fill in both direction for overscroll.
2286 - fill: 'both', // TODO: Should we preserve the fill instead?
2287 - // We play all gestures in reverse, except if we're in reverse direction
2288 - // in which case we need to play it in reverse of the reverse.
2289 - direction: reverse ? 'normal' : 'reverse',
2290 - // Range start needs to be higher than range end. If it goes in reverse
2291 - // we reverse the whole animation below.
2292 - rangeStart: (reverse ? rangeEnd : rangeStart) + '%',
2293 - rangeEnd: (reverse ? rangeStart : rangeEnd) + '%',
2294 - });
2278 + if (timeline instanceof AnimationTimeline) {
2279 + // Native Timeline
2280 + targetElement.animate(keyframes, {
2281 + pseudoElement: pseudoElement,
2282 + // Set the timeline to the current gesture timeline to drive the updates.
2283 + timeline: timeline,
2284 + // We reset all easing functions to linear so that it feels like you
2285 + // have direct impact on the transition and to avoid double bouncing
2286 + // from scroll bouncing.
2287 + easing: 'linear',
2288 + // We fill in both direction for overscroll.
2289 + fill: 'both', // TODO: Should we preserve the fill instead?
2290 + // We play all gestures in reverse, except if we're in reverse direction
2291 + // in which case we need to play it in reverse of the reverse.
2292 + direction: reverse ? 'normal' : 'reverse',
2293 + // Range start needs to be higher than range end. If it goes in reverse
2294 + // we reverse the whole animation below.
2295 + rangeStart: (reverse ? rangeEnd : rangeStart) + '%',
2296 + rangeEnd: (reverse ? rangeStart : rangeEnd) + '%',
2297 + });
2298 + } else {
2299 + // Custom Timeline
2300 + const animation = targetElement.animate(keyframes, {
2301 + pseudoElement: pseudoElement,
2302 + // We reset all easing functions to linear so that it feels like you
2303 + // have direct impact on the transition and to avoid double bouncing
2304 + // from scroll bouncing.
2305 + easing: 'linear',
2306 + // We fill in both direction for overscroll.
2307 + fill: 'both', // TODO: Should we preserve the fill instead?
2308 + // We play all gestures in reverse, except if we're in reverse direction
2309 + // in which case we need to play it in reverse of the reverse.
2310 + direction: reverse ? 'normal' : 'reverse',
2311 + // We set the delay and duration to represent the span of the range.
2312 + delay: reverse ? rangeEnd : rangeStart,
2313 + duration: reverse ? rangeStart - rangeEnd : rangeEnd - rangeStart,
2314 + });
2315 + // Let the custom timeline take control of driving the animation.
2316 + const cleanup = timeline.animate(animation);
2317 + if (cleanup) {
2318 + customTimelineCleanup.push(cleanup);
2319 + }
2320 + }
2321 }
2322
2323 export function startGestureTransition(
@@ -2320,6 +2346,7 @@ export function startGestureTransition(
2346 });
2347 // $FlowFixMe[prop-missing]
2348 ownerDocument.__reactViewTransition = transition;
2349 + const customTimelineCleanup: Array<() => void> = []; // Cleanup Animations started in a CustomTimeline
2350 const readyCallback = () => {
2351 const documentElement: Element = (ownerDocument.documentElement: any);
2352 // Loop through all View Transition Animations.
@@ -2419,6 +2446,7 @@ export function startGestureTransition(
2446 effect.target,
2447 pseudoElement,
2448 timeline,
2449 + customTimelineCleanup,
2450 adjustedRangeStart,
2451 adjustedRangeEnd,
2452 isGeneratedGroupAnim,
@@ -2445,6 +2473,7 @@ export function startGestureTransition(
2473 effect.target,
2474 pseudoElementName,
2475 timeline,
2476 + customTimelineCleanup,
2477 rangeStart,
2478 rangeEnd,
2479 false,
@@ -2494,6 +2523,10 @@ export function startGestureTransition(
2523 transition.ready.then(readyForAnimations, handleError);
2524 transition.finished.finally(() => {
2525 cancelAllViewTransitionAnimations((ownerDocument.documentElement: any));
2526 + for (let i = 0; i < customTimelineCleanup.length; i++) {
2527 + const cleanup = customTimelineCleanup[i];
2528 + cleanup();
2529 + }
2530 // $FlowFixMe[prop-missing]
2531 if (ownerDocument.__reactViewTransition === transition) {
2532 // $FlowFixMe[prop-missing]
@@ -2597,10 +2630,15 @@ export function createViewTransitionInstance(
2630 };
2631 }
2632
2600 -export type GestureTimeline = AnimationTimeline; // TODO: More provider types.
2633 +interface CustomTimeline {
2634 + currentTime: number;
2635 + animate(animation: Animation): void | (() => void);
2636 +}
2637 +
2638 +export type GestureTimeline = AnimationTimeline | CustomTimeline;
2639
2602 -export function getCurrentGestureOffset(provider: GestureTimeline): number {
2603 - const time = provider.currentTime;
2640 +export function getCurrentGestureOffset(timeline: GestureTimeline): number {
2641 + const time = timeline.currentTime;
2642 if (time === null) {
2643 throw new Error(
2644 'Cannot start a gesture with a disconnected AnimationTimeline.',