0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2024-12-16 21:46:22 -05:00

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 <sarah@rainsberger.ca>

* Less confusing after following review comments

* Less confusing after following review comments

---------

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Martin Trapp 2023-11-22 13:54:09 +01:00 committed by GitHub
parent ac908b7839
commit c87223c21a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 535 additions and 178 deletions

View file

@ -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!

View file

@ -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' {

View file

@ -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);

View file

@ -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"
},

View file

@ -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<string, TransitionAnimationPair>,
stylesheet: ViewTransitionStyleSheet
) => {
for (const [direction, images] of Object.entries(animations) as Entries<typeof animations>) {
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<typeof animations>) {
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<string, TransitionAnimationPair>
) {
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',

View file

@ -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<void>;
constructor(
from: URL,
to: URL,
direction: Direction | string,
navigationType: NavigationTypeString,
sourceElement: Element | undefined,
info: any,
newDocument: Document,
formData: FormData | undefined,
loader: (event: TransitionBeforePreparationEvent) => Promise<void>
) {
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<void>
) {
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;
}

View file

@ -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)';

View file

@ -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);
} else {
history.replaceState({ ...history.state, intraPage }, '');
history.pushState(
{ index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 },
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,
},
'',
toLocation.href
to.href
);
} else {
history.pushState(
{ ...options.state, index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 },
'',
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<any>[] = [];
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<void>;
const href = toLocation.href;
const init: RequestInit = {};
if (options.formData) {
init.method = 'POST';
init.body = options.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) {
location.href = 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);
}
const navigationType = historyState
? 'traverse'
: options.history === 'replace'
? 'replace'
: 'push';
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());
// 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;
if (samePage(from, to) && !options.formData /* not yet: && to.hash*/) {
if (navigationType !== 'traverse') {
updateScrollPosition({ scrollX, scrollY });
}
moveToLocation(to, from, options, historyState);
return;
}
if (import.meta.env.DEV) await prepareForClientOnlyComponents(newDocument, toLocation);
if (!popState) {
// save the current scroll position before we change the DOM and transition to the new page
history.replaceState({ ...history.state, scrollX, scrollY }, '');
const prepEvent = await doPreparation(
from,
to,
direction,
navigationType,
options.sourceElement,
options.info,
options.formData,
defaultLoader
);
if (prepEvent.defaultPrevented) {
location.href = to.href;
return;
}
document.documentElement.dataset.astroTransition = direction;
function pageMustReload(preparationEvent: TransitionBeforePreparationEvent) {
return (
preparationEvent.to.hash === '' ||
!samePage(preparationEvent.from, preparationEvent.to) ||
preparationEvent.sourceElement instanceof HTMLFormElement
);
}
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();
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 (
!preparationEvent.newDocument.querySelector('[name="astro-view-transitions-enabled"]') &&
!preparationEvent.formData
) {
preparationEvent.preventDefault();
return;
}
const links = preloadStyleLinks(preparationEvent.newDocument);
links.length && (await Promise.all(links));
if (import.meta.env.DEV)
await prepareForClientOnlyComponents(preparationEvent.newDocument, preparationEvent.to);
} else {
preparationEvent.newDocument = document;
return;
}
}
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);

View file

@ -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
};

View file

@ -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";
`;
}
},