toiljs 0.0.11 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +2 -0
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/cli/configure.js +10 -4
  4. package/build/cli/create.js +58 -30
  5. package/build/cli/diagnostics.d.ts +55 -0
  6. package/build/cli/diagnostics.js +333 -0
  7. package/build/cli/doctor.d.ts +6 -0
  8. package/build/cli/doctor.js +249 -0
  9. package/build/cli/index.js +26 -0
  10. package/build/cli/proc.d.ts +5 -0
  11. package/build/cli/proc.js +20 -0
  12. package/build/cli/ui.d.ts +1 -0
  13. package/build/cli/ui.js +1 -0
  14. package/build/cli/update.d.ts +7 -0
  15. package/build/cli/update.js +117 -0
  16. package/build/cli/updates.d.ts +10 -0
  17. package/build/cli/updates.js +45 -0
  18. package/build/client/.tsbuildinfo +1 -1
  19. package/build/client/dev/error-overlay.js +1 -1
  20. package/build/client/head/metadata.js +3 -1
  21. package/build/client/index.d.ts +5 -1
  22. package/build/client/index.js +2 -0
  23. package/build/client/navigation/navigation.js +1 -1
  24. package/build/client/routing/Router.js +2 -2
  25. package/build/client/search/search.d.ts +26 -0
  26. package/build/client/search/search.js +101 -0
  27. package/build/client/search/use-page-search.d.ts +8 -0
  28. package/build/client/search/use-page-search.js +21 -0
  29. package/build/compiler/.tsbuildinfo +1 -1
  30. package/build/compiler/generate.js +26 -23
  31. package/build/compiler/index.d.ts +2 -0
  32. package/build/compiler/index.js +1 -0
  33. package/build/compiler/pages.d.ts +8 -0
  34. package/build/compiler/pages.js +37 -0
  35. package/build/compiler/plugin.js +3 -1
  36. package/build/compiler/prerender.d.ts +1 -0
  37. package/build/compiler/prerender.js +11 -5
  38. package/build/compiler/seo.js +10 -3
  39. package/build/io/.tsbuildinfo +1 -1
  40. package/examples/basic/client/components/Header.tsx +43 -41
  41. package/examples/basic/client/components/HoneycombBackground.tsx +223 -230
  42. package/examples/basic/client/public/index.html +18 -16
  43. package/examples/basic/client/routes/(legal)/privacy.tsx +18 -19
  44. package/examples/basic/client/routes/(legal)/terms.tsx +15 -16
  45. package/examples/basic/client/routes/about.tsx +21 -22
  46. package/examples/basic/client/routes/blog/[id].tsx +26 -18
  47. package/examples/basic/client/routes/features/actions.tsx +67 -67
  48. package/examples/basic/client/routes/features/error/index.tsx +27 -27
  49. package/examples/basic/client/routes/features/head.tsx +38 -38
  50. package/examples/basic/client/routes/features/index.tsx +83 -75
  51. package/examples/basic/client/routes/features/realtime.tsx +34 -32
  52. package/examples/basic/client/routes/features/script.tsx +31 -31
  53. package/examples/basic/client/routes/features/seo.tsx +39 -39
  54. package/examples/basic/client/routes/features/template/index.tsx +20 -20
  55. package/examples/basic/client/routes/features/template/template.tsx +16 -18
  56. package/examples/basic/client/routes/gallery/@modal/(.)photo/[id].tsx +23 -23
  57. package/examples/basic/client/routes/gallery/index.tsx +42 -42
  58. package/examples/basic/client/routes/gallery/photo/[id].tsx +18 -18
  59. package/examples/basic/client/routes/get-started.tsx +157 -84
  60. package/examples/basic/client/routes/index.tsx +137 -96
  61. package/examples/basic/client/routes/loader-demo/index.tsx +59 -52
  62. package/examples/basic/client/routes/search.tsx +61 -0
  63. package/examples/basic/client/routes/test.tsx +7 -8
  64. package/examples/basic/client/styles/main.css +624 -552
  65. package/package.json +2 -2
  66. package/presets/eslint.js +10 -3
  67. package/src/cli/configure.ts +363 -353
  68. package/src/cli/create.ts +563 -530
  69. package/src/cli/diagnostics.ts +421 -0
  70. package/src/cli/doctor.ts +318 -0
  71. package/src/cli/features.ts +166 -160
  72. package/src/cli/index.ts +242 -211
  73. package/src/cli/proc.ts +30 -0
  74. package/src/cli/ui.ts +111 -103
  75. package/src/cli/update.ts +150 -0
  76. package/src/cli/updates.ts +69 -0
  77. package/src/client/components/Image.tsx +91 -89
  78. package/src/client/dev/error-overlay.tsx +193 -197
  79. package/src/client/head/metadata.ts +94 -92
  80. package/src/client/index.ts +79 -64
  81. package/src/client/navigation/Link.tsx +94 -100
  82. package/src/client/navigation/navigation.ts +215 -218
  83. package/src/client/routing/Router.tsx +210 -193
  84. package/src/client/routing/hooks.ts +110 -114
  85. package/src/client/routing/lazy.ts +77 -81
  86. package/src/client/search/search.ts +189 -0
  87. package/src/client/search/use-page-search.ts +73 -0
  88. package/src/compiler/config.ts +173 -171
  89. package/src/compiler/fonts.ts +89 -87
  90. package/src/compiler/generate.ts +378 -373
  91. package/src/compiler/image-report.ts +88 -85
  92. package/src/compiler/index.ts +2 -0
  93. package/src/compiler/pages.ts +70 -0
  94. package/src/compiler/plugin.ts +51 -47
  95. package/src/compiler/prerender.ts +152 -130
  96. package/src/compiler/routes.ts +132 -131
  97. package/src/compiler/seo.ts +381 -356
  98. package/src/compiler/vite.ts +155 -145
  99. package/src/io/FastSet.ts +99 -96
  100. package/test/configure.test.ts +94 -90
  101. package/test/doctor.test.ts +140 -0
  102. package/test/dom/Image.test.tsx +73 -46
  103. package/test/dom/Script.test.tsx +48 -45
  104. package/test/dom/action.test.tsx +146 -129
  105. package/test/dom/error-overlay.test.tsx +44 -44
  106. package/test/dom/loader.test.tsx +2 -2
  107. package/test/dom/revalidate.test.tsx +1 -1
  108. package/test/dom/route-head.test.tsx +1 -2
  109. package/test/dom/slot.test.tsx +131 -109
  110. package/test/dom/view-transitions.test.tsx +53 -51
  111. package/test/features.test.ts +149 -142
  112. package/test/fonts.test.ts +28 -26
  113. package/test/head.test.ts +45 -35
  114. package/test/metadata.test.ts +42 -41
  115. package/test/pages.test.ts +105 -0
  116. package/test/prerender.test.ts +54 -46
  117. package/test/search.test.ts +114 -0
  118. package/test/seo.test.ts +164 -142
  119. package/test/update.test.ts +44 -0
@@ -1,129 +1,146 @@
1
- // @vitest-environment jsdom
2
- import { act, cleanup, fireEvent, render, renderHook, waitFor } from '@testing-library/react';
3
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
-
5
- import { useAction } from '../../src/client/routing/action';
6
- import { Form } from '../../src/client/components/Form';
7
- import { clearLoaderData, loaderKey, readRouteData } from '../../src/client/routing/loader';
8
- import type { RouteDef } from '../../src/client/types';
9
-
10
- afterEach(cleanup);
11
- beforeEach(() => {
12
- clearLoaderData();
13
- });
14
-
15
- describe('useAction', () => {
16
- it('goes idle → pending → success and returns the result', async () => {
17
- const onSuccess = vi.fn();
18
- const { result } = renderHook(() =>
19
- useAction((n: number) => Promise.resolve(n * 2), { revalidate: false, onSuccess }),
20
- );
21
- expect(result.current.pending).toBe(false);
22
-
23
- let returned: number | undefined;
24
- await act(async () => {
25
- returned = await result.current.run(3);
26
- });
27
- expect(returned).toBe(6);
28
- expect(result.current.data).toBe(6);
29
- expect(result.current.error).toBeUndefined();
30
- expect(onSuccess).toHaveBeenCalledWith(6);
31
- });
32
-
33
- it('captures errors instead of rejecting, and calls onError', async () => {
34
- const onError = vi.fn();
35
- const { result } = renderHook(() =>
36
- useAction(
37
- () => {
38
- throw new Error('boom');
39
- },
40
- { revalidate: false, onError },
41
- ),
42
- );
43
-
44
- let returned: unknown = 'sentinel';
45
- await act(async () => {
46
- returned = await result.current.run();
47
- });
48
- expect(returned).toBeUndefined();
49
- expect((result.current.error as Error).message).toBe('boom');
50
- expect(onError).toHaveBeenCalledOnce();
51
- });
52
-
53
- it('reset() returns to idle', async () => {
54
- const { result } = renderHook(() => useAction(() => 'x', { revalidate: false }));
55
- await act(async () => {
56
- await result.current.run();
57
- });
58
- expect(result.current.data).toBe('x');
59
- act(() => {
60
- result.current.reset();
61
- });
62
- expect(result.current.data).toBeUndefined();
63
- expect(result.current.pending).toBe(false);
64
- });
65
-
66
- it('revalidate: true invalidates cached loader data', async () => {
67
- const route: RouteDef = {
68
- pattern: '/x',
69
- load: () => Promise.resolve({ default: () => null, loader: () => ({ n: 1 }), revalidate: false }),
70
- };
71
- const key = loaderKey('/x', '');
72
- // Seed the cache (suspends once, then resolves).
73
- try {
74
- readRouteData(route, {}, key, 1);
75
- } catch (thrown) {
76
- await (thrown as Promise<void>);
77
- }
78
- // Cached now: a re-read returns synchronously (no throw).
79
- expect(() => readRouteData(route, {}, key, 1)).not.toThrow();
80
-
81
- const { result } = renderHook(() => useAction(() => 'done', { revalidate: true }));
82
- await act(async () => {
83
- await result.current.run();
84
- });
85
- // Cache was cleared → reading again suspends (throws a promise).
86
- expect(() => readRouteData(route, {}, key, 1)).toThrow();
87
- });
88
- });
89
-
90
- describe('Form', () => {
91
- it('submits FormData (no reload) and exposes pending state', async () => {
92
- let received: FormDataEntryValue | null = null;
93
- const action = vi.fn((data: FormData) => {
94
- received = data.get('title');
95
- });
96
- const { getByText, getByPlaceholderText } = render(
97
- <Form action={action} revalidate={false}>
98
- {({ pending }) => (
99
- <>
100
- <input name="title" placeholder="t" />
101
- <button type="submit">{pending ? 'Saving…' : 'Save'}</button>
102
- </>
103
- )}
104
- </Form>,
105
- );
106
- fireEvent.change(getByPlaceholderText('t'), { target: { value: 'Hello' } });
107
- fireEvent.click(getByText('Save'));
108
- await waitFor(() => {
109
- expect(action).toHaveBeenCalledOnce();
110
- });
111
- expect(received).toBe('Hello');
112
- });
113
-
114
- it('resets fields after success when resetOnSuccess is set', async () => {
115
- const { getByText, getByPlaceholderText } = render(
116
- <Form action={() => undefined} revalidate={false} resetOnSuccess>
117
- <input name="title" placeholder="t" defaultValue="" />
118
- <button type="submit">Save</button>
119
- </Form>,
120
- );
121
- const input = getByPlaceholderText('t') as HTMLInputElement;
122
- fireEvent.change(input, { target: { value: 'typed' } });
123
- expect(input.value).toBe('typed');
124
- fireEvent.click(getByText('Save'));
125
- await waitFor(() => {
126
- expect(input.value).toBe('');
127
- });
128
- });
129
- });
1
+ // @vitest-environment jsdom
2
+ import { act, cleanup, fireEvent, render, renderHook, waitFor } from '@testing-library/react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { useAction } from '../../src/client/routing/action';
6
+ import { Form } from '../../src/client/components/Form';
7
+ import { clearLoaderData, loaderKey, readRouteData } from '../../src/client/routing/loader';
8
+ import type { RouteDef } from '../../src/client/types';
9
+
10
+ afterEach(cleanup);
11
+ beforeEach(() => {
12
+ clearLoaderData();
13
+ });
14
+
15
+ describe('useAction', () => {
16
+ it('goes idle → pending → success and returns the result', async () => {
17
+ const onSuccess = vi.fn();
18
+ const { result } = renderHook(() =>
19
+ useAction((n: number) => Promise.resolve(n * 2), { revalidate: false, onSuccess }),
20
+ );
21
+ expect(result.current.pending).toBe(false);
22
+
23
+ let returned: number | undefined;
24
+ await act(async () => {
25
+ returned = await result.current.run(3);
26
+ });
27
+ expect(returned).toBe(6);
28
+ expect(result.current.data).toBe(6);
29
+ expect(result.current.error).toBeUndefined();
30
+ expect(onSuccess).toHaveBeenCalledWith(6);
31
+ });
32
+
33
+ it('captures errors instead of rejecting, and calls onError', async () => {
34
+ const onError = vi.fn();
35
+ const { result } = renderHook(() =>
36
+ useAction(
37
+ () => {
38
+ throw new Error('boom');
39
+ },
40
+ { revalidate: false, onError },
41
+ ),
42
+ );
43
+
44
+ let returned: unknown = 'sentinel';
45
+ await act(async () => {
46
+ returned = await result.current.run();
47
+ });
48
+ expect(returned).toBeUndefined();
49
+ expect((result.current.error as Error).message).toBe('boom');
50
+ expect(onError).toHaveBeenCalledOnce();
51
+ });
52
+
53
+ it('reset() returns to idle', async () => {
54
+ const { result } = renderHook(() => useAction(() => 'x', { revalidate: false }));
55
+ await act(async () => {
56
+ await result.current.run();
57
+ });
58
+ expect(result.current.data).toBe('x');
59
+ act(() => {
60
+ result.current.reset();
61
+ });
62
+ expect(result.current.data).toBeUndefined();
63
+ expect(result.current.pending).toBe(false);
64
+ });
65
+
66
+ it('revalidate: true invalidates cached loader data', async () => {
67
+ const route: RouteDef = {
68
+ pattern: '/x',
69
+ load: () =>
70
+ Promise.resolve({
71
+ default: () => null,
72
+ loader: () => ({ n: 1 }),
73
+ revalidate: false,
74
+ }),
75
+ };
76
+ const key = loaderKey('/x', '');
77
+ // Seed the cache (suspends once, then resolves).
78
+ try {
79
+ readRouteData(route, {}, key, 1);
80
+ } catch (thrown) {
81
+ await (thrown as Promise<void>);
82
+ }
83
+ // Cached now: a re-read returns synchronously (no throw).
84
+ expect(() => readRouteData(route, {}, key, 1)).not.toThrow();
85
+
86
+ const { result } = renderHook(() => useAction(() => 'done', { revalidate: true }));
87
+ await act(async () => {
88
+ await result.current.run();
89
+ });
90
+ // Cache was cleared → reading again suspends (throws a promise).
91
+ expect(() => readRouteData(route, {}, key, 1)).toThrow();
92
+ });
93
+ });
94
+
95
+ describe('Form', () => {
96
+ it('submits FormData (no reload) and exposes pending state', async () => {
97
+ let received: FormDataEntryValue | null = null;
98
+ const action = vi.fn((data: FormData) => {
99
+ received = data.get('title');
100
+ });
101
+ const { getByText, getByPlaceholderText } = render(
102
+ <Form
103
+ action={action}
104
+ revalidate={false}>
105
+ {({ pending }) => (
106
+ <>
107
+ <input
108
+ name="title"
109
+ placeholder="t"
110
+ />
111
+ <button type="submit">{pending ? 'Saving…' : 'Save'}</button>
112
+ </>
113
+ )}
114
+ </Form>,
115
+ );
116
+ fireEvent.change(getByPlaceholderText('t'), { target: { value: 'Hello' } });
117
+ fireEvent.click(getByText('Save'));
118
+ await waitFor(() => {
119
+ expect(action).toHaveBeenCalledOnce();
120
+ });
121
+ expect(received).toBe('Hello');
122
+ });
123
+
124
+ it('resets fields after success when resetOnSuccess is set', async () => {
125
+ const { getByText, getByPlaceholderText } = render(
126
+ <Form
127
+ action={() => undefined}
128
+ revalidate={false}
129
+ resetOnSuccess>
130
+ <input
131
+ name="title"
132
+ placeholder="t"
133
+ defaultValue=""
134
+ />
135
+ <button type="submit">Save</button>
136
+ </Form>,
137
+ );
138
+ const input = getByPlaceholderText('t') as HTMLInputElement;
139
+ fireEvent.change(input, { target: { value: 'typed' } });
140
+ expect(input.value).toBe('typed');
141
+ fireEvent.click(getByText('Save'));
142
+ await waitFor(() => {
143
+ expect(input.value).toBe('');
144
+ });
145
+ });
146
+ });
@@ -1,44 +1,44 @@
1
- // @vitest-environment jsdom
2
- import { act, cleanup, fireEvent, render } from '@testing-library/react';
3
- import { afterEach, describe, expect, it, vi } from 'vitest';
4
-
5
- import {
6
- DevErrorBoundary,
7
- DevErrorOverlay,
8
- initDevErrorOverlay,
9
- } from '../../src/client/dev/error-overlay';
10
-
11
- afterEach(cleanup);
12
-
13
- function Boom(): never {
14
- throw new Error('render boom');
15
- }
16
-
17
- describe('dev error overlay', () => {
18
- it('surfaces an uncaught render error', () => {
19
- // React logs caught boundary errors to console.error — silence it for a clean test run.
20
- const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
21
- const { getByRole } = render(
22
- <>
23
- <DevErrorBoundary>
24
- <Boom />
25
- </DevErrorBoundary>
26
- <DevErrorOverlay />
27
- </>,
28
- );
29
- expect(getByRole('alert').textContent).toContain('render boom');
30
- spy.mockRestore();
31
- });
32
-
33
- it('surfaces an unhandled window error and dismisses it', async () => {
34
- initDevErrorOverlay();
35
- const { findByRole, queryByRole, getByText } = render(<DevErrorOverlay />);
36
- act(() => {
37
- window.dispatchEvent(new ErrorEvent('error', { error: new Error('async boom') }));
38
- });
39
- const alert = await findByRole('alert');
40
- expect(alert.textContent).toContain('async boom');
41
- fireEvent.click(getByText('Dismiss'));
42
- expect(queryByRole('alert')).toBeNull();
43
- });
44
- });
1
+ // @vitest-environment jsdom
2
+ import { act, cleanup, fireEvent, render } from '@testing-library/react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import {
6
+ DevErrorBoundary,
7
+ DevErrorOverlay,
8
+ initDevErrorOverlay,
9
+ } from '../../src/client/dev/error-overlay';
10
+
11
+ afterEach(cleanup);
12
+
13
+ function Boom(): never {
14
+ throw new Error('render boom');
15
+ }
16
+
17
+ describe('dev error overlay', () => {
18
+ it('surfaces an uncaught render error', () => {
19
+ // React logs caught boundary errors to console.error — silence it for a clean test run.
20
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
21
+ const { getByRole } = render(
22
+ <>
23
+ <DevErrorBoundary>
24
+ <Boom />
25
+ </DevErrorBoundary>
26
+ <DevErrorOverlay />
27
+ </>,
28
+ );
29
+ expect(getByRole('alert').textContent).toContain('render boom');
30
+ spy.mockRestore();
31
+ });
32
+
33
+ it('surfaces an unhandled window error and dismisses it', async () => {
34
+ initDevErrorOverlay();
35
+ const { findByRole, queryByRole, getByText } = render(<DevErrorOverlay />);
36
+ act(() => {
37
+ window.dispatchEvent(new ErrorEvent('error', { error: new Error('async boom') }));
38
+ });
39
+ const alert = await findByRole('alert');
40
+ expect(alert.textContent).toContain('async boom');
41
+ fireEvent.click(getByText('Dismiss'));
42
+ expect(queryByRole('alert')).toBeNull();
43
+ });
44
+ });
@@ -1,14 +1,14 @@
1
1
  // @vitest-environment jsdom
2
2
  import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest';
3
3
 
4
+ import type { Revalidate } from '../../src/client/routing/loader';
4
5
  import {
5
6
  clearLoaderData,
6
7
  invalidateLoaderData,
8
+ type LoaderData,
7
9
  loaderKey,
8
10
  readRouteData,
9
- type LoaderData,
10
11
  } from '../../src/client/routing/loader';
11
- import type { Revalidate } from '../../src/client/routing/loader';
12
12
  import type { RouteDef } from '../../src/client/types';
13
13
 
14
14
  /** Reads route data, awaiting the suspending promise once if it's pending. */
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment jsdom
2
2
  import { act, cleanup, render } from '@testing-library/react';
3
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3
+ import { afterEach, beforeEach, describe, it } from 'vitest';
4
4
 
5
5
  import { Router } from '../../src/client/routing/Router';
6
6
  import { clearLoaderData, revalidate, useLoaderData } from '../../src/client/routing/loader';
@@ -1,10 +1,9 @@
1
1
  // @vitest-environment jsdom
2
- import { describe, expect, it } from 'vitest';
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
3
 
4
4
  import { setRouteHead, useHead } from '../../src/client/head/head';
5
5
  import { resolveMetadata } from '../../src/client/head/metadata';
6
6
  import { cleanup, render } from '@testing-library/react';
7
- import { afterEach } from 'vitest';
8
7
 
9
8
  afterEach(() => {
10
9
  cleanup();
@@ -1,109 +1,131 @@
1
- // @vitest-environment jsdom
2
- import { act, cleanup, render } from '@testing-library/react';
3
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
- import type { ReactNode } from 'react';
5
-
6
- import { Slot } from '../../src/client/components/Slot';
7
- import { navigate } from '../../src/client/navigation/navigation';
8
- import { Router } from '../../src/client/routing/Router';
9
- import { clearLoaderData } from '../../src/client/routing/loader';
10
- import { SlotContext } from '../../src/client/routing/slot-context';
11
- import type { RouteDef } from '../../src/client/types';
12
-
13
- const layoutWithModal = () =>
14
- Promise.resolve({
15
- default: ({ children }: { children?: ReactNode }) => (
16
- <div>
17
- {children}
18
- <Slot name="modal" />
19
- </div>
20
- ),
21
- });
22
-
23
- afterEach(cleanup);
24
- beforeEach(() => {
25
- clearLoaderData();
26
- window.history.replaceState({}, '', '/');
27
- });
28
-
29
- describe('Slot', () => {
30
- it('renders the named slot element from context, or the fallback', () => {
31
- const { getByText, queryByText } = render(
32
- <SlotContext.Provider value={{ modal: <span>MODAL</span> }}>
33
- <Slot name="modal" />
34
- <Slot name="missing" fallback={<span>FB</span>} />
35
- </SlotContext.Provider>,
36
- );
37
- expect(getByText('MODAL')).toBeTruthy();
38
- expect(getByText('FB')).toBeTruthy();
39
- expect(queryByText('missing')).toBeNull();
40
- });
41
- });
42
-
43
- describe('Router parallel slots', () => {
44
- const routes: RouteDef[] = [
45
- { pattern: '/', load: () => Promise.resolve({ default: () => <main>PAGE</main> }) },
46
- ];
47
- // A layout that renders the page plus the "modal" slot.
48
- const layout = () =>
49
- Promise.resolve({
50
- default: ({ children }: { children?: ReactNode }) => (
51
- <div>
52
- {children}
53
- <Slot name="modal" />
54
- </div>
55
- ),
56
- });
57
- const slots: Record<string, RouteDef[]> = {
58
- modal: [{ pattern: '/', load: () => Promise.resolve({ default: () => <aside>SLOT</aside> }) }],
59
- };
60
-
61
- it('renders the main route and a matching slot together', async () => {
62
- const { findByText } = render(<Router routes={routes} layout={layout} slots={slots} />);
63
- // Both the page and the parallel slot render for the same URL.
64
- await findByText('PAGE');
65
- await findByText('SLOT');
66
- });
67
- });
68
-
69
- describe('intercepting routes', () => {
70
- const routes: RouteDef[] = [
71
- { pattern: '/photo/:id', load: () => Promise.resolve({ default: () => <main>PHOTO PAGE</main> }) },
72
- { pattern: '/', load: () => Promise.resolve({ default: () => <main>FEED</main> }) },
73
- ];
74
- const slots: Record<string, RouteDef[]> = {
75
- modal: [
76
- {
77
- pattern: '/photo/:id',
78
- intercept: true,
79
- load: () => Promise.resolve({ default: () => <aside>PHOTO MODAL</aside> }),
80
- },
81
- ],
82
- };
83
-
84
- // This test must run before any navigation so it observes a "hard" load (soft-nav state is false).
85
- it('shows the full page on a hard load (no interception)', async () => {
86
- window.history.replaceState({}, '', '/photo/1');
87
- const { findByText, queryByText } = render(
88
- <Router routes={routes} layout={layoutWithModal} slots={slots} />,
89
- );
90
- await findByText('PHOTO PAGE');
91
- expect(queryByText('PHOTO MODAL')).toBeNull();
92
- });
93
-
94
- it('intercepts on soft navigation: modal overlays, previous page stays', async () => {
95
- window.history.replaceState({}, '', '/');
96
- const { findByText, queryByText } = render(
97
- <Router routes={routes} layout={layoutWithModal} slots={slots} />,
98
- );
99
- await findByText('FEED');
100
- act(() => {
101
- navigate('/photo/1');
102
- });
103
- // The intercepting slot route shows the modal…
104
- await findByText('PHOTO MODAL');
105
- // …while the main view keeps the previous page (the backdrop), not the full photo page.
106
- expect(queryByText('FEED')).not.toBeNull();
107
- expect(queryByText('PHOTO PAGE')).toBeNull();
108
- });
109
- });
1
+ // @vitest-environment jsdom
2
+ import { act, cleanup, render } from '@testing-library/react';
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
+ import type { ReactNode } from 'react';
5
+
6
+ import { Slot } from '../../src/client/components/Slot';
7
+ import { navigate } from '../../src/client/navigation/navigation';
8
+ import { Router } from '../../src/client/routing/Router';
9
+ import { clearLoaderData } from '../../src/client/routing/loader';
10
+ import { SlotContext } from '../../src/client/routing/slot-context';
11
+ import type { RouteDef } from '../../src/client/types';
12
+
13
+ const layoutWithModal = () =>
14
+ Promise.resolve({
15
+ default: ({ children }: { children?: ReactNode }) => (
16
+ <div>
17
+ {children}
18
+ <Slot name="modal" />
19
+ </div>
20
+ ),
21
+ });
22
+
23
+ afterEach(cleanup);
24
+ beforeEach(() => {
25
+ clearLoaderData();
26
+ window.history.replaceState({}, '', '/');
27
+ });
28
+
29
+ describe('Slot', () => {
30
+ it('renders the named slot element from context, or the fallback', () => {
31
+ const { getByText, queryByText } = render(
32
+ <SlotContext.Provider value={{ modal: <span>MODAL</span> }}>
33
+ <Slot name="modal" />
34
+ <Slot
35
+ name="missing"
36
+ fallback={<span>FB</span>}
37
+ />
38
+ </SlotContext.Provider>,
39
+ );
40
+ expect(getByText('MODAL')).toBeTruthy();
41
+ expect(getByText('FB')).toBeTruthy();
42
+ expect(queryByText('missing')).toBeNull();
43
+ });
44
+ });
45
+
46
+ describe('Router parallel slots', () => {
47
+ const routes: RouteDef[] = [
48
+ { pattern: '/', load: () => Promise.resolve({ default: () => <main>PAGE</main> }) },
49
+ ];
50
+ // A layout that renders the page plus the "modal" slot.
51
+ const layout = () =>
52
+ Promise.resolve({
53
+ default: ({ children }: { children?: ReactNode }) => (
54
+ <div>
55
+ {children}
56
+ <Slot name="modal" />
57
+ </div>
58
+ ),
59
+ });
60
+ const slots: Record<string, RouteDef[]> = {
61
+ modal: [
62
+ { pattern: '/', load: () => Promise.resolve({ default: () => <aside>SLOT</aside> }) },
63
+ ],
64
+ };
65
+
66
+ it('renders the main route and a matching slot together', async () => {
67
+ const { findByText } = render(
68
+ <Router
69
+ routes={routes}
70
+ layout={layout}
71
+ slots={slots}
72
+ />,
73
+ );
74
+ // Both the page and the parallel slot render for the same URL.
75
+ await findByText('PAGE');
76
+ await findByText('SLOT');
77
+ });
78
+ });
79
+
80
+ describe('intercepting routes', () => {
81
+ const routes: RouteDef[] = [
82
+ {
83
+ pattern: '/photo/:id',
84
+ load: () => Promise.resolve({ default: () => <main>PHOTO PAGE</main> }),
85
+ },
86
+ { pattern: '/', load: () => Promise.resolve({ default: () => <main>FEED</main> }) },
87
+ ];
88
+ const slots: Record<string, RouteDef[]> = {
89
+ modal: [
90
+ {
91
+ pattern: '/photo/:id',
92
+ intercept: true,
93
+ load: () => Promise.resolve({ default: () => <aside>PHOTO MODAL</aside> }),
94
+ },
95
+ ],
96
+ };
97
+
98
+ // This test must run before any navigation so it observes a "hard" load (soft-nav state is false).
99
+ it('shows the full page on a hard load (no interception)', async () => {
100
+ window.history.replaceState({}, '', '/photo/1');
101
+ const { findByText, queryByText } = render(
102
+ <Router
103
+ routes={routes}
104
+ layout={layoutWithModal}
105
+ slots={slots}
106
+ />,
107
+ );
108
+ await findByText('PHOTO PAGE');
109
+ expect(queryByText('PHOTO MODAL')).toBeNull();
110
+ });
111
+
112
+ it('intercepts on soft navigation: modal overlays, previous page stays', async () => {
113
+ window.history.replaceState({}, '', '/');
114
+ const { findByText, queryByText } = render(
115
+ <Router
116
+ routes={routes}
117
+ layout={layoutWithModal}
118
+ slots={slots}
119
+ />,
120
+ );
121
+ await findByText('FEED');
122
+ act(() => {
123
+ navigate('/photo/1');
124
+ });
125
+ // The intercepting slot route shows the modal…
126
+ await findByText('PHOTO MODAL');
127
+ // …while the main view keeps the previous page (the backdrop), not the full photo page.
128
+ expect(queryByText('FEED')).not.toBeNull();
129
+ expect(queryByText('PHOTO PAGE')).toBeNull();
130
+ });
131
+ });