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
@@ -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
+ }
@@ -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: { route: RouteDef; params: RouteParams; dataKey: string }): ReactNode {
21
- const { Component, data } = readRouteData(props.route, props.params, props.dataKey);
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 = `${String(navigationEpoch())}:${pathname}${search}`;
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 fallback={fallback}>
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 (in a transition, so the current page stays on screen while the
79
- * next chunk loads) and reads the live `window.location` on render. Re-renders on any pathname,
80
- * search, or hash change.
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);