From c87223c21ab5d515fb8f04ee10be5c0ca51e0b29 Mon Sep 17 00:00:00 2001 From: Martin Trapp <94928215+martrapp@users.noreply.github.com> Date: Wed, 22 Nov 2023 13:54:09 +0100 Subject: [PATCH] New events for Astro's view transition API (#9090) * draft new view transition events * initial state for PR * remove intraPageTransitions flag based on review comments * add createAnimationScope after review comments * remove style elements from styles after review comments * remove quotes from animation css to enable set:text * added changeset * move scrollRestoration call from popstate handler to scroll update * Update .changeset/few-keys-heal.md Co-authored-by: Sarah Rainsberger * Less confusing after following review comments * Less confusing after following review comments --------- Co-authored-by: Sarah Rainsberger --- .changeset/few-keys-heal.md | 16 + packages/astro/client.d.ts | 27 +- .../astro/components/ViewTransitions.astro | 5 +- packages/astro/package.json | 2 + .../astro/src/runtime/server/transition.ts | 45 +- packages/astro/src/transitions/events.ts | 184 ++++++++ packages/astro/src/transitions/index.ts | 1 + packages/astro/src/transitions/router.ts | 400 +++++++++++------- packages/astro/src/transitions/types.ts | 10 + .../transitions/vite-plugin-transitions.ts | 9 +- 10 files changed, 528 insertions(+), 171 deletions(-) create mode 100644 .changeset/few-keys-heal.md create mode 100644 packages/astro/src/transitions/events.ts create mode 100644 packages/astro/src/transitions/types.ts diff --git a/.changeset/few-keys-heal.md b/.changeset/few-keys-heal.md new file mode 100644 index 000000000000..cab65e1450ae --- /dev/null +++ b/.changeset/few-keys-heal.md @@ -0,0 +1,16 @@ +--- +'astro': minor +--- +Take full control over the behavior of view transitions! + +Three new events now complement the existing `astro:after-swap` and `astro:page-load` events: + +``` javascript +astro:before-preparation // Control how the DOM and other resources of the target page are loaded +astro:after-preparation // Last changes before taking off? Remove that loading indicator? Here you go! +astro:before-swap // Control how the DOM is updated to match the new page +``` + +The `astro:before-*` events allow you to change properties and strategies of the view transition implementation. +The `astro:after-*` events are notifications that a phase is complete. +Head over to docs to see [the full view transitions lifecycle](https://docs.astro.build/en/guides/view-transitions/#lifecycle-events) including these new events! diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts index f2af4a88c0a1..dfcffbee378c 100644 --- a/packages/astro/client.d.ts +++ b/packages/astro/client.d.ts @@ -109,6 +109,7 @@ declare module 'astro:transitions' { type TransitionModule = typeof import('./dist/transitions/index.js'); export const slide: TransitionModule['slide']; export const fade: TransitionModule['fade']; + export const createAnimationScope: TransitionModule['createAnimationScope']; type ViewTransitionsModule = typeof import('./components/ViewTransitions.astro'); export const ViewTransitions: ViewTransitionsModule['default']; @@ -116,10 +117,30 @@ declare module 'astro:transitions' { declare module 'astro:transitions/client' { type TransitionRouterModule = typeof import('./dist/transitions/router.js'); - export const supportsViewTransitions: TransitionRouterModule['supportsViewTransitions']; - export const transitionEnabledOnThisPage: TransitionRouterModule['transitionEnabledOnThisPage']; export const navigate: TransitionRouterModule['navigate']; - export type Options = import('./dist/transitions/router.js').Options; + + type TransitionUtilModule = typeof import('./dist/transitions/util.js'); + export const supportsViewTransitions: TransitionUtilModule['supportsViewTransitions']; + export const getFallback: TransitionUtilModule['getFallback']; + export const transitionEnabledOnThisPage: TransitionUtilModule['transitionEnabledOnThisPage']; + + export type Fallback = import('./dist/transitions/types.ts').Fallback; + export type Direction = import('./dist/transitions/types.ts').Direction; + export type NavigationTypeString = import('./dist/transitions/types.ts').NavigationTypeString; + export type Options = import('./dist/transitions/types.ts').Options; + + type EventModule = typeof import('./dist/transitions/events.js'); + export const TRANSITION_BEFORE_PREPARATION: EventModule['TRANSITION_BEFORE_PREPARATION']; + export const TRANSITION_AFTER_PREPARATION: EventModule['TRANSITION_AFTER_PREPARATION']; + export const TRANSITION_BEFORE_SWAP: EventModule['TRANSITION_BEFORE_SWAP']; + export const TRANSITION_AFTER_SWAP: EventModule['TRANSITION_AFTER_SWAP']; + export const TRANSITION_PAGE_LOAD: EventModule['TRANSITION_PAGE_LOAD']; + export type TransitionBeforePreparationEvent = + import('./dist/transitions/events.ts').TransitionBeforePreparationEvent; + export type TransitionBeforeSwapEvent = + import('./dist/transitions/events.ts').TransitionBeforeSwapEvent; + export const isTransitionBeforePreparationEvent: EventModule['isTransitionBeforePreparationEvent']; + export const isTransitionBeforeSwapEvent: EventModule['isTransitionBeforeSwapEvent']; } declare module 'astro:prefetch' { diff --git a/packages/astro/components/ViewTransitions.astro b/packages/astro/components/ViewTransitions.astro index a06f1c2a6dc5..645f2046a30f 100644 --- a/packages/astro/components/ViewTransitions.astro +++ b/packages/astro/components/ViewTransitions.astro @@ -33,7 +33,7 @@ const { fallback = 'animate', handleForms } = Astro.props; // @ts-ignore import { init } from 'astro/prefetch'; - export type Fallback = 'none' | 'animate' | 'swap'; + type Fallback = 'none' | 'animate' | 'swap'; function getFallback(): Fallback { const el = document.querySelector('[name="astro-view-transitions-fallback"]'); @@ -85,6 +85,7 @@ const { fallback = 'animate', handleForms } = Astro.props; ev.preventDefault(); navigate(href, { history: link.dataset.astroHistory === 'replace' ? 'replace' : 'auto', + sourceElement: link, }); }); @@ -102,7 +103,7 @@ const { fallback = 'animate', handleForms } = Astro.props; let action = submitter?.getAttribute('formaction') ?? form.action ?? location.pathname; const method = submitter?.getAttribute('formmethod') ?? form.method; - const options: Options = {}; + const options: Options = { sourceElement: submitter ?? form }; if (method === 'get') { const params = new URLSearchParams(formData as any); const url = new URL(action); diff --git a/packages/astro/package.json b/packages/astro/package.json index 8c40b4dd2f25..d4dd53b344c7 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -78,7 +78,9 @@ "default": "./dist/core/middleware/namespace.js" }, "./transitions": "./dist/transitions/index.js", + "./transitions/events": "./dist/transitions/events.js", "./transitions/router": "./dist/transitions/router.js", + "./transitions/types": "./dist/transitions/types.js", "./prefetch": "./dist/prefetch/index.js", "./i18n": "./dist/i18n/index.js" }, diff --git a/packages/astro/src/runtime/server/transition.ts b/packages/astro/src/runtime/server/transition.ts index 17eece1d9819..d38a0eac658e 100644 --- a/packages/astro/src/runtime/server/transition.ts +++ b/packages/astro/src/runtime/server/transition.ts @@ -1,7 +1,9 @@ import type { SSRResult, TransitionAnimation, + TransitionAnimationPair, TransitionAnimationValue, + TransitionDirectionalAnimations, } from '../../@types/astro.js'; import { fade, slide } from '../../transitions/index.js'; import { markHTMLString } from './escape.js'; @@ -34,6 +36,19 @@ const getAnimations = (name: TransitionAnimationValue) => { if (typeof name === 'object') return name; }; +const addPairs = ( + animations: TransitionDirectionalAnimations | Record, + stylesheet: ViewTransitionStyleSheet +) => { + for (const [direction, images] of Object.entries(animations) as Entries) { + for (const [image, rules] of Object.entries(images) as Entries< + (typeof animations)[typeof direction] + >) { + stylesheet.addAnimationPair(direction, image, rules); + } + } +}; + export function renderTransition( result: SSRResult, hash: string, @@ -48,13 +63,7 @@ export function renderTransition( const animations = getAnimations(animationName); if (animations) { - for (const [direction, images] of Object.entries(animations) as Entries) { - for (const [image, rules] of Object.entries(images) as Entries< - (typeof animations)[typeof direction] - >) { - sheet.addAnimationPair(direction, image, rules); - } - } + addPairs(animations, sheet); } else if (animationName === 'none') { sheet.addFallback('old', 'animation: none; mix-blend-mode: normal;'); sheet.addModern('old', 'animation: none; opacity: 0; mix-blend-mode: normal;'); @@ -65,6 +74,19 @@ export function renderTransition( return scope; } +export function createAnimationScope( + transitionName: string, + animations: Record +) { + const hash = Math.random().toString(36).slice(2, 8); + const scope = `astro-${hash}`; + const sheet = new ViewTransitionStyleSheet(scope, transitionName); + + addPairs(animations, sheet); + + return { scope, styles: sheet.toString().replaceAll('"', '') }; +} + class ViewTransitionStyleSheet { private modern: string[] = []; private fallback: string[] = []; @@ -113,13 +135,18 @@ class ViewTransitionStyleSheet { } addAnimationPair( - direction: 'forwards' | 'backwards', + direction: 'forwards' | 'backwards' | string, image: 'old' | 'new', rules: TransitionAnimation | TransitionAnimation[] ) { const { scope, name } = this; const animation = stringifyAnimation(rules); - const prefix = direction === 'backwards' ? `[data-astro-transition=back]` : ''; + const prefix = + direction === 'backwards' + ? `[data-astro-transition=back]` + : direction === 'forwards' + ? '' + : `[data-astro-transition=${direction}]`; this.addRule('modern', `${prefix}::view-transition-${image}(${name}) { ${animation} }`); this.addRule( 'fallback', diff --git a/packages/astro/src/transitions/events.ts b/packages/astro/src/transitions/events.ts new file mode 100644 index 000000000000..b3921b31f0c9 --- /dev/null +++ b/packages/astro/src/transitions/events.ts @@ -0,0 +1,184 @@ +import { updateScrollPosition } from './router.js'; +import type { Direction, NavigationTypeString } from './types.js'; + +export const TRANSITION_BEFORE_PREPARATION = 'astro:before-preparation'; +export const TRANSITION_AFTER_PREPARATION = 'astro:after-preparation'; +export const TRANSITION_BEFORE_SWAP = 'astro:before-swap'; +export const TRANSITION_AFTER_SWAP = 'astro:after-swap'; +export const TRANSITION_PAGE_LOAD = 'astro:page-load'; + +type Events = + | typeof TRANSITION_AFTER_PREPARATION + | typeof TRANSITION_AFTER_SWAP + | typeof TRANSITION_PAGE_LOAD; +export const triggerEvent = (name: Events) => document.dispatchEvent(new Event(name)); +export const onPageLoad = () => triggerEvent(TRANSITION_PAGE_LOAD); + +/* + * Common stuff + */ +class BeforeEvent extends Event { + readonly from: URL; + to: URL; + direction: Direction | string; + readonly navigationType: NavigationTypeString; + readonly sourceElement: Element | undefined; + readonly info: any; + newDocument: Document; + + constructor( + type: string, + eventInitDict: EventInit | undefined, + from: URL, + to: URL, + direction: Direction | string, + navigationType: NavigationTypeString, + sourceElement: Element | undefined, + info: any, + newDocument: Document + ) { + super(type, eventInitDict); + this.from = from; + this.to = to; + this.direction = direction; + this.navigationType = navigationType; + this.sourceElement = sourceElement; + this.info = info; + this.newDocument = newDocument; + + Object.defineProperties(this, { + from: { enumerable: true }, + to: { enumerable: true, writable: true }, + direction: { enumerable: true, writable: true }, + navigationType: { enumerable: true }, + sourceElement: { enumerable: true }, + info: { enumerable: true }, + newDocument: { enumerable: true, writable: true }, + }); + } +} + +/* + * TransitionBeforePreparationEvent + + */ +export const isTransitionBeforePreparationEvent = ( + value: any +): value is TransitionBeforePreparationEvent => value.type === TRANSITION_BEFORE_PREPARATION; +export class TransitionBeforePreparationEvent extends BeforeEvent { + formData: FormData | undefined; + loader: () => Promise; + constructor( + from: URL, + to: URL, + direction: Direction | string, + navigationType: NavigationTypeString, + sourceElement: Element | undefined, + info: any, + newDocument: Document, + formData: FormData | undefined, + loader: (event: TransitionBeforePreparationEvent) => Promise + ) { + super( + TRANSITION_BEFORE_PREPARATION, + { cancelable: true }, + from, + to, + direction, + navigationType, + sourceElement, + info, + newDocument + ); + this.formData = formData; + this.loader = loader.bind(this, this); + Object.defineProperties(this, { + formData: { enumerable: true }, + loader: { enumerable: true, writable: true }, + }); + } +} + +/* + * TransitionBeforeSwapEvent + */ + +export const isTransitionBeforeSwapEvent = (value: any): value is TransitionBeforeSwapEvent => + value.type === TRANSITION_BEFORE_SWAP; +export class TransitionBeforeSwapEvent extends BeforeEvent { + readonly direction: Direction | string; + readonly viewTransition: ViewTransition; + swap: () => void; + + constructor( + afterPreparation: BeforeEvent, + viewTransition: ViewTransition, + swap: (event: TransitionBeforeSwapEvent) => void + ) { + super( + TRANSITION_BEFORE_SWAP, + undefined, + afterPreparation.from, + afterPreparation.to, + afterPreparation.direction, + afterPreparation.navigationType, + afterPreparation.sourceElement, + afterPreparation.info, + afterPreparation.newDocument + ); + this.direction = afterPreparation.direction; + this.viewTransition = viewTransition; + this.swap = swap.bind(this, this); + + Object.defineProperties(this, { + direction: { enumerable: true }, + viewTransition: { enumerable: true }, + swap: { enumerable: true, writable: true }, + }); + } +} + +export async function doPreparation( + from: URL, + to: URL, + direction: Direction | string, + navigationType: NavigationTypeString, + sourceElement: Element | undefined, + info: any, + formData: FormData | undefined, + defaultLoader: (event: TransitionBeforePreparationEvent) => Promise +) { + const event = new TransitionBeforePreparationEvent( + from, + to, + direction, + navigationType, + sourceElement, + info, + window.document, + formData, + defaultLoader + ); + if (document.dispatchEvent(event)) { + await event.loader(); + if (!event.defaultPrevented) { + triggerEvent(TRANSITION_AFTER_PREPARATION); + if (event.navigationType !== 'traverse') { + // save the current scroll position before we change the DOM and transition to the new page + updateScrollPosition({ scrollX, scrollY }); + } + } + } + return event; +} + +export async function doSwap( + afterPreparation: BeforeEvent, + viewTransition: ViewTransition, + defaultSwap: (event: TransitionBeforeSwapEvent) => void +) { + const event = new TransitionBeforeSwapEvent(afterPreparation, viewTransition, defaultSwap); + document.dispatchEvent(event); + event.swap(); + return event; +} diff --git a/packages/astro/src/transitions/index.ts b/packages/astro/src/transitions/index.ts index 0a58d2d4b48a..d87052f2daf2 100644 --- a/packages/astro/src/transitions/index.ts +++ b/packages/astro/src/transitions/index.ts @@ -1,4 +1,5 @@ import type { TransitionAnimationPair, TransitionDirectionalAnimations } from '../@types/astro.js'; +export { createAnimationScope } from '../runtime/server/transition.js'; const EASE_IN_OUT_QUART = 'cubic-bezier(0.76, 0, 0.24, 1)'; diff --git a/packages/astro/src/transitions/router.ts b/packages/astro/src/transitions/router.ts index c4da38c2c8be..3f62e2fdb9fa 100644 --- a/packages/astro/src/transitions/router.ts +++ b/packages/astro/src/transitions/router.ts @@ -1,23 +1,27 @@ -export type Fallback = 'none' | 'animate' | 'swap'; -export type Direction = 'forward' | 'back'; -export type Options = { - history?: 'auto' | 'push' | 'replace'; - formData?: FormData; -}; +import { + doPreparation, + TransitionBeforeSwapEvent, + type TransitionBeforePreparationEvent, + doSwap, + TRANSITION_AFTER_SWAP, +} from './events.js'; +import type { Fallback, Direction, Options } from './types.js'; type State = { index: number; scrollX: number; scrollY: number; - intraPage?: boolean; }; type Events = 'astro:page-load' | 'astro:after-swap'; // only update history entries that are managed by us // leave other entries alone and do not accidently add state. -const updateScrollPosition = (positions: { scrollX: number; scrollY: number }) => - history.state && history.replaceState({ ...history.state, ...positions }, ''); - +export const updateScrollPosition = (positions: { scrollX: number; scrollY: number }) => { + if (history.state) { + history.scrollRestoration = 'manual'; + history.replaceState({ ...history.state, ...positions }, ''); + } +}; const inBrowser = import.meta.env.SSR === false; export const supportsViewTransitions = inBrowser && !!document.startViewTransition; @@ -25,8 +29,21 @@ export const supportsViewTransitions = inBrowser && !!document.startViewTransiti export const transitionEnabledOnThisPage = () => inBrowser && !!document.querySelector('[name="astro-view-transitions-enabled"]'); -const samePage = (otherLocation: URL) => - location.pathname === otherLocation.pathname && location.search === otherLocation.search; +const samePage = (thisLocation: URL, otherLocation: URL) => + thisLocation.origin === otherLocation.origin && + thisLocation.pathname === otherLocation.pathname && + thisLocation.search === otherLocation.search; + +// When we traverse the history, the window.location is already set to the new location. +// This variable tells us where we came from +let originalLocation: URL; +// The result of startViewTransition (browser or simulation) +let viewTransition: ViewTransition | undefined; +// skip transition flag for fallback simulation +let skipTransition = false; +// The resolve function of the finished promise for fallback simulation +let viewTransitionFinished: () => void; + const triggerEvent = (name: Events) => document.dispatchEvent(new Event(name)); const onPageLoad = () => triggerEvent('astro:page-load'); const announce = () => { @@ -48,6 +65,9 @@ const announce = () => { }; const PERSIST_ATTR = 'data-astro-transition-persist'; +const DIRECTION_ATTR = 'data-astro-transition'; +const OLD_NEW_ATTR = 'data-astro-transition-fallback'; + const VITE_ID = 'data-vite-dev-id'; let parser: DOMParser; @@ -66,7 +86,8 @@ if (inBrowser) { } else if (transitionEnabledOnThisPage()) { // This page is loaded from the browser addressbar or via a link from extern, // it needs a state in the history - history.replaceState({ index: currentHistoryIndex, scrollX, scrollY, intraPage: false }, ''); + history.replaceState({ index: currentHistoryIndex, scrollX, scrollY }, ''); + history.scrollRestoration = 'manual'; } } @@ -147,50 +168,61 @@ function runScripts() { return wait; } -function isInfinite(animation: Animation) { - const effect = animation.effect; - if (!effect || !(effect instanceof KeyframeEffect) || !effect.target) return false; - const style = window.getComputedStyle(effect.target, effect.pseudoElement); - return style.animationIterationCount === 'infinite'; -} - // Add a new entry to the browser history. This also sets the new page in the browser addressbar. // Sets the scroll position according to the hash fragment of the new location. -const moveToLocation = (toLocation: URL, replace: boolean, intraPage: boolean) => { - const fresh = !samePage(toLocation); +const moveToLocation = (to: URL, from: URL, options: Options, historyState?: State) => { + const intraPage = samePage(from, to); + let scrolledToTop = false; - if (toLocation.href !== location.href) { - if (replace) { - history.replaceState({ ...history.state }, '', toLocation.href); + if (to.href !== location.href && !historyState) { + if (options.history === 'replace') { + const current = history.state; + history.replaceState( + { + ...options.state, + index: current.index, + scrollX: current.scrollX, + scrollY: current.scrollY, + }, + '', + to.href + ); } else { - history.replaceState({ ...history.state, intraPage }, ''); history.pushState( - { index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 }, + { ...options.state, index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 }, '', - toLocation.href + to.href ); } - // now we are on the new page for non-history navigations! - // (with history navigation page change happens before popstate is fired) - // freshly loaded pages start from the top - if (fresh) { - scrollTo({ left: 0, top: 0, behavior: 'instant' }); - scrolledToTop = true; - } + history.scrollRestoration = 'manual'; } - if (toLocation.hash) { - // because we are already on the target page ... - // ... what comes next is a intra-page navigation - // that won't reload the page but instead scroll to the fragment - location.href = toLocation.href; + // now we are on the new page for non-history navigations! + // (with history navigation page change happens before popstate is fired) + originalLocation = to; + + // freshly loaded pages start from the top + if (!intraPage) { + scrollTo({ left: 0, top: 0, behavior: 'instant' }); + scrolledToTop = true; + } + + if (historyState) { + scrollTo(historyState.scrollX, historyState.scrollY); } else { - if (!scrolledToTop) { - scrollTo({ left: 0, top: 0, behavior: 'instant' }); + if (to.hash) { + // because we are already on the target page ... + // ... what comes next is a intra-page navigation + // that won't reload the page but instead scroll to the fragment + location.href = to.href; + } else { + if (!scrolledToTop) { + scrollTo({ left: 0, top: 0, behavior: 'instant' }); + } } } }; -function stylePreloadLinks(newDocument: Document) { +function preloadStyleLinks(newDocument: Document) { const links: Promise[] = []; for (const el of newDocument.querySelectorAll('head link[rel=stylesheet]')) { // Do not preload links that are already on the page. @@ -221,24 +253,23 @@ function stylePreloadLinks(newDocument: Document) { // if popState is given, this holds the scroll position for history navigation // if fallback === "animate" then simulate view transitions async function updateDOM( - newDocument: Document, - toLocation: URL, + preparationEvent: TransitionBeforePreparationEvent, options: Options, - popState?: State, + historyState?: State, fallback?: Fallback ) { // Check for a head element that should persist and returns it, // either because it has the data attribute or is a link el. // Returns null if the element is not part of the new head, undefined if it should be left alone. - const persistedHeadElement = (el: HTMLElement): Element | null => { + const persistedHeadElement = (el: HTMLElement, newDoc: Document): Element | null => { const id = el.getAttribute(PERSIST_ATTR); - const newEl = id && newDocument.head.querySelector(`[${PERSIST_ATTR}="${id}"]`); + const newEl = id && newDoc.head.querySelector(`[${PERSIST_ATTR}="${id}"]`); if (newEl) { return newEl; } if (el.matches('link[rel=stylesheet]')) { const href = el.getAttribute('href'); - return newDocument.head.querySelector(`link[rel=stylesheet][href="${href}"]`); + return newDoc.head.querySelector(`link[rel=stylesheet][href="${href}"]`); } return null; }; @@ -282,22 +313,22 @@ async function updateDOM( } }; - const swap = () => { + const defaultSwap = (beforeSwapEvent: TransitionBeforeSwapEvent) => { // swap attributes of the html element // - delete all attributes from the current document // - insert all attributes from doc // - reinsert all original attributes that are named 'data-astro-*' const html = document.documentElement; - const astro = [...html.attributes].filter( + const astroAttributes = [...html.attributes].filter( ({ name }) => (html.removeAttribute(name), name.startsWith('data-astro-')) ); - [...newDocument.documentElement.attributes, ...astro].forEach(({ name, value }) => - html.setAttribute(name, value) + [...beforeSwapEvent.newDocument.documentElement.attributes, ...astroAttributes].forEach( + ({ name, value }) => html.setAttribute(name, value) ); // Replace scripts in both the head and body. for (const s1 of document.scripts) { - for (const s2 of newDocument.scripts) { + for (const s2 of beforeSwapEvent.newDocument.scripts) { if ( // Inline (!s1.src && s1.textContent === s2.textContent) || @@ -313,7 +344,7 @@ async function updateDOM( // Swap head for (const el of Array.from(document.head.children)) { - const newEl = persistedHeadElement(el as HTMLElement); + const newEl = persistedHeadElement(el as HTMLElement, beforeSwapEvent.newDocument); // If the element exists in the document already, remove it // from the new document and leave the current node alone if (newEl) { @@ -325,7 +356,7 @@ async function updateDOM( } // Everything left in the new head is new, append it all. - document.head.append(...newDocument.head.children); + document.head.append(...beforeSwapEvent.newDocument.head.children); // Persist elements in the existing body const oldBody = document.body; @@ -333,7 +364,7 @@ async function updateDOM( const savedFocus = saveFocus(); // this will reset scroll Position - document.body.replaceWith(newDocument.body); + document.body.replaceWith(beforeSwapEvent.newDocument.body); for (const el of oldBody.querySelectorAll(`[${PERSIST_ATTR}]`)) { const id = el.getAttribute(PERSIST_ATTR); @@ -345,103 +376,187 @@ async function updateDOM( } } restoreFocus(savedFocus); - - if (popState) { - scrollTo(popState.scrollX, popState.scrollY); // usings 'auto' scrollBehavior - } else { - moveToLocation(toLocation, options.history === 'replace', false); - } - - triggerEvent('astro:after-swap'); }; - const links = stylePreloadLinks(newDocument); - links.length && (await Promise.all(links)); - - if (fallback === 'animate') { + async function animate(phase: string) { + function isInfinite(animation: Animation) { + const effect = animation.effect; + if (!effect || !(effect instanceof KeyframeEffect) || !effect.target) return false; + const style = window.getComputedStyle(effect.target, effect.pseudoElement); + return style.animationIterationCount === 'infinite'; + } // Trigger the animations const currentAnimations = document.getAnimations(); - document.documentElement.dataset.astroTransitionFallback = 'old'; - const newAnimations = document - .getAnimations() - .filter((a) => !currentAnimations.includes(a) && !isInfinite(a)); - const finished = Promise.all(newAnimations.map((a) => a.finished)); - await finished; - swap(); - document.documentElement.dataset.astroTransitionFallback = 'new'; + document.documentElement.setAttribute(OLD_NEW_ATTR, phase); + const nextAnimations = document.getAnimations(); + const newAnimations = nextAnimations.filter( + (a) => !currentAnimations.includes(a) && !isInfinite(a) + ); + return Promise.all(newAnimations.map((a) => a.finished)); + } + + if (!skipTransition) { + document.documentElement.setAttribute(DIRECTION_ATTR, preparationEvent.direction); + + if (fallback === 'animate') { + await animate('old'); + } } else { - swap(); + // that's what Chrome does + throw new DOMException('Transition was skipped'); + } + + const swapEvent = await doSwap(preparationEvent, viewTransition!, defaultSwap); + moveToLocation(swapEvent.to, swapEvent.from, options, historyState); + triggerEvent(TRANSITION_AFTER_SWAP); + + if (fallback === 'animate' && !skipTransition) { + animate('new').then(() => viewTransitionFinished()); } } async function transition( direction: Direction, - toLocation: URL, + from: URL, + to: URL, options: Options, - popState?: State + historyState?: State ) { - let finished: Promise; - const href = toLocation.href; - const init: RequestInit = {}; - if (options.formData) { - init.method = 'POST'; - init.body = options.formData; + const navigationType = historyState + ? 'traverse' + : options.history === 'replace' + ? 'replace' + : 'push'; + + if (samePage(from, to) && !options.formData /* not yet: && to.hash*/) { + if (navigationType !== 'traverse') { + updateScrollPosition({ scrollX, scrollY }); + } + moveToLocation(to, from, options, historyState); + return; } - const response = await fetchHTML(href, init); - // If there is a problem fetching the new page, just do an MPA navigation to it. - if (response === null) { - location.href = href; + + const prepEvent = await doPreparation( + from, + to, + direction, + navigationType, + options.sourceElement, + options.info, + options.formData, + defaultLoader + ); + if (prepEvent.defaultPrevented) { + location.href = to.href; return; } - // if there was a redirection, show the final URL in the browser's address bar - if (response.redirected) { - toLocation = new URL(response.redirected); + + function pageMustReload(preparationEvent: TransitionBeforePreparationEvent) { + return ( + preparationEvent.to.hash === '' || + !samePage(preparationEvent.from, preparationEvent.to) || + preparationEvent.sourceElement instanceof HTMLFormElement + ); } - parser ??= new DOMParser(); + async function defaultLoader(preparationEvent: TransitionBeforePreparationEvent) { + if (pageMustReload(preparationEvent)) { + const href = preparationEvent.to.href; + const init: RequestInit = {}; + if (preparationEvent.formData) { + init.method = 'POST'; + init.body = preparationEvent.formData; + } + const response = await fetchHTML(href, init); + // If there is a problem fetching the new page, just do an MPA navigation to it. + if (response === null) { + preparationEvent.preventDefault(); + return; + } + // if there was a redirection, show the final URL in the browser's address bar + if (response.redirected) { + preparationEvent.to = new URL(response.redirected); + } + + parser ??= new DOMParser(); - const newDocument = parser.parseFromString(response.html, response.mediaType); - // The next line might look like a hack, - // but it is actually necessary as noscript elements - // and their contents are returned as markup by the parser, - // see https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString - newDocument.querySelectorAll('noscript').forEach((el) => el.remove()); + preparationEvent.newDocument = parser.parseFromString(response.html, response.mediaType); + // The next line might look like a hack, + // but it is actually necessary as noscript elements + // and their contents are returned as markup by the parser, + // see https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString + preparationEvent.newDocument.querySelectorAll('noscript').forEach((el) => el.remove()); - // If ViewTransitions is not enabled on the incoming page, do a full page load to it. - // Unless this was a form submission, in which case we do not want to trigger another mutation. - if (!newDocument.querySelector('[name="astro-view-transitions-enabled"]') && !options.formData) { - location.href = href; - return; - } + // If ViewTransitions is not enabled on the incoming page, do a full page load to it. + // Unless this was a form submission, in which case we do not want to trigger another mutation. + if ( + !preparationEvent.newDocument.querySelector('[name="astro-view-transitions-enabled"]') && + !preparationEvent.formData + ) { + preparationEvent.preventDefault(); + return; + } - if (import.meta.env.DEV) await prepareForClientOnlyComponents(newDocument, toLocation); + const links = preloadStyleLinks(preparationEvent.newDocument); + links.length && (await Promise.all(links)); - if (!popState) { - // save the current scroll position before we change the DOM and transition to the new page - history.replaceState({ ...history.state, scrollX, scrollY }, ''); + if (import.meta.env.DEV) + await prepareForClientOnlyComponents(preparationEvent.newDocument, preparationEvent.to); + } else { + preparationEvent.newDocument = document; + return; + } } - document.documentElement.dataset.astroTransition = direction; + + skipTransition = false; if (supportsViewTransitions) { - finished = document.startViewTransition(() => - updateDOM(newDocument, toLocation, options, popState) - ).finished; + viewTransition = document.startViewTransition( + async () => await updateDOM(prepEvent, options, historyState) + ); } else { - finished = updateDOM(newDocument, toLocation, options, popState, getFallback()); + const updateDone = (async () => { + // immediatelly paused to setup the ViewTransition object for Fallback mode + await new Promise((r) => setTimeout(r)); + await updateDOM(prepEvent, options, historyState, getFallback()); + })(); + + // When the updateDone promise is settled, + // we have run and awaited all swap functions and the after-swap event + // This qualifies for "updateCallbackDone". + // + // For the build in ViewTransition, "ready" settles shortly after "updateCallbackDone", + // i.e. after all pseudo elements are created and the animation is about to start. + // In simulation mode the "old" animation starts before swap, + // the "new" animation starts after swap. That is not really comparable. + // Thus we go with "very, very shortly after updateCallbackDone" and make both equal. + // + // "finished" resolves after all animations are done. + + viewTransition = { + updateCallbackDone: updateDone, // this is about correct + ready: updateDone, // good enough + finished: new Promise((r) => (viewTransitionFinished = r)), // see end of updateDOM + skipTransition: () => { + skipTransition = true; + }, + }; } - try { - await finished; - } finally { - // skip this for the moment as it tends to stop fallback animations - // document.documentElement.removeAttribute('data-astro-transition'); + + viewTransition.ready.then(async () => { await runScripts(); onPageLoad(); announce(); - } + }); + viewTransition.finished.then(() => { + document.documentElement.removeAttribute(DIRECTION_ATTR); + document.documentElement.removeAttribute(OLD_NEW_ATTR); + }); + await viewTransition.ready; } let navigateOnServerWarned = false; -export function navigate(href: string, options?: Options) { +export async function navigate(href: string, options?: Options) { if (inBrowser === false) { if (!navigateOnServerWarned) { // instantiate an error for the stacktrace to show to user. @@ -461,17 +576,7 @@ export function navigate(href: string, options?: Options) { location.href = href; return; } - const toLocation = new URL(href, location.href); - // We do not have page transitions on navigations to the same page (intra-page navigation) - // *unless* they are form posts which have side-effects and so need to happen - // but we want to handle prevent reload on navigation to the same page - // Same page means same origin, path and query params (but maybe different hash) - if (location.origin === toLocation.origin && samePage(toLocation) && !options?.formData) { - moveToLocation(toLocation, options?.history === 'replace', true); - } else { - // different origin will be detected by fetch - transition('forward', toLocation, options ?? {}); - } + await transition('forward', originalLocation, new URL(href, location.href), options ?? {}); } function onPopState(ev: PopStateEvent) { @@ -479,10 +584,6 @@ function onPopState(ev: PopStateEvent) { // The current page doesn't have View Transitions enabled // but the page we navigate to does (because it set the state). // Do a full page refresh to reload the client-side router from the new page. - // Scroll restauration will then happen during the reload when the router's code is re-executed - if (history.scrollRestoration) { - history.scrollRestoration = 'manual'; - } location.reload(); return; } @@ -492,28 +593,13 @@ function onPopState(ev: PopStateEvent) { // Just ignore stateless entries. // The browser will handle navigation fine without our help if (ev.state === null) { - if (history.scrollRestoration) { - history.scrollRestoration = 'auto'; - } return; } - - // With the default "auto", the browser will jump to the old scroll position - // before the ViewTransition is complete. - if (history.scrollRestoration) { - history.scrollRestoration = 'manual'; - } - const state: State = history.state; - if (state.intraPage) { - // this is non transition intra-page scrolling - scrollTo(state.scrollX, state.scrollY); - } else { - const nextIndex = state.index; - const direction: Direction = nextIndex > currentHistoryIndex ? 'forward' : 'back'; - currentHistoryIndex = nextIndex; - transition(direction, new URL(location.href), {}, state); - } + const nextIndex = state.index; + const direction: Direction = nextIndex > currentHistoryIndex ? 'forward' : 'back'; + currentHistoryIndex = nextIndex; + transition(direction, originalLocation, new URL(location.href), {}, state); } // There's not a good way to record scroll position before a back button. @@ -522,8 +608,10 @@ const onScroll = () => { updateScrollPosition({ scrollX, scrollY }); }; +// initialization if (inBrowser) { if (supportsViewTransitions || getFallback() !== 'none') { + originalLocation = new URL(location.href); addEventListener('popstate', onPopState); addEventListener('load', onPageLoad); if ('onscrollend' in window) addEventListener('scrollend', onScroll); diff --git a/packages/astro/src/transitions/types.ts b/packages/astro/src/transitions/types.ts new file mode 100644 index 000000000000..0e70825e5967 --- /dev/null +++ b/packages/astro/src/transitions/types.ts @@ -0,0 +1,10 @@ +export type Fallback = 'none' | 'animate' | 'swap'; +export type Direction = 'forward' | 'back'; +export type NavigationTypeString = 'push' | 'replace' | 'traverse'; +export type Options = { + history?: 'auto' | 'push' | 'replace'; + info?: any; + state?: any; + formData?: FormData; + sourceElement?: Element; // more than HTMLElement, e.g. SVGAElement +}; diff --git a/packages/astro/src/transitions/vite-plugin-transitions.ts b/packages/astro/src/transitions/vite-plugin-transitions.ts index 8d5dbe553804..247c61e2bf06 100644 --- a/packages/astro/src/transitions/vite-plugin-transitions.ts +++ b/packages/astro/src/transitions/vite-plugin-transitions.ts @@ -27,7 +27,14 @@ export default function astroTransitions({ settings }: { settings: AstroSettings } if (id === resolvedVirtualClientModuleId) { return ` - export * from "astro/transitions/router"; + export { navigate, supportsViewTransitions, transitionEnabledOnThisPage } from "astro/transitions/router"; + export * from "astro/transitions/types"; + export { + TRANSITION_BEFORE_PREPARATION, isTransitionBeforePreparationEvent, TransitionBeforePreparationEvent, + TRANSITION_AFTER_PREPARATION, + TRANSITION_BEFORE_SWAP, isTransitionBeforeSwapEvent, TransitionBeforeSwapEvent, + TRANSITION_AFTER_SWAP, TRANSITION_PAGE_LOAD + } from "astro/transitions/events"; `; } },