diff --git a/.eslintrc.js b/.eslintrc.js index 2736a5d6d3e57..f7f748516d9ab 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -622,6 +622,7 @@ module.exports = { ScrollTimeline: 'readonly', EventListenerOptionsOrUseCapture: 'readonly', FocusOptions: 'readonly', + OptionalEffectTiming: 'readonly', spyOnDev: 'readonly', spyOnDevAndProd: 'readonly', diff --git a/fixtures/view-transition/loader/package.json b/fixtures/view-transition/loader/package.json new file mode 100644 index 0000000000000..3dbc1ca591c05 --- /dev/null +++ b/fixtures/view-transition/loader/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/fixtures/view-transition/loader/server.js b/fixtures/view-transition/loader/server.js new file mode 100644 index 0000000000000..f56ac9fd039b5 --- /dev/null +++ b/fixtures/view-transition/loader/server.js @@ -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; diff --git a/fixtures/view-transition/package.json b/fixtures/view-transition/package.json index 8d222b29d3c07..44a8ff0bfa541 100644 --- a/fixtures/view-transition/package.json +++ b/fixtures/view-transition/package.json @@ -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" diff --git a/fixtures/view-transition/server/index.js b/fixtures/view-transition/server/index.js index 3f542b8f6e67d..e13d4706b9ef9 100644 --- a/fixtures/view-transition/server/index.js +++ b/fixtures/view-transition/server/index.js @@ -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); + }); }); } diff --git a/fixtures/view-transition/server/render.js b/fixtures/view-transition/server/render.js index 11d352eabdd72..08224a57c4da2 100644 --- a/fixtures/view-transition/server/render.js +++ b/fixtures/view-transition/server/render.js @@ -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') { diff --git a/fixtures/view-transition/src/components/App.js b/fixtures/view-transition/src/components/App.js index dd8dcb73a2ef2..bf7ff5c8916a4 100644 --- a/fixtures/view-transition/src/components/App.js +++ b/fixtures/view-transition/src/components/App.js @@ -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'; diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index c0d6f7a0a24ca..ef1a855320634 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -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)); diff --git a/fixtures/view-transition/src/components/SwipeRecognizer.js b/fixtures/view-transition/src/components/SwipeRecognizer.js index 7e7176d194d83..df4d743e1ba7f 100644 --- a/fixtures/view-transition/src/components/SwipeRecognizer.js +++ b/fixtures/view-transition/src/components/SwipeRecognizer.js @@ -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. @@ -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, () => { @@ -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') { @@ -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(() => { @@ -168,6 +239,9 @@ export default function SwipeRecognizer({ return (