Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,7 @@ module.exports = {
ScrollTimeline: 'readonly',
EventListenerOptionsOrUseCapture: 'readonly',
FocusOptions: 'readonly',
OptionalEffectTiming: 'readonly',

spyOnDev: 'readonly',
spyOnDevAndProd: 'readonly',
Expand Down
3 changes: 3 additions & 0 deletions fixtures/view-transition/loader/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"type": "module"
}
54 changes: 54 additions & 0 deletions fixtures/view-transition/loader/server.js
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 4 additions & 3 deletions fixtures/view-transition/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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"
Expand Down
12 changes: 7 additions & 5 deletions fixtures/view-transition/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
}

Expand Down
2 changes: 1 addition & 1 deletion fixtures/view-transition/server/render.js
Original file line number Diff line number Diff line change
@@ -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') {
Expand Down
4 changes: 2 additions & 2 deletions fixtures/view-transition/src/components/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
4 changes: 2 additions & 2 deletions fixtures/view-transition/src/components/Page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
22 changes: 15 additions & 7 deletions fixtures/view-transition/src/components/SwipeRecognizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import React, {
unstable_startGestureTransition as startGestureTransition,
} from 'react';

import ScrollTimelinePolyfill from 'animation-timelines/scroll-timeline';
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I published this as a third party package instead since it's generally useful and I can put more complex ones there.

https://www.npmjs.com/package/animation-timelines


// 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.
Expand All @@ -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,
() => {
Expand Down
2 changes: 1 addition & 1 deletion fixtures/view-transition/src/index.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
5 changes: 5 additions & 0 deletions fixtures/view-transition/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
82 changes: 60 additions & 22 deletions packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -2419,6 +2446,7 @@ export function startGestureTransition(
effect.target,
pseudoElement,
timeline,
customTimelineCleanup,
adjustedRangeStart,
adjustedRangeEnd,
isGeneratedGroupAnim,
Expand All @@ -2445,6 +2473,7 @@ export function startGestureTransition(
effect.target,
pseudoElementName,
timeline,
customTimelineCleanup,
rangeStart,
rangeEnd,
false,
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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.',
Expand Down
Loading