UNPKG

@furystack/shades

Version:

A lightweight UI framework for FuryStack with JSX support

1,002 lines 61.1 kB
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, } from './nested-router.js'; import { NestedRouteLink } from './nested-route-link.js'; describe('buildMatchChain', () => { it('should match a simple leaf route', () => { const route = { component: () => createComponent("div", null) }; 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 = { component: () => createComponent("div", null) }; const chain = buildMatchChain({ '/about': route }, '/missing'); expect(chain).toBeNull(); }); it('should match a parent with a child route', () => { const child = { component: () => createComponent("div", null, "child") }; const parent = { component: ({ outlet }) => createComponent("div", null, 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 = { component: () => createComponent("div", null, "child") }; const parent = { component: ({ outlet }) => createComponent("div", null, 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 = { component: () => createComponent("div", null) }; 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 = { component: () => createComponent("div", null, "grandchild") }; const child = { component: ({ outlet }) => createComponent("div", null, outlet), children: { '/gc': grandchild }, }; const parent = { component: ({ outlet }) => createComponent("div", null, 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 = { component: () => createComponent("div", null, "specific") }; const catchAll = { component: () => createComponent("div", null, "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 = { component: () => createComponent("div", null, "buttons") }; const parent = { component: ({ outlet }) => createComponent("div", null, "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 = { component: () => createComponent("div", null, "buttons") }; const parent = { component: ({ outlet }) => createComponent("div", null, "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 = { component: () => createComponent("div", null, "specific") }; const rootChild = { component: () => createComponent("div", null, "root-child") }; const rootParent = { component: ({ outlet }) => createComponent("div", null, 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 = { component: () => createComponent("div", null) }; const parent = { component: ({ outlet }) => createComponent("div", null, 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, params = {}) => ({ route: { component: () => createComponent("div", null, 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 = { component: () => createComponent("div", null) }; const oldChain = [ { route, match: { path: '/', params: { id: '1' } }, query: null, hash: undefined }, ]; const newChain = [ { 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 = { component: () => createComponent("div", null) }; const oldChain = [{ route, match: { path: '/', params: {} }, query: { page: 1 }, hash: 'a' }]; const newChain = [{ 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 = { component: () => createComponent("div", null) }; const chain = [{ 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 = { component: () => createComponent("div", null), query: (raw) => (typeof raw.page === 'number' ? { page: raw.page } : null), }; const chain = [{ 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 = { component: () => createComponent("div", null), query: (raw) => (typeof raw.page === 'number' ? { page: raw.page } : null), }; const chain = [{ 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 = { component: () => createComponent("div", null), hash: ['a', 'b'], }; const chain = [{ 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 = { component: () => createComponent("div", null) }; const declaringRoute = { component: () => createComponent("div", null), hash: ['a'], }; const chain = [ { 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, hash) => ({ route: { component: () => createComponent("div", null) }, 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 }) => (createComponent("div", null, "leaf", outlet ? 'has-outlet' : 'no-outlet'))); const chain = [ { 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 = []; const childComponent = vi.fn(({ outlet }) => { callOrder.push('child'); return createComponent("span", null, "child", outlet); }); const parentComponent = vi.fn(({ outlet }) => { callOrder.push('parent'); return createComponent("div", null, "parent", outlet); }); const chain = [ { 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() })); 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 = []; const makeComponent = () => vi.fn(({ currentUrl }) => { urls.push(currentUrl); return createComponent("div", null); }); const grandchild = makeComponent(); const child = makeComponent(); const parent = makeComponent(); const chain = [ { 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 = createComponent("div", null, "grandchild"); const childComponent = vi.fn(({ outlet }) => createComponent("section", null, "child", outlet)); const parentComponent = vi.fn(({ outlet }) => createComponent("main", null, "parent", outlet)); const chain = [ { 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 = '<div id="root"></div>'; }); 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 = []; 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'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent("div", null, createComponent(NestedRouteLink, { id: "child-a", path: "/parent/child-a" }, "child-a"), createComponent(NestedRouteLink, { id: "child-b", path: "/parent/child-b" }, "child-b"), createComponent(NestedRouteLink, { id: "other", path: "/other" }, "other"), createComponent(NestedRouteLink, { id: "nowhere", path: "/nowhere" }, "nowhere"), createComponent(NestedRouter, { routes: { '/parent': { component: ({ outlet }) => createComponent("div", { id: "wrapper" }, outlet ?? createComponent("div", { id: "content" }, "parent-index")), onVisit: onVisitParent, onLeave: onLeaveParent, children: { '/child-a': { component: () => createComponent("div", { id: "content" }, "child-a"), onVisit: onVisitChildA, onLeave: onLeaveChildA, }, '/child-b': { component: () => createComponent("div", { id: "content" }, "child-b"), onVisit: onVisitChildB, onLeave: onLeaveChildB, }, }, }, '/other': { component: () => createComponent("div", { id: "content" }, "other"), onVisit: onVisitOther, onLeave: onLeaveOther, }, }, notFound: createComponent("div", { id: "content" }, "not found") }))), }); const getContent = () => document.getElementById('content')?.innerHTML; const clickOn = (name) => 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 = '<div id="root"></div>'; }); 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 }) => createComponent("div", { id: "content" }, "hash=", hash ?? 'none')); await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(NestedRouter, { routes: { '/tabs': { component: componentFn, onVisit, onLeave, hash: ['a', 'b'], }, } })), }); 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 }) => (createComponent("div", { id: "content" }, "page=", query?.page ?? 'none'))); await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(NestedRouter, { routes: { '/list': { component: componentFn, onVisit, onLeave, query: (raw) => (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(() => createComponent("div", { id: "content" }, "a")); const bComponent = vi.fn(({ query, hash }) => (createComponent("div", { id: "content" }, "b-page=", query?.page ?? 'none', "-hash=", hash ?? 'none'))); await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(NestedRouter, { routes: { '/a': { component: aComponent }, '/b': { component: bComponent, hash: ['x'], query: (raw) => (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 = '<div id="root"></div>'; }); afterEach(() => { document.body.innerHTML = ''; }); it('should skip intermediate route when navigating rapidly (latest wins)', async () => { history.pushState(null, '', '/route-a'); const callOrder = []; 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'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent("div", null, createComponent(NestedRouteLink, { id: "go-a", path: "/route-a" }, "a"), createComponent(NestedRouteLink, { id: "go-b", path: "/route-b" }, "b"), createComponent(NestedRouteLink, { id: "go-c", path: "/route-c" }, "c"), createComponent(NestedRouter, { routes: { '/route-a': { component: () => createComponent("div", { id: "content" }, "route-a"), onVisit: onVisitA, onLeave: onLeaveA, }, '/route-b': { component: () => createComponent("div", { id: "content" }, "route-b"), onVisit: onVisitB, onLeave: onLeaveB, }, '/route-c': { component: () => createComponent("div", { id: "content" }, "route-c"), onVisit: onVisitC, onLeave: onLeaveC, }, } }))), }); const getContent = () => document.getElementById('content')?.innerHTML; const clickOn = (name) => 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 = '<div id="root"></div>'; }); 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 = []; const leaveElements = []; await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent("div", null, createComponent(NestedRouteLink, { id: "child-a", path: "/parent/child-a" }, "child-a"), createComponent(NestedRouteLink, { id: "child-b", path: "/parent/child-b" }, "child-b"), createComponent(NestedRouter, { routes: { '/parent': { component: ({ outlet }) => createComponent("div", { id: "wrapper" }, outlet ?? createComponent("span", null, "index")), onVisit: async ({ element }) => { visitElements.push({ route: 'parent', element }); }, onLeave: async ({ element }) => { leaveElements.push({ route: 'parent', element }); }, children: { '/child-a': { component: () => createComponent("div", { id: "child-a-content" }, "child-a"), onVisit: async ({ element }) => { visitElements.push({ route: 'child-a', element }); }, onLeave: async ({ element }) => { leaveElements.push({ route: 'child-a', element }); }, }, '/child-b': { component: () => createComponent("div", { id: "child-b-content" }, "child-b"), onVisit: async ({ element }) => { visitElements.push({ route: 'child-b', element }); }, onLeave: async ({ element }) => { leaveElements.push({ route: 'child-b', element }); }, }, }, }, } }))), }); const clickOn = (name) => 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.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.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.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.getAttribute('id')).toBe('child-b-content'); }); }); }); describe('NestedRouter flat routes', () => { beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; }); 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'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent("div", null, createComponent(NestedRouteLink, { id: "home", path: "/" }, "home"), createComponent(NestedRouteLink, { id: "about", path: "/about" }, "about"), createComponent(NestedRouteLink, { id: "contact", path: "/contact" }, "contact"), createComponent(NestedRouter, { routes: { '/about': { component: () => createComponent("div", { id: "content" }, "about-page") }, '/contact': { component: () => createComponent("div", { id: "content" }, "contact-page") }, '/': { component: () => createComponent("div", { id: "content" }, "home-page") }, }, notFound: createComponent("div", { id: "content" }, "not found") }))), }); const getContent = () => document.getElementById('content')?.innerHTML; const clickOn = (name) => 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 = '<div id="root"></div>'; }); 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'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(NestedRouter, { routes: { '/dashboard': { component: ({ outlet }) => (createComponent("div", { id: "layout" }, createComponent("header", { id: "header" }, "Dashboard Header"), createComponent("main", { id: "main" }, outlet ?? createComponent("div", { id: "child" }, "index")))), children: { '/settings': { component: () => createComponent("div", { id: "child" }, "settings-content"), }, '/profile': { component: () => createComponent("div", { id: "child" }, "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'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(NestedRouter, { routes: { '/dashboard': { component: ({ outlet }) => (createComponent("div", { id: "layout" }, createComponent("main", { id: "main" }, outlet ?? createComponent("div", { id: "child" }, "dashboard-index")))), children: { '/settings': { component: () => createComponent("div", { id: "child" }, "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 = '<div id="root"></div>'; }); afterEach(() => { document.body.innerHTML = ''; }); it('should re-render and fire lifecycle hooks when route params change', async () => { history.pushState(null, '', '/users/1'); const callOrder = []; 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'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent("div", null, createComponent(NestedRouteLink, { id: "user-1", path: "/users/1" }, "User 1"), createComponent(NestedRouteLink, { id: "user-2", path: "/users/2" }, "User 2"), createComponent(NestedRouteLink, { id: "user-3", path: "/users/3" }, "User 3"), createComponent(NestedRouter, { routes: { '/users/:id': { component: ({ match }) => createComponent("div", { id: "content" }, "user-", match.params.id), onVisit: onVisitUser, onLeave: onLeaveUser, }, } }))), }); const getContent = () => document.getElementById('content')?.innerHTML; const clickOn = (name) => 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'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent("div", null, createComponent(NestedRouteLink, { id: "alpha-dash", path: "/org/alpha/dashboard" }, "Alpha Dashboard"), createComponent(NestedRouteLink, { id: "beta-dash", path: "/org/beta/dashboard" }, "Beta Dashboard"), createComponent(NestedRouter, { routes: { '/org/:orgId': { component: ({ match, outlet }) => (createComponent("div", { id: "org" }, "org-", match.params.orgId, outlet)), onVisit: onVisitOrg, onLeave: onLeaveOrg, children: { '/dashboard': { component: () => createComponent("div", { id: "child" }, "dashboard"), onVisit: onVisitDash, onLeave: onLeaveDash, }, }, }, } }))), }); const clickOn = (name) => 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 = '<div id="root"></div>'; }); 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'); const routeMatchService = injector.get(RouteMatchService); const parentRoute = { meta: { title: 'Parent' }, component: ({ outlet }) => createComponent("div", null, outlet ?? createComponent("div", null, "parent-index")), children: { '/child-a': { meta: { title: 'Child A' }, component: () => createComponent("div", { id: "content" }, "child-a"), }, '/child-b': { meta: { title: 'Child B' }, component: () => createComponent("div", { id: "content" }, "child-b"), }, }, }; initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent("div", null, createComponent(NestedRouteLink, { id: "child-a", path: "/parent/child-a" }, "child-a"), createComponent(NestedRouteLink, { id: "child-b", path: "/parent/child-b" }, "child-b"), createComponent(NestedRouteLink, { id: "no