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.
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/configure.d.ts +1 -0
- package/build/cli/configure.js +83 -18
- package/build/cli/create.d.ts +1 -0
- package/build/cli/create.js +14 -3
- package/build/cli/features.d.ts +2 -0
- package/build/cli/features.js +22 -0
- package/build/cli/index.js +8 -0
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/components/Form.d.ts +12 -0
- package/build/client/components/Form.js +23 -0
- package/build/client/components/Image.d.ts +13 -0
- package/build/client/components/Image.js +22 -0
- package/build/client/components/Script.d.ts +13 -0
- package/build/client/components/Script.js +68 -0
- package/build/client/index.d.ts +10 -2
- package/build/client/index.js +5 -1
- package/build/client/routing/Router.js +4 -4
- package/build/client/routing/action.d.ts +17 -0
- package/build/client/routing/action.js +55 -0
- package/build/client/routing/hooks.d.ts +1 -0
- package/build/client/routing/hooks.js +4 -1
- package/build/client/routing/loader.d.ts +8 -2
- package/build/client/routing/loader.js +75 -24
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +2 -0
- package/build/compiler/config.js +1 -0
- package/build/compiler/generate.js +2 -0
- package/build/compiler/image-report.d.ts +2 -0
- package/build/compiler/image-report.js +62 -0
- package/build/compiler/vite.js +8 -0
- package/examples/basic/client/components/Header.tsx +38 -0
- package/examples/basic/client/components/HoneycombBackground.tsx +86 -18
- package/examples/basic/client/global-error.tsx +2 -2
- package/examples/basic/client/layout.tsx +2 -33
- package/examples/basic/client/public/images/test_image.webp +0 -0
- package/examples/basic/client/routes/index.tsx +8 -1
- package/examples/basic/client/routes/loader-demo/index.tsx +24 -1
- package/examples/basic/client/routes/test.tsx +8 -0
- package/examples/basic/client/styles/main.css +48 -1
- package/package.json +8 -6
- package/presets/eslint.js +4 -4
- package/src/cli/configure.ts +98 -17
- package/src/cli/create.ts +18 -2
- package/src/cli/features.ts +32 -0
- package/src/cli/index.ts +9 -0
- package/src/client/components/Form.tsx +65 -0
- package/src/client/components/Image.tsx +89 -0
- package/src/client/components/Script.tsx +113 -0
- package/src/client/index.ts +15 -2
- package/src/client/routing/Router.tsx +17 -5
- package/src/client/routing/action.ts +122 -0
- package/src/client/routing/hooks.ts +18 -5
- package/src/client/routing/loader.ts +146 -35
- package/src/compiler/config.ts +9 -0
- package/src/compiler/generate.ts +3 -0
- package/src/compiler/image-report.ts +85 -0
- package/src/compiler/vite.ts +12 -0
- package/test/dom/Image.test.tsx +46 -0
- package/test/dom/Script.test.tsx +45 -0
- package/test/dom/action.test.tsx +129 -0
- package/test/dom/loader.test.tsx +121 -0
- package/test/dom/router-loading.test.tsx +44 -0
- package/test/features.test.ts +31 -0
- 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()`.
|
|
6
|
-
*
|
|
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 {
|
|
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 =
|
|
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(
|
|
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 {
|
|
81
|
+
return {
|
|
82
|
+
data: { Component: mod.default, data },
|
|
83
|
+
revalidate: mod.revalidate ?? 0,
|
|
84
|
+
hasLoader: mod.loader != null,
|
|
85
|
+
};
|
|
49
86
|
}
|
|
50
87
|
|
|
51
|
-
/**
|
|
52
|
-
function
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
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(
|
|
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 (
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
100
|
-
|
|
101
|
-
|
|
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
|
}
|
package/src/compiler/config.ts
CHANGED
|
@@ -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
|
};
|
package/src/compiler/generate.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/compiler/vite.ts
CHANGED
|
@@ -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
|
+
});
|