From acd9723b05948f53a0a830a33082233b5b5e3294 Mon Sep 17 00:00:00 2001 From: Evan Sutherland Date: Sat, 21 Mar 2026 09:34:49 -0500 Subject: [PATCH] add new hoist property to routes --- src/services/createRoute.spec.ts | 486 ++++++++++++---------- src/services/createRoute.ts | 4 + src/services/createRouter.browser.spec.ts | 39 ++ src/types/createRouteOptions.ts | 8 +- 4 files changed, 317 insertions(+), 220 deletions(-) diff --git a/src/services/createRoute.spec.ts b/src/services/createRoute.spec.ts index 915f7f3c..59b034d1 100644 --- a/src/services/createRoute.spec.ts +++ b/src/services/createRoute.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, vi } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { createRoute } from '@/services/createRoute' import { component } from '@/utilities/testHelpers' import { createRouter } from '@/services/createRouter' @@ -6,298 +6,348 @@ import { DuplicateParamsError } from '@/errors/duplicateParamsError' import { withParams } from '@/services/withParams' import { createRejection } from './createRejection' -test('given parent, path is combined', () => { - const parent = createRoute({ - path: '/parent', - }) +describe('combine', () => { + test('given parent, path is combined', () => { + const parent = createRoute({ + path: '/parent', + }) + + const child = createRoute({ + parent: parent, + path: withParams('/child/[id]', { id: Number }), + }) - const child = createRoute({ - parent: parent, - path: withParams('/child/[id]', { id: Number }), + expect(child.stringify({ id: 123 })).toBe('/parent/child/123') }) - expect(child.stringify({ id: 123 })).toBe('/parent/child/123') -}) + test('given undefined path, path is combined', () => { + const parent = createRoute({ + path: '/parent', + }) -test('given undefined path, path is combined', () => { - const parent = createRoute({ - path: '/parent', - }) + const child = createRoute({ + parent: parent, + }) - const child = createRoute({ - parent: parent, - }) + const grandChild = createRoute({ + parent: child, + path: '/grand-child', + }) + + const kinless = createRoute({}) - const grandChild = createRoute({ - parent: child, - path: '/grand-child', + expect(kinless.stringify()).toBe('/') + expect(child.stringify()).toBe('/parent') + expect(grandChild.stringify()).toBe('/parent/grand-child') }) - const kinless = createRoute({}) + test('given parent, query is combined', () => { + const parent = createRoute({ + query: 'static=123', + }) - expect(kinless.stringify()).toBe('/') - expect(child.stringify()).toBe('/parent') - expect(grandChild.stringify()).toBe('/parent/grand-child') -}) + const child = createRoute({ + parent: parent, + query: withParams('sort=[sort]', { sort: Boolean }), + }) -test('given parent, query is combined', () => { - const parent = createRoute({ - query: 'static=123', + expect(child.stringify({ sort: true })).toBe('/?static=123&sort=true') }) - const child = createRoute({ - parent: parent, - query: withParams('sort=[sort]', { sort: Boolean }), - }) + test('given parent, state is combined into state', () => { + const parent = createRoute({ + state: { + foo: Number, + }, + }) - expect(child.stringify({ sort: true })).toBe('/?static=123&sort=true') -}) + const child = createRoute({ + parent: parent, + state: { + bar: String, + }, + }) -test('given parent, state is combined into state', () => { - const parent = createRoute({ - state: { + expect(child.state).toMatchObject({ foo: Number, - }, - }) - - const child = createRoute({ - parent: parent, - state: { bar: String, - }, + }) }) - expect(child.state).toMatchObject({ - foo: Number, - bar: String, - }) -}) + test('given parent and child without state, state matches parent', () => { + const parent = createRoute({ + state: { + foo: Number, + }, + }) + + const child = createRoute({ + parent: parent, + }) -test('given parent and child without state, state matches parent', () => { - const parent = createRoute({ - state: { + expect(child.state).toMatchObject({ foo: Number, - }, + }) }) - const child = createRoute({ - parent: parent, - }) + test('given parent, meta is combined', () => { + const parent = createRoute({ + meta: { + foo: 123, + }, + }) - expect(child.state).toMatchObject({ - foo: Number, - }) -}) + const child = createRoute({ + parent: parent, + meta: { + bar: 'zoo', + }, + }) -test('given parent, meta is combined', () => { - const parent = createRoute({ - meta: { + expect(child.meta).toMatchObject({ foo: 123, - }, - }) - - const child = createRoute({ - parent: parent, - meta: { bar: 'zoo', - }, + }) }) - expect(child.meta).toMatchObject({ - foo: 123, - bar: 'zoo', - }) -}) + test('given parent, context is combined', () => { + const parentRejection = createRejection({ type: 'aRejection' }) + const childRelated = createRoute({ name: 'bRoute' }) -test('given parent, context is combined', () => { - const parentRejection = createRejection({ type: 'aRejection' }) - const childRelated = createRoute({ name: 'bRoute' }) + const parent = createRoute({ + meta: { + foo: 123, + }, + context: [parentRejection], + }) - const parent = createRoute({ - meta: { - foo: 123, - }, - context: [parentRejection], - }) + const child = createRoute({ + parent, + context: [childRelated], + meta: { + bar: 'zoo', + }, + }) - const child = createRoute({ - parent, - context: [childRelated], - meta: { - bar: 'zoo', - }, + expect(child.context).toMatchObject([parentRejection, childRelated]) }) - expect(child.context).toMatchObject([parentRejection, childRelated]) -}) + test('given parent and child without meta, meta matches parent', () => { + const parent = createRoute({ + meta: { + foo: 123, + }, + }) + + const child = createRoute({ + parent: parent, + }) -test('given parent and child without meta, meta matches parent', () => { - const parent = createRoute({ - meta: { + expect(child.meta).toMatchObject({ foo: 123, - }, + }) }) - const child = createRoute({ - parent: parent, - }) + test('given child has hoist, everything is combined except url', () => { + const parent = createRoute({ + path: '/parent/[?parent]', + query: 'parent=123', + hash: 'parent', + state: { + parent: 'parent', + }, + meta: { + parent: 'parent', + }, + }) - expect(child.meta).toMatchObject({ - foo: 123, - }) -}) + const child = createRoute({ + parent, + hoist: true, + path: '/child/[?child]', + query: 'child=456', + hash: 'child', + state: { + child: 'child', + }, + meta: { + child: 'child', + }, + }) -test('parent context is passed to child props', async () => { - const spy = vi.fn() - const parent = createRoute({ - name: 'parent', - }) + const params = child.parse('/child/42?child=456#child') + expect(params).toMatchObject({ + child: '42', + }) - const child = createRoute({ - name: 'child', - parent: parent, - path: '/child', - }, (_, { parent }) => { - return spy(parent) - }) + // @ts-expect-error - parent is not a param + params.parent = true - const router = createRouter([parent, child], { - initialUrl: '/child', + expect(child.stringify({ child: '42' })).toBe('/child/42?child=456#child') + expect(child.state).toMatchObject({ + parent: 'parent', + child: 'child', + }) + expect(child.meta).toMatchObject({ + parent: 'parent', + child: 'child', + }) }) +}) - await router.start() +describe('props', () => { + test('parent context is passed to child props', async () => { + const spy = vi.fn() + const parent = createRoute({ + name: 'parent', + }) - expect(spy).toHaveBeenCalledWith({ name: 'parent', props: undefined }) -}) + const child = createRoute({ + name: 'child', + parent: parent, + path: '/child', + }, (_, { parent }) => { + return spy(parent) + }) -test('sync parent props are passed to child props', async () => { - const spy = vi.fn() + const router = createRouter([parent, child], { + initialUrl: '/child', + }) - const parent = createRoute({ - name: 'parent', - }, () => ({ foo: 123 })) + await router.start() - const child = createRoute({ - name: 'child', - parent: parent, - path: '/child', - }, (__, { parent }) => { - return spy({ value: parent.props.foo }) + expect(spy).toHaveBeenCalledWith({ name: 'parent', props: undefined }) }) - const router = createRouter([parent, child], { - initialUrl: '/child', - }) + test('sync parent props are passed to child props', async () => { + const spy = vi.fn() - await router.start() + const parent = createRoute({ + name: 'parent', + }, () => ({ foo: 123 })) - expect(spy).toHaveBeenCalledWith({ value: 123 }) -}) + const child = createRoute({ + name: 'child', + parent: parent, + path: '/child', + }, (__, { parent }) => { + return spy({ value: parent.props.foo }) + }) -test('async parent props are passed to child props', async () => { - const spy = vi.fn() + const router = createRouter([parent, child], { + initialUrl: '/child', + }) - const parent = createRoute({ - name: 'parent', - }, async () => ({ foo: 123 })) + await router.start() - const child = createRoute({ - name: 'child', - parent: parent, - path: '/child', - }, async (__, { parent }) => { - expect(parent.props).toBeDefined() - expect(parent.props).toBeInstanceOf(Promise) + expect(spy).toHaveBeenCalledWith({ value: 123 }) + }) - const { foo: value } = await parent.props + test('async parent props are passed to child props', async () => { + const spy = vi.fn() - return spy({ value }) - }) + const parent = createRoute({ + name: 'parent', + }, async () => ({ foo: 123 })) - const router = createRouter([parent, child], { - initialUrl: '/child', - }) + const child = createRoute({ + name: 'child', + parent: parent, + path: '/child', + }, async (__, { parent }) => { + expect(parent.props).toBeDefined() + expect(parent.props).toBeInstanceOf(Promise) - await router.start() + const { foo: value } = await parent.props - expect(spy).toHaveBeenCalledWith({ value: 123 }) -}) + return spy({ value }) + }) + + const router = createRouter([parent, child], { + initialUrl: '/child', + }) -test('sync parent props with multiple views are passed to child props', async () => { - const spy = vi.fn() - - const parent = createRoute({ - name: 'parent', - components: { - one: component, - two: component, - three: component, - }, - }, { - one: () => ({ foo: 123 }), - two: () => ({ bar: 456 }), + await router.start() + + expect(spy).toHaveBeenCalledWith({ value: 123 }) }) - const child = createRoute({ - name: 'child', - parent: parent, - path: '/child', - }, (__, { parent }) => { - return spy({ - value1: parent.props.one.foo, - value2: parent.props.two.bar, + test('sync parent props with multiple views are passed to child props', async () => { + const spy = vi.fn() + + const parent = createRoute({ + name: 'parent', + components: { + one: component, + two: component, + three: component, + }, + }, { + one: () => ({ foo: 123 }), + two: () => ({ bar: 456 }), }) - }) - const router = createRouter([parent, child], { - initialUrl: '/child', - }) + const child = createRoute({ + name: 'child', + parent: parent, + path: '/child', + }, (__, { parent }) => { + return spy({ + value1: parent.props.one.foo, + value2: parent.props.two.bar, + }) + }) - await router.start() + const router = createRouter([parent, child], { + initialUrl: '/child', + }) - expect(spy).toHaveBeenCalledWith({ value1: 123, value2: 456 }) -}) + await router.start() -test('async parent props with multiple views are passed to child props', async () => { - const spy = vi.fn() - - const parent = createRoute({ - name: 'parent', - components: { - one: component, - two: component, - three: component, - }, - }, { - one: async () => ({ foo: 123 }), - two: async () => ({ bar: 456 }), + expect(spy).toHaveBeenCalledWith({ value1: 123, value2: 456 }) }) - const child = createRoute({ - name: 'child', - parent: parent, - path: '/child', - }, async (__, { parent }) => { - expect(parent.props).toBeDefined() - expect(parent.props.one).toBeInstanceOf(Promise) - expect(parent.props.two).toBeInstanceOf(Promise) + test('async parent props with multiple views are passed to child props', async () => { + const spy = vi.fn() - const { foo: value1 } = await parent.props.one - const { bar: value2 } = await parent.props.two + const parent = createRoute({ + name: 'parent', + components: { + one: component, + two: component, + three: component, + }, + }, { + one: async () => ({ foo: 123 }), + two: async () => ({ bar: 456 }), + }) - return spy({ - value1, - value2, + const child = createRoute({ + name: 'child', + parent: parent, + path: '/child', + }, async (__, { parent }) => { + expect(parent.props).toBeDefined() + expect(parent.props.one).toBeInstanceOf(Promise) + expect(parent.props.two).toBeInstanceOf(Promise) + + const { foo: value1 } = await parent.props.one + const { bar: value2 } = await parent.props.two + + return spy({ + value1, + value2, + }) }) - }) - const router = createRouter([parent, child], { - initialUrl: '/child', - }) + const router = createRouter([parent, child], { + initialUrl: '/child', + }) - await router.start() + await router.start() - expect(spy).toHaveBeenCalledWith({ value1: 123, value2: 456 }) + expect(spy).toHaveBeenCalledWith({ value1: 123, value2: 456 }) + }) }) test.each([ diff --git a/src/services/createRoute.ts b/src/services/createRoute.ts index 576b42de..fdee1f48 100644 --- a/src/services/createRoute.ts +++ b/src/services/createRoute.ts @@ -84,6 +84,10 @@ export function createRoute(options: CreateRouteOptions, props?: CreateRouteProp if (isWithParent(options)) { const merged = combineRoutes(options.parent, route) + if (options.hoist) { + return merged + } + const url = combineUrl(options.parent, { path, query, diff --git a/src/services/createRouter.browser.spec.ts b/src/services/createRouter.browser.spec.ts index 0a1326aa..f2044035 100644 --- a/src/services/createRouter.browser.spec.ts +++ b/src/services/createRouter.browser.spec.ts @@ -75,3 +75,42 @@ describe('options.rejections', () => { expect(wrapper.html()).toBe('
This is a custom rejection
') }) }) + +test('given child has hoist, keeps parent context and components without parent url', async () => { + const parent = createRoute({ + name: 'parent', + path: '/parent', + component: { template: '
' }, + }) + + const child = createRoute({ + name: 'child', + parent, + hoist: true, + path: '/child/[?child]', + component: { template: '' }, + }) + + const router = createRouter([child], { initialUrl: '/' }) + + const root = { + template: '', + } + + const wrapper = mount(root, { + global: { + plugins: [router], + }, + }) + + await router.start() + + await router.push('child', { child: '42' }) + + expect(router.route).toMatchObject(expect.objectContaining({ + name: 'child', + href: '/child/42', + })) + + expect(wrapper.html()).toBe('
') +}) diff --git a/src/types/createRouteOptions.ts b/src/types/createRouteOptions.ts index 458a2aac..a380bed1 100644 --- a/src/types/createRouteOptions.ts +++ b/src/types/createRouteOptions.ts @@ -114,6 +114,10 @@ export type CreateRouteOptions< * Related routes and rejections for the route. The context is exposed to the hooks and props callback functions for this route. */ context?: RouteContext[], + /** + * When true, the route will be hoisted to the top of the route tree. The route will continue to inherit meta, state, hooks, matches, and context from it's parent, but not the "url" properties. + */ + hoist?: boolean, } export type PropsGetter< @@ -173,7 +177,7 @@ export type ToRoute< : TOptions extends { parent: infer TParent extends Route } ? Route< ToName, - CombineUrl>, + TOptions['hoist'] extends true ? ToUrl : CombineUrl>, CombineMeta, ToMeta>, CombineState, ToState>, ToMatches extends TProps ? undefined : TProps>, @@ -189,7 +193,7 @@ export type ToRoute< > export function combineRoutes(parent: Route, child: Route): Route { - if(!isRoute(parent) || !isRoute(child)) { + if (!isRoute(parent) || !isRoute(child)) { throw new Error('combineRoutes called with invalid route arguments') }