import { createInjector } from '@furystack/inject'
import { serializeValue } from '@furystack/rest'
import { sleepAsync, usingAsync } from '@furystack/utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { initializeShadeRoot } from '../initialize.js'
import { createComponent } from '../shade-component.js'
import { flushUpdates } from '../shade.js'
import { RouteMatchService } from '../services/route-match-service.js'
import { LocationService } from '../services/location-service.js'
import {
buildMatchChain,
enrichMatchChain,
findDivergenceIndex,
hasQueryOrHashChanged,
NestedRouter,
renderMatchChain,
resolveViewTransition,
type MatchChainEntry,
type NestedRoute,
} from './nested-router.js'
import { NestedRouteLink } from './nested-route-link.js'
describe('buildMatchChain', () => {
it('should match a simple leaf route', () => {
const route: NestedRoute = { component: () =>
}
const chain = buildMatchChain({ '/about': route }, '/about')
expect(chain).toHaveLength(1)
expect(chain![0].route).toBe(route)
})
it('should return null when no route matches', () => {
const route: NestedRoute = { component: () => }
const chain = buildMatchChain({ '/about': route }, '/missing')
expect(chain).toBeNull()
})
it('should match a parent with a child route', () => {
const child: NestedRoute = { component: () => child
}
const parent: NestedRoute = {
component: ({ outlet }) => {outlet}
,
children: { '/sub': child },
}
const chain = buildMatchChain({ '/parent': parent }, '/parent/sub')
expect(chain).toHaveLength(2)
expect(chain![0].route).toBe(parent)
expect(chain![1].route).toBe(child)
})
it('should match parent alone when no child matches', () => {
const child: NestedRoute = { component: () => child
}
const parent: NestedRoute = {
component: ({ outlet }) => {outlet}
,
children: { '/sub': child },
}
const chain = buildMatchChain({ '/parent': parent }, '/parent')
expect(chain).toHaveLength(1)
expect(chain![0].route).toBe(parent)
})
it('should extract route parameters from the URL', () => {
const route: NestedRoute<{ id: string }> = { component: () => }
const chain = buildMatchChain({ '/users/:id': route }, '/users/42')
expect(chain).toHaveLength(1)
expect(chain![0].match.params).toEqual({ id: '42' })
})
it('should match deep nesting (3 levels)', () => {
const grandchild: NestedRoute = { component: () => grandchild
}
const child: NestedRoute = {
component: ({ outlet }) => {outlet}
,
children: { '/gc': grandchild },
}
const parent: NestedRoute = {
component: ({ outlet }) => {outlet}
,
children: { '/child': child },
}
const chain = buildMatchChain({ '/root': parent }, '/root/child/gc')
expect(chain).toHaveLength(3)
expect(chain![0].route).toBe(parent)
expect(chain![1].route).toBe(child)
expect(chain![2].route).toBe(grandchild)
})
it('should return the first matching route in definition order', () => {
const specific: NestedRoute = { component: () => specific
}
const catchAll: NestedRoute = { component: () => catch-all
}
const chain = buildMatchChain({ '/:slug': specific, '/': catchAll }, '/hello')
expect(chain).toHaveLength(1)
expect(chain![0].route).toBe(specific)
expect(chain![0].match.params).toEqual({ slug: 'hello' })
})
it('should match root "/" parent with children against child URLs (path-to-regexp v8 workaround)', () => {
const child: NestedRoute = { component: () => buttons
}
const parent: NestedRoute = {
component: ({ outlet }) => layout{outlet}
,
children: { '/buttons': child },
}
const chain = buildMatchChain({ '/': parent }, '/buttons')
expect(chain).toHaveLength(2)
expect(chain![0].route).toBe(parent)
expect(chain![1].route).toBe(child)
})
it('should match root "/" parent alone when URL is exactly "/"', () => {
const child: NestedRoute = { component: () => buttons
}
const parent: NestedRoute = {
component: ({ outlet }) => layout{outlet}
,
children: { '/buttons': child },
}
const chain = buildMatchChain({ '/': parent }, '/')
expect(chain).toHaveLength(1)
expect(chain![0].route).toBe(parent)
})
it('should prefer a more specific route over the root "/" parent', () => {
const specificChild: NestedRoute = { component: () => specific
}
const rootChild: NestedRoute = { component: () => root-child
}
const rootParent: NestedRoute = {
component: ({ outlet }) => {outlet}
,
children: { '/other': rootChild },
}
const chain = buildMatchChain({ '/specific': specificChild, '/': rootParent }, '/specific')
expect(chain).toHaveLength(1)
expect(chain![0].route).toBe(specificChild)
})
it('should extract parameters from both parent and child', () => {
const child: NestedRoute<{ postId: string }> = { component: () => }
const parent: NestedRoute<{ userId: string }> = {
component: ({ outlet }) => {outlet}
,
children: { '/posts/:postId': child },
}
const chain = buildMatchChain({ '/users/:userId': parent }, '/users/5/posts/10')
expect(chain).toHaveLength(2)
expect(chain![0].match.params).toEqual({ userId: '5' })
expect(chain![1].match.params).toEqual({ postId: '10' })
})
})
describe('findDivergenceIndex', () => {
const makeEntry = (id: number, params: Record = {}): MatchChainEntry => ({
route: { component: () => {id}
},
match: { path: '/', params },
query: null,
hash: undefined,
})
it('should return 0 for completely different chains', () => {
const oldChain = [makeEntry(1)]
const newChain = [makeEntry(2)]
expect(findDivergenceIndex(oldChain, newChain)).toBe(0)
})
it('should return minLength when one chain is a prefix of the other', () => {
const entry = makeEntry(1)
const oldChain = [entry]
const newChain = [entry, makeEntry(2)]
expect(findDivergenceIndex(oldChain, newChain)).toBe(1)
})
it('should return the length when chains are identical', () => {
const entry1 = makeEntry(1)
const entry2 = makeEntry(2)
const chain = [entry1, entry2]
expect(findDivergenceIndex(chain, chain)).toBe(2)
})
it('should detect divergence from changed params', () => {
const route: NestedRoute = { component: () => }
const oldChain: MatchChainEntry[] = [
{ route, match: { path: '/', params: { id: '1' } }, query: null, hash: undefined },
]
const newChain: MatchChainEntry[] = [
{ route, match: { path: '/', params: { id: '2' } }, query: null, hash: undefined },
]
expect(findDivergenceIndex(oldChain, newChain)).toBe(0)
})
it('should ignore query and hash differences when deciding divergence', () => {
const route: NestedRoute = { component: () => }
const oldChain: MatchChainEntry[] = [{ route, match: { path: '/', params: {} }, query: { page: 1 }, hash: 'a' }]
const newChain: MatchChainEntry[] = [{ route, match: { path: '/', params: {} }, query: { page: 2 }, hash: 'b' }]
expect(findDivergenceIndex(oldChain, newChain)).toBe(1)
})
})
describe('enrichMatchChain', () => {
it('should return the input chain unchanged when no entry declares query or hash', () => {
const route: NestedRoute = { component: () => }
const chain: MatchChainEntry[] = [{ route, match: { path: '/', params: {} }, query: null, hash: undefined }]
const result = enrichMatchChain(chain, {}, '')
expect(result).toBe(chain)
})
it('should populate query from the route validator', () => {
const route: NestedRoute = {
component: () => ,
query: (raw): { page: number } | null => (typeof raw.page === 'number' ? { page: raw.page } : null),
}
const chain: MatchChainEntry[] = [{ route, match: { path: '/', params: {} }, query: null, hash: undefined }]
const result = enrichMatchChain(chain, { page: 3 }, '')
expect(result).not.toBe(chain)
expect(result[0].query).toEqual({ page: 3 })
})
it('should set query to null when the validator rejects the current search', () => {
const route: NestedRoute = {
component: () => ,
query: (raw): { page: number } | null => (typeof raw.page === 'number' ? { page: raw.page } : null),
}
const chain: MatchChainEntry[] = [{ route, match: { path: '/', params: {} }, query: null, hash: undefined }]
const result = enrichMatchChain(chain, { page: 'nope' }, '')
expect(result[0].query).toBeNull()
})
it('should populate hash only when the current hash is listed in the declared tuple', () => {
const route: NestedRoute = {
component: () => ,
hash: ['a', 'b'] as const,
}
const chain: MatchChainEntry[] = [{ route, match: { path: '/', params: {} }, query: null, hash: undefined }]
expect(enrichMatchChain(chain, {}, 'a')[0].hash).toBe('a')
expect(enrichMatchChain(chain, {}, 'unknown')[0].hash).toBeUndefined()
})
it('should leave entries without a declared schema as null/undefined', () => {
const bareRoute: NestedRoute = { component: () => }
const declaringRoute: NestedRoute = {
component: () => ,
hash: ['a'] as const,
}
const chain: MatchChainEntry[] = [
{ route: bareRoute, match: { path: '/', params: {} }, query: null, hash: undefined },
{ route: declaringRoute, match: { path: '/', params: {} }, query: null, hash: undefined },
]
const result = enrichMatchChain(chain, {}, 'a')
expect(result[0].query).toBeNull()
expect(result[0].hash).toBeUndefined()
expect(result[1].hash).toBe('a')
})
})
describe('hasQueryOrHashChanged', () => {
const makeEntry = (query: unknown, hash: string | undefined): MatchChainEntry => ({
route: { component: () => },
match: { path: '/', params: {} },
query,
hash,
})
it('should return false when both chains are empty', () => {
expect(hasQueryOrHashChanged([], [])).toBe(false)
})
it('should return false for identical chains', () => {
const oldChain = [makeEntry({ page: 1 }, 'a')]
const newChain = [makeEntry({ page: 1 }, 'a')]
expect(hasQueryOrHashChanged(oldChain, newChain)).toBe(false)
})
it('should return true when the hash differs', () => {
expect(hasQueryOrHashChanged([makeEntry(null, 'a')], [makeEntry(null, 'b')])).toBe(true)
})
it('should return true when the query differs', () => {
expect(hasQueryOrHashChanged([makeEntry({ page: 1 }, undefined)], [makeEntry({ page: 2 }, undefined)])).toBe(true)
})
it('should treat equal query objects as unchanged regardless of key order', () => {
const oldChain = [makeEntry({ a: 1, b: 2 }, undefined)]
const newChain = [makeEntry({ b: 2, a: 1 }, undefined)]
expect(hasQueryOrHashChanged(oldChain, newChain)).toBe(false)
})
it('should detect query diffs on arrays', () => {
expect(hasQueryOrHashChanged([makeEntry([1, 2], undefined)], [makeEntry([1, 3], undefined)])).toBe(true)
})
it('should ignore chain entries beyond the shorter length', () => {
const oldChain = [makeEntry(null, 'a')]
const newChain = [makeEntry(null, 'a'), makeEntry({ x: 1 }, 'b')]
expect(hasQueryOrHashChanged(oldChain, newChain)).toBe(false)
})
})
describe('renderMatchChain', () => {
it('should render a single leaf route with outlet undefined', () => {
const componentFn = vi.fn(({ outlet }: { outlet?: JSX.Element }) => (
leaf{outlet ? 'has-outlet' : 'no-outlet'}
))
const chain: MatchChainEntry[] = [
{
route: { component: componentFn },
match: { path: '/leaf', params: {} },
query: null,
hash: undefined,
},
]
const result = renderMatchChain(chain, '/leaf')
expect(componentFn).toHaveBeenCalledTimes(1)
expect(componentFn).toHaveBeenCalledWith({
currentUrl: '/leaf',
match: { path: '/leaf', params: {} },
query: null,
hash: undefined,
outlet: undefined,
})
expect(result.chainElements).toHaveLength(1)
expect(result.jsx).toBe(result.chainElements[0])
})
it('should render inside-out: innermost first, then pass as outlet to parent', () => {
const callOrder: string[] = []
const childComponent = vi.fn(({ outlet }: { outlet?: JSX.Element }) => {
callOrder.push('child')
return child{outlet}
})
const parentComponent = vi.fn(({ outlet }: { outlet?: JSX.Element }) => {
callOrder.push('parent')
return parent{outlet}
})
const chain: MatchChainEntry[] = [
{
route: { component: parentComponent },
match: { path: '/parent', params: {} },
query: null,
hash: undefined,
},
{
route: { component: childComponent },
match: { path: '/child', params: {} },
query: null,
hash: undefined,
},
]
const result = renderMatchChain(chain, '/parent/child')
expect(callOrder).toEqual(['child', 'parent'])
expect(childComponent).toHaveBeenCalledWith(expect.objectContaining({ outlet: undefined }))
expect(parentComponent).toHaveBeenCalledWith(expect.objectContaining({ outlet: expect.anything() as unknown }))
expect(result.chainElements).toHaveLength(2)
expect(result.jsx).toBe(result.chainElements[0])
expect(result.chainElements[0]).not.toBe(result.chainElements[1])
})
it('should pass currentUrl to every component in the chain', () => {
const urls: string[] = []
const makeComponent = () =>
vi.fn(({ currentUrl }: { currentUrl: string; outlet?: JSX.Element }) => {
urls.push(currentUrl)
return
})
const grandchild = makeComponent()
const child = makeComponent()
const parent = makeComponent()
const chain: MatchChainEntry[] = [
{ route: { component: parent }, match: { path: '/a', params: {} }, query: null, hash: undefined },
{ route: { component: child }, match: { path: '/b', params: {} }, query: null, hash: undefined },
{ route: { component: grandchild }, match: { path: '/c', params: {} }, query: null, hash: undefined },
]
renderMatchChain(chain, '/a/b/c')
expect(urls).toEqual(['/a/b/c', '/a/b/c', '/a/b/c'])
})
it('should return per-entry chainElements where each entry is the component output at that level', () => {
const grandchildEl = grandchild
const childComponent = vi.fn(({ outlet }: { outlet?: JSX.Element }) => )
const parentComponent = vi.fn(({ outlet }: { outlet?: JSX.Element }) => parent{outlet})
const chain: MatchChainEntry[] = [
{ route: { component: parentComponent }, match: { path: '/a', params: {} }, query: null, hash: undefined },
{ route: { component: childComponent }, match: { path: '/b', params: {} }, query: null, hash: undefined },
{ route: { component: () => grandchildEl }, match: { path: '/c', params: {} }, query: null, hash: undefined },
]
const result = renderMatchChain(chain, '/a/b/c')
expect(result.chainElements).toHaveLength(3)
// chainElements[2] is the leaf (grandchild output)
expect(result.chainElements[2]).toBe(grandchildEl)
// chainElements[1] is the child wrapping the grandchild
expect(result.chainElements[1]).not.toBe(grandchildEl)
// chainElements[0] is the outermost parent (same as jsx)
expect(result.chainElements[0]).toBe(result.jsx)
// Each level wraps the next, so they must all be different
expect(result.chainElements[0]).not.toBe(result.chainElements[1])
expect(result.chainElements[1]).not.toBe(result.chainElements[2])
})
})
describe('NestedRouter lifecycle hooks', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
afterEach(() => {
document.body.innerHTML = ''
})
it('should correctly fire onVisit/onLeave across nested child switches and notFound transitions', async () => {
history.pushState(null, '', '/parent/child-a')
const callOrder: string[] = []
const onVisitParent = vi.fn(async () => {
callOrder.push('visit-parent')
})
const onLeaveParent = vi.fn(async () => {
callOrder.push('leave-parent')
})
const onVisitChildA = vi.fn(async () => {
callOrder.push('visit-child-a')
})
const onLeaveChildA = vi.fn(async () => {
callOrder.push('leave-child-a')
})
const onVisitChildB = vi.fn(async () => {
callOrder.push('visit-child-b')
})
const onLeaveChildB = vi.fn(async () => {
callOrder.push('leave-child-b')
})
const onVisitOther = vi.fn(async () => {
callOrder.push('visit-other')
})
const onLeaveOther = vi.fn(async () => {
callOrder.push('leave-other')
})
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
initializeShadeRoot({
injector,
rootElement,
jsxElement: (
child-a
child-b
other
nowhere
,
onVisit: onVisitParent,
onLeave: onLeaveParent,
children: {
'/child-a': {
component: () => child-a
,
onVisit: onVisitChildA,
onLeave: onLeaveChildA,
},
'/child-b': {
component: () => child-b
,
onVisit: onVisitChildB,
onLeave: onLeaveChildB,
},
},
},
'/other': {
component: () => other
,
onVisit: onVisitOther,
onLeave: onLeaveOther,
},
}}
notFound={not found
}
/>
),
})
const getContent = () => document.getElementById('content')?.innerHTML
const clickOn = (name: string) => document.getElementById(name)?.click()
// --- Initial load at /parent/child-a ---
await flushUpdates()
expect(getContent()).toBe('child-a')
expect(onVisitParent).toBeCalledTimes(1)
expect(onVisitChildA).toBeCalledTimes(1)
expect(callOrder).toEqual(['visit-parent', 'visit-child-a'])
// --- Click same route: no lifecycle hooks should fire ---
callOrder.length = 0
clickOn('child-a')
await flushUpdates()
expect(onVisitParent).toBeCalledTimes(1)
expect(onVisitChildA).toBeCalledTimes(1)
expect(callOrder).toEqual([])
// --- Switch child: only child lifecycle fires, parent stays ---
callOrder.length = 0
clickOn('child-b')
await flushUpdates()
expect(getContent()).toBe('child-b')
expect(onLeaveChildA).toBeCalledTimes(1)
expect(onVisitChildB).toBeCalledTimes(1)
expect(onLeaveParent).not.toBeCalled()
expect(onVisitParent).toBeCalledTimes(1)
expect(callOrder).toEqual(['leave-child-a', 'visit-child-b'])
// --- Navigate to a completely different branch ---
callOrder.length = 0
clickOn('other')
await flushUpdates()
await flushUpdates()
expect(getContent()).toBe('other')
expect(onLeaveChildB).toBeCalledTimes(1)
expect(onLeaveParent).toBeCalledTimes(1)
expect(onVisitOther).toBeCalledTimes(1)
// onLeave fires innermost-first, then onVisit for the new branch
expect(callOrder).toEqual(['leave-child-b', 'leave-parent', 'visit-other'])
// --- Navigate to non-matching URL: onLeave for all active ---
callOrder.length = 0
clickOn('nowhere')
await flushUpdates()
expect(getContent()).toBe('not found')
expect(onLeaveOther).toBeCalledTimes(1)
expect(callOrder).toEqual(['leave-other'])
})
})
})
describe('NestedRouter query / hash re-render', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
afterEach(() => {
document.body.innerHTML = ''
})
it('should re-render the chain on a hash-only change without firing lifecycle hooks', async () => {
history.pushState(null, '', '/tabs')
const onVisit = vi.fn(async () => {})
const onLeave = vi.fn(async () => {})
const componentFn = vi.fn(({ hash }: { hash: string | undefined }) => hash={hash ?? 'none'}
)
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
initializeShadeRoot({
injector,
rootElement,
jsxElement: (
),
})
await flushUpdates()
expect(document.getElementById('content')?.innerHTML).toBe('hash=none')
expect(onVisit).toBeCalledTimes(1)
expect(onLeave).not.toBeCalled()
const locationService = injector.get(LocationService)
locationService.navigate('/tabs#a')
await flushUpdates()
await flushUpdates()
expect(document.getElementById('content')?.innerHTML).toBe('hash=a')
expect(onVisit).toBeCalledTimes(1)
expect(onLeave).not.toBeCalled()
locationService.navigate('/tabs#b')
await flushUpdates()
await flushUpdates()
expect(document.getElementById('content')?.innerHTML).toBe('hash=b')
expect(onVisit).toBeCalledTimes(1)
expect(onLeave).not.toBeCalled()
})
})
it('should re-render the chain on a query-only change without firing lifecycle hooks', async () => {
history.pushState(null, '', '/list')
const onVisit = vi.fn(async () => {})
const onLeave = vi.fn(async () => {})
const componentFn = vi.fn(({ query }: { query: { page: number } | null }) => (
page={query?.page ?? 'none'}
))
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
initializeShadeRoot({
injector,
rootElement,
jsxElement: (
(typeof raw.page === 'number' ? { page: raw.page } : null),
},
}}
/>
),
})
await flushUpdates()
expect(document.getElementById('content')?.innerHTML).toBe('page=none')
expect(onVisit).toBeCalledTimes(1)
const locationService = injector.get(LocationService)
locationService.navigate(`/list?page=${serializeValue(2)}`)
await flushUpdates()
await flushUpdates()
expect(document.getElementById('content')?.innerHTML).toBe('page=2')
expect(onVisit).toBeCalledTimes(1)
expect(onLeave).not.toBeCalled()
locationService.navigate(`/list?page=${serializeValue(5)}`)
await flushUpdates()
await flushUpdates()
expect(document.getElementById('content')?.innerHTML).toBe('page=5')
expect(onVisit).toBeCalledTimes(1)
expect(onLeave).not.toBeCalled()
})
})
it('should coalesce path + query + hash bursts into a single updateUrl', async () => {
history.pushState(null, '', '/a')
const aComponent = vi.fn(() => a
)
const bComponent = vi.fn(({ query, hash }: { query: { page: number } | null; hash: string | undefined }) => (
b-page={query?.page ?? 'none'}-hash={hash ?? 'none'}
))
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
initializeShadeRoot({
injector,
rootElement,
jsxElement: (
(typeof raw.page === 'number' ? { page: raw.page } : null),
},
}}
/>
),
})
await flushUpdates()
expect(document.getElementById('content')?.innerHTML).toBe('a')
bComponent.mockClear()
injector.get(LocationService).navigate(`/b?page=${serializeValue(7)}#x`)
await flushUpdates()
await flushUpdates()
expect(document.getElementById('content')?.innerHTML).toBe('b-page=7-hash=x')
// A single logical navigation should render the /b component exactly once,
// not three times (once per observable subscription).
expect(bComponent).toHaveBeenCalledTimes(1)
})
})
})
describe('NestedRouter latest-wins on rapid navigation', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
afterEach(() => {
document.body.innerHTML = ''
})
it('should skip intermediate route when navigating rapidly (latest wins)', async () => {
history.pushState(null, '', '/route-a')
const callOrder: string[] = []
const onVisitA = vi.fn(async () => {
callOrder.push('visit-a')
})
const onLeaveA = vi.fn(async () => {
callOrder.push('leave-a')
})
const onVisitB = vi.fn(async () => {
await sleepAsync(200)
callOrder.push('visit-b')
})
const onLeaveB = vi.fn(async () => {
callOrder.push('leave-b')
})
const onVisitC = vi.fn(async () => {
callOrder.push('visit-c')
})
const onLeaveC = vi.fn(async () => {
callOrder.push('leave-c')
})
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
initializeShadeRoot({
injector,
rootElement,
jsxElement: (
a
b
c
route-a
,
onVisit: onVisitA,
onLeave: onLeaveA,
},
'/route-b': {
component: () => route-b
,
onVisit: onVisitB,
onLeave: onLeaveB,
},
'/route-c': {
component: () => route-c
,
onVisit: onVisitC,
onLeave: onLeaveC,
},
}}
/>
),
})
const getContent = () => document.getElementById('content')?.innerHTML
const clickOn = (name: string) => document.getElementById(name)?.click()
// --- Initial load at /route-a ---
await flushUpdates()
expect(getContent()).toBe('route-a')
expect(onVisitA).toHaveBeenCalledTimes(1)
// --- Rapid navigation: click B then immediately C ---
callOrder.length = 0
clickOn('go-b')
// Don't await — immediately navigate again
clickOn('go-c')
// Wait long enough for both transitions to settle (onVisitB has 200ms delay)
await sleepAsync(500)
// The final destination should be route-c
expect(getContent()).toBe('route-c')
expect(onVisitC).toHaveBeenCalledTimes(1)
// route-b's onVisit should have been abandoned (never completed or never started)
// because the version token was superseded by the /route-c navigation
expect(callOrder).not.toContain('visit-b')
})
})
})
describe('NestedRouter lifecycle element scope', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
afterEach(() => {
document.body.innerHTML = ''
})
it('should pass the child element (not the full tree) to onLeave/onVisit when switching between sibling children', async () => {
history.pushState(null, '', '/parent/child-a')
const visitElements: Array<{ route: string; element: JSX.Element }> = []
const leaveElements: Array<{ route: string; element: JSX.Element }> = []
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
initializeShadeRoot({
injector,
rootElement,
jsxElement: (
child-a
child-b
{outlet ?? index}
,
onVisit: async ({ element }) => {
visitElements.push({ route: 'parent', element })
},
onLeave: async ({ element }) => {
leaveElements.push({ route: 'parent', element })
},
children: {
'/child-a': {
component: () => child-a
,
onVisit: async ({ element }) => {
visitElements.push({ route: 'child-a', element })
},
onLeave: async ({ element }) => {
leaveElements.push({ route: 'child-a', element })
},
},
'/child-b': {
component: () => child-b
,
onVisit: async ({ element }) => {
visitElements.push({ route: 'child-b', element })
},
onLeave: async ({ element }) => {
leaveElements.push({ route: 'child-b', element })
},
},
},
},
}}
/>
),
})
const clickOn = (name: string) => document.getElementById(name)?.click()
// --- Initial load at /parent/child-a ---
await flushUpdates()
expect(visitElements).toHaveLength(2)
// Parent's onVisit element should be the full tree (parent wrapping child)
expect(visitElements[0].route).toBe('parent')
expect((visitElements[0].element as HTMLElement).getAttribute('id')).toBe('wrapper')
// Child-a's onVisit element should be just the child element, not the wrapper
expect(visitElements[1].route).toBe('child-a')
expect((visitElements[1].element as HTMLElement).getAttribute('id')).toBe('child-a-content')
// --- Switch to child-b: parent stays, only child lifecycle fires ---
visitElements.length = 0
leaveElements.length = 0
clickOn('child-b')
await flushUpdates()
// onLeave should receive the child-a element, not the full wrapper
expect(leaveElements).toHaveLength(1)
expect(leaveElements[0].route).toBe('child-a')
expect((leaveElements[0].element as HTMLElement).getAttribute('id')).toBe('child-a-content')
// onVisit should receive the child-b element, not the full wrapper
expect(visitElements).toHaveLength(1)
expect(visitElements[0].route).toBe('child-b')
expect((visitElements[0].element as HTMLElement).getAttribute('id')).toBe('child-b-content')
})
})
})
describe('NestedRouter flat routes', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
afterEach(() => {
document.body.innerHTML = ''
})
it('should render and navigate between flat (non-nested) Record routes', async () => {
history.pushState(null, '', '/')
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
initializeShadeRoot({
injector,
rootElement,
jsxElement: (
home
about
contact
about-page
},
'/contact': { component: () => contact-page
},
'/': { component: () => home-page
},
}}
notFound={not found
}
/>
),
})
const getContent = () => document.getElementById('content')?.innerHTML
const clickOn = (name: string) => document.getElementById(name)?.click()
await flushUpdates()
expect(getContent()).toBe('home-page')
clickOn('about')
await flushUpdates()
expect(getContent()).toBe('about-page')
clickOn('contact')
await flushUpdates()
expect(getContent()).toBe('contact-page')
clickOn('home')
await flushUpdates()
expect(getContent()).toBe('home-page')
})
})
})
describe('NestedRouter outlet composition', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
afterEach(() => {
document.body.innerHTML = ''
})
it('should compose parent layout wrapping child content via outlet', async () => {
history.pushState(null, '', '/dashboard/settings')
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
initializeShadeRoot({
injector,
rootElement,
jsxElement: (
(
),
children: {
'/settings': {
component: () => settings-content
,
},
'/profile': {
component: () => profile-content
,
},
},
},
}}
/>
),
})
await flushUpdates()
// Parent layout should be rendered with child inside
expect(document.getElementById('header')?.innerHTML).toBe('Dashboard Header')
expect(document.getElementById('child')?.innerHTML).toBe('settings-content')
// Child is inside #main which is inside #layout
const layout = document.getElementById('layout')
expect(layout).toBeTruthy()
expect(layout!.querySelector('#main #child')).toBeTruthy()
})
})
it('should render parent with fallback when navigating to parent URL without a child match', async () => {
history.pushState(null, '', '/dashboard')
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
initializeShadeRoot({
injector,
rootElement,
jsxElement: (
(
{outlet ?? dashboard-index
}
),
children: {
'/settings': {
component: () => settings
,
},
},
},
}}
/>
),
})
await flushUpdates()
// Parent matched alone, outlet is undefined, so the fallback renders
expect(document.getElementById('child')?.innerHTML).toBe('dashboard-index')
})
})
})
describe('NestedRouter route param changes', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
afterEach(() => {
document.body.innerHTML = ''
})
it('should re-render and fire lifecycle hooks when route params change', async () => {
history.pushState(null, '', '/users/1')
const callOrder: string[] = []
const onVisitUser = vi.fn(async () => {
callOrder.push('visit-user')
})
const onLeaveUser = vi.fn(async () => {
callOrder.push('leave-user')
})
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
initializeShadeRoot({
injector,
rootElement,
jsxElement: (
User 1
User 2
User 3
user-{(match.params as { id: string }).id}
,
onVisit: onVisitUser,
onLeave: onLeaveUser,
},
}}
/>
),
})
const getContent = () => document.getElementById('content')?.innerHTML
const clickOn = (name: string) => document.getElementById(name)?.click()
// Initial load at /users/1
await flushUpdates()
expect(getContent()).toBe('user-1')
expect(onVisitUser).toHaveBeenCalledTimes(1)
expect(callOrder).toEqual(['visit-user'])
// Navigate to /users/2 — same route, different param → lifecycle should fire
callOrder.length = 0
clickOn('user-2')
await flushUpdates()
expect(getContent()).toBe('user-2')
expect(onLeaveUser).toHaveBeenCalledTimes(1)
expect(onVisitUser).toHaveBeenCalledTimes(2)
expect(callOrder).toEqual(['leave-user', 'visit-user'])
// Navigate to /users/3
callOrder.length = 0
clickOn('user-3')
await flushUpdates()
expect(getContent()).toBe('user-3')
expect(onLeaveUser).toHaveBeenCalledTimes(2)
expect(onVisitUser).toHaveBeenCalledTimes(3)
expect(callOrder).toEqual(['leave-user', 'visit-user'])
// Click same user — no lifecycle change
callOrder.length = 0
clickOn('user-3')
await flushUpdates()
expect(getContent()).toBe('user-3')
expect(onLeaveUser).toHaveBeenCalledTimes(2)
expect(onVisitUser).toHaveBeenCalledTimes(3)
expect(callOrder).toEqual([])
})
})
it('should re-render nested child when parent params change', async () => {
history.pushState(null, '', '/org/alpha/dashboard')
const onVisitOrg = vi.fn()
const onLeaveOrg = vi.fn()
const onVisitDash = vi.fn()
const onLeaveDash = vi.fn()
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
initializeShadeRoot({
injector,
rootElement,
jsxElement: (
Alpha Dashboard
Beta Dashboard
(
org-{(match.params as { orgId: string }).orgId}
{outlet}
),
onVisit: onVisitOrg,
onLeave: onLeaveOrg,
children: {
'/dashboard': {
component: () => dashboard
,
onVisit: onVisitDash,
onLeave: onLeaveDash,
},
},
},
}}
/>
),
})
const clickOn = (name: string) => document.getElementById(name)?.click()
await flushUpdates()
expect(document.getElementById('org')?.textContent).toContain('org-alpha')
expect(document.getElementById('child')?.innerHTML).toBe('dashboard')
expect(onVisitOrg).toHaveBeenCalledTimes(1)
expect(onVisitDash).toHaveBeenCalledTimes(1)
// Change parent param: org/alpha → org/beta, child stays /dashboard
// Both parent and child should get leave/visit since parent diverges
clickOn('beta-dash')
await flushUpdates()
await flushUpdates()
expect(document.getElementById('org')?.textContent).toContain('org-beta')
expect(document.getElementById('child')?.innerHTML).toBe('dashboard')
expect(onLeaveOrg).toHaveBeenCalledTimes(1)
expect(onLeaveDash).toHaveBeenCalledTimes(1)
expect(onVisitOrg).toHaveBeenCalledTimes(2)
expect(onVisitDash).toHaveBeenCalledTimes(2)
})
})
})
describe('NestedRouter + RouteMatchService integration', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
afterEach(() => {
document.body.innerHTML = ''
})
it('should update RouteMatchService with the current match chain on navigation', async () => {
history.pushState(null, '', '/parent/child-a')
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const routeMatchService = injector.get(RouteMatchService)
const parentRoute: NestedRoute = {
meta: { title: 'Parent' },
component: ({ outlet }) => ,
children: {
'/child-a': {
meta: { title: 'Child A' },
component: () => child-a
,
},
'/child-b': {
meta: { title: 'Child B' },
component: () => child-b
,
},
},
}
initializeShadeRoot({
injector,
rootElement,
jsxElement: (
child-a
child-b
nowhere
not found
} />
),
})
// Initial load
await flushUpdates()
const initialChain = routeMatchService.currentMatchChain.getValue()
expect(initialChain).toHaveLength(2)
expect(initialChain[0].route.meta?.title).toBe('Parent')
expect(initialChain[1].route.meta?.title).toBe('Child A')
// Navigate to sibling child
document.getElementById('child-b')?.click()
await flushUpdates()
const updatedChain = routeMatchService.currentMatchChain.getValue()
expect(updatedChain).toHaveLength(2)
expect(updatedChain[0].route.meta?.title).toBe('Parent')
expect(updatedChain[1].route.meta?.title).toBe('Child B')
// Navigate to not-found
document.getElementById('nowhere')?.click()
await flushUpdates()
const notFoundChain = routeMatchService.currentMatchChain.getValue()
expect(notFoundChain).toEqual([])
})
})
it('should expose match params through RouteMatchService', async () => {
history.pushState(null, '', '/users/42')
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const routeMatchService = injector.get(RouteMatchService)
initializeShadeRoot({
injector,
rootElement,
jsxElement: (
} }) => `User ${match.params.id}`,
},
component: ({ match }) => user-{(match.params as { id: string }).id}
,
},
}}
/>
),
})
await flushUpdates()
const chain = routeMatchService.currentMatchChain.getValue()
expect(chain).toHaveLength(1)
expect(chain[0].match.params).toEqual({ id: '42' })
})
})
})
describe('resolveViewTransition', () => {
const makeEntry = (viewTransition?: boolean | { types?: string[] }): MatchChainEntry => ({
route: { component: () => , viewTransition },
match: { path: '/', params: {} },
query: null,
hash: undefined,
})
it('should return false when router config is undefined and route has no override', () => {
expect(resolveViewTransition(undefined, [makeEntry()])).toBe(false)
})
it('should return false when router config is false', () => {
expect(resolveViewTransition(false, [makeEntry()])).toBe(false)
})
it('should return config when router config is true', () => {
expect(resolveViewTransition(true, [makeEntry()])).toEqual({ types: undefined })
})
it('should return false when router is true but leaf route opts out', () => {
expect(resolveViewTransition(true, [makeEntry(false)])).toBe(false)
})
it('should use router-level types when route has no override', () => {
expect(resolveViewTransition({ types: ['slide'] }, [makeEntry()])).toEqual({ types: ['slide'] })
})
it('should prefer route-level types over router-level types', () => {
expect(resolveViewTransition({ types: ['slide'] }, [makeEntry({ types: ['fade'] })])).toEqual({
types: ['fade'],
})
})
it('should enable transitions when only the leaf route enables it', () => {
expect(resolveViewTransition(undefined, [makeEntry(true)])).toEqual({ types: undefined })
})
it('should use types from the innermost (leaf) route in a chain', () => {
const parent = makeEntry({ types: ['parent-type'] })
const child = makeEntry({ types: ['child-type'] })
expect(resolveViewTransition(true, [parent, child])).toEqual({ types: ['child-type'] })
})
})
describe('NestedRouter view transitions', () => {
let startViewTransitionSpy: ReturnType
beforeEach(() => {
document.body.innerHTML = ''
startViewTransitionSpy = vi.fn((optionsOrCallback: StartViewTransitionOptions | (() => void)) => {
const update = typeof optionsOrCallback === 'function' ? optionsOrCallback : optionsOrCallback.update
update?.()
return {
finished: Promise.resolve(),
ready: Promise.resolve(),
updateCallbackDone: Promise.resolve(),
skipTransition: vi.fn(),
} as unknown as ViewTransition
})
document.startViewTransition = startViewTransitionSpy as typeof document.startViewTransition
})
afterEach(() => {
document.body.innerHTML = ''
delete (document as unknown as Record).startViewTransition
})
it('should call startViewTransition when viewTransition is enabled', async () => {
history.pushState(null, '', '/')
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
initializeShadeRoot({
injector,
rootElement,
jsxElement: (
about
about
},
'/': { component: () => home
},
}}
/>
),
})
await flushUpdates()
startViewTransitionSpy.mockClear()
document.getElementById('go-about')?.click()
await flushUpdates()
expect(startViewTransitionSpy).toHaveBeenCalledTimes(1)
expect(document.getElementById('content')?.innerHTML).toBe('about')
})
})
it('should not call startViewTransition when viewTransition is not set', async () => {
history.pushState(null, '', '/')
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
initializeShadeRoot({
injector,
rootElement,
jsxElement: (
about
about
},
'/': { component: () => home
},
}}
/>
),
})
await flushUpdates()
startViewTransitionSpy.mockClear()
document.getElementById('go-about')?.click()
await flushUpdates()
expect(startViewTransitionSpy).not.toHaveBeenCalled()
expect(document.getElementById('content')?.innerHTML).toBe('about')
})
})
it('should pass types to startViewTransition when configured', async () => {
history.pushState(null, '', '/')
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
initializeShadeRoot({
injector,
rootElement,
jsxElement: (
about
about
},
'/': { component: () => home
},
}}
/>
),
})
await flushUpdates()
startViewTransitionSpy.mockClear()
document.getElementById('go-about')?.click()
await flushUpdates()
expect(startViewTransitionSpy).toHaveBeenCalledWith(expect.objectContaining({ types: ['slide'] }))
})
})
it('should respect per-route viewTransition: false override', async () => {
history.pushState(null, '', '/')
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
initializeShadeRoot({
injector,
rootElement,
jsxElement: (
about
about
,
viewTransition: false,
},
'/': { component: () => home
},
}}
/>
),
})
await flushUpdates()
startViewTransitionSpy.mockClear()
document.getElementById('go-about')?.click()
await flushUpdates()
expect(startViewTransitionSpy).not.toHaveBeenCalled()
expect(document.getElementById('content')?.innerHTML).toBe('about')
})
})
it('should fall back gracefully when startViewTransition is not available', async () => {
delete (document as unknown as Record).startViewTransition
history.pushState(null, '', '/')
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
initializeShadeRoot({
injector,
rootElement,
jsxElement: (
about
about
},
'/': { component: () => home
},
}}
/>
),
})
await flushUpdates()
document.getElementById('go-about')?.click()
await flushUpdates()
expect(document.getElementById('content')?.innerHTML).toBe('about')
})
})
})