From 726a9f13cdd5b484d44f632f32c26054ca393b16 Mon Sep 17 00:00:00 2001 From: Evan Sutherland Date: Fri, 20 Mar 2026 15:09:44 -0500 Subject: [PATCH 1/5] rename titles to routeTitles --- src/services/createExternalRoute.ts | 2 +- src/services/createRoute.ts | 2 +- src/types/route.ts | 4 ++-- ...ser.spec.ts => routeTitle.browser.spec.ts} | 12 +++++------ src/types/{titles.ts => routeTitle.ts} | 20 +++++++++---------- 5 files changed, 20 insertions(+), 20 deletions(-) rename src/types/{titles.browser.spec.ts => routeTitle.browser.spec.ts} (95%) rename src/types/{titles.ts => routeTitle.ts} (57%) diff --git a/src/services/createExternalRoute.ts b/src/services/createExternalRoute.ts index ff7621e4..ca6c9e9d 100644 --- a/src/services/createExternalRoute.ts +++ b/src/services/createExternalRoute.ts @@ -11,7 +11,7 @@ import { combineUrl } from '@/services/combineUrl' import { ExternalRouteHooks } from '@/types/hooks' import { ExtractRouteContext } from '@/types/routeContext' import { RouteRedirects } from '@/types/redirects' -import { createRouteTitle, RouteSetTitle } from '@/types/titles' +import { createRouteTitle, RouteSetTitle } from '@/types/routeTitle' export function createExternalRoute< const TOptions extends CreateRouteOptions & WithHost & WithoutParent diff --git a/src/services/createRoute.ts b/src/services/createRoute.ts index 576b42de..40f5e2bf 100644 --- a/src/services/createRoute.ts +++ b/src/services/createRoute.ts @@ -11,7 +11,7 @@ import { combineUrl } from '@/services/combineUrl' import { InternalRouteHooks } from '@/types/hooks' import { ExtractRouteContext } from '@/types/routeContext' import { RouteRedirects } from '@/types/redirects' -import { createRouteTitle, RouteSetTitle } from '@/types/titles' +import { createRouteTitle, RouteSetTitle } from '@/types/routeTitle' type CreateRouteWithProps< TOptions extends CreateRouteOptions, diff --git a/src/types/route.ts b/src/types/route.ts index d5c9cfb4..9d0f3997 100644 --- a/src/types/route.ts +++ b/src/types/route.ts @@ -5,7 +5,7 @@ import { LastInArray } from '@/types/utilities' import { CreateRouteOptions } from '@/types/createRouteOptions' import { RouteContext } from '@/types/routeContext' import { Url } from '@/types/url' -import { GetTitle } from '@/types/titles' +import { GetRouteTitle } from '@/types/routeTitle' import { Hooks } from '@/models/hooks' import { RouteRedirect } from './redirects' @@ -20,7 +20,7 @@ export type RouteInternal = { depth: number, hooks: Hooks[], redirect: RouteRedirect, - getTitle: GetTitle, + getTitle: GetRouteTitle, } /** diff --git a/src/types/titles.browser.spec.ts b/src/types/routeTitle.browser.spec.ts similarity index 95% rename from src/types/titles.browser.spec.ts rename to src/types/routeTitle.browser.spec.ts index 0c189f1c..9213a430 100644 --- a/src/types/titles.browser.spec.ts +++ b/src/types/routeTitle.browser.spec.ts @@ -1,7 +1,7 @@ import { expect, test, vi } from 'vitest' import { createRoute } from '@/services/createRoute' import { component } from '@/utilities/testHelpers' -import { createRouter } from '@/main' +import { createRouter } from '@/services/createRouter' import { flushPromises } from '@vue/test-utils' test('route with title updates document title', async () => { @@ -20,7 +20,7 @@ test('route with title updates document title', async () => { initialUrl: '/', }) - router.start() + await router.start() await flushPromises() @@ -51,7 +51,7 @@ test('route with title and parent with title does not call parent getTitle', asy initialUrl: '/parent/child', }) - router.start() + await router.start() await flushPromises() @@ -88,7 +88,7 @@ test('route with title and parent with title does call parent getTitle when call initialUrl: '/parent/child', }) - router.start() + await router.start() await flushPromises() @@ -132,7 +132,7 @@ test('route with title and parent with title does call parent getTitle when call initialUrl: '/parent/child/grandchild', }) - router.start() + await router.start() await flushPromises() @@ -161,7 +161,7 @@ test('route without title and parent with title updates document title', async ( initialUrl: '/parent/child', }) - router.start() + await router.start() await flushPromises() diff --git a/src/types/titles.ts b/src/types/routeTitle.ts similarity index 57% rename from src/types/titles.ts rename to src/types/routeTitle.ts index a0fe627d..5494245b 100644 --- a/src/types/titles.ts +++ b/src/types/routeTitle.ts @@ -2,35 +2,35 @@ import { ResolvedRoute, ResolvedRouteUnion } from '@/types/resolved' import { isRoute, Route } from './route' import { MaybePromise } from './utilities' -export type SetTitleContext = { +export type SetRouteTitleContext = { from: ResolvedRoute, getParentTitle: () => Promise, } -export type SetTitleCallback = (to: ResolvedRouteUnion, context: SetTitleContext) => MaybePromise -export type GetTitle = (to: ResolvedRouteUnion) => Promise -export type SetTitle = (callback: SetTitleCallback) => void +export type SetRouteTitleCallback = (to: ResolvedRouteUnion, context: SetRouteTitleContext) => MaybePromise +export type GetRouteTitle = (to: ResolvedRouteUnion) => Promise +export type SetRouteTitle = (callback: SetRouteTitleCallback) => void export type RouteSetTitle = { /** * Adds a callback to set the document title for the route. */ - setTitle: SetTitle, + setTitle: SetRouteTitle, } type CreateRouteTitle = { - setTitle: SetTitle, - getTitle: GetTitle, + setTitle: SetRouteTitle, + getTitle: GetRouteTitle, } export function createRouteTitle(parent: Route | undefined): CreateRouteTitle { - let setTitleCallback: SetTitleCallback | undefined + let setTitleCallback: SetRouteTitleCallback | undefined - const setTitle: SetTitle = (callback) => { + const setTitle: SetRouteTitle = (callback) => { setTitleCallback = callback } - const getTitle: GetTitle = async (to) => { + const getTitle: GetRouteTitle = async (to) => { const getParentTitle = async (): Promise => { if (parent && isRoute(parent)) { return parent.getTitle(to) From 9e9d6c648a18075d92fee7b032d28fa3f97104a0 Mon Sep 17 00:00:00 2001 From: Evan Sutherland Date: Fri, 20 Mar 2026 15:10:04 -0500 Subject: [PATCH 2/5] add get/set title paradigm to rejections --- src/services/createRejection.ts | 14 +++-- src/types/rejection.ts | 2 + src/types/rejectionTitle.browser.spec.ts | 67 ++++++++++++++++++++++++ src/types/rejectionTitle.ts | 40 ++++++++++++++ 4 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 src/types/rejectionTitle.browser.spec.ts create mode 100644 src/types/rejectionTitle.ts diff --git a/src/services/createRejection.ts b/src/services/createRejection.ts index af037929..c1a6bdbe 100644 --- a/src/services/createRejection.ts +++ b/src/services/createRejection.ts @@ -4,32 +4,36 @@ import { RejectionHooks } from '@/types/hooks' import { IS_REJECTION_SYMBOL, Rejection, RejectionInternal } from '@/types/rejection' import { Component, markRaw } from 'vue' import { ResolvedRoute } from '@/types/resolved' -import { createRouteId } from './createRouteId' -import { createResolvedRouteQuery } from './createResolvedRouteQuery' +import { createRouteId } from '@/services/createRouteId' +import { createResolvedRouteQuery } from '@/services/createResolvedRouteQuery' +import { createRejectionTitle, RejectionSetTitle } from '@/types/rejectionTitle' export function createRejection(options: { type: TType, component?: Component, -}): Rejection & RejectionHooks +}): Rejection & RejectionHooks & RejectionSetTitle -export function createRejection(options: { type: string, component?: Component }): Rejection & RejectionHooks { +export function createRejection(options: { type: string, component?: Component }): Rejection { const component = markRaw(options.component ?? genericRejection(options.type)) const route = getRejectionRoute(options.type, component) const { store, ...hooks } = createRejectionHooks() + const { setTitle, getTitle } = createRejectionTitle() const internal = { [IS_REJECTION_SYMBOL]: true, route, hooks: [store], + getTitle, } satisfies RejectionInternal const rejection = { type: options.type, component, + setTitle, ...hooks, ...internal, - } satisfies Rejection & RejectionInternal & RejectionHooks + } satisfies Rejection & RejectionInternal & RejectionHooks & RejectionSetTitle return rejection } diff --git a/src/types/rejection.ts b/src/types/rejection.ts index aadc2927..7bf90e67 100644 --- a/src/types/rejection.ts +++ b/src/types/rejection.ts @@ -3,6 +3,7 @@ import { ResolvedRoute } from '@/types/resolved' import { Router } from '@/types/router' import { RouterReject } from '@/types/routerReject' import { Hooks } from '@/models/hooks' +import { GetRejectionTitle } from '@/types/rejectionTitle' export const BUILT_IN_REJECTION_TYPES = ['NotFound'] as const export type BuiltInRejectionType = (typeof BUILT_IN_REJECTION_TYPES)[number] @@ -20,6 +21,7 @@ export type RejectionInternal = { [IS_REJECTION_SYMBOL]: true, route: ResolvedRoute, hooks: Hooks[], + getTitle: GetRejectionTitle, } /** diff --git a/src/types/rejectionTitle.browser.spec.ts b/src/types/rejectionTitle.browser.spec.ts new file mode 100644 index 00000000..40ecd7b8 --- /dev/null +++ b/src/types/rejectionTitle.browser.spec.ts @@ -0,0 +1,67 @@ +import { expect, test, vi } from 'vitest' +import { createRejection } from '@/services/createRejection' +import { component, routes } from '@/utilities/testHelpers' +import { createRouter } from '@/services/createRouter' +import { createRoute } from '@/services/createRoute' +import { flushPromises } from '@vue/test-utils' + +test('rejection with title updates document title', async () => { + const rejection = createRejection({ type: 'CustomRejection' }) + + const title = 'foo' + const callback = vi.fn(() => title) + + rejection.setTitle(callback) + + const router = createRouter(routes, { + initialUrl: '/', + rejections: [rejection], + }) + + await router.start() + + await flushPromises() + + expect(callback).toHaveBeenCalledTimes(0) + expect(document.title).toBe('') + + router.reject('CustomRejection') + + await flushPromises() + + expect(callback).toHaveBeenCalledTimes(1) + expect(document.title).toBe(title) +}) + +test('route with title and rejection with title updates document title in correct order', async () => { + const rejectionTitle = 'rejection title' + const rejectionTitleCallback = vi.fn(() => rejectionTitle) + const rejection = createRejection({ type: 'CustomRejection' }) + + rejection.setTitle(rejectionTitleCallback) + + const routeTitle = 'route title' + const route = createRoute({ + name: 'target', + path: '/', + context: [rejection], + component, + }) + + route.setTitle(() => routeTitle) + + route.onBeforeRouteEnter((_to, { reject }) => { + throw reject('CustomRejection') + }) + + const router = createRouter([route], { + initialUrl: '/', + }) + + await router.start() + + await flushPromises() + + expect(rejectionTitleCallback).toHaveBeenCalledTimes(1) + expect(document.title).toBe(rejectionTitle) +}) diff --git a/src/types/rejectionTitle.ts b/src/types/rejectionTitle.ts new file mode 100644 index 00000000..488f5f0e --- /dev/null +++ b/src/types/rejectionTitle.ts @@ -0,0 +1,40 @@ +import { ResolvedRoute } from '@/types/resolved' +import { MaybePromise } from '@/types/utilities' + +export type SetRejectionTitleContext = { + to: ResolvedRoute | null, + from: ResolvedRoute | null, +} + +export type SetRejectionTitleCallback = (context: SetRejectionTitleContext) => MaybePromise +export type GetRejectionTitle = (context: SetRejectionTitleContext) => Promise +export type SetRejectionTitle = (callback: SetRejectionTitleCallback) => void + +export type RejectionSetTitle = { + /** + * Adds a callback to set the document title for the rejection. + */ + setTitle: SetRejectionTitle, +} + +type CreateRejectionTitle = { + setTitle: SetRejectionTitle, + getTitle: GetRejectionTitle, +} + +export function createRejectionTitle(): CreateRejectionTitle { + let setTitleCallback: SetRejectionTitleCallback | undefined + + const setTitle: SetRejectionTitle = (callback) => { + setTitleCallback = callback + } + + const getTitle: GetRejectionTitle = async (context) => { + return setTitleCallback?.(context) + } + + return { + setTitle, + getTitle, + } +} From 9ec8602d97922153c05732a8a33b4e5f6e0f6719 Mon Sep 17 00:00:00 2001 From: Evan Sutherland Date: Fri, 20 Mar 2026 15:10:49 -0500 Subject: [PATCH 3/5] update logic for setDocumentTitle to accept both routes and/or rejections --- src/services/createRouter.ts | 4 +- .../setDocumentTitle.browser.spec.ts | 44 +++++++++++++++++++ src/utilities/setDocumentTitle.ts | 21 ++++++--- 3 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 src/utilities/setDocumentTitle.browser.spec.ts diff --git a/src/services/createRouter.ts b/src/services/createRouter.ts index ccb3870e..8a2e3c1e 100644 --- a/src/services/createRouter.ts +++ b/src/services/createRouter.ts @@ -186,7 +186,7 @@ export function createRouter< throw new Error(`Switch is not exhaustive for after hook response status: ${JSON.stringify(exhaustive)}`) } - setDocumentTitle(to) + setDocumentTitle({ to, from, rejection: currentRejection.value }) history.startListening() } @@ -318,11 +318,13 @@ export function createRouter< } hooks.runRejectionHooks(rejection, { to, from }) + updateRejection(rejection) } const reject: RouterReject = (type) => { setRejection(type) + setDocumentTitle({ rejection: currentRejection.value }) } const { currentRejection, updateRejection, clearRejection } = createCurrentRejection() diff --git a/src/utilities/setDocumentTitle.browser.spec.ts b/src/utilities/setDocumentTitle.browser.spec.ts new file mode 100644 index 00000000..9c879a6a --- /dev/null +++ b/src/utilities/setDocumentTitle.browser.spec.ts @@ -0,0 +1,44 @@ +import { expect, test, vi } from 'vitest' +import { setDocumentTitle } from '@/utilities/setDocumentTitle' +import { createRejection } from '@/services/createRejection' +import { createRoute } from '@/services/createRoute' +import { createResolvedRoute } from '@/services/createResolvedRoute' +import { flushPromises } from '@vue/test-utils' + +test('when called with rejection, only calls getTitle on rejection', async () => { + const rejectionSetTitleCallback = vi.fn(() => 'Rejection Title') + const rejection = createRejection({ type: 'CustomRejection' }) + rejection.setTitle(rejectionSetTitleCallback) + + const routeTo = createRoute({ name: 'to' }) + const to = createResolvedRoute(routeTo) + + const routeFrom = createRoute({ name: 'from' }) + const from = createResolvedRoute(routeFrom) + + setDocumentTitle({ to, from, rejection }) + + await flushPromises() + + expect(rejectionSetTitleCallback).toHaveBeenCalledTimes(1) + expect(rejectionSetTitleCallback).toHaveBeenCalledWith({ to, from }) + expect(document.title).toBe('Rejection Title') +}) + +test('when called with to route, uses existing title on resolved', async () => { + const routeSetTitleCallback = vi.fn(() => 'Route Title') + const route = createRoute({ name: 'to' }) + route.setTitle(routeSetTitleCallback) + + const to = createResolvedRoute(route) + + await flushPromises() + + expect(routeSetTitleCallback).toHaveBeenCalledTimes(1) + + setDocumentTitle({ to }) + + await flushPromises() + + expect(document.title).toBe('Route Title') +}) diff --git a/src/utilities/setDocumentTitle.ts b/src/utilities/setDocumentTitle.ts index f47c02d7..ba2e0fb1 100644 --- a/src/utilities/setDocumentTitle.ts +++ b/src/utilities/setDocumentTitle.ts @@ -1,17 +1,28 @@ +import { isRejection, Rejection } from '@/types/rejection' import { ResolvedRoute } from '@/types/resolved' import { isRoute } from '@/types/route' import { isBrowser } from '@/utilities/isBrowser' let defaultTitle: string | undefined -export function setDocumentTitle(to: ResolvedRoute): void { - if (!isRoute(to) || !isBrowser()) { +type SetDocumentTitleContext = { + to?: ResolvedRoute | null, + from?: ResolvedRoute | null, + rejection?: Rejection | null, +} + +export function setDocumentTitle({ to = null, from = null, rejection = null }: SetDocumentTitleContext): void { + if (!isBrowser()) { return } defaultTitle ??= document.title - to.title.then((value) => { - document.title = value ?? defaultTitle ?? '' - }) + if (isRejection(rejection)) { + return void rejection.getTitle({ to, from }).then((value) => document.title = value ?? defaultTitle ?? '') + } + + if (isRoute(to)) { + return void to.title.then((value) => document.title = value ?? defaultTitle ?? '') + } } From ce9bf00fa68235ee9b913229570d62048d641170 Mon Sep 17 00:00:00 2001 From: Evan Sutherland Date: Sat, 21 Mar 2026 11:25:22 -0500 Subject: [PATCH 4/5] move title work into rejections underlying route --- src/services/createCurrentRejection.ts | 16 ++++- src/services/createRejection.ts | 45 +++---------- src/services/createRouter.ts | 11 +-- src/types/rejection.ts | 6 +- src/types/rejectionTitle.browser.spec.ts | 67 ------------------- src/types/rejectionTitle.ts | 40 ----------- src/types/routeTitle.ts | 2 +- .../setDocumentTitle.browser.spec.ts | 44 ------------ src/utilities/setDocumentTitle.ts | 21 ++---- 9 files changed, 39 insertions(+), 213 deletions(-) delete mode 100644 src/types/rejectionTitle.browser.spec.ts delete mode 100644 src/types/rejectionTitle.ts delete mode 100644 src/utilities/setDocumentTitle.browser.spec.ts diff --git a/src/services/createCurrentRejection.ts b/src/services/createCurrentRejection.ts index 6bb505c9..e5f124ea 100644 --- a/src/services/createCurrentRejection.ts +++ b/src/services/createCurrentRejection.ts @@ -1,11 +1,14 @@ -import { Rejection, RouterRejection } from '@/types/rejection' -import { ref } from 'vue' +import { isRejection, Rejection, RouterRejection } from '@/types/rejection' +import { ref, ComputedRef, computed } from 'vue' +import { ResolvedRoute } from '@/types/resolved' +import { createResolvedRoute } from './createResolvedRoute' type RejectionUpdate = (rejection: Rejection) => void type RejectionClear = () => void type CurrentRejectionContext = { currentRejection: RouterRejection, + currentRejectionRoute: ComputedRef, updateRejection: RejectionUpdate, clearRejection: RejectionClear, } @@ -21,8 +24,17 @@ export function createCurrentRejection(): CurrentRejectionContext { const currentRejection: RouterRejection = ref(null) + const currentRejectionRoute = computed(() => { + if (isRejection(currentRejection.value)) { + return createResolvedRoute(currentRejection.value.route) + } + + return null + }) + return { currentRejection, + currentRejectionRoute, updateRejection, clearRejection, } diff --git a/src/services/createRejection.ts b/src/services/createRejection.ts index c1a6bdbe..ea9a193d 100644 --- a/src/services/createRejection.ts +++ b/src/services/createRejection.ts @@ -3,28 +3,29 @@ import { genericRejection } from '@/components/rejection' import { RejectionHooks } from '@/types/hooks' import { IS_REJECTION_SYMBOL, Rejection, RejectionInternal } from '@/types/rejection' import { Component, markRaw } from 'vue' -import { ResolvedRoute } from '@/types/resolved' -import { createRouteId } from '@/services/createRouteId' -import { createResolvedRouteQuery } from '@/services/createResolvedRouteQuery' -import { createRejectionTitle, RejectionSetTitle } from '@/types/rejectionTitle' +import { createRoute } from '@/services/createRoute' +import { RouteSetTitle } from '@/types/routeTitle' export function createRejection(options: { type: TType, component?: Component, -}): Rejection & RejectionHooks & RejectionSetTitle +}): Rejection & RejectionHooks & RouteSetTitle export function createRejection(options: { type: string, component?: Component }): Rejection { + const { store, ...hooks } = createRejectionHooks() + const component = markRaw(options.component ?? genericRejection(options.type)) - const route = getRejectionRoute(options.type, component) + const route = createRoute({ + name: options.type, + component, + }) - const { store, ...hooks } = createRejectionHooks() - const { setTitle, getTitle } = createRejectionTitle() + const { setTitle } = route const internal = { [IS_REJECTION_SYMBOL]: true, route, hooks: [store], - getTitle, } satisfies RejectionInternal const rejection = { @@ -33,31 +34,7 @@ export function createRejection(options: { type: string, component?: Component } setTitle, ...hooks, ...internal, - } satisfies Rejection & RejectionInternal & RejectionHooks & RejectionSetTitle + } satisfies Rejection & RejectionInternal & RejectionHooks & RouteSetTitle return rejection } - -function getRejectionRoute(type: string, component: Component): ResolvedRoute { - const route = { - id: createRouteId(), - component, - meta: {}, - state: {}, - } - - const resolved = { - id: route.id, - matched: route, - matches: [route], - name: type, - query: createResolvedRouteQuery(''), - params: {}, - state: {}, - href: '/', - hash: '', - title: Promise.resolve(undefined), - } satisfies ResolvedRoute - - return resolved -} diff --git a/src/services/createRouter.ts b/src/services/createRouter.ts index 8a2e3c1e..03d672cb 100644 --- a/src/services/createRouter.ts +++ b/src/services/createRouter.ts @@ -91,6 +91,7 @@ export function createRouter< const shouldRemoveTrailingSlashes = options?.removeTrailingSlashes ?? true const { routes, getRouteByName, getRejectionByType } = getRoutesForRouter(routesOrArrayOfRoutes, plugins, options) const notFoundRejection = getRejectionByType('NotFound') + const notFoundRoute = createResolvedRoute(notFoundRejection.route) const hooks = createRouterHooks() @@ -130,7 +131,7 @@ export function createRouter< history.stopListening() - const to = find(url, options) ?? notFoundRejection.route + const to = find(url, options) ?? notFoundRoute const from = getFromRouteForHooks(navigationId) @@ -186,7 +187,7 @@ export function createRouter< throw new Error(`Switch is not exhaustive for after hook response status: ${JSON.stringify(exhaustive)}`) } - setDocumentTitle({ to, from, rejection: currentRejection.value }) + setDocumentTitle(currentRejectionRoute.value ?? to) history.startListening() } @@ -324,11 +325,11 @@ export function createRouter< const reject: RouterReject = (type) => { setRejection(type) - setDocumentTitle({ rejection: currentRejection.value }) + setDocumentTitle(currentRejectionRoute.value) } - const { currentRejection, updateRejection, clearRejection } = createCurrentRejection() - const { currentRoute, routerRoute, updateRoute } = createCurrentRoute(routerKey, notFoundRejection.route, push) + const { currentRejection, currentRejectionRoute, updateRejection, clearRejection } = createCurrentRejection() + const { currentRoute, routerRoute, updateRoute } = createCurrentRoute(routerKey, notFoundRoute, push) const initialUrl = getInitialUrl(options?.initialUrl) const initialState = history.location.state diff --git a/src/types/rejection.ts b/src/types/rejection.ts index 7bf90e67..7607adb9 100644 --- a/src/types/rejection.ts +++ b/src/types/rejection.ts @@ -1,9 +1,8 @@ import { Component, Ref } from 'vue' -import { ResolvedRoute } from '@/types/resolved' +import { Route } from '@/types/route' import { Router } from '@/types/router' import { RouterReject } from '@/types/routerReject' import { Hooks } from '@/models/hooks' -import { GetRejectionTitle } from '@/types/rejectionTitle' export const BUILT_IN_REJECTION_TYPES = ['NotFound'] as const export type BuiltInRejectionType = (typeof BUILT_IN_REJECTION_TYPES)[number] @@ -19,9 +18,8 @@ export function isRejection(value: unknown): value is Rejection & RejectionInter export type RejectionInternal = { [IS_REJECTION_SYMBOL]: true, - route: ResolvedRoute, + route: Route, hooks: Hooks[], - getTitle: GetRejectionTitle, } /** diff --git a/src/types/rejectionTitle.browser.spec.ts b/src/types/rejectionTitle.browser.spec.ts deleted file mode 100644 index 40ecd7b8..00000000 --- a/src/types/rejectionTitle.browser.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { expect, test, vi } from 'vitest' -import { createRejection } from '@/services/createRejection' -import { component, routes } from '@/utilities/testHelpers' -import { createRouter } from '@/services/createRouter' -import { createRoute } from '@/services/createRoute' -import { flushPromises } from '@vue/test-utils' - -test('rejection with title updates document title', async () => { - const rejection = createRejection({ type: 'CustomRejection' }) - - const title = 'foo' - const callback = vi.fn(() => title) - - rejection.setTitle(callback) - - const router = createRouter(routes, { - initialUrl: '/', - rejections: [rejection], - }) - - await router.start() - - await flushPromises() - - expect(callback).toHaveBeenCalledTimes(0) - expect(document.title).toBe('') - - router.reject('CustomRejection') - - await flushPromises() - - expect(callback).toHaveBeenCalledTimes(1) - expect(document.title).toBe(title) -}) - -test('route with title and rejection with title updates document title in correct order', async () => { - const rejectionTitle = 'rejection title' - const rejectionTitleCallback = vi.fn(() => rejectionTitle) - const rejection = createRejection({ type: 'CustomRejection' }) - - rejection.setTitle(rejectionTitleCallback) - - const routeTitle = 'route title' - const route = createRoute({ - name: 'target', - path: '/', - context: [rejection], - component, - }) - - route.setTitle(() => routeTitle) - - route.onBeforeRouteEnter((_to, { reject }) => { - throw reject('CustomRejection') - }) - - const router = createRouter([route], { - initialUrl: '/', - }) - - await router.start() - - await flushPromises() - - expect(rejectionTitleCallback).toHaveBeenCalledTimes(1) - expect(document.title).toBe(rejectionTitle) -}) diff --git a/src/types/rejectionTitle.ts b/src/types/rejectionTitle.ts deleted file mode 100644 index 488f5f0e..00000000 --- a/src/types/rejectionTitle.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ResolvedRoute } from '@/types/resolved' -import { MaybePromise } from '@/types/utilities' - -export type SetRejectionTitleContext = { - to: ResolvedRoute | null, - from: ResolvedRoute | null, -} - -export type SetRejectionTitleCallback = (context: SetRejectionTitleContext) => MaybePromise -export type GetRejectionTitle = (context: SetRejectionTitleContext) => Promise -export type SetRejectionTitle = (callback: SetRejectionTitleCallback) => void - -export type RejectionSetTitle = { - /** - * Adds a callback to set the document title for the rejection. - */ - setTitle: SetRejectionTitle, -} - -type CreateRejectionTitle = { - setTitle: SetRejectionTitle, - getTitle: GetRejectionTitle, -} - -export function createRejectionTitle(): CreateRejectionTitle { - let setTitleCallback: SetRejectionTitleCallback | undefined - - const setTitle: SetRejectionTitle = (callback) => { - setTitleCallback = callback - } - - const getTitle: GetRejectionTitle = async (context) => { - return setTitleCallback?.(context) - } - - return { - setTitle, - getTitle, - } -} diff --git a/src/types/routeTitle.ts b/src/types/routeTitle.ts index 5494245b..f46e43d3 100644 --- a/src/types/routeTitle.ts +++ b/src/types/routeTitle.ts @@ -23,7 +23,7 @@ type CreateRouteTitle = { getTitle: GetRouteTitle, } -export function createRouteTitle(parent: Route | undefined): CreateRouteTitle { +export function createRouteTitle(parent?: Route): CreateRouteTitle { let setTitleCallback: SetRouteTitleCallback | undefined const setTitle: SetRouteTitle = (callback) => { diff --git a/src/utilities/setDocumentTitle.browser.spec.ts b/src/utilities/setDocumentTitle.browser.spec.ts deleted file mode 100644 index 9c879a6a..00000000 --- a/src/utilities/setDocumentTitle.browser.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { expect, test, vi } from 'vitest' -import { setDocumentTitle } from '@/utilities/setDocumentTitle' -import { createRejection } from '@/services/createRejection' -import { createRoute } from '@/services/createRoute' -import { createResolvedRoute } from '@/services/createResolvedRoute' -import { flushPromises } from '@vue/test-utils' - -test('when called with rejection, only calls getTitle on rejection', async () => { - const rejectionSetTitleCallback = vi.fn(() => 'Rejection Title') - const rejection = createRejection({ type: 'CustomRejection' }) - rejection.setTitle(rejectionSetTitleCallback) - - const routeTo = createRoute({ name: 'to' }) - const to = createResolvedRoute(routeTo) - - const routeFrom = createRoute({ name: 'from' }) - const from = createResolvedRoute(routeFrom) - - setDocumentTitle({ to, from, rejection }) - - await flushPromises() - - expect(rejectionSetTitleCallback).toHaveBeenCalledTimes(1) - expect(rejectionSetTitleCallback).toHaveBeenCalledWith({ to, from }) - expect(document.title).toBe('Rejection Title') -}) - -test('when called with to route, uses existing title on resolved', async () => { - const routeSetTitleCallback = vi.fn(() => 'Route Title') - const route = createRoute({ name: 'to' }) - route.setTitle(routeSetTitleCallback) - - const to = createResolvedRoute(route) - - await flushPromises() - - expect(routeSetTitleCallback).toHaveBeenCalledTimes(1) - - setDocumentTitle({ to }) - - await flushPromises() - - expect(document.title).toBe('Route Title') -}) diff --git a/src/utilities/setDocumentTitle.ts b/src/utilities/setDocumentTitle.ts index ba2e0fb1..b346f548 100644 --- a/src/utilities/setDocumentTitle.ts +++ b/src/utilities/setDocumentTitle.ts @@ -1,28 +1,17 @@ -import { isRejection, Rejection } from '@/types/rejection' import { ResolvedRoute } from '@/types/resolved' import { isRoute } from '@/types/route' import { isBrowser } from '@/utilities/isBrowser' let defaultTitle: string | undefined -type SetDocumentTitleContext = { - to?: ResolvedRoute | null, - from?: ResolvedRoute | null, - rejection?: Rejection | null, -} - -export function setDocumentTitle({ to = null, from = null, rejection = null }: SetDocumentTitleContext): void { - if (!isBrowser()) { +export function setDocumentTitle(to: ResolvedRoute | null): void { + if (!isRoute(to) || !isBrowser()) { return } defaultTitle ??= document.title - if (isRejection(rejection)) { - return void rejection.getTitle({ to, from }).then((value) => document.title = value ?? defaultTitle ?? '') - } - - if (isRoute(to)) { - return void to.title.then((value) => document.title = value ?? defaultTitle ?? '') - } + to.title.then((value) => { + document.title = value ?? defaultTitle ?? '' + }) } From ffc333aefd2cdea8196a58d7c52d3b75220f38d9 Mon Sep 17 00:00:00 2001 From: Evan Sutherland Date: Sun, 22 Mar 2026 09:17:52 -0500 Subject: [PATCH 5/5] continue leaning into rejection route, remove component --- src/components/routerView.browser.spec.ts | 4 ++-- src/components/routerView.ts | 6 +++--- src/services/createRejection.ts | 10 ++++------ src/types/rejection.ts | 6 +----- 4 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/components/routerView.browser.spec.ts b/src/components/routerView.browser.spec.ts index 4d587547..7796e6e0 100644 --- a/src/components/routerView.browser.spec.ts +++ b/src/components/routerView.browser.spec.ts @@ -644,7 +644,7 @@ test('Renders the rejection component when the rejection is not registered on th const myRejection = createRejection({ type: 'myRejection', component: { - template: rejectionText + template: rejectionText, }, }) @@ -678,4 +678,4 @@ test('Renders the rejection component when the rejection is not registered on th await flushPromises() expect(wrapper.text()).toBe(rejectionText) -}) \ No newline at end of file +}) diff --git a/src/components/routerView.ts b/src/components/routerView.ts index 3f2ad0a0..8ead9957 100644 --- a/src/components/routerView.ts +++ b/src/components/routerView.ts @@ -3,7 +3,7 @@ import { createUseRejection } from '@/compositions/useRejection' import { createUseRoute } from '@/compositions/useRoute' import { createUseRouter } from '@/compositions/useRouter' import { createUseRouterDepth } from '@/compositions/useRouterDepth' -import { RouterRejection } from '@/types/rejection' +import { isRejection, RouterRejection } from '@/types/rejection' import { RouterRoute } from '@/types/routerRoute' import { Router } from '@/types/router' import { Component, computed, defineComponent, EmitsOptions, h, InjectionKey, onServerPrefetch, SetupContext, SlotsType, UnwrapRef, VNode } from 'vue' @@ -46,8 +46,8 @@ export function createRouterView(routerKey: InjectionKey return null } - if (rejection.value) { - return rejection.value.component + if (isRejection(rejection.value) && !!rejection.value.route.matched.component) { + return rejection.value.route.matched.component } const match = route.matches.at(depth) diff --git a/src/services/createRejection.ts b/src/services/createRejection.ts index ea9a193d..1f82f485 100644 --- a/src/services/createRejection.ts +++ b/src/services/createRejection.ts @@ -11,13 +11,12 @@ export function createRejection(options: { component?: Component, }): Rejection & RejectionHooks & RouteSetTitle -export function createRejection(options: { type: string, component?: Component }): Rejection { +export function createRejection({ type, component }: { type: string, component?: Component }): Rejection { const { store, ...hooks } = createRejectionHooks() - const component = markRaw(options.component ?? genericRejection(options.type)) const route = createRoute({ - name: options.type, - component, + name: type, + component: markRaw(component ?? genericRejection(type)), }) const { setTitle } = route @@ -29,8 +28,7 @@ export function createRejection(options: { type: string, component?: Component } } satisfies RejectionInternal const rejection = { - type: options.type, - component, + type, setTitle, ...hooks, ...internal, diff --git a/src/types/rejection.ts b/src/types/rejection.ts index 7607adb9..3e328839 100644 --- a/src/types/rejection.ts +++ b/src/types/rejection.ts @@ -1,4 +1,4 @@ -import { Component, Ref } from 'vue' +import { Ref } from 'vue' import { Route } from '@/types/route' import { Router } from '@/types/router' import { RouterReject } from '@/types/routerReject' @@ -32,10 +32,6 @@ export type Rejection = { * The type of rejection. */ type: TType, - /** - * The component to render when the rejection occurs. - */ - component: Component, } export type RejectionType =