toiljs 0.0.7 → 0.0.8

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 (65) hide show
  1. package/build/cli/.tsbuildinfo +1 -1
  2. package/build/cli/configure.d.ts +1 -0
  3. package/build/cli/configure.js +83 -18
  4. package/build/cli/create.d.ts +1 -0
  5. package/build/cli/create.js +14 -3
  6. package/build/cli/features.d.ts +2 -0
  7. package/build/cli/features.js +22 -0
  8. package/build/cli/index.js +8 -0
  9. package/build/client/.tsbuildinfo +1 -1
  10. package/build/client/components/Form.d.ts +12 -0
  11. package/build/client/components/Form.js +23 -0
  12. package/build/client/components/Image.d.ts +13 -0
  13. package/build/client/components/Image.js +22 -0
  14. package/build/client/components/Script.d.ts +13 -0
  15. package/build/client/components/Script.js +68 -0
  16. package/build/client/index.d.ts +10 -2
  17. package/build/client/index.js +5 -1
  18. package/build/client/routing/Router.js +4 -4
  19. package/build/client/routing/action.d.ts +17 -0
  20. package/build/client/routing/action.js +55 -0
  21. package/build/client/routing/hooks.d.ts +1 -0
  22. package/build/client/routing/hooks.js +4 -1
  23. package/build/client/routing/loader.d.ts +8 -2
  24. package/build/client/routing/loader.js +75 -24
  25. package/build/compiler/.tsbuildinfo +1 -1
  26. package/build/compiler/config.d.ts +2 -0
  27. package/build/compiler/config.js +1 -0
  28. package/build/compiler/generate.js +2 -0
  29. package/build/compiler/image-report.d.ts +2 -0
  30. package/build/compiler/image-report.js +62 -0
  31. package/build/compiler/vite.js +8 -0
  32. package/examples/basic/client/components/Header.tsx +38 -0
  33. package/examples/basic/client/components/HoneycombBackground.tsx +86 -18
  34. package/examples/basic/client/global-error.tsx +2 -2
  35. package/examples/basic/client/layout.tsx +2 -33
  36. package/examples/basic/client/public/images/test_image.webp +0 -0
  37. package/examples/basic/client/routes/index.tsx +8 -1
  38. package/examples/basic/client/routes/loader-demo/index.tsx +24 -1
  39. package/examples/basic/client/routes/test.tsx +8 -0
  40. package/examples/basic/client/styles/main.css +48 -1
  41. package/package.json +8 -6
  42. package/presets/eslint.js +4 -4
  43. package/src/cli/configure.ts +98 -17
  44. package/src/cli/create.ts +18 -2
  45. package/src/cli/features.ts +32 -0
  46. package/src/cli/index.ts +9 -0
  47. package/src/client/components/Form.tsx +65 -0
  48. package/src/client/components/Image.tsx +89 -0
  49. package/src/client/components/Script.tsx +113 -0
  50. package/src/client/index.ts +15 -2
  51. package/src/client/routing/Router.tsx +17 -5
  52. package/src/client/routing/action.ts +122 -0
  53. package/src/client/routing/hooks.ts +18 -5
  54. package/src/client/routing/loader.ts +146 -35
  55. package/src/compiler/config.ts +9 -0
  56. package/src/compiler/generate.ts +3 -0
  57. package/src/compiler/image-report.ts +85 -0
  58. package/src/compiler/vite.ts +12 -0
  59. package/test/dom/Image.test.tsx +46 -0
  60. package/test/dom/Script.test.tsx +45 -0
  61. package/test/dom/action.test.tsx +129 -0
  62. package/test/dom/loader.test.tsx +121 -0
  63. package/test/dom/router-loading.test.tsx +44 -0
  64. package/test/features.test.ts +31 -0
  65. package/examples/basic/client/template.tsx +0 -7
@@ -2,12 +2,17 @@
2
2
  * Route data loaders. A route file may export a `loader` alongside its default component; the
3
3
  * loader runs on navigation, in parallel with the route's chunk load, and the page suspends until
4
4
  * it resolves (so `loading.tsx` shows and `useNavigationPending` is true). The page reads the
5
- * result with `useLoaderData()`. Results are cached per navigation+URL (revalidated on each
6
- * navigation); `clearLoaderData()` (router.refresh) busts the cache.
5
+ * result with `useLoaderData<typeof loader>()`.
6
+ *
7
+ * Caching: results are cached by URL (`pathname + search`). Re-renders reuse the cached result (the
8
+ * loader does not re-run). Across navigations, the cache policy comes from the route's optional
9
+ * `export const revalidate` ({@link Revalidate}): by default the loader re-runs on every navigation;
10
+ * a number keeps data fresh for that many seconds; `false` caches until manual invalidation.
11
+ * `revalidate()` / `router.refresh()` bust the cache to force a refetch.
7
12
  */
8
13
  import { createContext, useContext, type ComponentType } from 'react';
9
14
 
10
- import { navigationEpoch } from '../navigation/navigation.js';
15
+ import { refresh as rerender } from '../navigation/navigation.js';
11
16
  import type { RouteDef } from '../types.js';
12
17
  import type { RouteParams } from './match.js';
13
18
 
@@ -20,9 +25,21 @@ export interface LoaderArgs {
20
25
  /** A route `loader`: `export const loader = ({ params }) => …` (sync or async). */
21
26
  export type LoaderFunction<T = unknown> = (args: LoaderArgs) => T | Promise<T>;
22
27
 
28
+ /**
29
+ * Per-route cache policy, set with `export const revalidate` in a route file:
30
+ * - `0` (default): re-run the loader on every navigation to the route.
31
+ * - a positive number: reuse cached data across navigations for that many **seconds**, then refetch.
32
+ * - `false`: cache indefinitely until manually invalidated (`revalidate()` / `router.refresh()`).
33
+ */
34
+ export type Revalidate = number | false;
35
+
36
+ /** Resolves the data type for {@link useLoaderData}: `typeof loader` → its (awaited) return, else `T`. */
37
+ export type LoaderData<T> = T extends (...args: never[]) => infer R ? Awaited<R> : T;
38
+
23
39
  interface RouteModule {
24
40
  default: ComponentType;
25
41
  loader?: LoaderFunction;
42
+ revalidate?: Revalidate;
26
43
  }
27
44
  interface RouteData {
28
45
  Component: ComponentType;
@@ -33,59 +50,105 @@ interface Entry {
33
50
  promise: Promise<void>;
34
51
  value?: RouteData;
35
52
  error?: unknown;
53
+ /** `Date.now()` when the fetch settled (`0` while pending). */
54
+ loadedAt: number;
55
+ /** Cache policy captured from the route module once loaded. */
56
+ revalidate: Revalidate;
57
+ /** Navigation epoch at which this entry was (re)fetched. */
58
+ epoch: number;
59
+ /** Whether the route exports a `loader` — a route without one has no data that can change. */
60
+ hasLoader: boolean;
36
61
  }
37
62
 
38
63
  const cache = new Map<string, Entry>();
39
- const MAX_ENTRIES = 16;
64
+ const MAX_ENTRIES = 32;
65
+
66
+ /** Cache key for a URL: path + query (hash is ignored — it never changes loader data). */
67
+ export function loaderKey(pathname: string, search: string): string {
68
+ return `${pathname}${search}`;
69
+ }
40
70
 
41
71
  /** Loads the route module and runs its loader (if any), in parallel where possible. */
42
- async function loadRoute(route: RouteDef, params: RouteParams): Promise<RouteData> {
72
+ async function loadRoute(
73
+ route: RouteDef,
74
+ params: RouteParams,
75
+ ): Promise<{ data: RouteData; revalidate: Revalidate; hasLoader: boolean }> {
43
76
  const mod: RouteModule = await route.load();
44
77
  const searchParams = new URLSearchParams(
45
78
  typeof window === 'undefined' ? '' : window.location.search,
46
79
  );
47
80
  const data = mod.loader ? await mod.loader({ params, searchParams }) : undefined;
48
- return { Component: mod.default, data };
81
+ return {
82
+ data: { Component: mod.default, data },
83
+ revalidate: mod.revalidate ?? 0,
84
+ hasLoader: mod.loader != null,
85
+ };
49
86
  }
50
87
 
51
- /** Drops entries from previous navigations (and caps the cache as a backstop). */
52
- function prune(): void {
53
- const current = `${String(navigationEpoch())}:`;
54
- for (const key of cache.keys()) {
55
- if (!key.startsWith(current)) cache.delete(key);
56
- }
88
+ /** Whether a settled entry must be refetched for the current navigation. */
89
+ function isStale(entry: Entry, epoch: number): boolean {
90
+ if (entry.status === 'error') return true; // always retry a failed load
91
+ // A route with no loader has no data that can change — keep it cached so repeat navigations
92
+ // render synchronously (instant) instead of re-suspending and remounting on every switch.
93
+ if (!entry.hasLoader) return false;
94
+ if (entry.revalidate === false) return false; // cache forever
95
+ if (entry.revalidate === 0) return entry.epoch !== epoch; // refetch once per navigation
96
+ return Date.now() - entry.loadedAt >= entry.revalidate * 1000; // time-based staleness
97
+ }
98
+
99
+ /** Starts (and caches) a fresh fetch for `key`. */
100
+ function startFetch(route: RouteDef, params: RouteParams, key: string, epoch: number): Entry {
101
+ const created: Entry = {
102
+ status: 'pending',
103
+ promise: Promise.resolve(),
104
+ loadedAt: 0,
105
+ revalidate: 0,
106
+ epoch,
107
+ hasLoader: false,
108
+ };
109
+ created.promise = loadRoute(route, params).then(
110
+ (result) => {
111
+ created.value = result.data;
112
+ created.revalidate = result.revalidate;
113
+ created.hasLoader = result.hasLoader;
114
+ created.loadedAt = Date.now();
115
+ created.status = 'done';
116
+ },
117
+ (error: unknown) => {
118
+ created.error = error;
119
+ created.loadedAt = Date.now();
120
+ created.status = 'error';
121
+ },
122
+ );
123
+ cache.set(key, created);
57
124
  while (cache.size > MAX_ENTRIES) {
58
125
  const oldest = cache.keys().next().value;
59
- if (oldest === undefined) break;
126
+ if (oldest === undefined || oldest === key) break;
60
127
  cache.delete(oldest);
61
128
  }
129
+ return created;
62
130
  }
63
131
 
64
132
  /**
65
- * Reads (or starts) the route's module + loader data for `key`, suspending until ready. Throws the
66
- * loader's error so the route's `error.tsx` boundary can catch it.
133
+ * Reads (or starts) the route's module + loader data for the URL `key`, suspending until ready.
134
+ * Throws the loader's error so the route's `error.tsx` boundary can catch it. `epoch` is the current
135
+ * navigation epoch, used to refetch once-per-navigation under the default cache policy.
67
136
  */
68
- export function readRouteData(route: RouteDef, params: RouteParams, key: string): RouteData {
137
+ export function readRouteData(
138
+ route: RouteDef,
139
+ params: RouteParams,
140
+ key: string,
141
+ epoch: number,
142
+ ): RouteData {
69
143
  let entry = cache.get(key);
70
- if (!entry) {
71
- const created: Entry = { status: 'pending', promise: Promise.resolve() };
72
- created.promise = loadRoute(route, params).then(
73
- (value) => {
74
- created.value = value;
75
- created.status = 'done';
76
- },
77
- (error: unknown) => {
78
- created.error = error;
79
- created.status = 'error';
80
- },
81
- );
82
- cache.set(key, created);
83
- prune();
84
- entry = created;
144
+ if (entry && entry.status !== 'pending' && isStale(entry, epoch)) {
145
+ entry = undefined; // stale drop and refetch below
85
146
  }
147
+ entry ??= startFetch(route, params, key, epoch);
86
148
  if (entry.status === 'pending') throw entry.promise;
87
149
  if (entry.status === 'error') throw entry.error;
88
- return entry.value as RouteData;
150
+ if (!entry.value) throw entry.promise;
151
+ return entry.value;
89
152
  }
90
153
 
91
154
  /** Clears all cached loader data, so the next render re-runs loaders (used by router.refresh). */
@@ -93,10 +156,58 @@ export function clearLoaderData(): void {
93
156
  cache.clear();
94
157
  }
95
158
 
159
+ /** Cache key for an href (relative or absolute), matching {@link loaderKey}. */
160
+ function keyForHref(href: string): string | undefined {
161
+ if (typeof window === 'undefined') return undefined;
162
+ try {
163
+ const url = new URL(href, window.location.href);
164
+ return loaderKey(url.pathname, url.search);
165
+ } catch {
166
+ return undefined;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Invalidates cached loader data so it refetches on the next render. With no argument, clears every
172
+ * route; with an `href`, clears just that route's entry. Pair with a re-render (the active route
173
+ * refetches and suspends) — see {@link revalidate}.
174
+ */
175
+ export function invalidateLoaderData(href?: string): void {
176
+ if (href === undefined) {
177
+ cache.clear();
178
+ return;
179
+ }
180
+ const key = keyForHref(href);
181
+ if (key !== undefined) cache.delete(key);
182
+ }
183
+
184
+ /**
185
+ * Invalidates loader data and re-renders so the active route refetches. Call after a mutation to
186
+ * refresh the current route (`revalidate()`), or target another route by href
187
+ * (`revalidate('/posts')`). Usable outside React (e.g. in an event handler after a `fetch`).
188
+ */
189
+ export function revalidate(href?: string): void {
190
+ invalidateLoaderData(href);
191
+ rerender();
192
+ }
193
+
96
194
  /** Holds the active route's loader data; provided by the Router, read by {@link useLoaderData}. */
97
195
  export const LoaderDataContext = createContext<unknown>(undefined);
98
196
 
99
- /** The data returned by the active route's `loader` (`undefined` if it has none). */
100
- export function useLoaderData<T = unknown>(): T {
101
- return useContext(LoaderDataContext) as T;
197
+ /**
198
+ * The data returned by the active route's `loader`. Three ways to type it, easiest first:
199
+ *
200
+ * 1. **Pass the loader** — zero generics, fully inferred from your loader's return:
201
+ * `const data = useLoaderData(loader);`
202
+ * 2. Pass `typeof loader` as a type argument: `useLoaderData<typeof loader>();`
203
+ * 3. Pass an explicit shape: `useLoaderData<Post>();`
204
+ *
205
+ * With no argument and no type, it returns `unknown` (never `any`) — so the data is there at runtime,
206
+ * but you must annotate or narrow before using it. There's no way to infer the type from a bare call:
207
+ * TypeScript can't tell which file (and so which `loader`) the call belongs to — hence option 1.
208
+ */
209
+ export function useLoaderData<L extends LoaderFunction>(loader: L): Awaited<ReturnType<L>>;
210
+ export function useLoaderData<T = unknown>(): LoaderData<T>;
211
+ export function useLoaderData(_loader?: LoaderFunction): unknown {
212
+ return useContext(LoaderDataContext);
102
213
  }
@@ -24,6 +24,12 @@ export interface ClientConfig {
24
24
  readonly base?: string;
25
25
  /** Dev server port. Default `3000`. */
26
26
  readonly port?: number;
27
+ /**
28
+ * Optimize imported images at build time (resize/convert via `vite-imagetools` + sharp): an
29
+ * import like `logo.png?w=400;800&format=webp&as=srcset` emits resized, compressed variants.
30
+ * Default `true`. Set `false` to disable the pipeline (images are then served as-is).
31
+ */
32
+ readonly images?: boolean;
27
33
  /**
28
34
  * Raw Vite escape hatch, deep-merged over the framework's opinionated config.
29
35
  * This is NOT the client config itself — toil owns the Vite setup; use this only
@@ -68,6 +74,8 @@ export interface ResolvedToilConfig {
68
74
  readonly outDir: string;
69
75
  readonly base: string;
70
76
  readonly port: number;
77
+ /** Whether build-time image optimization (`vite-imagetools`) is enabled. */
78
+ readonly images: boolean;
71
79
  /** Absolute path to the framework client runtime (`toiljs/client`). */
72
80
  readonly runtimePath: string;
73
81
  readonly vite: InlineConfig;
@@ -128,6 +136,7 @@ export async function loadConfig(
128
136
  outDir: client.outDir ?? 'build/client',
129
137
  base: client.base ?? '/',
130
138
  port: opts.port ?? client.port ?? 3000,
139
+ images: client.images ?? true,
131
140
  runtimePath: resolveRuntimePath(),
132
141
  vite: client.vite ?? {},
133
142
  };
@@ -36,10 +36,13 @@ const ASSET_MODULES = ASSET_EXTENSIONS.map(
36
36
 
37
37
  export const TOIL_ENV_DTS =
38
38
  `// AUTO-GENERATED by toil — do not edit.\n` +
39
+ // Types for image-optimization query imports (`import img from './x.png?w=400&format=webp'`).
40
+ `/// <reference types="vite-imagetools/client" />\n` +
39
41
  `declare const Toil: typeof import('toiljs/client');\n` +
40
42
  `declare namespace Toil {\n` +
41
43
  ` type LoaderArgs = import('toiljs/client').LoaderArgs;\n` +
42
44
  ` type LoaderFunction<T = unknown> = import('toiljs/client').LoaderFunction<T>;\n` +
45
+ ` type Revalidate = import('toiljs/client').Revalidate;\n` +
43
46
  ` type RouteErrorProps = import('toiljs/client').RouteErrorProps;\n` +
44
47
  `}\n` +
45
48
  `declare const BinaryWriter: typeof import('toiljs/io').BinaryWriter;\n` +
@@ -0,0 +1,85 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import pc from 'picocolors';
5
+ import type { Logger, Plugin } from 'vite';
6
+
7
+ /** Raster/vector outputs the image pipeline may emit. */
8
+ const IMAGE_RE = /\.(png|jpe?g|webp|avif|gif|tiff|svg)$/i;
9
+
10
+ /** Formats a byte count like Vite's asset table (kB, base 1000). */
11
+ function kb(bytes: number): string {
12
+ return `${(bytes / 1000).toFixed(2)} kB`;
13
+ }
14
+
15
+ interface Variant {
16
+ readonly out: string;
17
+ readonly outSize: number;
18
+ }
19
+
20
+ /**
21
+ * Build-only plugin that reports which imported images the pipeline optimized — each source image,
22
+ * its emitted variant(s), and the size saved. `public/` assets (copied as-is) never enter the
23
+ * bundle, so they don't appear here. Logs nothing when no images were processed.
24
+ *
25
+ * `viteRoot` is Vite's root (the `.toil` dir) that emitted assets' `originalFileNames` are relative
26
+ * to; `projectRoot` is used only to print friendly source paths.
27
+ */
28
+ export function imageReportPlugin(projectRoot: string, viteRoot: string): Plugin {
29
+ let logger: Logger | undefined;
30
+ return {
31
+ name: 'toil:image-report',
32
+ apply: 'build',
33
+ configResolved(config) {
34
+ logger = config.logger;
35
+ },
36
+ writeBundle(_options, bundle) {
37
+ // Group emitted image assets by their source file.
38
+ const bySource = new Map<string, { label: string; inSize: number | null; variants: Variant[] }>();
39
+ for (const file of Object.values(bundle)) {
40
+ if (file.type !== 'asset' || !IMAGE_RE.test(file.fileName)) continue;
41
+ const source = file.originalFileNames[0];
42
+ const key = source ?? file.fileName;
43
+ const outSize =
44
+ typeof file.source === 'string'
45
+ ? Buffer.byteLength(file.source)
46
+ : file.source.byteLength;
47
+
48
+ let entry = bySource.get(key);
49
+ if (!entry) {
50
+ let inSize: number | null = null;
51
+ let label = '(generated)';
52
+ if (source !== undefined) {
53
+ const abs = path.resolve(viteRoot, source);
54
+ label = path.relative(projectRoot, abs);
55
+ try {
56
+ inSize = fs.statSync(abs).size;
57
+ } catch {
58
+ inSize = null;
59
+ }
60
+ }
61
+ entry = { label, inSize, variants: [] };
62
+ bySource.set(key, entry);
63
+ }
64
+ entry.variants.push({ out: file.fileName, outSize });
65
+ }
66
+
67
+ if (bySource.size === 0 || !logger) return;
68
+
69
+ const count = bySource.size;
70
+ logger.info('');
71
+ logger.info(pc.green(` ✓ optimized ${String(count)} image${count === 1 ? '' : 's'}`));
72
+ for (const { label, inSize, variants } of bySource.values()) {
73
+ logger.info(` ${pc.dim(label)}`);
74
+ for (const v of variants) {
75
+ const saved =
76
+ inSize && inSize > 0
77
+ ? pc.green(` -${String(Math.round((1 - v.outSize / inSize) * 100))}%`)
78
+ : '';
79
+ const from = inSize ? `${kb(inSize)} → ` : '';
80
+ logger.info(` ${pc.dim('→')} ${v.out} ${from}${kb(v.outSize)}${saved}`);
81
+ }
82
+ }
83
+ },
84
+ };
85
+ }
@@ -3,10 +3,12 @@ import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
 
5
5
  import react from '@vitejs/plugin-react';
6
+ import { imagetools } from 'vite-imagetools';
6
7
  import { nodePolyfills } from 'vite-plugin-node-polyfills';
7
8
  import { mergeConfig, type InlineConfig, type PluginOption } from 'vite';
8
9
 
9
10
  import { type ResolvedToilConfig } from './config.js';
11
+ import { imageReportPlugin } from './image-report.js';
10
12
  import { toilPlugin } from './plugin.js';
11
13
 
12
14
  /** Image extensions routed to `images/` in the build output. */
@@ -73,6 +75,16 @@ export async function createViteConfig(cfg: ResolvedToilConfig): Promise<InlineC
73
75
  configFile: false,
74
76
  plugins: [
75
77
  tailwind,
78
+ // Build-time image resize/optimization. Every *imported* raster image is compressed to
79
+ // webp by default (so a plain `<img src={imported}>` is optimized too, not just
80
+ // `Toil.Image`); add `?w=400;800&format=…` to resize or pick a format. `public/` assets
81
+ // referenced by string path are served as-is. Disabled by `client.images: false`.
82
+ cfg.images
83
+ ? imagetools({
84
+ defaultDirectives: () => new URLSearchParams({ format: 'webp', quality: '80' }),
85
+ })
86
+ : undefined,
87
+ cfg.images ? imageReportPlugin(cfg.root, cfg.toilDir) : undefined,
76
88
  nodePolyfills({ globals: { Buffer: true, global: true, process: true } }),
77
89
  react(),
78
90
  toilPlugin(cfg),
@@ -0,0 +1,46 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render } from '@testing-library/react';
3
+ import { afterEach, describe, expect, it } from 'vitest';
4
+
5
+ import { Image } from '../../src/client/components/Image';
6
+
7
+ afterEach(cleanup);
8
+
9
+ describe('Image', () => {
10
+ it('lazy-loads and decodes async by default, with the given dimensions', () => {
11
+ const { getByAltText } = render(<Image src="/a.png" alt="a" width={200} height={100} />);
12
+ const img = getByAltText('a') as HTMLImageElement;
13
+ expect(img.getAttribute('src')).toBe('/a.png');
14
+ expect(img.getAttribute('loading')).toBe('lazy');
15
+ expect(img.getAttribute('decoding')).toBe('async');
16
+ expect(img.getAttribute('width')).toBe('200');
17
+ expect(img.getAttribute('height')).toBe('100');
18
+ expect(img.getAttribute('fetchpriority')).toBe('auto');
19
+ });
20
+
21
+ it('priority images load eagerly with high fetch priority', () => {
22
+ const { getByAltText } = render(<Image src="/hero.png" alt="hero" priority />);
23
+ const img = getByAltText('hero') as HTMLImageElement;
24
+ expect(img.getAttribute('loading')).toBe('eager');
25
+ expect(img.getAttribute('fetchpriority')).toBe('high');
26
+ });
27
+
28
+ it('fill drops width/height and absolutely positions the image', () => {
29
+ const { getByAltText } = render(<Image src="/bg.png" alt="bg" fill objectFit="cover" />);
30
+ const img = getByAltText('bg') as HTMLImageElement;
31
+ expect(img.hasAttribute('width')).toBe(false);
32
+ expect(img.hasAttribute('height')).toBe(false);
33
+ expect(img.style.position).toBe('absolute');
34
+ expect(img.style.objectFit).toBe('cover');
35
+ });
36
+
37
+ it('shows a blur placeholder until the image loads', () => {
38
+ const { getByAltText } = render(
39
+ <Image src="/p.png" alt="p" width={10} height={10} placeholder="blur" blurDataURL="data:image/x" />,
40
+ );
41
+ const img = getByAltText('p') as HTMLImageElement;
42
+ expect(img.style.backgroundImage).toContain('data:image/x');
43
+ fireEvent.load(img);
44
+ expect(img.style.backgroundImage).toBe('');
45
+ });
46
+ });
@@ -0,0 +1,45 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render } from '@testing-library/react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { Script } from '../../src/client/components/Script';
6
+
7
+ afterEach(cleanup);
8
+
9
+ const scriptsFor = (key: string): HTMLScriptElement[] =>
10
+ Array.from(document.querySelectorAll<HTMLScriptElement>(`script[data-toil-script="${key}"]`));
11
+
12
+ describe('Script', () => {
13
+ it('injects an async external script on mount (afterInteractive)', () => {
14
+ render(<Script src="https://cdn.example.com/a.js" />);
15
+ const els = scriptsFor('https://cdn.example.com/a.js');
16
+ expect(els).toHaveLength(1);
17
+ expect(els[0].async).toBe(true);
18
+ });
19
+
20
+ it('dedups: the same src is only injected once across instances', () => {
21
+ const src = 'https://cdn.example.com/dedup.js';
22
+ render(
23
+ <>
24
+ <Script src={src} />
25
+ <Script src={src} />
26
+ </>,
27
+ );
28
+ expect(scriptsFor(src)).toHaveLength(1);
29
+ });
30
+
31
+ it('injects an inline script body and fires onLoad + onReady', () => {
32
+ const onLoad = vi.fn();
33
+ const onReady = vi.fn();
34
+ render(
35
+ <Script id="inline-1" onLoad={onLoad} onReady={onReady}>
36
+ {'window.__toilTest = 1;'}
37
+ </Script>,
38
+ );
39
+ const els = scriptsFor('inline-1');
40
+ expect(els).toHaveLength(1);
41
+ expect(els[0].textContent).toBe('window.__toilTest = 1;');
42
+ expect(onLoad).toHaveBeenCalledOnce();
43
+ expect(onReady).toHaveBeenCalledOnce();
44
+ });
45
+ });
@@ -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
+ });