toiljs 0.0.7 → 0.0.9

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 (135) hide show
  1. package/build/backend/.tsbuildinfo +1 -1
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/cli/configure.d.ts +1 -0
  4. package/build/cli/configure.js +85 -20
  5. package/build/cli/create.d.ts +1 -0
  6. package/build/cli/create.js +18 -7
  7. package/build/cli/features.d.ts +2 -0
  8. package/build/cli/features.js +22 -0
  9. package/build/cli/index.js +8 -0
  10. package/build/client/.tsbuildinfo +1 -1
  11. package/build/client/components/Form.d.ts +12 -0
  12. package/build/client/components/Form.js +23 -0
  13. package/build/client/components/Image.d.ts +13 -0
  14. package/build/client/components/Image.js +22 -0
  15. package/build/client/components/Script.d.ts +13 -0
  16. package/build/client/components/Script.js +68 -0
  17. package/build/client/components/Slot.d.ts +6 -0
  18. package/build/client/components/Slot.js +6 -0
  19. package/build/client/dev/error-overlay.d.ts +20 -0
  20. package/build/client/dev/error-overlay.js +123 -0
  21. package/build/client/head/head.d.ts +2 -0
  22. package/build/client/head/head.js +17 -2
  23. package/build/client/head/metadata.d.ts +29 -0
  24. package/build/client/head/metadata.js +38 -0
  25. package/build/client/index.d.ts +15 -3
  26. package/build/client/index.js +8 -2
  27. package/build/client/navigation/navigation.d.ts +3 -0
  28. package/build/client/navigation/navigation.js +42 -1
  29. package/build/client/routing/Router.d.ts +1 -0
  30. package/build/client/routing/Router.js +56 -34
  31. package/build/client/routing/action.d.ts +17 -0
  32. package/build/client/routing/action.js +55 -0
  33. package/build/client/routing/hooks.d.ts +1 -0
  34. package/build/client/routing/hooks.js +6 -7
  35. package/build/client/routing/loader.d.ts +10 -2
  36. package/build/client/routing/loader.js +83 -24
  37. package/build/client/routing/mount.d.ts +1 -1
  38. package/build/client/routing/mount.js +12 -4
  39. package/build/client/routing/slot-context.d.ts +2 -0
  40. package/build/client/routing/slot-context.js +2 -0
  41. package/build/client/types.d.ts +1 -0
  42. package/build/compiler/.tsbuildinfo +1 -1
  43. package/build/compiler/config.d.ts +10 -0
  44. package/build/compiler/config.js +5 -1
  45. package/build/compiler/docs.js +26 -26
  46. package/build/compiler/fonts.d.ts +4 -0
  47. package/build/compiler/fonts.js +64 -0
  48. package/build/compiler/generate.js +67 -32
  49. package/build/compiler/image-report.d.ts +2 -0
  50. package/build/compiler/image-report.js +62 -0
  51. package/build/compiler/plugin.js +1 -1
  52. package/build/compiler/prerender.d.ts +7 -0
  53. package/build/compiler/prerender.js +111 -0
  54. package/build/compiler/routes.d.ts +3 -0
  55. package/build/compiler/routes.js +50 -5
  56. package/build/compiler/seo.d.ts +70 -0
  57. package/build/compiler/seo.js +221 -0
  58. package/build/compiler/vite.js +13 -1
  59. package/build/io/.tsbuildinfo +1 -1
  60. package/build/shared/.tsbuildinfo +1 -1
  61. package/examples/basic/client/404.tsx +1 -1
  62. package/examples/basic/client/components/Header.tsx +38 -0
  63. package/examples/basic/client/components/HoneycombBackground.tsx +86 -18
  64. package/examples/basic/client/global-error.tsx +3 -3
  65. package/examples/basic/client/layout.tsx +2 -33
  66. package/examples/basic/client/public/images/test_image.webp +0 -0
  67. package/examples/basic/client/routes/about.tsx +8 -0
  68. package/examples/basic/client/routes/get-started.tsx +1 -1
  69. package/examples/basic/client/routes/index.tsx +8 -1
  70. package/examples/basic/client/routes/io.tsx +1 -1
  71. package/examples/basic/client/routes/loader-demo/index.tsx +29 -1
  72. package/examples/basic/client/routes/test.tsx +8 -0
  73. package/examples/basic/client/styles/main.css +48 -1
  74. package/package.json +8 -6
  75. package/presets/eslint.js +7 -4
  76. package/presets/tsconfig.json +1 -1
  77. package/src/backend/index.ts +1 -1
  78. package/src/cli/configure.ts +102 -21
  79. package/src/cli/create.ts +25 -9
  80. package/src/cli/features.ts +33 -1
  81. package/src/cli/index.ts +10 -1
  82. package/src/cli/ui.ts +1 -1
  83. package/src/cli/validate.ts +1 -1
  84. package/src/client/components/Form.tsx +65 -0
  85. package/src/client/components/Image.tsx +89 -0
  86. package/src/client/components/Script.tsx +113 -0
  87. package/src/client/components/Slot.tsx +21 -0
  88. package/src/client/dev/error-overlay.tsx +197 -0
  89. package/src/client/head/head.ts +28 -3
  90. package/src/client/head/metadata.ts +92 -0
  91. package/src/client/index.ts +20 -3
  92. package/src/client/navigation/Link.tsx +1 -1
  93. package/src/client/navigation/navigation.ts +74 -4
  94. package/src/client/navigation/prefetch.ts +2 -2
  95. package/src/client/routing/Router.tsx +128 -62
  96. package/src/client/routing/action.ts +122 -0
  97. package/src/client/routing/error-boundary.tsx +1 -1
  98. package/src/client/routing/hooks.ts +17 -23
  99. package/src/client/routing/loader.ts +158 -35
  100. package/src/client/routing/mount.tsx +25 -3
  101. package/src/client/routing/slot-context.ts +7 -0
  102. package/src/client/types.ts +6 -4
  103. package/src/compiler/config.ts +40 -3
  104. package/src/compiler/docs.ts +26 -26
  105. package/src/compiler/fonts.ts +87 -0
  106. package/src/compiler/generate.ts +69 -31
  107. package/src/compiler/image-report.ts +85 -0
  108. package/src/compiler/plugin.ts +2 -2
  109. package/src/compiler/prerender.ts +130 -0
  110. package/src/compiler/routes.ts +62 -7
  111. package/src/compiler/seo.ts +356 -0
  112. package/src/compiler/vite.ts +21 -4
  113. package/src/io/FastSet.ts +1 -1
  114. package/src/io/index.ts +1 -1
  115. package/src/io/types.ts +1 -1
  116. package/src/server/index.ts +1 -1
  117. package/src/server/main.ts +1 -1
  118. package/src/shared/index.ts +1 -1
  119. package/test/dom/Image.test.tsx +46 -0
  120. package/test/dom/Script.test.tsx +45 -0
  121. package/test/dom/action.test.tsx +129 -0
  122. package/test/dom/error-overlay.test.tsx +44 -0
  123. package/test/dom/loader.test.tsx +121 -0
  124. package/test/dom/revalidate.test.tsx +38 -0
  125. package/test/dom/route-head.test.tsx +34 -0
  126. package/test/dom/router-loading.test.tsx +44 -0
  127. package/test/dom/slot.test.tsx +109 -0
  128. package/test/dom/view-transitions.test.tsx +51 -0
  129. package/test/features.test.ts +31 -0
  130. package/test/fonts.test.ts +26 -0
  131. package/test/metadata.test.ts +41 -0
  132. package/test/prerender.test.ts +46 -0
  133. package/test/routes.test.ts +20 -1
  134. package/test/seo.test.ts +142 -0
  135. package/examples/basic/client/template.tsx +0 -7
@@ -0,0 +1,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: () => 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
+ });
@@ -0,0 +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
+ });
@@ -0,0 +1,121 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest';
3
+
4
+ import {
5
+ clearLoaderData,
6
+ invalidateLoaderData,
7
+ loaderKey,
8
+ readRouteData,
9
+ type LoaderData,
10
+ } from '../../src/client/routing/loader';
11
+ import type { Revalidate } from '../../src/client/routing/loader';
12
+ import type { RouteDef } from '../../src/client/types';
13
+
14
+ /** Reads route data, awaiting the suspending promise once if it's pending. */
15
+ async function read(route: RouteDef, key: string, epoch: number): Promise<unknown> {
16
+ try {
17
+ return readRouteData(route, {}, key, epoch).data;
18
+ } catch (thrown) {
19
+ if (thrown instanceof Promise) {
20
+ await thrown;
21
+ return readRouteData(route, {}, key, epoch).data;
22
+ }
23
+ throw thrown;
24
+ }
25
+ }
26
+
27
+ /** A route whose `load()` count tells us how many times the loader actually ran. */
28
+ function makeRoute(revalidate?: Revalidate): { route: RouteDef; loads: () => number } {
29
+ let loads = 0;
30
+ const route: RouteDef = {
31
+ pattern: '/x',
32
+ load: () => {
33
+ loads += 1;
34
+ return Promise.resolve({
35
+ default: () => null,
36
+ loader: () => ({ n: loads }),
37
+ revalidate,
38
+ });
39
+ },
40
+ };
41
+ return { route, loads: () => loads };
42
+ }
43
+
44
+ beforeEach(() => {
45
+ clearLoaderData();
46
+ });
47
+ afterEach(() => {
48
+ vi.useRealTimers();
49
+ });
50
+
51
+ describe('loader caching', () => {
52
+ it('reuses cached data on re-read within the same navigation (loader runs once)', async () => {
53
+ const { route, loads } = makeRoute();
54
+ const key = loaderKey('/x', '');
55
+ await read(route, key, 1);
56
+ await read(route, key, 1);
57
+ expect(loads()).toBe(1);
58
+ });
59
+
60
+ it('a route with no loader stays cached across navigations (never re-suspends)', async () => {
61
+ let loads = 0;
62
+ const route: RouteDef = {
63
+ pattern: '/p',
64
+ load: () => {
65
+ loads += 1;
66
+ return Promise.resolve({ default: () => null });
67
+ },
68
+ };
69
+ const key = loaderKey('/p', '');
70
+ await read(route, key, 1);
71
+ await read(route, key, 2);
72
+ await read(route, key, 3);
73
+ expect(loads).toBe(1);
74
+ });
75
+
76
+ it('refetches on a new navigation under the default policy', async () => {
77
+ const { route, loads } = makeRoute();
78
+ const key = loaderKey('/x', '');
79
+ await read(route, key, 1);
80
+ await read(route, key, 2);
81
+ expect(loads()).toBe(2);
82
+ });
83
+
84
+ it('revalidate=false caches across navigations', async () => {
85
+ const { route, loads } = makeRoute(false);
86
+ const key = loaderKey('/x', '');
87
+ await read(route, key, 1);
88
+ await read(route, key, 2);
89
+ expect(loads()).toBe(1);
90
+ });
91
+
92
+ it('numeric revalidate caches until the staleTime elapses', async () => {
93
+ vi.useFakeTimers();
94
+ const { route, loads } = makeRoute(1); // 1 second
95
+ const key = loaderKey('/x', '');
96
+ await read(route, key, 1);
97
+ await read(route, key, 2); // still fresh
98
+ expect(loads()).toBe(1);
99
+ vi.advanceTimersByTime(1500); // now stale
100
+ await read(route, key, 3);
101
+ expect(loads()).toBe(2);
102
+ });
103
+
104
+ it('invalidateLoaderData(href) forces a refetch of that route', async () => {
105
+ const { route, loads } = makeRoute(false);
106
+ const key = loaderKey('/x', '');
107
+ await read(route, key, 1);
108
+ invalidateLoaderData('/x');
109
+ await read(route, key, 1);
110
+ expect(loads()).toBe(2);
111
+ });
112
+ });
113
+
114
+ describe('useLoaderData type inference', () => {
115
+ it('LoaderData<typeof loader> resolves to the loader return type', () => {
116
+ const loader = async () => Promise.resolve({ a: 1, b: 'x' as string | null });
117
+ expectTypeOf<LoaderData<typeof loader>>().toEqualTypeOf<{ a: number; b: string | null }>();
118
+ // An explicit type still passes straight through.
119
+ expectTypeOf<LoaderData<{ id: number }>>().toEqualTypeOf<{ id: number }>();
120
+ });
121
+ });
@@ -0,0 +1,38 @@
1
+ // @vitest-environment jsdom
2
+ import { act, cleanup, render } from '@testing-library/react';
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
+
5
+ import { Router } from '../../src/client/routing/Router';
6
+ import { clearLoaderData, revalidate, useLoaderData } from '../../src/client/routing/loader';
7
+ import type { RouteDef } from '../../src/client/types';
8
+
9
+ afterEach(cleanup);
10
+ beforeEach(() => {
11
+ clearLoaderData();
12
+ window.history.replaceState({}, '', '/');
13
+ });
14
+
15
+ describe('revalidate refetches', () => {
16
+ it('re-runs the loader and updates the rendered data', async () => {
17
+ let n = 0;
18
+ function Page(): React.ReactNode {
19
+ const value = useLoaderData<number>();
20
+ return <p>val:{String(value)}</p>;
21
+ }
22
+ const routes: RouteDef[] = [
23
+ {
24
+ pattern: '/',
25
+ load: () => Promise.resolve({ default: Page, loader: () => (n += 1) }),
26
+ // matches the example: this route has a loading.tsx (keyed boundary + transition).
27
+ loading: () => Promise.resolve({ default: () => <p>loading</p> }),
28
+ },
29
+ ];
30
+ const { findByText } = render(<Router routes={routes} />);
31
+ await findByText('val:1');
32
+
33
+ act(() => {
34
+ revalidate();
35
+ });
36
+ await findByText('val:2');
37
+ });
38
+ });
@@ -0,0 +1,34 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { setRouteHead, useHead } from '../../src/client/head/head';
5
+ import { resolveMetadata } from '../../src/client/head/metadata';
6
+ import { cleanup, render } from '@testing-library/react';
7
+ import { afterEach } from 'vitest';
8
+
9
+ afterEach(() => {
10
+ cleanup();
11
+ setRouteHead(null);
12
+ });
13
+
14
+ const desc = (): string | null =>
15
+ document.head.querySelector('meta[name="description"]')?.getAttribute('content') ?? null;
16
+
17
+ describe('route head (metadata baseline)', () => {
18
+ it('applies a resolved metadata head to the document', () => {
19
+ setRouteHead(resolveMetadata({ title: 'About', description: 'about page' }));
20
+ expect(document.title).toBe('About');
21
+ expect(desc()).toBe('about page');
22
+ });
23
+
24
+ it('is the lowest priority — component useHead overrides it', () => {
25
+ setRouteHead(resolveMetadata({ title: 'Base', description: 'base' }));
26
+ function Page() {
27
+ useHead({ title: 'Override', meta: [{ name: 'description', content: 'override' }] });
28
+ return null;
29
+ }
30
+ render(<Page />);
31
+ expect(document.title).toBe('Override');
32
+ expect(desc()).toBe('override');
33
+ });
34
+ });
@@ -0,0 +1,44 @@
1
+ // @vitest-environment jsdom
2
+ import { act, cleanup, render, waitFor } from '@testing-library/react';
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
+
5
+ import { navigate } from '../../src/client/navigation/navigation';
6
+ import { Router } from '../../src/client/routing/Router';
7
+ import type { RouteDef } from '../../src/client/types';
8
+
9
+ afterEach(cleanup);
10
+ beforeEach(() => {
11
+ window.history.replaceState({}, '', '/');
12
+ });
13
+
14
+ const routes: RouteDef[] = [
15
+ {
16
+ pattern: '/',
17
+ load: () => Promise.resolve({ default: () => <div>HOME</div> }),
18
+ },
19
+ {
20
+ // Page chunk never resolves, so the route stays suspended — exercising the fallback path.
21
+ pattern: '/slow',
22
+ load: () => new Promise<{ default: () => null }>(() => undefined),
23
+ loading: () => Promise.resolve({ default: () => <div>LOADING</div> }),
24
+ },
25
+ ];
26
+
27
+ describe('Router loading fallback', () => {
28
+ it("shows the route's loading.tsx immediately when navigating to a suspending route", async () => {
29
+ const { findByText, queryByText } = render(<Router routes={routes} />);
30
+ await findByText('HOME');
31
+
32
+ // A route with a `loading.tsx` keys its Suspense boundary per URL, so even though navigation
33
+ // runs in a transition the fallback appears immediately (it isn't suppressed / frozen).
34
+ act(() => {
35
+ navigate('/slow');
36
+ });
37
+
38
+ await waitFor(() => {
39
+ expect(queryByText('LOADING')).not.toBeNull();
40
+ });
41
+ // The keyed boundary remounts for the new route, so the previous page is gone (not frozen).
42
+ expect(queryByText('HOME')).toBeNull();
43
+ });
44
+ });
@@ -0,0 +1,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 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
+ });
@@ -0,0 +1,51 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { navigate, setViewTransitions } from '../../src/client/navigation/navigation';
5
+
6
+ interface VTDoc {
7
+ startViewTransition?: (cb: () => void) => unknown;
8
+ }
9
+ const doc = document as Document & VTDoc;
10
+
11
+ afterEach(() => {
12
+ setViewTransitions(false);
13
+ delete doc.startViewTransition;
14
+ vi.restoreAllMocks();
15
+ window.history.replaceState({}, '', '/');
16
+ });
17
+
18
+ describe('view transitions', () => {
19
+ function stubReducedMotion(matches: boolean): void {
20
+ window.matchMedia = vi.fn().mockReturnValue({ matches }) as unknown as typeof window.matchMedia;
21
+ }
22
+
23
+ it('wraps navigation in startViewTransition when enabled and supported', () => {
24
+ const vt = vi.fn((cb: () => void) => {
25
+ cb();
26
+ });
27
+ doc.startViewTransition = vt;
28
+ stubReducedMotion(false);
29
+ setViewTransitions(true);
30
+ navigate('/a');
31
+ expect(vt).toHaveBeenCalledOnce();
32
+ });
33
+
34
+ it('skips the view transition under prefers-reduced-motion', () => {
35
+ const vt = vi.fn();
36
+ doc.startViewTransition = vt;
37
+ stubReducedMotion(true);
38
+ setViewTransitions(true);
39
+ navigate('/b');
40
+ expect(vt).not.toHaveBeenCalled();
41
+ });
42
+
43
+ it('does not use view transitions when disabled', () => {
44
+ const vt = vi.fn();
45
+ doc.startViewTransition = vt;
46
+ stubReducedMotion(false);
47
+ setViewTransitions(false);
48
+ navigate('/c');
49
+ expect(vt).not.toHaveBeenCalled();
50
+ });
51
+ });
@@ -1,11 +1,13 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
3
  import {
4
+ defaultConfigSource,
4
5
  detectPreprocessor,
5
6
  detectTailwind,
6
7
  packageDiff,
7
8
  preprocessorForExt,
8
9
  requiredPackages,
10
+ setConfigImages,
9
11
  setStyleImports,
10
12
  styleEntry,
11
13
  styleImportLines,
@@ -109,3 +111,32 @@ describe('detect from dependencies', () => {
109
111
  expect(detectTailwind({ react: '^19' })).toBe(false);
110
112
  });
111
113
  });
114
+
115
+ describe('setConfigImages / defaultConfigSource', () => {
116
+ it('flips an existing images flag', () => {
117
+ const src = 'export default defineConfig({\n client: {\n images: true,\n },\n});\n';
118
+ expect(setConfigImages(src, false)).toContain('images: false');
119
+ expect(setConfigImages(src, false)).not.toContain('images: true');
120
+ });
121
+
122
+ it('adds images to an existing client block', () => {
123
+ const out = setConfigImages('export default defineConfig({ client: { base: "/" } });', false);
124
+ expect(out).toContain('images: false');
125
+ expect(out).toContain('base: "/"');
126
+ });
127
+
128
+ it('adds a client block to a bare config', () => {
129
+ const out = setConfigImages('export default defineConfig({});', false);
130
+ expect(out).toContain('client: { images: false }');
131
+ });
132
+
133
+ it('returns null when the shape is unrecognized', () => {
134
+ expect(setConfigImages('const x = 1;', false)).toBeNull();
135
+ });
136
+
137
+ it('round-trips through defaultConfigSource', () => {
138
+ const src = defaultConfigSource(false);
139
+ expect(src).toContain('images: false');
140
+ expect(setConfigImages(src, true)).toContain('images: true');
141
+ });
142
+ });
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { fontPreloadTags } from '../src/compiler/fonts';
4
+
5
+ describe('fontPreloadTags', () => {
6
+ it('builds a crossorigin preload link per font, skipping non-fonts', () => {
7
+ const tags = fontPreloadTags(['fonts/a-abc.woff2', 'assets/x-1.js', 'fonts/b.ttf'], '/');
8
+ expect(tags).toHaveLength(2);
9
+ expect(tags[0]).toEqual({
10
+ tag: 'link',
11
+ attrs: {
12
+ rel: 'preload',
13
+ as: 'font',
14
+ type: 'font/woff2',
15
+ href: '/fonts/a-abc.woff2',
16
+ crossorigin: '',
17
+ },
18
+ injectTo: 'head',
19
+ });
20
+ expect(tags[1].attrs?.type).toBe('font/ttf');
21
+ });
22
+
23
+ it('respects a non-root base path', () => {
24
+ expect(fontPreloadTags(['fonts/a.woff2'], '/app/')[0].attrs?.href).toBe('/app/fonts/a.woff2');
25
+ });
26
+ });