summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Martin Trapp <94928215+martrapp@users.noreply.github.com> 2023-11-22 13:54:09 +0100
committerGravatar GitHub <noreply@github.com> 2023-11-22 07:54:09 -0500
commitc87223c21ab5d515fb8f04ee10be5c0ca51e0b29 (patch)
tree4e56efbe5f0969605bb2cc86d2d960296b384576
parentac908b78391711bfcc590bfafb27484b646ffa85 (diff)
downloadastro-c87223c21ab5d515fb8f04ee10be5c0ca51e0b29.tar.gz
astro-c87223c21ab5d515fb8f04ee10be5c0ca51e0b29.tar.zst
astro-c87223c21ab5d515fb8f04ee10be5c0ca51e0b29.zip
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>
-rw-r--r--.changeset/few-keys-heal.md16
-rw-r--r--packages/astro/client.d.ts27
-rw-r--r--packages/astro/components/ViewTransitions.astro5
-rw-r--r--packages/astro/package.json2
-rw-r--r--packages/astro/src/runtime/server/transition.ts45
-rw-r--r--packages/astro/src/transitions/events.ts184
-rw-r--r--packages/astro/src/transitions/index.ts1
-rw-r--r--packages/astro/src/transitions/router.ts400
-rw-r--r--packages/astro/src/transitions/types.ts10
-rw-r--r--packages/astro/src/transitions/vite-plugin-transitions.ts9
10 files changed, 528 insertions, 171 deletions
diff --git a/.changeset/few-keys-heal.md b/.changeset/few-keys-heal.md
new file mode 100644
index 000000000..cab65e145
--- /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 f2af4a88c..dfcffbee3 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 a06f1c2a6..645f2046a 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 8c40b4dd2..d4dd53b34 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 17eece1d9..d38a0eac6 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<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',
diff --git a/packages/astro/src/transitions/events.ts b/packages/astro/src/transitions/events.ts
new file mode 100644
index 000000000..b3921b31f
--- /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<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;
+}
diff --git a/packages/astro/src/transitions/index.ts b/packages/astro/src/transitions/index.ts
index 0a58d2d4b..d87052f2d 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 c4da38c2c..3f62e2fdb 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<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 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 000000000..0e70825e5
--- /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 8d5dbe553..247c61e2b 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";
`;
}
},