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
package/src/cli/features.ts
CHANGED
|
@@ -114,6 +114,38 @@ export function setStyleImports(source: string, f: StyleFeatures): string {
|
|
|
114
114
|
return `${head}\n\n${block}\n${tail}`.replace(/\n{3,}/g, '\n\n');
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/** A `toil.config` source containing `client: { images: <bool> }` (for scaffolding when none exists). */
|
|
118
|
+
export function defaultConfigSource(images: boolean): string {
|
|
119
|
+
return (
|
|
120
|
+
"import { defineConfig } from 'toiljs/compiler';\n\n" +
|
|
121
|
+
'export default defineConfig({\n' +
|
|
122
|
+
' client: {\n' +
|
|
123
|
+
' // Optimize images at build time (resize/compress imported images).\n' +
|
|
124
|
+
` images: ${String(images)},\n` +
|
|
125
|
+
' },\n' +
|
|
126
|
+
'});\n'
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Sets the `client.images` flag in a `toil.config` source, returning the updated source — or `null`
|
|
132
|
+
* if the file's shape isn't recognized (the caller should then fall back to a manual note). Handles
|
|
133
|
+
* an existing `images:` value, an existing `client: {` block, or a bare `defineConfig({ … })`.
|
|
134
|
+
*/
|
|
135
|
+
export function setConfigImages(source: string, enabled: boolean): string | null {
|
|
136
|
+
const value = String(enabled);
|
|
137
|
+
if (/\bimages\s*:\s*(?:true|false)/.test(source)) {
|
|
138
|
+
return source.replace(/\bimages\s*:\s*(?:true|false)/, `images: ${value}`);
|
|
139
|
+
}
|
|
140
|
+
if (/\bclient\s*:\s*\{/.test(source)) {
|
|
141
|
+
return source.replace(/\bclient\s*:\s*\{/, `client: {\n images: ${value},`);
|
|
142
|
+
}
|
|
143
|
+
if (/defineConfig\(\s*\{/.test(source)) {
|
|
144
|
+
return source.replace(/defineConfig\(\s*\{/, `defineConfig({\n client: { images: ${value} },`);
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
117
149
|
/** Detects the active preprocessor from a project's combined dependency map. */
|
|
118
150
|
export function detectPreprocessor(deps: Record<string, string>): Preprocessor {
|
|
119
151
|
if ('sass' in deps) return 'sass';
|
package/src/cli/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ interface Flags {
|
|
|
19
19
|
preprocessor?: Preprocessor;
|
|
20
20
|
tailwind?: boolean;
|
|
21
21
|
ai?: boolean;
|
|
22
|
+
images?: boolean;
|
|
22
23
|
install?: boolean;
|
|
23
24
|
git?: boolean;
|
|
24
25
|
pm?: string;
|
|
@@ -64,6 +65,12 @@ function parseArgs(argv: string[]): Flags {
|
|
|
64
65
|
case '--no-ai':
|
|
65
66
|
flags.ai = false;
|
|
66
67
|
break;
|
|
68
|
+
case '--images':
|
|
69
|
+
flags.images = true;
|
|
70
|
+
break;
|
|
71
|
+
case '--no-images':
|
|
72
|
+
flags.images = false;
|
|
73
|
+
break;
|
|
67
74
|
case '--install':
|
|
68
75
|
flags.install = true;
|
|
69
76
|
break;
|
|
@@ -134,6 +141,7 @@ async function main(): Promise<void> {
|
|
|
134
141
|
preprocessor: flags.preprocessor,
|
|
135
142
|
tailwind: flags.tailwind,
|
|
136
143
|
ai: flags.ai,
|
|
144
|
+
images: flags.images,
|
|
137
145
|
install: flags.install,
|
|
138
146
|
git: flags.git,
|
|
139
147
|
pm: flags.pm,
|
|
@@ -148,6 +156,7 @@ async function main(): Promise<void> {
|
|
|
148
156
|
root: flags.root,
|
|
149
157
|
preprocessor: flags.preprocessor,
|
|
150
158
|
tailwind: flags.tailwind,
|
|
159
|
+
images: flags.images,
|
|
151
160
|
install: flags.install,
|
|
152
161
|
cwd: process.cwd(),
|
|
153
162
|
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useRef, type ReactNode, type SyntheticEvent } from 'react';
|
|
2
|
+
|
|
3
|
+
import { useAction, type ActionState, type RevalidateTarget } from '../routing/action.js';
|
|
4
|
+
|
|
5
|
+
/** Props for {@link Form}. */
|
|
6
|
+
export interface FormProps {
|
|
7
|
+
/** Handles the submission, receiving the form's `FormData`. May be async. */
|
|
8
|
+
action: (data: FormData) => void | Promise<void>;
|
|
9
|
+
/** Loader data to revalidate after a successful submit. Default `true` (the current route). */
|
|
10
|
+
revalidate?: RevalidateTarget;
|
|
11
|
+
/** Called after a successful submit. */
|
|
12
|
+
onSuccess?: () => void;
|
|
13
|
+
/** Called when the action throws. */
|
|
14
|
+
onError?: (error: unknown) => void;
|
|
15
|
+
/** Reset the form fields after a successful submit. Default `false`. */
|
|
16
|
+
resetOnSuccess?: boolean;
|
|
17
|
+
className?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Form contents. Pass a render function to receive live submit state — e.g. to disable the
|
|
20
|
+
* button while pending: `{({ pending }) => <button disabled={pending}>Save</button>}`.
|
|
21
|
+
*/
|
|
22
|
+
children?: ReactNode | ((state: ActionState<void>) => ReactNode);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A `<form>` that runs an {@link useAction} on submit (no page reload) and revalidates loader data
|
|
27
|
+
* on success — the write half of the loader/action data loop. Tracks pending/error state, which a
|
|
28
|
+
* render-function child can read.
|
|
29
|
+
*/
|
|
30
|
+
export function Form({
|
|
31
|
+
action,
|
|
32
|
+
revalidate,
|
|
33
|
+
onSuccess,
|
|
34
|
+
onError,
|
|
35
|
+
resetOnSuccess = false,
|
|
36
|
+
className,
|
|
37
|
+
children,
|
|
38
|
+
}: FormProps): ReactNode {
|
|
39
|
+
const formRef = useRef<HTMLFormElement | null>(null);
|
|
40
|
+
const handle = useAction((data: FormData) => action(data), {
|
|
41
|
+
revalidate,
|
|
42
|
+
onError,
|
|
43
|
+
onSuccess: () => {
|
|
44
|
+
if (resetOnSuccess) formRef.current?.reset();
|
|
45
|
+
onSuccess?.();
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const onSubmit = (event: SyntheticEvent<HTMLFormElement>): void => {
|
|
50
|
+
event.preventDefault();
|
|
51
|
+
formRef.current = event.currentTarget;
|
|
52
|
+
void handle.run(new FormData(event.currentTarget));
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<form
|
|
57
|
+
ref={formRef}
|
|
58
|
+
className={className}
|
|
59
|
+
onSubmit={onSubmit}>
|
|
60
|
+
{typeof children === 'function'
|
|
61
|
+
? children({ pending: handle.pending, error: handle.error, data: handle.data })
|
|
62
|
+
: children}
|
|
63
|
+
</form>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useState, type CSSProperties, type ComponentPropsWithRef, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Props for {@link Image}: every standard `<img>` attribute, plus toil's layout/loading controls.
|
|
5
|
+
* `src` and `alt` are required (`alt` is enforced for accessibility — pass `alt=""` for decorative
|
|
6
|
+
* images). `width`/`height` (or `fill`) reserve space to prevent layout shift.
|
|
7
|
+
*/
|
|
8
|
+
export interface ImageProps
|
|
9
|
+
extends Omit<ComponentPropsWithRef<'img'>, 'loading' | 'placeholder' | 'width' | 'height'> {
|
|
10
|
+
src: string;
|
|
11
|
+
alt: string;
|
|
12
|
+
/** Intrinsic width in px. Set together with `height` to reserve space (avoids layout shift). */
|
|
13
|
+
width?: number;
|
|
14
|
+
/** Intrinsic height in px. Set together with `width` to reserve space (avoids layout shift). */
|
|
15
|
+
height?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Fill the nearest positioned ancestor (the parent must be `position: relative|absolute|fixed`).
|
|
18
|
+
* The image is absolutely positioned at 100% × 100%; `width`/`height` are ignored. Pair with
|
|
19
|
+
* `objectFit` to control cropping.
|
|
20
|
+
*/
|
|
21
|
+
fill?: boolean;
|
|
22
|
+
/** `object-fit` for the rendered image (handy with `fill`). */
|
|
23
|
+
objectFit?: CSSProperties['objectFit'];
|
|
24
|
+
/**
|
|
25
|
+
* Mark this as a high-priority (LCP) image: eager load + `fetchpriority="high"` and no lazy
|
|
26
|
+
* loading. Use for above-the-fold hero images; everything else stays lazy. Default `false`.
|
|
27
|
+
*/
|
|
28
|
+
priority?: boolean;
|
|
29
|
+
/** Placeholder shown until the image loads: `'empty'` (default) or `'blur'` (needs `blurDataURL`). */
|
|
30
|
+
placeholder?: 'empty' | 'blur';
|
|
31
|
+
/** A tiny (base64) image shown blurred behind the image while it loads, when `placeholder="blur"`. */
|
|
32
|
+
blurDataURL?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A drop-in `<img>` replacement that prevents layout shift and lazy-loads by default. It reserves
|
|
37
|
+
* space from `width`/`height` (or fills its container with `fill`), decodes async, lazy-loads unless
|
|
38
|
+
* `priority`, and can fade in from a `blur` placeholder. This is a client-only component — there is
|
|
39
|
+
* no server-side resizing; pass an already-optimized `src` (Vite hashes imported assets for you).
|
|
40
|
+
*/
|
|
41
|
+
export function Image(props: ImageProps): ReactNode {
|
|
42
|
+
const {
|
|
43
|
+
src,
|
|
44
|
+
alt,
|
|
45
|
+
width,
|
|
46
|
+
height,
|
|
47
|
+
fill = false,
|
|
48
|
+
objectFit,
|
|
49
|
+
priority = false,
|
|
50
|
+
placeholder = 'empty',
|
|
51
|
+
blurDataURL,
|
|
52
|
+
style,
|
|
53
|
+
onLoad,
|
|
54
|
+
...rest
|
|
55
|
+
} = props;
|
|
56
|
+
|
|
57
|
+
const [loaded, setLoaded] = useState(false);
|
|
58
|
+
const showBlur = placeholder === 'blur' && blurDataURL !== undefined && !loaded;
|
|
59
|
+
|
|
60
|
+
const layoutStyle: CSSProperties = fill
|
|
61
|
+
? { position: 'absolute', inset: 0, width: '100%', height: '100%' }
|
|
62
|
+
: {};
|
|
63
|
+
const blurStyle: CSSProperties = showBlur
|
|
64
|
+
? {
|
|
65
|
+
backgroundImage: `url(${blurDataURL})`,
|
|
66
|
+
backgroundSize: 'cover',
|
|
67
|
+
backgroundPosition: 'center',
|
|
68
|
+
filter: 'blur(20px)',
|
|
69
|
+
}
|
|
70
|
+
: {};
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<img
|
|
74
|
+
{...rest}
|
|
75
|
+
src={src}
|
|
76
|
+
alt={alt}
|
|
77
|
+
width={fill ? undefined : width}
|
|
78
|
+
height={fill ? undefined : height}
|
|
79
|
+
loading={priority ? 'eager' : 'lazy'}
|
|
80
|
+
decoding="async"
|
|
81
|
+
fetchPriority={priority ? 'high' : 'auto'}
|
|
82
|
+
onLoad={(event) => {
|
|
83
|
+
setLoaded(true);
|
|
84
|
+
onLoad?.(event);
|
|
85
|
+
}}
|
|
86
|
+
style={{ ...layoutStyle, objectFit, ...blurStyle, ...style }}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useEffect, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* When a {@link Script} is injected, relative to the app becoming interactive:
|
|
5
|
+
* - `afterInteractive` (default) — on mount, once the app is running. Good for analytics, widgets.
|
|
6
|
+
* - `lazyOnload` — deferred until the browser is idle (after `window.load`). For low-priority scripts.
|
|
7
|
+
* - `beforeInteractive` — as early as possible. In a client-only SPA there is no SSR, so this still
|
|
8
|
+
* runs after hydration, but synchronously on first mount with high fetch priority.
|
|
9
|
+
*/
|
|
10
|
+
export type ScriptStrategy = 'beforeInteractive' | 'afterInteractive' | 'lazyOnload';
|
|
11
|
+
|
|
12
|
+
/** Props for {@link Script}. Provide either `src` (external) or inline `children` (script body). */
|
|
13
|
+
export interface ScriptProps {
|
|
14
|
+
/** URL of an external script. Omit when providing an inline script body via `children`. */
|
|
15
|
+
src?: string;
|
|
16
|
+
/** When to load the script. Default `'afterInteractive'`. */
|
|
17
|
+
strategy?: ScriptStrategy;
|
|
18
|
+
/** Stable identity for dedup (required for inline scripts; defaults to `src` for external ones). */
|
|
19
|
+
id?: string;
|
|
20
|
+
/** `type` attribute (e.g. `'module'`, `'application/json'`). */
|
|
21
|
+
type?: string;
|
|
22
|
+
/** Fired once the script has loaded (external) or been inserted (inline). */
|
|
23
|
+
onLoad?: () => void;
|
|
24
|
+
/** Fired after load, and on every later mount once the script is already loaded. */
|
|
25
|
+
onReady?: () => void;
|
|
26
|
+
/** Fired if an external script fails to load. */
|
|
27
|
+
onError?: (error: unknown) => void;
|
|
28
|
+
/** Inline script body. Mutually exclusive with `src`. */
|
|
29
|
+
children?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type LoadState = 'loading' | 'ready';
|
|
33
|
+
/** Module-level registry so a given script is injected/executed at most once across the app. */
|
|
34
|
+
const registry = new Map<string, LoadState>();
|
|
35
|
+
|
|
36
|
+
function inject(props: ScriptProps, key: string): void {
|
|
37
|
+
const { src, type, onLoad, onReady, onError, children } = props;
|
|
38
|
+
const el = document.createElement('script');
|
|
39
|
+
el.dataset.toilScript = key;
|
|
40
|
+
if (type !== undefined) el.type = type;
|
|
41
|
+
|
|
42
|
+
if (src !== undefined) {
|
|
43
|
+
el.src = src;
|
|
44
|
+
el.async = true;
|
|
45
|
+
el.addEventListener('load', () => {
|
|
46
|
+
registry.set(key, 'ready');
|
|
47
|
+
onLoad?.();
|
|
48
|
+
onReady?.();
|
|
49
|
+
});
|
|
50
|
+
el.addEventListener('error', (event) => {
|
|
51
|
+
registry.delete(key); // allow a later remount to retry
|
|
52
|
+
onError?.(event);
|
|
53
|
+
});
|
|
54
|
+
document.head.appendChild(el);
|
|
55
|
+
} else {
|
|
56
|
+
el.textContent = children ?? '';
|
|
57
|
+
document.head.appendChild(el);
|
|
58
|
+
registry.set(key, 'ready');
|
|
59
|
+
onLoad?.();
|
|
60
|
+
onReady?.();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Loads an external or inline `<script>` with a load `strategy`, deduplicated across the app so the
|
|
66
|
+
* same script never executes twice. Renders nothing. Mirrors the ergonomics of Next.js `next/script`
|
|
67
|
+
* for a client-only SPA.
|
|
68
|
+
*/
|
|
69
|
+
export function Script(props: ScriptProps): ReactNode {
|
|
70
|
+
const { src, id, strategy = 'afterInteractive', onReady } = props;
|
|
71
|
+
const key = id ?? src;
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (key === undefined) {
|
|
75
|
+
// No id and no src: nothing to dedup or load (an inline script needs at least an id).
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const state = registry.get(key);
|
|
80
|
+
if (state === 'ready') {
|
|
81
|
+
onReady?.();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (state === 'loading') {
|
|
85
|
+
return; // another instance is already injecting it
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
registry.set(key, 'loading');
|
|
89
|
+
const run = (): void => {
|
|
90
|
+
inject(props, key);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
if (strategy === 'lazyOnload') {
|
|
94
|
+
if (document.readyState === 'complete') {
|
|
95
|
+
const idle = window.requestIdleCallback?.bind(window);
|
|
96
|
+
if (idle) idle(run);
|
|
97
|
+
else setTimeout(run, 0);
|
|
98
|
+
} else {
|
|
99
|
+
window.addEventListener('load', run, { once: true });
|
|
100
|
+
}
|
|
101
|
+
return () => {
|
|
102
|
+
window.removeEventListener('load', run);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// beforeInteractive + afterInteractive: inject now (on mount).
|
|
107
|
+
run();
|
|
108
|
+
// Intentionally keyed on identity only: inject once per script key; later prop changes
|
|
109
|
+
// (handlers, body) are read at inject time and must not re-run/re-inject the script.
|
|
110
|
+
}, [key, strategy]);
|
|
111
|
+
|
|
112
|
+
return null;
|
|
113
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -26,8 +26,15 @@ export {
|
|
|
26
26
|
useNavigationPending,
|
|
27
27
|
} from './routing/hooks.js';
|
|
28
28
|
export type { RouterInstance } from './routing/hooks.js';
|
|
29
|
-
export { useLoaderData } from './routing/loader.js';
|
|
30
|
-
export type { LoaderArgs, LoaderFunction } from './routing/loader.js';
|
|
29
|
+
export { useLoaderData, revalidate, invalidateLoaderData } from './routing/loader.js';
|
|
30
|
+
export type { LoaderArgs, LoaderFunction, LoaderData, Revalidate } from './routing/loader.js';
|
|
31
|
+
export { useAction } from './routing/action.js';
|
|
32
|
+
export type {
|
|
33
|
+
UseActionOptions,
|
|
34
|
+
ActionState,
|
|
35
|
+
ActionHandle,
|
|
36
|
+
RevalidateTarget,
|
|
37
|
+
} from './routing/action.js';
|
|
31
38
|
export { prefetch } from './navigation/prefetch.js';
|
|
32
39
|
export type {
|
|
33
40
|
RouteDef,
|
|
@@ -45,3 +52,9 @@ export { connectChannel, useChannel, resolveChannelUrl } from './channel/channel
|
|
|
45
52
|
export type { Channel, ChannelOptions, ChannelHook, ChannelData } from './channel/channel.js';
|
|
46
53
|
export { useHead, useTitle, Head, mergeHead } from './head/head.js';
|
|
47
54
|
export type { HeadSpec, MetaTag, LinkTag, ResolvedHead } from './head/head.js';
|
|
55
|
+
export { Image } from './components/Image.js';
|
|
56
|
+
export type { ImageProps } from './components/Image.js';
|
|
57
|
+
export { Script } from './components/Script.js';
|
|
58
|
+
export type { ScriptProps, ScriptStrategy } from './components/Script.js';
|
|
59
|
+
export { Form } from './components/Form.js';
|
|
60
|
+
export type { FormProps } from './components/Form.js';
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
resolveLayout,
|
|
10
10
|
resolveNotFound,
|
|
11
11
|
} from './lazy.js';
|
|
12
|
-
import { LoaderDataContext, readRouteData } from './loader.js';
|
|
12
|
+
import { loaderKey, LoaderDataContext, readRouteData } from './loader.js';
|
|
13
13
|
import { matchRoute, type RouteParams } from './match.js';
|
|
14
14
|
import { ParamsContext } from './params-context.js';
|
|
15
15
|
import { navigationEpoch, settleNavigation } from '../navigation/navigation.js';
|
|
@@ -17,8 +17,13 @@ import { applyScroll } from '../navigation/scroll.js';
|
|
|
17
17
|
import type { ErrorComponentLoader, LayoutLoader, NotFoundLoader, RouteDef } from '../types.js';
|
|
18
18
|
|
|
19
19
|
/** Loads a matched route's module + loader data (suspending), then renders it with the data in context. */
|
|
20
|
-
function RoutePage(props: {
|
|
21
|
-
|
|
20
|
+
function RoutePage(props: {
|
|
21
|
+
route: RouteDef;
|
|
22
|
+
params: RouteParams;
|
|
23
|
+
dataKey: string;
|
|
24
|
+
epoch: number;
|
|
25
|
+
}): ReactNode {
|
|
26
|
+
const { Component, data } = readRouteData(props.route, props.params, props.dataKey, props.epoch);
|
|
22
27
|
return <LoaderDataContext.Provider value={data}>{createElement(Component)}</LoaderDataContext.Provider>;
|
|
23
28
|
}
|
|
24
29
|
|
|
@@ -56,13 +61,20 @@ export function Router(props: {
|
|
|
56
61
|
? createElement(Suspense, { fallback: null }, createElement(loadingComponent(matched.loading)))
|
|
57
62
|
: null;
|
|
58
63
|
const search = typeof window === 'undefined' ? '' : window.location.search;
|
|
59
|
-
const dataKey =
|
|
64
|
+
const dataKey = loaderKey(pathname, search);
|
|
65
|
+
// Navigation runs in a transition (smooth — the old page stays during load). A route with a
|
|
66
|
+
// `loading.tsx` opts into an immediate loading state: keying its Suspense boundary per URL
|
|
67
|
+
// makes React show the fallback even inside the transition. Routes without one keep a stable
|
|
68
|
+
// boundary, so the transition holds the previous page instead of flashing a blank fallback.
|
|
60
69
|
content = (
|
|
61
|
-
<Suspense
|
|
70
|
+
<Suspense
|
|
71
|
+
key={matched.loading ? dataKey : undefined}
|
|
72
|
+
fallback={fallback}>
|
|
62
73
|
<RoutePage
|
|
63
74
|
route={matched}
|
|
64
75
|
params={params}
|
|
65
76
|
dataKey={dataKey}
|
|
77
|
+
epoch={navigationEpoch()}
|
|
66
78
|
/>
|
|
67
79
|
</Suspense>
|
|
68
80
|
);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutations (writes) — the counterpart to loaders (reads). A loader fetches data on navigation;
|
|
3
|
+
* an action performs a write (save, delete, a server/WASM call) on demand, then revalidates the
|
|
4
|
+
* affected loader data so the UI reflects the change. `useAction` tracks pending/error/result state;
|
|
5
|
+
* `<Form>` is sugar over it for the form case.
|
|
6
|
+
*/
|
|
7
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
8
|
+
|
|
9
|
+
import { invalidateLoaderData } from './loader.js';
|
|
10
|
+
import { refresh } from '../navigation/navigation.js';
|
|
11
|
+
import type { Href } from '../types.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Which loader data to refetch after an action succeeds:
|
|
15
|
+
* - `true` (default) — the current route.
|
|
16
|
+
* - an `Href` (or array) — those specific routes.
|
|
17
|
+
* - `false` — nothing.
|
|
18
|
+
*/
|
|
19
|
+
export type RevalidateTarget = boolean | Href | readonly Href[];
|
|
20
|
+
|
|
21
|
+
/** Options for {@link useAction}. */
|
|
22
|
+
export interface UseActionOptions<TData> {
|
|
23
|
+
/** Loader data to revalidate after success. Default `true` (the current route). */
|
|
24
|
+
readonly revalidate?: RevalidateTarget;
|
|
25
|
+
/** Called after a successful run, with the action's return value. */
|
|
26
|
+
readonly onSuccess?: (data: TData) => void;
|
|
27
|
+
/** Called when the action throws. */
|
|
28
|
+
readonly onError?: (error: unknown) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Live state of an action. */
|
|
32
|
+
export interface ActionState<TData> {
|
|
33
|
+
/** True while a run is in flight. */
|
|
34
|
+
readonly pending: boolean;
|
|
35
|
+
/** The error from the last failed run, or `undefined`. */
|
|
36
|
+
readonly error: unknown;
|
|
37
|
+
/** The value returned by the last successful run, or `undefined`. */
|
|
38
|
+
readonly data: TData | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Handle returned by {@link useAction}: current state plus `run` / `reset`. */
|
|
42
|
+
export interface ActionHandle<TInput, TData> extends ActionState<TData> {
|
|
43
|
+
/**
|
|
44
|
+
* Run the action. Resolves to the result on success, or `undefined` if it threw (the error is
|
|
45
|
+
* captured in `error` instead of rejecting, so a fire-and-forget `onClick` can't leak an
|
|
46
|
+
* unhandled rejection).
|
|
47
|
+
*/
|
|
48
|
+
run: (input: TInput) => Promise<TData | undefined>;
|
|
49
|
+
/** Reset back to idle (clears `pending` / `error` / `data`). */
|
|
50
|
+
reset: () => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Refetches loader data per a {@link RevalidateTarget}, then re-renders once. */
|
|
54
|
+
function applyRevalidate(target: RevalidateTarget | undefined): void {
|
|
55
|
+
if (target === false) return;
|
|
56
|
+
if (target === undefined || target === true) {
|
|
57
|
+
invalidateLoaderData();
|
|
58
|
+
} else {
|
|
59
|
+
const hrefs = typeof target === 'string' ? [target] : target;
|
|
60
|
+
for (const href of hrefs) invalidateLoaderData(href);
|
|
61
|
+
}
|
|
62
|
+
refresh();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Runs a mutation with pending/error/result tracking, revalidating loader data on success. Example:
|
|
67
|
+
*
|
|
68
|
+
* ```ts
|
|
69
|
+
* const save = useAction((title: string) => api.save(title), { revalidate: true });
|
|
70
|
+
* <button disabled={save.pending} onClick={() => void save.run(title)}>Save</button>
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export function useAction<TInput = void, TData = unknown>(
|
|
74
|
+
fn: (input: TInput) => TData | Promise<TData>,
|
|
75
|
+
options: UseActionOptions<TData> = {},
|
|
76
|
+
): ActionHandle<TInput, TData> {
|
|
77
|
+
const [state, setState] = useState<ActionState<TData>>({
|
|
78
|
+
pending: false,
|
|
79
|
+
error: undefined,
|
|
80
|
+
data: undefined,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Hold the latest fn/options so `run` keeps a stable identity across renders.
|
|
84
|
+
const latest = useRef({ fn, options });
|
|
85
|
+
latest.current = { fn, options };
|
|
86
|
+
const runId = useRef(0);
|
|
87
|
+
const mounted = useRef(true);
|
|
88
|
+
useEffect(
|
|
89
|
+
() => () => {
|
|
90
|
+
mounted.current = false;
|
|
91
|
+
},
|
|
92
|
+
[],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const run = useCallback(async (input: TInput): Promise<TData | undefined> => {
|
|
96
|
+
const id = ++runId.current;
|
|
97
|
+
setState((s) => ({ ...s, pending: true, error: undefined }));
|
|
98
|
+
try {
|
|
99
|
+
const data = await latest.current.fn(input);
|
|
100
|
+
// Ignore a stale run that a newer one (or unmount) has superseded.
|
|
101
|
+
if (mounted.current && id === runId.current) {
|
|
102
|
+
setState({ pending: false, error: undefined, data });
|
|
103
|
+
}
|
|
104
|
+
applyRevalidate(latest.current.options.revalidate);
|
|
105
|
+
latest.current.options.onSuccess?.(data);
|
|
106
|
+
return data;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (mounted.current && id === runId.current) {
|
|
109
|
+
setState({ pending: false, error, data: undefined });
|
|
110
|
+
}
|
|
111
|
+
latest.current.options.onError?.(error);
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
const reset = useCallback(() => {
|
|
117
|
+
runId.current += 1;
|
|
118
|
+
setState({ pending: false, error: undefined, data: undefined });
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
return { ...state, run, reset };
|
|
122
|
+
}
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
subscribePending,
|
|
23
23
|
type NavigateOptions,
|
|
24
24
|
} from '../navigation/navigation.js';
|
|
25
|
-
import { clearLoaderData } from './loader.js';
|
|
25
|
+
import { clearLoaderData, revalidate as revalidateData } from './loader.js';
|
|
26
26
|
import { ParamsContext } from './params-context.js';
|
|
27
27
|
import { prefetch } from '../navigation/prefetch.js';
|
|
28
28
|
import type { Href } from '../types.js';
|
|
@@ -37,8 +37,13 @@ export interface RouterInstance {
|
|
|
37
37
|
back(): void;
|
|
38
38
|
/** Go forward one history entry. */
|
|
39
39
|
forward(): void;
|
|
40
|
-
/** Re-render the current route and re-run its loader. */
|
|
40
|
+
/** Re-render the current route and re-run its loader (clears all cached loader data). */
|
|
41
41
|
refresh(): void;
|
|
42
|
+
/**
|
|
43
|
+
* Invalidate cached loader data and re-render so it refetches. No argument refetches the active
|
|
44
|
+
* route; pass an `href` to target a specific route. Use after a mutation.
|
|
45
|
+
*/
|
|
46
|
+
revalidate(href?: Href): void;
|
|
42
47
|
/** Prefetch a route's chunk ahead of navigation. */
|
|
43
48
|
prefetch(href: Href): void;
|
|
44
49
|
}
|
|
@@ -56,6 +61,9 @@ const ROUTER: RouterInstance = {
|
|
|
56
61
|
clearLoaderData();
|
|
57
62
|
refresh();
|
|
58
63
|
},
|
|
64
|
+
revalidate: (href) => {
|
|
65
|
+
revalidateData(href);
|
|
66
|
+
},
|
|
59
67
|
prefetch,
|
|
60
68
|
};
|
|
61
69
|
|
|
@@ -75,9 +83,14 @@ export function useRouter(): RouterInstance {
|
|
|
75
83
|
}
|
|
76
84
|
|
|
77
85
|
/**
|
|
78
|
-
* Subscribes to location changes
|
|
79
|
-
*
|
|
80
|
-
*
|
|
86
|
+
* Subscribes to location changes and reads the live `window.location` on render. Re-renders on any
|
|
87
|
+
* pathname, search, or hash change.
|
|
88
|
+
*
|
|
89
|
+
* The update runs in a `startTransition` so navigation is smooth: React keeps the current page on
|
|
90
|
+
* screen while the next route's chunk/data load, instead of flashing a blank fallback. Routes that
|
|
91
|
+
* define a `loading.tsx` opt back into an immediate loading state — the Router keys their Suspense
|
|
92
|
+
* boundary per navigation, so the fallback shows even within the transition (no frozen page). Warm
|
|
93
|
+
* routes (prefetched, no loader) render synchronously and commit instantly.
|
|
81
94
|
*/
|
|
82
95
|
function useLocationSubscription(): void {
|
|
83
96
|
const [, forceUpdate] = useReducer((n: number): number => n + 1, 0);
|