From 736f930c9ae7ce3b4d8ddc1a7602ed01e94375e5 Mon Sep 17 00:00:00 2001 From: ha1fstack <61955474+ha1fstack@users.noreply.github.com> Date: Thu, 13 Nov 2025 22:24:33 +0900 Subject: [PATCH 1/5] feat: add view transition events --- packages/router-core/src/router.ts | 72 +++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 962c536045a..bc1acc6aeab 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -544,6 +544,10 @@ type NavigationEventInfo = { hashChanged: boolean } +export type ViewTransitionEventInfo = { + transition: ViewTransition +} + export interface RouterEvents { onBeforeNavigate: { type: 'onBeforeNavigate' @@ -563,6 +567,22 @@ export interface RouterEvents { onRendered: { type: 'onRendered' } & NavigationEventInfo + onViewTransitionStart: { + type: 'onViewTransitionStart' + } & ViewTransitionEventInfo & + NavigationEventInfo + onViewTransitionReady: { + type: 'onViewTransitionReady' + } & ViewTransitionEventInfo & + NavigationEventInfo + onViewTransitionUpdateCallbackDone: { + type: 'onViewTransitionUpdateCallbackDone' + } & ViewTransitionEventInfo & + NavigationEventInfo + onViewTransitionFinish: { + type: 'onViewTransitionFinish' + } & ViewTransitionEventInfo & + NavigationEventInfo } export type RouterEvent = RouterEvents[keyof RouterEvents] @@ -827,6 +847,7 @@ export function getLocationChangeInfo(routerState: { const hashChanged = fromLocation?.hash !== toLocation.hash return { fromLocation, toLocation, pathChanged, hrefChanged, hashChanged } } +export type LocationChangeInfo = ReturnType export type CreateRouterFn = < TRouteTree extends AnyRoute, @@ -2088,22 +2109,21 @@ export class RouterCore< const next = this.latestLocation const prevLocation = this.state.resolvedLocation + const locationChangeInfo = getLocationChangeInfo({ + resolvedLocation: prevLocation, + location: next, + }) + if (!this.state.redirect) { this.emit({ type: 'onBeforeNavigate', - ...getLocationChangeInfo({ - resolvedLocation: prevLocation, - location: next, - }), + ...locationChangeInfo, }) } this.emit({ type: 'onBeforeLoad', - ...getLocationChangeInfo({ - resolvedLocation: prevLocation, - location: next, - }), + ...locationChangeInfo, }) await loadMatches({ @@ -2114,10 +2134,9 @@ export class RouterCore< updateMatch: this.updateMatch, // eslint-disable-next-line @typescript-eslint/require-await onReady: async () => { - // eslint-disable-next-line @typescript-eslint/require-await // Wrap batch in framework-specific transition wrapper (e.g., Solid's startTransition) this.startTransition(() => { - this.startViewTransition(async () => { + this.startViewTransition(locationChangeInfo, async () => { // this.viewTransitionPromise = createControlledPromise() // Commit the pending matches. If a previous match was @@ -2237,7 +2256,10 @@ export class RouterCore< } } - startViewTransition = (fn: () => Promise) => { + startViewTransition = ( + locationChangeInfo: LocationChangeInfo, + fn: () => Promise, + ) => { // Determine if we should start a view transition from the navigation // or from the router default const shouldViewTransition = @@ -2286,7 +2308,33 @@ export class RouterCore< startViewTransitionParams = fn } - document.startViewTransition(startViewTransitionParams) + const transition = document.startViewTransition(startViewTransitionParams) + this.emit({ + type: 'onViewTransitionStart', + transition, + ...locationChangeInfo, + }) + transition.ready.then(() => { + this.emit({ + type: 'onViewTransitionReady', + transition, + ...locationChangeInfo, + }) + }) + transition.updateCallbackDone.then(() => { + this.emit({ + type: 'onViewTransitionUpdateCallbackDone', + transition, + ...locationChangeInfo, + }) + }) + transition.finished.then(() => { + this.emit({ + type: 'onViewTransitionFinish', + transition, + ...locationChangeInfo, + }) + }) } else { fn() } From 0243c8a18047339a034d373b0f8f54cfb29ed33f Mon Sep 17 00:00:00 2001 From: ha1fstack <61955474+ha1fstack@users.noreply.github.com> Date: Fri, 14 Nov 2025 01:34:52 +0900 Subject: [PATCH 2/5] fix: ts<5.6 compatibility --- packages/router-core/src/router.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index bc1acc6aeab..007aaebb648 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -545,6 +545,7 @@ type NavigationEventInfo = { } export type ViewTransitionEventInfo = { + // @ts-ignore -- ViewTransition support since ts 5.6 transition: ViewTransition } From 813e9a9b1fb0df4fcb3f285d5fb9626ffb4618ea Mon Sep 17 00:00:00 2001 From: ha1fstack <61955474+ha1fstack@users.noreply.github.com> Date: Fri, 14 Nov 2025 01:36:07 +0900 Subject: [PATCH 3/5] docs: update api types --- .../react/api/router/RouterEventsType.md | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/docs/router/framework/react/api/router/RouterEventsType.md b/docs/router/framework/react/api/router/RouterEventsType.md index 0d31f05ce38..cb94a2e4830 100644 --- a/docs/router/framework/react/api/router/RouterEventsType.md +++ b/docs/router/framework/react/api/router/RouterEventsType.md @@ -13,6 +13,7 @@ type RouterEvents = { toLocation: ParsedLocation pathChanged: boolean hrefChanged: boolean + hashChanged: boolean } onBeforeLoad: { type: 'onBeforeLoad' @@ -20,6 +21,7 @@ type RouterEvents = { toLocation: ParsedLocation pathChanged: boolean hrefChanged: boolean + hashChanged: boolean } onLoad: { type: 'onLoad' @@ -27,6 +29,7 @@ type RouterEvents = { toLocation: ParsedLocation pathChanged: boolean hrefChanged: boolean + hashChanged: boolean } onResolved: { type: 'onResolved' @@ -34,6 +37,7 @@ type RouterEvents = { toLocation: ParsedLocation pathChanged: boolean hrefChanged: boolean + hashChanged: boolean } onBeforeRouteMount: { type: 'onBeforeRouteMount' @@ -41,6 +45,7 @@ type RouterEvents = { toLocation: ParsedLocation pathChanged: boolean hrefChanged: boolean + hashChanged: boolean } onInjectedHtml: { type: 'onInjectedHtml' @@ -50,6 +55,45 @@ type RouterEvents = { type: 'onRendered' fromLocation?: ParsedLocation toLocation: ParsedLocation + pathChanged: boolean + hrefChanged: boolean + hashChanged: boolean + } + onViewTransitionStart: { + type: 'onViewTransitionStart' + transition: ViewTransition + fromLocation?: ParsedLocation + toLocation: ParsedLocation + pathChanged: boolean + hrefChanged: boolean + hashChanged: boolean + } + onViewTransitionReady: { + type: 'onViewTransitionReady' + transition: ViewTransition + fromLocation?: ParsedLocation + toLocation: ParsedLocation + pathChanged: boolean + hrefChanged: boolean + hashChanged: boolean + } + onViewTransitionUpdateCallbackDone: { + type: 'onViewTransitionUpdateCallbackDone' + transition: ViewTransition + fromLocation?: ParsedLocation + toLocation: ParsedLocation + pathChanged: boolean + hrefChanged: boolean + hashChanged: boolean + } + onViewTransitionFinish: { + type: 'onViewTransitionFinish' + transition: ViewTransition + fromLocation?: ParsedLocation + toLocation: ParsedLocation + pathChanged: boolean + hrefChanged: boolean + hashChanged: boolean } } ``` @@ -60,7 +104,7 @@ Once an event is emitted, the following properties will be present on the event ### `type` property -- Type: `onBeforeNavigate | onBeforeLoad | onLoad | onBeforeRouteMount | onResolved` +- Type: `onBeforeNavigate | onBeforeLoad | onLoad | onBeforeRouteMount | onResolved | onRendered | onViewTransitionStart | onViewTransitionReady | onViewTransitionUpdateCallbackDone | onViewTransitionFinish` - The type of the event - This is useful for discriminating between events in a listener function. @@ -84,6 +128,18 @@ Once an event is emitted, the following properties will be present on the event - Type: `boolean` - `true` if the href has changed between the `fromLocation` and `toLocation`. +### `hashChanged` property + +- Type: `boolean` +- `true` if the hash has changed between the `fromLocation` and `toLocation`. + +### `transition` property + +- Type: `ViewTransition` +- Available on: `onViewTransitionStart`, `onViewTransitionReady`, `onViewTransitionUpdateCallbackDone`, `onViewTransitionFinish` +- The [ViewTransition](https://developer.mozilla.org/en-US/docs/Web/API/ViewTransition) object representing the view transition in progress. +- This property allows you to interact with the view transition lifecycle, including access to promises like `ready`, `updateCallbackDone`, and `finished`. + ## Example ```tsx From f034cd9fa7caf266433a7bf2110fdba3665bb54c Mon Sep 17 00:00:00 2001 From: ha1fstack <61955474+ha1fstack@users.noreply.github.com> Date: Fri, 14 Nov 2025 02:00:41 +0900 Subject: [PATCH 4/5] fix: use finally instead of then --- packages/router-core/src/router.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 007aaebb648..630b182f437 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2315,21 +2315,21 @@ export class RouterCore< transition, ...locationChangeInfo, }) - transition.ready.then(() => { + transition.ready.finally(() => { this.emit({ type: 'onViewTransitionReady', transition, ...locationChangeInfo, }) }) - transition.updateCallbackDone.then(() => { + transition.updateCallbackDone.finally(() => { this.emit({ type: 'onViewTransitionUpdateCallbackDone', transition, ...locationChangeInfo, }) }) - transition.finished.then(() => { + transition.finished.finally(() => { this.emit({ type: 'onViewTransitionFinish', transition, From e482a3a3142fae7806b41e5462fc8f34d0503e02 Mon Sep 17 00:00:00 2001 From: ha1fstack <61955474+ha1fstack@users.noreply.github.com> Date: Fri, 14 Nov 2025 02:07:46 +0900 Subject: [PATCH 5/5] chore: better handling of LocationChangeInfo --- packages/router-core/src/index.ts | 1 + packages/router-core/src/router.ts | 44 ++++++++++++++---------------- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index b86a1ed67f5..fba7ce7a823 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -208,6 +208,7 @@ export { } from './router' export type { + LocationChangeInfo, ViewTransitionOptions, TrailingSlashOption, Register, diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 630b182f437..238a7b5a446 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -787,16 +787,18 @@ export type AnyRouterWithContext = RouterCore< export type AnyRouter = RouterCore +export interface LocationChangeInfo { + fromLocation?: ParsedLocation + toLocation: ParsedLocation + pathChanged: boolean + hrefChanged: boolean + hashChanged: boolean +} + export interface ViewTransitionOptions { types: | Array - | ((locationChangeInfo: { - fromLocation?: ParsedLocation - toLocation: ParsedLocation - pathChanged: boolean - hrefChanged: boolean - hashChanged: boolean - }) => Array | false) + | ((locationChangeInfo: LocationChangeInfo) => Array | false) } // TODO where is this used? can we remove this? @@ -840,7 +842,7 @@ export type TrailingSlashOption = export function getLocationChangeInfo(routerState: { resolvedLocation?: ParsedLocation location: ParsedLocation -}) { +}): LocationChangeInfo { const fromLocation = routerState.resolvedLocation const toLocation = routerState.location const pathChanged = fromLocation?.pathname !== toLocation.pathname @@ -848,7 +850,6 @@ export function getLocationChangeInfo(routerState: { const hashChanged = fromLocation?.hash !== toLocation.hash return { fromLocation, toLocation, pathChanged, hrefChanged, hashChanged } } -export type LocationChangeInfo = ReturnType export type CreateRouterFn = < TRouteTree extends AnyRoute, @@ -2137,7 +2138,7 @@ export class RouterCore< onReady: async () => { // Wrap batch in framework-specific transition wrapper (e.g., Solid's startTransition) this.startTransition(() => { - this.startViewTransition(locationChangeInfo, async () => { + this.startViewTransition(async () => { // this.viewTransitionPromise = createControlledPromise() // Commit the pending matches. If a previous match was @@ -2257,10 +2258,7 @@ export class RouterCore< } } - startViewTransition = ( - locationChangeInfo: LocationChangeInfo, - fn: () => Promise, - ) => { + startViewTransition = (fn: () => Promise) => { // Determine if we should start a view transition from the navigation // or from the router default const shouldViewTransition = @@ -2279,21 +2277,21 @@ export class RouterCore< // TODO: Fix this when dom types are updated let startViewTransitionParams: any + const next = this.latestLocation + const prevLocation = this.state.resolvedLocation + + const locationChangeInfo = getLocationChangeInfo({ + resolvedLocation: prevLocation, + location: next, + }) + if ( typeof shouldViewTransition === 'object' && this.isViewTransitionTypesSupported ) { - const next = this.latestLocation - const prevLocation = this.state.resolvedLocation - const resolvedViewTransitionTypes = typeof shouldViewTransition.types === 'function' - ? shouldViewTransition.types( - getLocationChangeInfo({ - resolvedLocation: prevLocation, - location: next, - }), - ) + ? shouldViewTransition.types(locationChangeInfo) : shouldViewTransition.types if (resolvedViewTransitionTypes === false) {