Skip to content

Commit 162ea94

Browse files
committed
feat: Add "when" property to Fallback component
Fixes #44.
1 parent 53790e2 commit 162ea94

File tree

8 files changed

+802
-10
lines changed

8 files changed

+802
-10
lines changed

docs/testing-guide.md

Lines changed: 457 additions & 0 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"files": [
1717
"dist",
1818
"!dist/**/*.test.*",
19-
"!dist/**/*.spec.*"
19+
"!dist/**/*.spec.*",
20+
"!dist/testing"
2021
],
2122
"sideEffects": [
2223
"**/*.css"

src/lib/Fallback/Fallback.svelte

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script lang="ts">
22
import { resolveHashValue } from '$lib/core/resolveHashValue.js';
33
import { getRouterContext } from '$lib/Router/Router.svelte';
4-
import type { RouteStatus } from '$lib/types.js';
4+
import type { RouteStatus, WhenPredicate } from '$lib/types.js';
55
import type { Snippet } from 'svelte';
66
77
type Props = {
@@ -29,6 +29,26 @@
2929
* ```
3030
*/
3131
hash?: boolean | string;
32+
/**
33+
* Overrides the default activation conditions for the fallback content inside the component.
34+
*
35+
* This is useful in complex routing scenarios, where fallback content is being prevented from showing due to
36+
* certain route or routes matching at certain points, leaving no opportunity for the router to be "out of
37+
* matching routes".
38+
*
39+
* **This completely disconnects the `Fallback` component from the router's matching logic.**
40+
*
41+
* @example
42+
* ```svelte
43+
* <!--
44+
* Here, onlyLayoutRoutesRemain is a function that checks if layout routes are the only ones currently matching.
45+
* -->
46+
* <Fallback when={(rs) => onlyLayoutRoutesRemain(rs)}>
47+
* ...
48+
* </Fallback>
49+
* ```
50+
*/
51+
when?: WhenPredicate;
3252
/**
3353
* Renders the children of the component.
3454
*
@@ -41,11 +61,11 @@
4161
children?: Snippet<[any, Record<string, RouteStatus>]>;
4262
};
4363
44-
let { hash, children }: Props = $props();
64+
let { hash, when, children }: Props = $props();
4565
4666
const router = getRouterContext(resolveHashValue(hash));
4767
</script>
4868

49-
{#if router?.noMatches}
69+
{#if (router && when?.(router.routeStatus, router.noMatches)) || (!when && router?.noMatches)}
5070
{@render children?.(router.state, router.routeStatus)}
5171
{/if}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { init } from "$lib/index.js";
2+
import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest";
3+
import { render } from "@testing-library/svelte";
4+
import Fallback from "./Fallback.svelte";
5+
import { addMatchingRoute, addRoutes, createRouterTestSetup, createTestSnippet, ROUTING_UNIVERSES } from "$lib/testing/test-utils.js";
6+
import { flushSync } from "svelte";
7+
8+
function defaultPropsTests(setup: ReturnType<typeof createRouterTestSetup>) {
9+
const contentText = "Fallback content.";
10+
const content = createTestSnippet(contentText);
11+
12+
beforeEach(() => {
13+
// Fresh router instance for each test
14+
setup.init();
15+
});
16+
17+
afterAll(() => {
18+
// Clean disposal after all tests
19+
setup.dispose();
20+
});
21+
22+
test("Should render whenever the parent router matches no routes.", async () => {
23+
// Arrange.
24+
const { hash, router, context } = setup;
25+
26+
// Act.
27+
const { findByText } = render(Fallback, { props: { hash, children: content }, context });
28+
29+
// Assert.
30+
await expect(findByText(contentText)).resolves.toBeDefined();
31+
});
32+
33+
test("Should not render whenever the parent router matches at least one route.", async () => {
34+
// Arrange.
35+
const { hash, router, context } = setup;
36+
addMatchingRoute(router);
37+
38+
// Act.
39+
const { queryByText } = render(Fallback, { props: { hash, children: content }, context });
40+
41+
// Assert.
42+
expect(queryByText(contentText)).toBeNull();
43+
});
44+
}
45+
46+
function explicitPropsTests(setup: ReturnType<typeof createRouterTestSetup>) {
47+
const contentText = "Fallback content.";
48+
const content = createTestSnippet(contentText);
49+
50+
beforeEach(() => {
51+
// Fresh router instance for each test
52+
setup.init();
53+
});
54+
55+
afterAll(() => {
56+
// Clean disposal after all tests
57+
setup.dispose();
58+
});
59+
60+
test.each([
61+
{
62+
routes: {
63+
matching: 1
64+
},
65+
text: "matching routes"
66+
},
67+
{
68+
routes: {
69+
nonMatching: 1
70+
},
71+
text: "no matching routes"
72+
}
73+
])("Should render when the 'when' predicate returns true when there are $text .", async ({ routes }) => {
74+
// Arrange.
75+
const { hash, router, context } = setup;
76+
addRoutes(router, routes);
77+
78+
// Act.
79+
const { findByText } = render(Fallback, {
80+
props: { hash, when: () => true, children: content },
81+
context
82+
});
83+
84+
// Assert.
85+
await expect(findByText(contentText)).resolves.toBeDefined();
86+
});
87+
test.each([
88+
{
89+
routes: {
90+
matching: 1
91+
},
92+
text: "matching routes"
93+
},
94+
{
95+
routes: {
96+
nonMatching: 1
97+
},
98+
text: "no matching routes"
99+
}
100+
])("Should not render when the 'when' predicate returns false when there are $text .", async ({ routes }) => {
101+
// Arrange.
102+
const { hash, router, context } = setup;
103+
addRoutes(router, routes);
104+
105+
// Act.
106+
const { queryByText } = render(Fallback, {
107+
props: { hash, when: () => false, children: content },
108+
context
109+
});
110+
111+
// Assert.
112+
expect(queryByText(contentText)).toBeNull();
113+
});
114+
}
115+
116+
function reactivityTests(setup: ReturnType<typeof createRouterTestSetup>) {
117+
const contentText = "Fallback content.";
118+
const content = createTestSnippet(contentText);
119+
120+
beforeEach(() => {
121+
// Fresh router instance for each test
122+
setup.init();
123+
});
124+
125+
afterAll(() => {
126+
// Clean disposal after all tests
127+
setup.dispose();
128+
});
129+
130+
test("Should re-render when the 'when' predicate function is exchanged.", async () => {
131+
// Arrange.
132+
const { hash, router, context } = setup;
133+
const { findByText, queryByText, rerender } = render(Fallback, {
134+
props: { hash, when: () => false, children: content },
135+
context
136+
});
137+
expect(queryByText(contentText)).toBeNull();
138+
139+
// Act.
140+
await rerender({ hash, when: () => true, children: content });
141+
142+
// Assert.
143+
await expect(findByText(contentText)).resolves.toBeDefined();
144+
});
145+
test("Should re-render when the 'when' predicate function reactively changes its return value.", async () => {
146+
// Arrange.
147+
const { hash, router, context } = setup;
148+
let rv = $state(false);
149+
const { findByText, queryByText, rerender } = render(Fallback, {
150+
props: { hash, when: () => rv, children: content },
151+
context
152+
});
153+
expect(queryByText(contentText)).toBeNull();
154+
155+
// Act.
156+
rv = true;
157+
flushSync();
158+
159+
// Assert.
160+
await expect(findByText(contentText)).resolves.toBeDefined();
161+
});
162+
}
163+
164+
ROUTING_UNIVERSES.forEach(ru => {
165+
describe(`Fallback - ${ru.text}`, () => {
166+
const setup = createRouterTestSetup(ru.hash);
167+
let cleanup: () => void;
168+
beforeAll(() => {
169+
cleanup = init({
170+
implicitMode: ru.implicitMode,
171+
hashMode: ru.hashMode,
172+
});
173+
});
174+
afterAll(() => {
175+
cleanup();
176+
});
177+
describe("Default Props", () => {
178+
defaultPropsTests(setup);
179+
});
180+
describe("Explicit Props", () => {
181+
explicitPropsTests(setup);
182+
});
183+
describe("Reactivity", () => {
184+
reactivityTests(setup);
185+
});
186+
});
187+
});

src/lib/Router/Router.svelte

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script lang="ts" module>
22
import { RouterEngine } from '$lib/core/RouterEngine.svelte.js';
33
import { resolveHashValue } from '$lib/core/resolveHashValue.js';
4-
import type { RouteStatus } from '$lib/types.js';
4+
import type { Hash, RouteStatus } from '$lib/types.js';
55
66
const parentCtxKey = Symbol();
77
const hashParentCtxKey = Symbol();
@@ -15,10 +15,15 @@
1515
* @param hash Hash value that identifies the desired router context.
1616
* @returns The closest router context found for the specified hash, or `undefined` if there is none.
1717
*/
18-
export function getRouterContext(hash: boolean | string) {
19-
return getContext<RouterEngine | undefined>(
20-
typeof hash === 'string' ? multiHashSymbol(hash) : hash ? hashParentCtxKey : parentCtxKey
21-
);
18+
export function getRouterContext(hash: Hash) {
19+
return getContext<RouterEngine | undefined>(getRouterContextKey(hash));
20+
}
21+
22+
/**
23+
* Only exported for unit testing.
24+
*/
25+
export function getRouterContextKey(hash: Hash) {
26+
return typeof hash === 'string' ? multiHashSymbol(hash) : hash ? hashParentCtxKey : parentCtxKey;
2227
}
2328
2429
/**

src/lib/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export { default as Link } from "$lib/Link/Link.svelte";
4545
export { default as LinkContext } from "$lib/LinkContext/LinkContext.svelte";
4646
export * from "$lib/Route/Route.svelte";
4747
export { default as Route } from "$lib/Route/Route.svelte";
48-
export * from "$lib/Router/Router.svelte";
48+
export { getRouterContext, setRouterContext } from "$lib/Router/Router.svelte";
4949
export { default as Router } from "$lib/Router/Router.svelte";
5050
export * from "$lib/Fallback/Fallback.svelte";
5151
export { default as Fallback } from "$lib/Fallback/Fallback.svelte";

src/lib/testing/test-utils.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { type Hash } from "$lib/index.js";
2+
import { RouterEngine } from "$lib/core/RouterEngine.svelte.js";
3+
import { getRouterContextKey } from "../Router/Router.svelte";
4+
import { createRawSnippet } from "svelte";
5+
import type { RoutingOptions } from "$lib/core/options.js";
6+
import { resolveHashValue } from "$lib/core/resolveHashValue.js";
7+
8+
/**
9+
* Standard routing universe test configurations
10+
*/
11+
export const ROUTING_UNIVERSES: {
12+
hash: Hash | undefined;
13+
implicitMode: RoutingOptions['implicitMode'];
14+
hashMode: Exclude<RoutingOptions['hashMode'], undefined>;
15+
text: string;
16+
name: string;
17+
}[] = [
18+
{ hash: undefined, implicitMode: 'path', hashMode: 'single', text: "IMP", name: "Implicit Path Routing" },
19+
{ hash: undefined, implicitMode: 'hash', hashMode: 'single', text: "IMH", name: "Implicit Hash Routing" },
20+
{ hash: false, implicitMode: 'path', hashMode: 'single', text: "PR", name: "Path Routing" },
21+
{ hash: true, implicitMode: 'path', hashMode: 'single', text: "HR", name: "Hash Routing" },
22+
{ hash: 'p1', implicitMode: 'path', hashMode: 'multi', text: "MHR", name: "Multi Hash Routing" },
23+
] as const;
24+
25+
/**
26+
* Creates a router and context setup for testing
27+
*/
28+
export function createRouterTestSetup(hash: Hash | undefined) {
29+
let router: RouterEngine | undefined;
30+
let context: Map<any, any>;
31+
32+
const init = () => {
33+
// Dispose previous router if it exists
34+
router?.dispose();
35+
36+
// Create fresh router and context for each test
37+
router = new RouterEngine({ hash });
38+
context = new Map();
39+
context.set(getRouterContextKey(resolveHashValue(hash)), router);
40+
};
41+
42+
const dispose = () => {
43+
router?.dispose();
44+
router = undefined;
45+
context = new Map();
46+
};
47+
48+
return {
49+
get hash() { return hash; },
50+
get router() {
51+
if (!router) throw new Error('Router not initialized. Call init() first.');
52+
return router;
53+
},
54+
get context() {
55+
if (!context) throw new Error('Context not initialized. Call init() first.');
56+
return context;
57+
},
58+
init,
59+
dispose
60+
};
61+
}
62+
63+
/**
64+
* Creates a test snippet with the given content
65+
*/
66+
export function createTestSnippet(contentText: string) {
67+
return createRawSnippet(() => {
68+
return {
69+
render: () => `<div>${contentText}</div>`
70+
};
71+
});
72+
}
73+
74+
/**
75+
* Generates a new random route key
76+
*/
77+
export function newRandomRouteKey() {
78+
return `route-${Math.random().toString(36).substr(2, 9)}`;
79+
}
80+
81+
/**
82+
* Adds a matching route to the router
83+
*/
84+
export function addMatchingRoute(router: RouterEngine, routeName?: string) {
85+
routeName ||= newRandomRouteKey();
86+
router.routes[routeName] = {
87+
and: () => true
88+
};
89+
return routeName;
90+
}
91+
92+
/**
93+
* Adds a non-matching route to the router
94+
*/
95+
export function addNonMatchingRoute(router: RouterEngine, routeName?: string) {
96+
routeName ||= newRandomRouteKey();
97+
router.routes[routeName] = {
98+
and: () => false
99+
};
100+
return routeName;
101+
}
102+
103+
export function addRoutes(router: RouterEngine, routes: { matching?: number; nonMatching?: number; }) {
104+
const { matching = 0, nonMatching = 0 } = routes;
105+
const routeNames = [];
106+
for (let i = 0; i < matching; i++) {
107+
routeNames.push(addMatchingRoute(router));
108+
}
109+
for (let i = 0; i < nonMatching; i++) {
110+
routeNames.push(addNonMatchingRoute(router));
111+
}
112+
return routeNames;
113+
}

0 commit comments

Comments
 (0)