Skip to content

Disable ScrollTimeline in Safari #33499

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

import ScrollTimelinePolyfill from 'animation-timelines/scroll-timeline';
import TouchPanTimeline from 'animation-timelines/touch-pan-timeline';

const ua = typeof navigator === 'undefined' ? '' : navigator.userAgent;
const isSafariMobile =
ua.indexOf('Safari') !== -1 &&
(ua.indexOf('iPhone') !== -1 ||
ua.indexOf('iPad') !== -1 ||
ua.indexOf('iPod') !== -1);

// 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 @@ -21,18 +31,72 @@ export default function SwipeRecognizer({

const scrollRef = useRef(null);
const activeGesture = useRef(null);
function onScroll() {
if (activeGesture.current !== null) {
const touchTimeline = useRef(null);

function onTouchStart(event) {
if (!isSafariMobile && typeof ScrollTimeline === 'function') {
// If not Safari and native ScrollTimeline is supported, then we use that.
return;
}
if (typeof ScrollTimeline !== 'function') {
if (touchTimeline.current) {
// We can catch the gesture before it settles.
return;
}
// eslint-disable-next-line no-undef
const scrollTimeline = new ScrollTimeline({
source: scrollRef.current,
const scrollElement = scrollRef.current;
const bounds =
axis === 'x' ? scrollElement.clientWidth : scrollElement.clientHeight;
const range =
direction === 'left' || direction === 'up' ? [bounds, 0] : [0, -bounds];
const timeline = new TouchPanTimeline({
touch: event,
source: scrollElement,
axis: axis,
range: range,
snap: range,
});
touchTimeline.current = timeline;
timeline.settled.then(() => {
if (touchTimeline.current !== timeline) {
return;
}
touchTimeline.current = null;
const changed =
direction === 'left' || direction === 'up'
? timeline.currentTime < 50
: timeline.currentTime > 50;
onGestureEnd(changed);
});
}

function onTouchEnd() {
if (activeGesture.current === null) {
// If we didn't start a gesture before we release, we can release our
// timeline.
touchTimeline.current = null;
}
}

function onScroll() {
if (activeGesture.current !== null) {
return;
}

let scrollTimeline;
if (touchTimeline.current) {
// We're in a polyfilled touch gesture. Let's use that timeline instead.
scrollTimeline = touchTimeline.current;
} else 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,
});
}
activeGesture.current = startGestureTransition(
scrollTimeline,
() => {
Expand All @@ -49,7 +113,23 @@ export default function SwipeRecognizer({
}
);
}
function onGestureEnd(changed) {
// Reset scroll
if (changed) {
// Trigger side-effects
startTransition(action);
}
if (activeGesture.current !== null) {
const cancelGesture = activeGesture.current;
activeGesture.current = null;
cancelGesture();
}
}
function onScrollEnd() {
if (touchTimeline.current) {
// We have a touch gesture controlling the swipe.
return;
}
let changed;
const scrollElement = scrollRef.current;
if (axis === 'x') {
Expand All @@ -67,16 +147,7 @@ export default function SwipeRecognizer({
? scrollElement.scrollTop < halfway
: scrollElement.scrollTop > halfway;
}
// Reset scroll
if (changed) {
// Trigger side-effects
startTransition(action);
}
if (activeGesture.current !== null) {
const cancelGesture = activeGesture.current;
activeGesture.current = null;
cancelGesture();
}
onGestureEnd(changed);
}

useEffect(() => {
Expand Down Expand Up @@ -168,6 +239,9 @@ export default function SwipeRecognizer({
return (
<div
style={scrollStyle}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchEnd}
onScroll={onScroll}
onScrollEnd={onScrollEnd}
ref={scrollRef}>
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
Loading
Loading