Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
14 changes: 10 additions & 4 deletions packages/core/src/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Promise<boolean | void>>()

Expand Down Expand Up @@ -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,
})
})
}

Expand Down
59 changes: 47 additions & 12 deletions packages/core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<T extends RequestPayload = RequestPayload>(href: string | URL, options: VisitOptions<T> = {}): void {
const visit: PendingVisit = this.getPendingVisit(href, {
...options,
Expand All @@ -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)

Expand Down Expand Up @@ -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<void> => {
return new Promise((resolve) => {
Expand Down Expand Up @@ -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(
Expand All @@ -361,6 +395,7 @@ export class Router {
reset: [],
preserveUrl: false,
prefetch: false,
viewTransition: this.defaultViewTransitionOptions,
...options,
}

Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ declare module 'axios' {
export type Errors = Record<string, string>
export type ErrorBag = Record<string, Errors>

// 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<FormDataConvertible>
| { [key: string]: FormDataConvertible }
Expand Down Expand Up @@ -126,6 +134,7 @@ export type Visit<T extends RequestPayload = RequestPayload> = {
fresh: boolean
reset: string[]
preserveUrl: boolean
viewTransition?: ViewTransitionOptions
}

export type GlobalEventsMap = {
Expand Down Expand Up @@ -279,7 +288,9 @@ export type PendingVisitOptions = {

export type PendingVisit = Visit & PendingVisitOptions

export type ActiveVisit = PendingVisit & Required<VisitOptions>
export type ActiveVisit = PendingVisit & Required<VisitOptions> & {
viewTransition: ViewTransitionOptions
}

export type InternalActiveVisit = ActiveVisit & {
onPrefetchResponse?: (response: Response) => void
Expand Down
68 changes: 68 additions & 0 deletions packages/core/src/viewTransition.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
visit: ActiveVisit,
updateCallback: () => Promise<T> | T,
): Promise<T> {
const vtOptions = visit.viewTransition

if (!this.shouldUseViewTransition(vtOptions)) {
return updateCallback()
}

return new Promise<T>((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<T>(
visit: ActiveVisit,
updateCallback: () => Promise<T> | T,
): Promise<T> {
const vtOptions = visit.viewTransition

if (this.shouldUseViewTransition(vtOptions)) {
return this.withViewTransition(visit, updateCallback)
}

// Fallback for browsers without view transition support
return Promise.resolve(updateCallback())
}
}