@furystack/shades
Version:
A lightweight UI framework for FuryStack with JSX support
1,002 lines • 61.1 kB
JavaScript
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