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 }) =>
child{outlet}
) 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
{outlet ??
parent-index
}
, 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: ( (
{outlet ??
index
}
), 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 }) =>
{outlet ??
parent-index
}
, 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') }) }) })