diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e46475f53..ea00a7c5e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,6 +3,7 @@ import { Router } from './router' export { default as createHeadManager } from './head' export { hide as hideProgress, reveal as revealProgress, default as setupProgress } from './progress' export { default as shouldIntercept } from './shouldIntercept' +export { ViewTransitionManager } from './viewTransition' export * from './types' export { hrefToUrl, mergeDataIntoQueryString, urlWithoutHash } from './url' export { type Router } diff --git a/packages/core/src/response.ts b/packages/core/src/response.ts index f3442ca5f..c70ff8bd2 100644 --- a/packages/core/src/response.ts +++ b/packages/core/src/response.ts @@ -8,6 +8,7 @@ import { RequestParams } from './requestParams' import { SessionStorage } from './sessionStorage' import { ActiveVisit, ErrorBag, Errors, Page } from './types' import { hrefToUrl, isSameUrlWithoutHash, setHashIfSameUrl } from './url' +import { ViewTransitionManager } from './viewTransition' const queue = new Queue>() @@ -151,10 +152,15 @@ export class Response { pageResponse.url = history.preserveUrl ? currentPage.get().url : this.pageUrl(pageResponse) - return currentPage.set(pageResponse, { - replace: this.requestParams.all().replace, - preserveScroll: this.requestParams.all().preserveScroll, - preserveState: this.requestParams.all().preserveState, + const visit = this.requestParams.all() + + // Use view transition if enabled + return ViewTransitionManager.createFallbackWrapper(visit, () => { + return currentPage.set(pageResponse, { + replace: visit.replace, + preserveScroll: visit.preserveScroll, + preserveState: visit.preserveState, + }) }) } diff --git a/packages/core/src/router.ts b/packages/core/src/router.ts index ff466863f..ed56d0726 100644 --- a/packages/core/src/router.ts +++ b/packages/core/src/router.ts @@ -30,10 +30,19 @@ import { VisitCallbacks, VisitHelperOptions, VisitOptions, + ViewTransitionOptions, } from './types' import { transformUrlAndData } from './url' export class Router { + /** + * Default view transition options for all visits. + * Can be overridden per visit by passing viewTransition options. + */ + protected defaultViewTransitionOptions: ViewTransitionOptions = { + enabled: false, + } + protected syncRequestStream = new RequestStream({ maxConcurrent: 1, interruptible: true, @@ -157,6 +166,25 @@ export class Router { }) } + /** + * Configure default view transition options for all visits. + * + * @example + * ```typescript + * // Enable view transitions globally + * router.setDefaultViewTransition({ enabled: true }) + * + * // Enable with custom callbacks + * router.setDefaultViewTransition({ + * enabled: true, + * onViewTransitionStart: (t) => console.log('Starting transition') + * }) + * ``` + */ + public setDefaultViewTransition(options: ViewTransitionOptions): void { + this.defaultViewTransitionOptions = { ...this.defaultViewTransitionOptions, ...options } + } + public visit(href: string | URL, options: VisitOptions = {}): void { const visit: PendingVisit = this.getPendingVisit(href, { ...options, @@ -179,10 +207,11 @@ export class Router { Scroll.save() } - const requestParams: PendingVisit & VisitCallbacks = { + const requestParams: ActiveVisit = { ...visit, ...events, - } + viewTransition: visit.viewTransition || this.defaultViewTransitionOptions, + } as ActiveVisit const prefetched = prefetchedRequests.get(requestParams) @@ -242,10 +271,11 @@ export class Router { this.asyncRequestStream.interruptInFlight() - const requestParams: PendingVisit & VisitCallbacks = { + const requestParams: ActiveVisit = { ...visit, ...events, - } + viewTransition: visit.viewTransition || this.defaultViewTransitionOptions, + } as ActiveVisit const ensureCurrentPageIsSet = (): Promise => { return new Promise((resolve) => { @@ -327,15 +357,19 @@ export class Router { } protected getPrefetchParams(href: string | URL, options: VisitOptions): ActiveVisit { + const visit = this.getPendingVisit(href, { + ...options, + async: true, + showProgress: false, + prefetch: true, + }) + const events = this.getVisitEvents(options) + return { - ...this.getPendingVisit(href, { - ...options, - async: true, - showProgress: false, - prefetch: true, - }), - ...this.getVisitEvents(options), - } + ...visit, + ...events, + viewTransition: visit.viewTransition || this.defaultViewTransitionOptions, + } as ActiveVisit } protected getPendingVisit( @@ -361,6 +395,7 @@ export class Router { reset: [], preserveUrl: false, prefetch: false, + viewTransition: this.defaultViewTransitionOptions, ...options, } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b9a1bd5d0..523ffe9c2 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -10,6 +10,14 @@ declare module 'axios' { export type Errors = Record export type ErrorBag = Record +// View Transition API types +export type ViewTransitionOptions = { + enabled?: boolean + onViewTransitionStart?: (transition: any) => void + onViewTransitionEnd?: (transition: any) => void + onViewTransitionError?: (error: Error) => void +} + export type FormDataConvertible = | Array | { [key: string]: FormDataConvertible } @@ -126,6 +134,7 @@ export type Visit = { fresh: boolean reset: string[] preserveUrl: boolean + viewTransition?: ViewTransitionOptions } export type GlobalEventsMap = { @@ -279,7 +288,9 @@ export type PendingVisitOptions = { export type PendingVisit = Visit & PendingVisitOptions -export type ActiveVisit = PendingVisit & Required +export type ActiveVisit = PendingVisit & Required & { + viewTransition: ViewTransitionOptions +} export type InternalActiveVisit = ActiveVisit & { onPrefetchResponse?: (response: Response) => void diff --git a/packages/core/src/viewTransition.ts b/packages/core/src/viewTransition.ts new file mode 100644 index 000000000..e607097db --- /dev/null +++ b/packages/core/src/viewTransition.ts @@ -0,0 +1,68 @@ +import { ActiveVisit, ViewTransitionOptions } from './types' + +export class ViewTransitionManager { + private static isViewTransitionSupported(): boolean { + return typeof document !== 'undefined' && 'startViewTransition' in document + } + + private static shouldUseViewTransition(options?: ViewTransitionOptions): boolean { + if (!options?.enabled) { + return false + } + + return this.isViewTransitionSupported() + } + + public static async withViewTransition( + visit: ActiveVisit, + updateCallback: () => Promise | T, + ): Promise { + const vtOptions = visit.viewTransition + + if (!this.shouldUseViewTransition(vtOptions)) { + return updateCallback() + } + + return new Promise((resolve, reject) => { + const transition = (document as any).startViewTransition(async () => { + try { + const result = await updateCallback() + resolve(result) + } catch (error) { + reject(error) + } + }) + + // Call custom callback if provided + vtOptions?.onViewTransitionStart?.(transition) + + // Handle transition completion + transition.finished + .then(() => { + vtOptions?.onViewTransitionEnd?.(transition) + }) + .catch((error: Error) => { + if (error.name === 'AbortError') { + // View transition was skipped or aborted + return + } + vtOptions?.onViewTransitionError?.(error) + console.warn('View transition error:', error) + }) + }) + } + + public static createFallbackWrapper( + visit: ActiveVisit, + updateCallback: () => Promise | T, + ): Promise { + const vtOptions = visit.viewTransition + + if (this.shouldUseViewTransition(vtOptions)) { + return this.withViewTransition(visit, updateCallback) + } + + // Fallback for browsers without view transition support + return Promise.resolve(updateCallback()) + } +}