From 4c83187d34903476456dfa78752263e59afb85f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ramirez=20Vargas=2C=20Jos=C3=A9=20Pablo?= Date: Wed, 26 Nov 2025 11:19:54 -0600 Subject: [PATCH] feat!: Group children parameters together --- README.md | 6 +- src/lib/Fallback/Fallback.svelte | 22 +-- src/lib/Fallback/Fallback.svelte.test.ts | 130 ++++++++++++- src/lib/Fallback/README.md | 4 +- src/lib/Link/Link.svelte | 33 ++-- src/lib/Link/Link.svelte.test.ts | 154 +++++++++++++++- src/lib/Link/README.md | 4 +- src/lib/Route/README.md | 8 +- src/lib/Route/Route.svelte | 47 ++--- src/lib/Route/Route.svelte.test.ts | 201 ++++++++++++++++++--- src/lib/Router/README.md | 49 ++--- src/lib/Router/Router.svelte | 14 +- src/lib/Router/Router.svelte.test.ts | 189 +++++++++++++++---- src/lib/kernel/RouteHelper.svelte.ts | 4 +- src/lib/testing/TestRouteWithRouter.svelte | 4 +- src/lib/testing/test-utils.ts | 8 +- src/lib/types.ts | 71 +++++++- 17 files changed, 762 insertions(+), 186 deletions(-) diff --git a/README.md b/README.md index 1b10f56..7535394 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ de-synchronizing state. ### Install the package ```bash -npm i @svelte-router/core@beta // For now, until v1.0.0 is released +npm i @svelte-router/core@beta # For now, until v1.0.0 is released ``` ### Initialize the Library @@ -120,8 +120,8 @@ details. - {#snippet children(params)} - + {#snippet children({ rp })} + {/snippet} ... diff --git a/src/lib/Fallback/Fallback.svelte b/src/lib/Fallback/Fallback.svelte index 3b227e3..40abd2a 100644 --- a/src/lib/Fallback/Fallback.svelte +++ b/src/lib/Fallback/Fallback.svelte @@ -1,7 +1,7 @@ {#if (router && when?.(router.routeStatus, router.noMatches)) || (!when && router?.noMatches)} - {@render children?.(router.state, router.routeStatus)} + {@render children?.({ state: router.state, rs: router.routeStatus })} {/if} diff --git a/src/lib/Fallback/Fallback.svelte.test.ts b/src/lib/Fallback/Fallback.svelte.test.ts index 2cf6952..8c08938 100644 --- a/src/lib/Fallback/Fallback.svelte.test.ts +++ b/src/lib/Fallback/Fallback.svelte.test.ts @@ -1,26 +1,28 @@ import { init } from "$lib/init.js"; import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest"; import { render } from "@testing-library/svelte"; +import { createRawSnippet } from "svelte"; import Fallback from "./Fallback.svelte"; import { addMatchingRoute, addRoutes, createRouterTestSetup, createTestSnippet, ROUTING_UNIVERSES, ALL_HASHES } from "$test/test-utils.js"; import { flushSync } from "svelte"; import { resetRoutingOptions, setRoutingOptions } from "$lib/kernel/options.js"; -import type { ExtendedRoutingOptions } from "$lib/types.js"; +import type { ExtendedRoutingOptions, RouterChildrenContext } from "$lib/types.js"; +import { location } from "$lib/kernel/Location.js"; function defaultPropsTests(setup: ReturnType) { const contentText = "Fallback content."; const content = createTestSnippet(contentText); - + beforeEach(() => { // Fresh router instance for each test setup.init(); }); - + afterAll(() => { // Clean disposal after all tests setup.dispose(); }); - + test("Should render whenever the parent router matches no routes.", async () => { // Arrange. const { hash, router, context } = setup; @@ -31,7 +33,7 @@ function defaultPropsTests(setup: ReturnType) { // Assert. await expect(findByText(contentText)).resolves.toBeDefined(); }); - + test("Should not render whenever the parent router matches at least one route.", async () => { // Arrange. const { hash, router, context } = setup; @@ -163,6 +165,116 @@ function reactivityTests(setup: ReturnType) { }); } + +function fallbackChildrenSnippetContextTests(setup: ReturnType) { + beforeEach(() => { + // Fresh router instance for each test + setup.init(); + }); + + afterAll(() => { + // Clean disposal after all tests + setup.dispose(); + }); + + test("Should pass RouterChildrenContext with correct structure to children snippet when fallback activates.", async () => { + // Arrange. + const { hash, context } = setup; + let capturedContext: RouterChildrenContext; + const content = createRawSnippet<[RouterChildrenContext]>((contextObj) => { + capturedContext = contextObj(); + return { render: () => '
Fallback Context Test
' }; + }); + + // Act. + render(Fallback, { + props: { hash, children: content }, + context + }); + + // Assert. + expect(capturedContext!).toBeDefined(); + expect(capturedContext!).toHaveProperty('state'); + expect(capturedContext!).toHaveProperty('rs'); + expect(typeof capturedContext!.rs).toBe('object'); + }); + + test("Should provide current router state in children snippet context.", async () => { + // Arrange. + const { hash, context } = setup; + let capturedContext: RouterChildrenContext; + const newState = { msg: "Test State" }; + location.navigate('/', { state: newState }); + const content = createRawSnippet<[RouterChildrenContext]>((contextObj) => { + capturedContext = contextObj(); + return { render: () => '
Fallback State Test
' }; + }); + + // Act. + render(Fallback, { + props: { hash, children: content }, + context + }); + + // Assert. + expect(capturedContext!.state).toBeDefined(); + expect(capturedContext!.state).toEqual(newState); + }); + + test("Should provide route status record in children snippet context.", async () => { + // Arrange. + const { hash, router, context } = setup; + let capturedContext: RouterChildrenContext; + const content = createRawSnippet<[RouterChildrenContext]>((contextObj) => { + capturedContext = contextObj(); + return { render: () => '
Fallback RouteStatus Test
' }; + }); + + // Add some non-matching routes to verify structure + addRoutes(router, { nonMatching: 2 }); + + // Act. + render(Fallback, { + props: { hash, children: content }, + context + }); + + // Assert. + expect(capturedContext!.rs).toBeDefined(); + expect(typeof capturedContext!.rs).toBe('object'); + expect(Object.keys(capturedContext!.rs)).toHaveLength(2); + // Verify each route status has correct structure + Object.keys(capturedContext!.rs).forEach(key => { + expect(capturedContext?.rs[key]).toHaveProperty('match'); + expect(typeof capturedContext?.rs[key].match).toBe('boolean'); + }); + }); + + test("Should not render children snippet when parent router has matching routes.", async () => { + // Arrange. + const { hash, router, context } = setup; + let capturedContext: RouterChildrenContext; + let callCount = 0; + const content = createRawSnippet<[RouterChildrenContext]>((contextObj) => { + capturedContext = contextObj(); + callCount++; + return { render: () => '
Should Not Render
' }; + }); + + // Add matching route to prevent fallback activation + addMatchingRoute(router); + + // Act. + render(Fallback, { + props: { hash, children: content }, + context + }); + + // Assert - snippet should not be called when routes are matching. + expect(callCount).toBe(0); + }); +} + describe("Routing Mode Assertions", () => { const contentText = "Fallback content."; const content = createTestSnippet(contentText); @@ -207,8 +319,8 @@ describe("Routing Mode Assertions", () => { // Act & Assert expect(() => { - render(Fallback, { - props: { hash, children: content }, + render(Fallback, { + props: { hash, children: content }, }); }).toThrow(); }); @@ -236,5 +348,9 @@ ROUTING_UNIVERSES.forEach(ru => { describe("Reactivity", () => { reactivityTests(setup); }); + + describe("Children Snippet Context", () => { + fallbackChildrenSnippetContextTests(setup); + }); }); }); diff --git a/src/lib/Fallback/README.md b/src/lib/Fallback/README.md index 1c6ef32..16d0664 100644 --- a/src/lib/Fallback/README.md +++ b/src/lib/Fallback/README.md @@ -10,9 +10,9 @@ route status data is calculated. | Property | Type | Default Value | Bindable | Description | |-|-|-|-|-| -| `hash` | `boolean \| string` | `undefined` | | Sets the hash mode of the component. | +| `hash` | `Hash` | `undefined` | | Sets the hash mode of the component. | | `when` | `WhenPredicate` | `undefined` | | Overrides the default activation conditions for the fallback content inside the component. | -| `children` | `Snippet<[any, Record]>` | `undefined` | | Renders the children of the component. | +| `children` | `Snippet<[RouterChildrenContext]>` | `undefined` | | Renders the children of the component. | [Online Documentation](https://wjfe-n-savant.hashnode.space/wjfe-n-savant/components/fallback) diff --git a/src/lib/Link/Link.svelte b/src/lib/Link/Link.svelte index 65dc68f..0b63ac4 100644 --- a/src/lib/Link/Link.svelte +++ b/src/lib/Link/Link.svelte @@ -6,7 +6,7 @@ import { getLinkContext, type ILinkContext } from '$lib/LinkContext/LinkContext.svelte'; import { isRouteActive } from '$lib/public-utils.js'; import { getRouterContext } from '$lib/Router/Router.svelte'; - import type { Hash, RouteStatus } from '$lib/types.js'; + import type { Hash, LinkChildrenContext } from '$lib/types.js'; import { assertAllowedRoutingMode, expandAriaAttributes, joinStyles } from '$lib/utils.js'; import { type Snippet } from 'svelte'; import type { AriaAttributes, HTMLAnchorAttributes } from 'svelte/elements'; @@ -60,12 +60,9 @@ activeFor?: string; /** * Renders the children of the component. - * @param state The state object stored in in the window's History API for the universe the link is - * associated to. - * @param routeStatus The router's route status data, if the `Link` component is within the context of a - * router. + * @param context The component's context available to children. */ - children?: Snippet<[any, Record | undefined]>; + children?: Snippet<[LinkChildrenContext]>; }; let { @@ -111,14 +108,18 @@ }; }); const isActive = $derived(isRouteActive(router, activeFor)); - const calcHref = $derived(href === '' ? location.url.href : calculateHref( - { - hash: resolvedHash, - preserveQuery: calcPreserveQuery - }, - calcPrependBasePath ? router?.basePath : undefined, - href - )); + const calcHref = $derived( + href === '' + ? location.url.href + : calculateHref( + { + hash: resolvedHash, + preserveQuery: calcPreserveQuery + }, + calcPrependBasePath ? router?.basePath : undefined, + href + ) + ); function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLAnchorElement }) { incomingOnclick?.(event); @@ -134,8 +135,8 @@ class={[cssClass, (isActive && calcActiveState?.class) || undefined]} style={isActive ? joinStyles(style, calcActiveState?.style) : style} onclick={handleClick} - {...(isActive ? calcActiveStateAria : undefined)} + {...isActive ? calcActiveStateAria : undefined} {...restProps} > - {@render children?.(location.getState(resolvedHash), router?.routeStatus)} + {@render children?.({ state: location.getState(resolvedHash), rs: router?.routeStatus })} diff --git a/src/lib/Link/Link.svelte.test.ts b/src/lib/Link/Link.svelte.test.ts index e1b98e2..81054f4 100644 --- a/src/lib/Link/Link.svelte.test.ts +++ b/src/lib/Link/Link.svelte.test.ts @@ -2,11 +2,12 @@ import { init } from "$lib/init.js"; import { location } from "$lib/kernel/Location.js"; import { describe, test, expect, beforeAll, afterAll, beforeEach, vi, afterEach } from "vitest"; import { render, fireEvent } from "@testing-library/svelte"; +import { createRawSnippet } from "svelte"; import Link from "./Link.svelte"; -import { createRouterTestSetup, createTestSnippet, ROUTING_UNIVERSES, ALL_HASHES, createWindowMock, setupBrowserMocks, type RoutingUniverse, addMatchingRoute } from "$test/test-utils.js"; +import { createRouterTestSetup, createTestSnippet, ROUTING_UNIVERSES, ALL_HASHES, setupBrowserMocks, type RoutingUniverse, addMatchingRoute } from "$test/test-utils.js"; import { flushSync } from "svelte"; import { resetRoutingOptions, setRoutingOptions } from "$lib/kernel/options.js"; -import type { ExtendedRoutingOptions } from "$lib/types.js"; +import type { ExtendedRoutingOptions, LinkChildrenContext } from "$lib/types.js"; import { linkCtxKey, type ILinkContext } from "$lib/LinkContext/LinkContext.svelte"; import { calculateHref } from "$lib/kernel/calculateHref.js"; @@ -926,6 +927,151 @@ describe("Routing Mode Assertions", () => { }); }); +function linkChildrenSnippetContextTests(setup: ReturnType) { + beforeEach(() => { + // Fresh router instance for each test + setup.init(); + }); + + afterAll(() => { + // Clean disposal after all tests + setup.dispose(); + }); + + test("Should pass LinkChildrenContext with correct structure to children snippet.", async () => { + // Arrange. + const { hash, context } = setup; + let capturedContext: LinkChildrenContext; + const content = createRawSnippet<[LinkChildrenContext]>((contextObj) => { + capturedContext = contextObj(); + return { render: () => '
Link Context Test
' }; + }) as any; // Cast to handle union type requirement + + // Act. + render(Link, { + props: { hash, href: "/test", children: content }, + context + }); + + // Assert. + expect(capturedContext!).toBeDefined(); + expect(capturedContext!).toHaveProperty('state'); + expect(capturedContext!).toHaveProperty('rs'); + expect(typeof capturedContext!.state).toBe('object'); + // rs can be undefined if no parent router + expect(capturedContext!.rs === undefined || typeof capturedContext!.rs === 'object').toBe(true); + }); + + test("Should provide current location state in children snippet context.", async () => { + // Arrange. + const { hash, context } = setup; + let capturedContext: LinkChildrenContext; + const content = createRawSnippet<[LinkChildrenContext]>((contextObj) => { + capturedContext = contextObj(); + return { render: () => '
Link State Test
' }; + }) as any; // Cast to handle union type requirement + + // Act. + render(Link, { + props: { hash, href: "/test", children: content }, + context + }); + + // Assert. + expect(capturedContext!.state).toBeDefined(); + expect(capturedContext!.state).toEqual(expect.any(Object)); + }); + + test("Should provide route status when parent router exists.", async () => { + // Arrange. + const { hash, router, context } = setup; + let capturedContext: LinkChildrenContext; + const content = createRawSnippet<[LinkChildrenContext]>((contextObj) => { + capturedContext = contextObj(); + return { render: () => '
Link Router Status Test
' }; + }) as any; // Cast to handle union type requirement + + // Add a route to the parent router + addMatchingRoute(router); + + // Act. + render(Link, { + props: { hash, href: "/test", children: content }, + context + }); + + // Assert. + expect(capturedContext!.rs).toBeDefined(); + expect(typeof capturedContext!.rs).toBe('object'); + + // Should have route entries from parent router + const routeKeys = Object.keys(capturedContext!.rs || {}); + expect(routeKeys.length).toBeGreaterThan(0); + + // Verify route status structure + routeKeys.forEach(key => { + expect(capturedContext?.rs![key]).toHaveProperty('match'); + expect(typeof capturedContext?.rs![key].match).toBe('boolean'); + }); + }); + + test("Should handle undefined route status when no parent router exists.", async () => { + // Arrange. + const hash = undefined; // No parent router context + let capturedContext: LinkChildrenContext; + const content = createRawSnippet<[LinkChildrenContext]>((contextObj) => { + capturedContext = contextObj(); + return { render: () => '
Link No Router Test
' }; + }) as any; // Cast to handle union type requirement + + // Act. + render(Link, { + props: { hash, href: "/test", children: content } + // No context - Link should work without parent router + }); + + // Assert. + expect(capturedContext!).toBeDefined(); + expect(capturedContext!).toHaveProperty('state'); + expect(capturedContext!).toHaveProperty('rs'); + expect(capturedContext!.state).toBeDefined(); + expect(capturedContext!.rs).toBeUndefined(); // No parent router means no route status + }); + + test("Should maintain consistent context structure across different link states.", async () => { + // Arrange. + const { hash, router, context } = setup; + const callHistory: LinkChildrenContext[] = []; + const content = createRawSnippet<[LinkChildrenContext]>((contextObj) => { + callHistory.push({ ...contextObj() }); + return { render: () => '
Link Consistency Test
' }; + }) as any; // Cast to handle union type requirement + + // Add some routes to have a more complex router state + const routeName = addMatchingRoute(router); + + // Act. + const { rerender } = render(Link, { + props: { hash, href: "/test", children: content }, + context + }); + + // Change link properties to trigger re-render + await rerender({ hash, href: "/different", activeFor: routeName, children: content }); + + // Assert. + expect(callHistory.length).toBeGreaterThan(0); + + // All calls should have consistent structure + callHistory.forEach(call => { + expect(call).toHaveProperty('state'); + expect(call).toHaveProperty('rs'); + expect(typeof call.state).toBe('object'); + expect(call.rs === undefined || typeof call.rs === 'object').toBe(true); + }); + }); +} + ROUTING_UNIVERSES.forEach(ru => { describe(`Link - ${ru.text}`, () => { const setup = createRouterTestSetup(ru.hash); @@ -961,6 +1107,10 @@ ROUTING_UNIVERSES.forEach(ru => { describe("Reactivity", () => { reactivityTests(setup); }); + + describe("Children Snippet Context", () => { + linkChildrenSnippetContextTests(setup); + }); }); }); diff --git a/src/lib/Link/README.md b/src/lib/Link/README.md index a1448c1..a3bf5e5 100644 --- a/src/lib/Link/README.md +++ b/src/lib/Link/README.md @@ -15,7 +15,7 @@ SPA-friendly navigation (navigation without reloading). | `activeState` | `ActiveState` | `undefined` | | Sets the various options that are used to automatically style the anchor tag whenever a particular route becomes active. | | `prependBasePath` | `boolean` | `false` | | Configures the component to prepend the parent router's base path to the `href` property. | | `preserveQuery` | `PreserveQuery` | `false` | | Configures the component to preserve the query string whenever it triggers navigation. | -| `children` | `Snippet<[any, Record \| undefined]>` | `undefined` | | Renders the children of the component. | +| `children` | `Snippet<[LinkChildrenContext]>` | `undefined` | | Renders the children of the component. | [Online Documentation](https://wjfe-n-savant.hashnode.space/wjfe-n-savant/components/link) @@ -48,7 +48,7 @@ automatically trigger its active appearance based on a specific route becoming a href="/admin/users" prependBasePath activeFor="adminUsers" - activeState={{ class: 'active', aria: { 'aria-current': 'page' } }} + activeState={{ class: 'active', aria: { current: 'page' } }} > Click Me! diff --git a/src/lib/Route/README.md b/src/lib/Route/README.md index b8d5128..abea079 100644 --- a/src/lib/Route/README.md +++ b/src/lib/Route/README.md @@ -12,12 +12,12 @@ they can be embedded anywhere down the hierarchy, including being children of ot |-|-|-|-|-| | `key` | `string` | (none) | | Sets the route's unique key. | | `path` | `string \| RegExp` | (none) | | Sets the route's path pattern, or a regular expression used to test and match the browser's URL. | -| `and` | `(params: Record, ParameterValue> \| undefined) => boolean` | `undefined` | | Sets a function for additional matching conditions. | +| `and` | `(params: RouteParamsRecord \| undefined) => boolean` | `undefined` | | Sets a function for additional matching conditions. | | `ignoreForFallback` | `boolean` | `false` | | Controls whether the matching status of this route affects the visibility of fallback content. | | `caseSensitive` | `boolean` | `false` | | Sets whether the route's path pattern should be matched case-sensitively. | -| `hash` | `boolean \| string` | `undefined` | | Sets the hash mode of the route. | -| `params` | `Record, ParameterValue>` | `undefined` | Yes | Provides a way to obtain a route's parameters through property binding. | -| `children` | `Snippet<[Record, ParameterValue> \| undefined, any, Record]>` | `undefined` | | Renders the children of the route. | +| `hash` | `Hash` | `undefined` | | Sets the hash mode of the route. | +| `params` | `RouteParamsRecord` | `undefined` | Yes | Provides a way to obtain a route's parameters through property binding. | +| `children` | `Snippet<[RouteChildrenContext]>` | `undefined` | | Renders the children of the route. | [Online Documentation](https://wjfe-n-savant.hashnode.space/wjfe-n-savant/components/route) diff --git a/src/lib/Route/Route.svelte b/src/lib/Route/Route.svelte index e5d7d17..fc321aa 100644 --- a/src/lib/Route/Route.svelte +++ b/src/lib/Route/Route.svelte @@ -1,26 +1,8 @@ - - -{#if (router.routeStatus[key]?.match ?? (!and && !path))} +{#if router.routeStatus[key]?.match ?? (!and && !path)} - {@render children?.(router.routeStatus[key]?.routeParams, router.state, router.routeStatus)} + {@render children?.({ + rp: router.routeStatus[key]?.routeParams as RouteParamsRecord, + state: router.state, + rs: router.routeStatus + })} {/if} diff --git a/src/lib/Route/Route.svelte.test.ts b/src/lib/Route/Route.svelte.test.ts index cd8ea49..9fc4bff 100644 --- a/src/lib/Route/Route.svelte.test.ts +++ b/src/lib/Route/Route.svelte.test.ts @@ -1,12 +1,13 @@ import { describe, test, expect, beforeEach, vi, beforeAll, afterAll, afterEach } from "vitest"; import { render } from "@testing-library/svelte"; +import { createRawSnippet, flushSync } from "svelte"; import Route from "./Route.svelte"; -import { createTestSnippet, createRouterTestSetup, ROUTING_UNIVERSES, ALL_HASHES } from "$test/test-utils.js"; +import { createTestSnippet, createRouterTestSetup, ROUTING_UNIVERSES, ALL_HASHES, addMatchingRoute } from "$test/test-utils.js"; import { init } from "$lib/init.js"; import { location } from "$lib/kernel/Location.js"; import TestRouteWithRouter from "$test/TestRouteWithRouter.svelte"; import { resetRoutingOptions, setRoutingOptions } from "$lib/kernel/options.js"; -import type { ExtendedRoutingOptions, InitOptions } from "$lib/types.js"; +import type { ExtendedRoutingOptions, RouteChildrenContext } from "$lib/types.js"; function basicRouteTests(setup: ReturnType) { beforeEach(() => { @@ -47,7 +48,7 @@ function basicRouteTests(setup: ReturnType) { // Act & Assert - Should not register route without path or and expect(() => { render(TestRouteWithRouter, { - props: { + props: { hash, routeKey: "no-path-route", routePath: undefined as any, @@ -139,7 +140,7 @@ function routePropsTests(setup: ReturnType) { // Act. render(TestRouteWithRouter, { - props: { + props: { hash, routeKey: "and-route", routePath: "/test", @@ -285,7 +286,7 @@ function routeReactivityTests(setup: ReturnType) { }, context }); - + const initialRoute = routerInstance?.routes["reactive-route"]; expect(initialRoute?.pattern).toBe(initialPath); @@ -469,17 +470,17 @@ function routeBindingTestsForUniverse(setup: ReturnType {}); + await vi.waitFor(() => { }); // Assert. expect(paramsSetter).toHaveBeenCalled(); - + // Multi-hash routing (MHR) has different behavior and may not work with simple URLs in tests if (ru.text === 'MHR') { // Skip assertion for MHR as it requires more complex setup return; } - + expect(capturedParams).toEqual({ id: 123 }); // Number due to auto-conversion }); @@ -513,7 +514,7 @@ function routeBindingTestsForUniverse(setup: ReturnType {}); + await vi.waitFor(() => { }); // Assert. expect(paramsSetter).toHaveBeenCalled(); @@ -551,7 +552,7 @@ function routeBindingTestsForUniverse(setup: ReturnType {}); + await vi.waitFor(() => { }); // Assert. expect(paramsSetter).toHaveBeenCalled(); @@ -587,16 +588,16 @@ function routeBindingTestsForUniverse(setup: ReturnType {}); - + await vi.waitFor(() => { }); + const firstParams = capturedParams; - + // Multi-hash routing (MHR) has different behavior and may not work with simple URLs in tests if (ru.text === 'MHR') { // Skip assertion for MHR as it requires more complex setup return; } - + expect(firstParams).toEqual({ id: 123 }); // Number due to auto-conversion // Act - Navigate to different matching path @@ -610,7 +611,7 @@ function routeBindingTestsForUniverse(setup: ReturnType {}); + await vi.waitFor(() => { }); // Assert. expect(capturedParams).toEqual({ id: 456 }); // Number due to auto-conversion @@ -647,17 +648,17 @@ function routeBindingTestsForUniverse(setup: ReturnType {}); + await vi.waitFor(() => { }); // Assert. expect(paramsSetter).toHaveBeenCalled(); - + // Multi-hash routing (MHR) has different behavior and may not work with simple URLs in tests if (ru.text === 'MHR') { // Skip assertion for MHR as it requires more complex setup return; } - + expect(capturedParams).toEqual({ userId: 123, postId: 456 }); // Numbers due to auto-conversion }); @@ -691,17 +692,17 @@ function routeBindingTestsForUniverse(setup: ReturnType {}); + await vi.waitFor(() => { }); // Assert. expect(paramsSetter).toHaveBeenCalled(); - + // Multi-hash routing (MHR) has different behavior and may not work with simple URLs in tests if (ru.text === 'MHR') { // Skip assertion for MHR as it requires more complex setup return; } - + expect(capturedParams).toEqual({ rest: "/documents/readme.txt" }); }); } @@ -751,7 +752,7 @@ describe("Routing Mode Assertions", () => { props: { key: 'r1', hash, - }, + }, }); }).toThrow(); }); @@ -764,12 +765,12 @@ describe("Routing Mode Assertions", () => { // Act & Assert expect(() => { - render(Route, { - props: { + render(Route, { + props: { hash, - key: "test-route", - }, - context: setup.context + key: "test-route", + }, + context: setup.context }); }).not.toThrow(); @@ -778,19 +779,157 @@ describe("Routing Mode Assertions", () => { }); }); +function routeChildrenSnippetContextTests(setup: ReturnType) { + beforeEach(() => { + setup.init(); + }); + + afterAll(() => { + setup.dispose(); + }); + + test("Should pass RouteChildrenContext with correct structure to children snippet when route matches.", async () => { + // Arrange. + const { hash, context, router } = setup; + const routeKey = "test-route"; + addMatchingRoute(router, { + name: routeKey, + }); + let capturedContext: RouteChildrenContext; + const routeChildren = createRawSnippet<[RouteChildrenContext]>((contextObj) => { + capturedContext = contextObj(); + return { render: () => '
Context Test
' }; + }); + + render(Route, { + props: { + hash, + key: routeKey, + children: routeChildren, + }, + context + }); + + // Assert. + expect(capturedContext!).toBeDefined(); + expect(capturedContext!).toHaveProperty('rp'); // Route parameters + expect(capturedContext!).toHaveProperty('state'); // State + expect(capturedContext!).toHaveProperty('rs'); // Route status + }); + + test("Should provide route parameters in children snippet context.", async () => { + // Arrange. + const { hash, context, router } = setup; + const routeKey = "test-route"; + location.navigate("/user/42", { hash }); + let capturedContext: RouteChildrenContext; + const routeChildren = createRawSnippet<[RouteChildrenContext]>((contextObj) => { + capturedContext = contextObj(); + return { render: () => '
Route
' }; + }); + + render(Route, { + props: { + hash, + key: routeKey, + path: '/user/:id', + children: routeChildren + }, + context + }); + flushSync(); + + // Assert. + expect(capturedContext!.rp).toBeDefined(); + }); + + test("Should provide route status and state in children snippet context.", async () => { + // Arrange. + const { hash, context } = setup; + const newState = { msg: "Hello, Route!" }; + location.navigate("/", { hash, state: newState }); + let capturedContext: RouteChildrenContext; + const routeChildren = createRawSnippet<[RouteChildrenContext]>((contextObj) => { + capturedContext = contextObj(); + return { render: () => '
Route Status Test
' }; + }); + + render(Route, { + props: { + hash, + key: "test-route", + children: routeChildren + }, + context + }); + + // Assert. + expect(capturedContext!.state).toBeDefined(); + expect(capturedContext!.rs).toBeDefined(); + expect(typeof capturedContext!.rs).toBe('object'); + expect(capturedContext!.state).toEqual(newState); + }); + + test("Should not render children snippet when route does not match.", async () => { + // Arrange. + const { hash, context } = setup; + let callCount = 0; + const routeChildren = createRawSnippet<[RouteChildrenContext]>((contextObj) => { + callCount++; + return { render: () => '
Should Not Render
' }; + }); + + render(Route, { + props: { + hash, + key: "test-route", + and: () => false, + children: routeChildren + }, + context + }); + + // Assert - snippet should not be called when route doesn't match. + expect(callCount).toBe(0); + }); + + test("Should provide empty or undefined rp when route has no parameters.", async () => { + // Arrange. + const { hash, context } = setup; + let capturedContext: RouteChildrenContext; + const routeChildren = createRawSnippet<[RouteChildrenContext]>((contextObj) => { + capturedContext = contextObj(); + return { render: () => '
No Params Route
' }; + }); + + render(Route, { + props: { + hash, + key: "no-params-route", + children: routeChildren + }, + context + }); + + // Assert. + expect(capturedContext!).toBeDefined(); + expect(capturedContext!.rp === undefined || Object.keys(capturedContext!.rp || {}).length === 0).toBe(true); + }); +} + // Run tests for each routing universe for (const ru of ROUTING_UNIVERSES) { describe(`Route - ${ru.text}`, () => { const setup = createRouterTestSetup(ru.hash); let cleanup: () => void; - + beforeAll(() => { cleanup = init({ defaultHash: ru.defaultHash, hashMode: ru.hashMode, }); }); - + afterAll(() => { cleanup?.(); }); @@ -822,5 +961,9 @@ for (const ru of ROUTING_UNIVERSES) { describe("Binding", () => { routeBindingTestsForUniverse(setup, ru); }); + + describe("Children Snippet Context", () => { + routeChildrenSnippetContextTests(setup); + }); }); } diff --git a/src/lib/Router/README.md b/src/lib/Router/README.md index 36449d7..1d65846 100644 --- a/src/lib/Router/README.md +++ b/src/lib/Router/README.md @@ -10,8 +10,8 @@ children via context. | `router` | `RouterEngine` | `undefined` | Yes | Gets or sets the router engine instance to be used by this router. | | `basePath` | `string` | `'/'` | | Sets the router's base path, which is a segment of the URL that is implicitly added to all routes. | | `id` | `string` | `undefined` | | Gives the router an identifier that shows up in `RouterTrace` components. | -| `hash` | `boolean \| string` | `undefined` | | Sets the hash mode of the router. | -| `children` | `Snippet<[any, Record]>` | `undefined` | | Renders the children of the router. | +| `hash` | `Hash` | `undefined` | | Sets the hash mode of the router. | +| `children` | `Snippet<[RouterChildrenContext]>` | `undefined` | | Renders the children of the router. | [Online Documentation](https://wjfe-n-savant.hashnode.space/wjfe-n-savant/components/router) @@ -27,13 +27,13 @@ Simplest form of use. - +

Welcome to the home page!

- +

About Us

- +

Contact Us

@@ -65,22 +65,22 @@ as needed. - +

Welcome to the home page!

The Route component matched /root/home.

- +

About Us

- +

Contact Us

- +

Admin Dashboard

The Route component matched /root/admin/dashboard.

- +

Admin Users

@@ -89,26 +89,26 @@ as needed. ### Fallback Content -Use the `fallback()` snippet of the router to present content when no routes match. +Use the `Fallback` component to present content when no routes match. ```svelte - +

Welcome to the home page!

- +

About Us

- +

Contact Us

- {#snippet fallback()} +

404 Not Found

- {/snippet} +
``` @@ -125,10 +125,10 @@ Parameters are expressed in the form `:[?]`. The optional `"?"` makes the - {#snippet children(params)} - - {#if params.detailed} - + {#snippet children({ rp })} + + {#if rp?.detailed} + {/if} {/snippet} @@ -143,11 +143,14 @@ never use this name as a name for one of your parameters. ```svelte - - ... + + {#snippet children({ rp })} + + {/snippet} ``` diff --git a/src/lib/Router/Router.svelte b/src/lib/Router/Router.svelte index b7b9658..3908852 100644 --- a/src/lib/Router/Router.svelte +++ b/src/lib/Router/Router.svelte @@ -1,7 +1,7 @@ -{@render children?.(router.state, router.routeStatus)} +{@render children?.({state: router.state, rs: router.routeStatus})} diff --git a/src/lib/Router/Router.svelte.test.ts b/src/lib/Router/Router.svelte.test.ts index eeb6bf8..79fdf52 100644 --- a/src/lib/Router/Router.svelte.test.ts +++ b/src/lib/Router/Router.svelte.test.ts @@ -2,9 +2,11 @@ import { describe, test, expect, beforeEach, vi, beforeAll, afterAll } from "vit import { render } from "@testing-library/svelte"; import Router, { getRouterContextKey } from "./Router.svelte"; import { RouterEngine } from "$lib/kernel/RouterEngine.svelte.js"; -import { createTestSnippet, createRouterTestSetup, ROUTING_UNIVERSES } from "$test/test-utils.js"; -import { flushSync } from "svelte"; +import { createTestSnippet, createRouterTestSetup, ROUTING_UNIVERSES, addRoutes } from "$test/test-utils.js"; +import { flushSync, createRawSnippet } from "svelte"; import { init } from "$lib/init.js"; +import type { RouterChildrenContext } from "$lib/types.js"; +import { location } from "$lib/kernel/Location.js"; function basicRouterTests(setup: ReturnType) { beforeEach(() => { @@ -97,12 +99,12 @@ function routerPropsTests(setup: ReturnType) { // Act. render(Router, { - props: { - hash, + props: { + hash, basePath, get router() { return routerInstance; }, set router(value) { routerInstance = value; }, - children: content + children: content }, context }); @@ -120,12 +122,12 @@ function routerPropsTests(setup: ReturnType) { // Act. render(Router, { - props: { - hash, + props: { + hash, id: routerId, get router() { return routerInstance; }, set router(value) { routerInstance = value; }, - children: content + children: content }, context }); @@ -166,24 +168,24 @@ function routerReactivityTests(setup: ReturnType) let routerInstance: RouterEngine | undefined; const { rerender } = render(Router, { - props: { - hash, + props: { + hash, basePath: initialBasePath, get router() { return routerInstance; }, set router(value) { routerInstance = value; }, - children: content + children: content }, context }); expect(routerInstance?.basePath).toBe(initialBasePath); // Act. - await rerender({ - hash, + await rerender({ + hash, basePath: updatedBasePath, get router() { return routerInstance; }, set router(value) { routerInstance = value; }, - children: content + children: content }); // Assert. @@ -199,24 +201,24 @@ function routerReactivityTests(setup: ReturnType) let routerInstance: RouterEngine | undefined; const { rerender } = render(Router, { - props: { - hash, + props: { + hash, id: initialId, get router() { return routerInstance; }, set router(value) { routerInstance = value; }, - children: content + children: content }, context }); expect(routerInstance?.id).toBe(initialId); // Act. - await rerender({ - hash, + await rerender({ + hash, id: updatedId, get router() { return routerInstance; }, set router(value) { routerInstance = value; }, - children: content + children: content }); // Assert. @@ -231,12 +233,12 @@ function routerReactivityTests(setup: ReturnType) let routerInstance: RouterEngine | undefined; render(Router, { - props: { - hash, + props: { + hash, get basePath() { return basePath; }, get router() { return routerInstance; }, set router(value) { routerInstance = value; }, - children: content + children: content }, context }); @@ -258,12 +260,12 @@ function routerReactivityTests(setup: ReturnType) let routerInstance: RouterEngine | undefined; render(Router, { - props: { - hash, + props: { + hash, get id() { return id; }, get router() { return routerInstance; }, set router(value) { routerInstance = value; }, - children: content + children: content }, context }); @@ -293,7 +295,7 @@ function contextFunctionTests() { const multiHashKey1 = getRouterContextKey("nav"); const multiHashKey2 = getRouterContextKey("nav"); const multiHashKey3 = getRouterContextKey("sidebar"); - + expect(multiHashKey1).toBeDefined(); expect(multiHashKey1).toBe(multiHashKey2); // Same string should give same key expect(multiHashKey1).not.toBe(multiHashKey3); // Different strings should give different keys @@ -316,7 +318,7 @@ function routerDisposalTests(setup: ReturnType) { const { hash, context } = setup; const content = createTestSnippet('
Test content
'); let capturedRouter: any; - + const { unmount } = render(Router, { props: { hash, @@ -401,8 +403,8 @@ function routerBindingTests(setup: ReturnType) { const { hash, context } = setup; const content = createTestSnippet('
BasePath Binding Test
'); let boundRouter: any; - const setterSpy = vi.fn((value) => { - boundRouter = value; + const setterSpy = vi.fn((value) => { + boundRouter = value; }); const { rerender } = render(Router, { @@ -439,12 +441,12 @@ function routerBindingTests(setup: ReturnType) { const content = createTestSnippet('
Reactive Binding Test
'); let boundRouter = $state(undefined); let setterCallCount = 0; - + render(Router, { props: { hash, get router() { return boundRouter; }, - set router(value) { + set router(value) { boundRouter = value; setterCallCount++; }, @@ -456,25 +458,138 @@ function routerBindingTests(setup: ReturnType) { // Assert. expect(setterCallCount).toBe(1); expect(boundRouter).toBeDefined(); - + // The bound router should be accessible and functional expect(typeof boundRouter.dispose).toBe('function'); }); } +function childrenSnippetContextTests(setup: ReturnType) { + beforeEach(() => { + // Fresh router instance for each test + setup.init(); + }); + + afterAll(() => { + // Clean disposal after all tests + setup.dispose(); + }); + + test("Should pass RouterChildrenContext with correct structure to children snippet.", async () => { + // Arrange. + const { hash } = setup; + let capturedContext: RouterChildrenContext; + const content = createRawSnippet<[RouterChildrenContext]>((contextObj) => { + capturedContext = contextObj(); + return { render: () => '
Router Context Test
' }; + }); + + // Act. + render(Router, { + props: { hash, children: content } + }); + + // Assert. + expect(capturedContext!).toBeDefined(); + expect(capturedContext!).toHaveProperty('state'); + expect(capturedContext!).toHaveProperty('rs'); + expect(typeof capturedContext!.rs).toBe('object'); + }); + + test("Should provide current router state in children snippet context.", async () => { + // Arrange. + const { hash } = setup; + let capturedContext: RouterChildrenContext; + const newState = { msg: 'Test State' }; + location.navigate("/", { state: newState }); + const content = createRawSnippet<[RouterChildrenContext]>((contextObj) => { + capturedContext = contextObj(); + return { render: () => '
State Test
' }; + }); + + // Act. + render(Router, { + props: { hash, children: content } + }); + + // Assert. + expect(capturedContext!.state).toBeDefined(); + expect(capturedContext!.state).toEqual(expect.any(Object)); + }); + + test("Should provide route status record in children snippet context.", async () => { + // Arrange. + const { hash } = setup; + let capturedContext: RouterChildrenContext; + let routerInstance: any; + const content = createRawSnippet<[RouterChildrenContext]>((contextObj) => { + capturedContext = contextObj(); + return { render: () => '
RouteStatus Test
' }; + }); + + // Act. + render(Router, { + props: { + hash, + children: content, + get router() { return routerInstance; }, + set router(value) { routerInstance = value; } + } + }); + + // Add some routes to the Router component's engine to verify route status structure + if (routerInstance) { + addRoutes(routerInstance, { matching: 1, nonMatching: 1 }); + } + + // Assert. + expect(capturedContext!.rs).toBeDefined(); + expect(typeof capturedContext!.rs).toBe('object'); + }); + + test("Should update children snippet context reactively when router state changes.", async () => { + // Arrange. + const { hash } = setup; + const callHistory: RouterChildrenContext[] = []; + const content = createRawSnippet<[RouterChildrenContext]>((contextObj) => { + callHistory.push({ ...contextObj() }); + return { render: () => '
Reactive Test
' }; + }); + + render(Router, { + props: { hash, children: content } + }); + + // Act - trigger a state change by navigating + // This should cause the context to be updated + // Note: The specific method depends on the routing universe + flushSync(); // Ensure any pending updates are processed + + // Assert. + // At minimum, we should have the initial call + expect(callHistory.length).toBeGreaterThanOrEqual(1); + + // Verify the structure is consistent across calls + callHistory.forEach(call => { + expect(call).toHaveProperty('state'); + expect(call).toHaveProperty('rs'); + }); + }); +} + // Run tests for each routing universe for (const ru of ROUTING_UNIVERSES) { describe(`Router - ${ru.text}`, () => { const setup = createRouterTestSetup(ru.hash); let cleanup: () => void; - + beforeAll(() => { cleanup = init({ defaultHash: ru.defaultHash, hashMode: ru.hashMode, }); }); - + afterAll(() => { cleanup?.(); }); @@ -498,6 +613,10 @@ for (const ru of ROUTING_UNIVERSES) { describe("Binding", () => { routerBindingTests(setup); }); + + describe("Children Snippet Context", () => { + childrenSnippetContextTests(setup); + }); }); } diff --git a/src/lib/kernel/RouteHelper.svelte.ts b/src/lib/kernel/RouteHelper.svelte.ts index 505002a..96e58ef 100644 --- a/src/lib/kernel/RouteHelper.svelte.ts +++ b/src/lib/kernel/RouteHelper.svelte.ts @@ -1,5 +1,5 @@ import { joinPaths } from "$lib/public-utils.js"; -import type { AndUntyped, Hash, PatternRouteInfo, RouteStatus } from "$lib/types.js"; +import type { AndUntyped, Hash, PatternRouteInfo, RouteParamsRecord } from "$lib/types.js"; import { noTrailingSlash } from "$lib/utils.js"; import { location } from "./Location.js"; @@ -81,7 +81,7 @@ export class RouteHelper { */ testRoute(routeMatchInfo: { regex?: RegExp; and?: AndUntyped; }) { const matches = routeMatchInfo.regex ? routeMatchInfo.regex.exec(this.testPath) : null; - const routeParams = matches?.groups ? { ...matches.groups } as RouteStatus['routeParams'] : undefined; + const routeParams = matches?.groups ? { ...matches.groups } as RouteParamsRecord : undefined; if (routeParams) { for (let key in routeParams) { if (routeParams[key] === undefined) { diff --git a/src/lib/testing/TestRouteWithRouter.svelte b/src/lib/testing/TestRouteWithRouter.svelte index ef7d9f1..0b89c52 100644 --- a/src/lib/testing/TestRouteWithRouter.svelte +++ b/src/lib/testing/TestRouteWithRouter.svelte @@ -40,9 +40,9 @@ {hash} bind:params > - {#snippet children(params, state, routeStatus)} + {#snippet children({ rp, state, rs })} {#if routeChildren} - {@render routeChildren(params, state, routeStatus)} + {@render routeChildren(rp, state, rs)} {:else}
Route Content - Key: {routeKey} diff --git a/src/lib/testing/test-utils.ts b/src/lib/testing/test-utils.ts index b4580aa..517eb62 100644 --- a/src/lib/testing/test-utils.ts +++ b/src/lib/testing/test-utils.ts @@ -158,10 +158,10 @@ type RouteSpecs = { specs: Omit & { name?: string; }; } -export function addRoutes(router: RouterEngine, routes: { matching?: number; nonMatching?: number; }, ...add: (RouteInfo & { name?: string; })[]): string[]; -export function addRoutes(router: RouterEngine, routes: { matching?: RouteSpecs ; nonMatching?: RouteSpecs; }, ...add: (RouteInfo & { name?: string; })[]): string[]; -export function addRoutes(router: RouterEngine, routes: { matching?: number | RouteSpecs; nonMatching?: number | RouteSpecs; }, ...add: (RouteInfo & { name?: string; })[]): string[] { - const { matching = 0, nonMatching = 0 } = routes; +export function addRoutes(router: RouterEngine, routes: undefined | { matching?: number; nonMatching?: number; }, ...add: (RouteInfo & { name?: string; })[]): string[]; +export function addRoutes(router: RouterEngine, routes: undefined | { matching?: RouteSpecs ; nonMatching?: RouteSpecs; }, ...add: (RouteInfo & { name?: string; })[]): string[]; +export function addRoutes(router: RouterEngine, routes: undefined | { matching?: number | RouteSpecs; nonMatching?: number | RouteSpecs; }, ...add: (RouteInfo & { name?: string; })[]): string[] { + const { matching = 0, nonMatching = 0 } = routes || {}; const routeNames: string[] = []; [[matching, addMatchingRoute] as const, [nonMatching, addNonMatchingRoute] as const].forEach(x => { const [r, fn] = x; diff --git a/src/lib/types.ts b/src/lib/types.ts index 6bde376..e689fbf 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -47,13 +47,78 @@ export type RouteStatus = { * * This is only available if the route has matched. */ - routeParams?: Record; + routeParams?: RouteParamsRecord; } +/** + * Resolves the parameter name from potentially optional parameters. + */ +export type ParamName = T extends `${infer P}?` ? P : T; + +/** + * Extracts the parameters from a route pattern. + */ +export type RouteParameters = T extends string + ? T extends `${string}:${infer Param}/${infer Rest}` + ? ParamName | RouteParameters + : T extends `${string}:${infer Param}` + ? ParamName + : T extends `${string}/*` + ? 'rest' + : T extends '*' + ? 'rest' + : never + : string; + +/** + * Defines a record type mapping route parameter names to their values. + */ +export type RouteParamsRecord = T extends "" ? Record : Record, ParameterValue>; + +/** + * Defines a record type mapping route identifiers to their route matching status. + */ +export type RouteStatusRecord = Record; + +/** + * Defines the context type provided by router components through their `children` snippet. + */ +export type RouterChildrenContext = { + /** + * Holds the state data associated to the current routing universe. + */ + state: any; + /** + * Holds the route status data for all routes managed by the router. + */ + rs: RouteStatusRecord; +}; + +/** + * Defines the context type provided by route components through their `children` snippet. + */ +export type RouteChildrenContext = RouterChildrenContext & { + /** + * Holds the route parameters for the route. + */ + rp?: RouteParamsRecord; +}; + +/** + * Defines the context type provided by link components through their `children` snippet. + */ +export type LinkChildrenContext = Omit & { + /** + * Holds the route status data for all routes managed by the parent router, if said parent + * exists. + */ + rs?: RouteStatusRecord | undefined; +}; + /** * Defines the shape of predicate functions that are used to further test if a route should be matched. */ -export type AndUntyped = (params: Record | undefined) => boolean; +export type AndUntyped = (params: RouteParamsRecord | undefined) => boolean; /** * Defines the core properties of a route definition. @@ -114,7 +179,7 @@ export type RedirectedRouteInfo = NoIgnoreForFallback & { * The HREF to navigate to (via `location.navigate()` or `location.goTo()`). It can be a string or a function that * receives the matched route parameters and returns a string. */ - href: string | ((routeParams: Record | undefined) => string); + href: string | ((routeParams: RouteParamsRecord | undefined) => string); } & ({ /** * Indicates that the redirection should use the `Location.goTo` method.