Disable ScrollTimeline in Safari (#33499)

Stacked on #33501.

This disables the use of ScrollTimeline when detected in Safari in the
recommended SwipeRecognizer approach. I'm instead using a polyfill using
touch events on iOS.

Safari seems set to [release ScrollTimeline
soon](https://webkit.org/blog/16993/news-from-wwdc25-web-technology-coming-this-fall-in-safari-26-beta/).
Unfortunately it's not really what you'd expect.

First of all, [it's not running in sync with the
scroll](https://bugs.webkit.org/show_bug.cgi?id=288402) which is kind of
its main point. Instead, it is running at 60fps and out of sync with the
scroll just like JS. In fact, it is worse than JS because with JS you
can at least spawn CSS animations that run at 120fps. So our polyfill
can respond to touches at 60fps while gesturing and then run at 120fps
upon release. That's better than with ScrollTimeline.

Second, [there's a bug which interrupts scrolling if you start a
ViewTransition](https://bugs.webkit.org/show_bug.cgi?id=288795) when the
element is being removed as part of that. The element can still respond
to touches so in a polyfill this isn't an issue. But it essentially
makes it useless to use ScrollTimeline with swipe-away gestures.

So we're better off in every scenario by not using it.

The UA detection is a bit unfortunate. Not sure if there's something
more specific but we also had to do a UA detection for Chrome for View
Transitions. Those are the only two we have in all of React.


![safarimeme](https://github.com/user-attachments/assets/d4ca9eba-489e-4ade-b462-2ffeee3a470c)
This commit is contained in:
Sebastian Markbåge 2025-07-02 17:01:49 -04:00 committed by GitHub
parent 94fce500bc
commit dcf83f7c2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -6,6 +6,14 @@ import React, {
} 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
@ -23,13 +31,61 @@ export default function SwipeRecognizer({
const scrollRef = useRef(null);
const activeGesture = useRef(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 (touchTimeline.current) {
// We can catch the gesture before it settles.
return;
}
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 (typeof ScrollTimeline === 'function') {
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,
@ -57,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') {
@ -75,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(() => {
@ -176,6 +239,9 @@ export default function SwipeRecognizer({
return (
<div
style={scrollStyle}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchEnd}
onScroll={onScroll}
onScrollEnd={onScrollEnd}
ref={scrollRef}>