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.
This commit is contained in:
Sebastian Markbåge 2025-07-02 16:07:46 -04:00 committed by GitHub
parent 73aa744b70
commit fc41c24aa6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 155 additions and 43 deletions

View File

@ -622,6 +622,7 @@ module.exports = {
ScrollTimeline: 'readonly',
EventListenerOptionsOrUseCapture: 'readonly',
FocusOptions: 'readonly',
OptionalEffectTiming: 'readonly',
spyOnDev: 'readonly',
spyOnDevAndProd: 'readonly',

View File

@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@ -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;

View File

@ -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.4"
},
"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"

View File

@ -20,14 +20,16 @@ if (process.env.NODE_ENV === 'development') {
for (var key in require.cache) {
delete require.cache[key];
}
const render = require('./render').default;
import('./render.js').then(({default: render}) => {
render(req.url, res);
});
});
} else {
const render = require('./render').default;
import('./render.js').then(({default: render}) => {
app.get('/', function (req, res) {
render(req.url, res);
});
});
}
// Static resources

View File

@ -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') {

View File

@ -6,8 +6,8 @@ import React, {
unstable_addTransitionType as addTransitionType,
} 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';

View File

@ -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));

View File

@ -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
const scrollTimeline = new ScrollTimeline({
scrollTimeline = new ScrollTimeline({
source: scrollRef.current,
axis: axis,
});
} else {
scrollTimeline = new ScrollTimelinePolyfill({
source: scrollRef.current,
axis: axis,
});
}
activeGesture.current = startGestureTransition(
scrollTimeline,
() => {

View File

@ -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,

View File

@ -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"

View File

@ -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,6 +2275,8 @@ function animateGesture(
}
// TODO: Reverse the reverse if the original direction is reverse.
const 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.
@ -2292,6 +2295,29 @@ function animateGesture(
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 function getCurrentGestureOffset(provider: GestureTimeline): number {
const time = provider.currentTime;
export type GestureTimeline = AnimationTimeline | CustomTimeline;
export function getCurrentGestureOffset(timeline: GestureTimeline): number {
const time = timeline.currentTime;
if (time === null) {
throw new Error(
'Cannot start a gesture with a disconnected AnimationTimeline.',