toiljs 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/configure.d.ts +1 -0
- package/build/cli/configure.js +85 -20
- package/build/cli/create.d.ts +1 -0
- package/build/cli/create.js +18 -7
- 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/components/Slot.d.ts +6 -0
- package/build/client/components/Slot.js +6 -0
- package/build/client/dev/error-overlay.d.ts +20 -0
- package/build/client/dev/error-overlay.js +123 -0
- package/build/client/head/head.d.ts +2 -0
- package/build/client/head/head.js +17 -2
- package/build/client/head/metadata.d.ts +29 -0
- package/build/client/head/metadata.js +38 -0
- package/build/client/index.d.ts +15 -3
- package/build/client/index.js +8 -2
- package/build/client/navigation/navigation.d.ts +3 -0
- package/build/client/navigation/navigation.js +42 -1
- package/build/client/routing/Router.d.ts +1 -0
- package/build/client/routing/Router.js +56 -34
- 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 +6 -7
- package/build/client/routing/loader.d.ts +10 -2
- package/build/client/routing/loader.js +83 -24
- package/build/client/routing/mount.d.ts +1 -1
- package/build/client/routing/mount.js +12 -4
- package/build/client/routing/slot-context.d.ts +2 -0
- package/build/client/routing/slot-context.js +2 -0
- package/build/client/types.d.ts +1 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +10 -0
- package/build/compiler/config.js +5 -1
- package/build/compiler/docs.js +26 -26
- package/build/compiler/fonts.d.ts +4 -0
- package/build/compiler/fonts.js +64 -0
- package/build/compiler/generate.js +67 -32
- package/build/compiler/image-report.d.ts +2 -0
- package/build/compiler/image-report.js +62 -0
- package/build/compiler/plugin.js +1 -1
- package/build/compiler/prerender.d.ts +7 -0
- package/build/compiler/prerender.js +111 -0
- package/build/compiler/routes.d.ts +3 -0
- package/build/compiler/routes.js +50 -5
- package/build/compiler/seo.d.ts +70 -0
- package/build/compiler/seo.js +221 -0
- package/build/compiler/vite.js +13 -1
- package/build/io/.tsbuildinfo +1 -1
- package/build/shared/.tsbuildinfo +1 -1
- package/examples/basic/client/404.tsx +1 -1
- 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 +3 -3
- package/examples/basic/client/layout.tsx +2 -33
- package/examples/basic/client/public/images/test_image.webp +0 -0
- package/examples/basic/client/routes/about.tsx +8 -0
- package/examples/basic/client/routes/get-started.tsx +1 -1
- package/examples/basic/client/routes/index.tsx +8 -1
- package/examples/basic/client/routes/io.tsx +1 -1
- package/examples/basic/client/routes/loader-demo/index.tsx +29 -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 +7 -4
- package/presets/tsconfig.json +1 -1
- package/src/backend/index.ts +1 -1
- package/src/cli/configure.ts +102 -21
- package/src/cli/create.ts +25 -9
- package/src/cli/features.ts +33 -1
- package/src/cli/index.ts +10 -1
- package/src/cli/ui.ts +1 -1
- package/src/cli/validate.ts +1 -1
- 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/components/Slot.tsx +21 -0
- package/src/client/dev/error-overlay.tsx +197 -0
- package/src/client/head/head.ts +28 -3
- package/src/client/head/metadata.ts +92 -0
- package/src/client/index.ts +20 -3
- package/src/client/navigation/Link.tsx +1 -1
- package/src/client/navigation/navigation.ts +74 -4
- package/src/client/navigation/prefetch.ts +2 -2
- package/src/client/routing/Router.tsx +128 -62
- package/src/client/routing/action.ts +122 -0
- package/src/client/routing/error-boundary.tsx +1 -1
- package/src/client/routing/hooks.ts +17 -23
- package/src/client/routing/loader.ts +158 -35
- package/src/client/routing/mount.tsx +25 -3
- package/src/client/routing/slot-context.ts +7 -0
- package/src/client/types.ts +6 -4
- package/src/compiler/config.ts +40 -3
- package/src/compiler/docs.ts +26 -26
- package/src/compiler/fonts.ts +87 -0
- package/src/compiler/generate.ts +69 -31
- package/src/compiler/image-report.ts +85 -0
- package/src/compiler/plugin.ts +2 -2
- package/src/compiler/prerender.ts +130 -0
- package/src/compiler/routes.ts +62 -7
- package/src/compiler/seo.ts +356 -0
- package/src/compiler/vite.ts +21 -4
- package/src/io/FastSet.ts +1 -1
- package/src/io/index.ts +1 -1
- package/src/io/types.ts +1 -1
- package/src/server/index.ts +1 -1
- package/src/server/main.ts +1 -1
- package/src/shared/index.ts +1 -1
- 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/error-overlay.test.tsx +44 -0
- package/test/dom/loader.test.tsx +121 -0
- package/test/dom/revalidate.test.tsx +38 -0
- package/test/dom/route-head.test.tsx +34 -0
- package/test/dom/router-loading.test.tsx +44 -0
- package/test/dom/slot.test.tsx +109 -0
- package/test/dom/view-transitions.test.tsx +51 -0
- package/test/features.test.ts +31 -0
- package/test/fonts.test.ts +26 -0
- package/test/metadata.test.ts +41 -0
- package/test/prerender.test.ts +46 -0
- package/test/routes.test.ts +20 -1
- package/test/seo.test.ts +142 -0
- package/examples/basic/client/template.tsx +0 -7
|
@@ -9,27 +9,120 @@ 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
|
+
import { useRouteHead } from '../head/head.js';
|
|
14
15
|
import { ParamsContext } from './params-context.js';
|
|
15
|
-
import {
|
|
16
|
+
import { SlotContext } from './slot-context.js';
|
|
17
|
+
import {
|
|
18
|
+
isSoftNavigation,
|
|
19
|
+
navigationEpoch,
|
|
20
|
+
previousPathname,
|
|
21
|
+
settleNavigation,
|
|
22
|
+
} from '../navigation/navigation.js';
|
|
16
23
|
import { applyScroll } from '../navigation/scroll.js';
|
|
17
24
|
import type { ErrorComponentLoader, LayoutLoader, NotFoundLoader, RouteDef } from '../types.js';
|
|
18
25
|
|
|
19
26
|
/** Loads a matched route's module + loader data (suspending), then renders it with the data in context. */
|
|
20
|
-
function RoutePage(props: {
|
|
21
|
-
|
|
27
|
+
function RoutePage(props: {
|
|
28
|
+
route: RouteDef;
|
|
29
|
+
params: RouteParams;
|
|
30
|
+
dataKey: string;
|
|
31
|
+
epoch: number;
|
|
32
|
+
}): ReactNode {
|
|
33
|
+
const { Component, data, head } = readRouteData(props.route, props.params, props.dataKey, props.epoch);
|
|
34
|
+
useRouteHead(head);
|
|
22
35
|
return <LoaderDataContext.Provider value={data}>{createElement(Component)}</LoaderDataContext.Provider>;
|
|
23
36
|
}
|
|
24
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Wraps a matched route's page in its loading boundary, templates, nested layouts, and error
|
|
40
|
+
* boundary. `keyPrefix` namespaces the loader-cache key and boundary keys so a parallel slot and the
|
|
41
|
+
* main route can match the same URL without colliding.
|
|
42
|
+
*/
|
|
43
|
+
function renderMatched(
|
|
44
|
+
matched: RouteDef,
|
|
45
|
+
params: RouteParams,
|
|
46
|
+
pathname: string,
|
|
47
|
+
epoch: number,
|
|
48
|
+
keyPrefix: string,
|
|
49
|
+
): ReactNode {
|
|
50
|
+
const search = typeof window === 'undefined' ? '' : window.location.search;
|
|
51
|
+
const dataKey = keyPrefix + loaderKey(pathname, search);
|
|
52
|
+
const fallback: ReactNode = matched.loading
|
|
53
|
+
? createElement(Suspense, { fallback: null }, createElement(loadingComponent(matched.loading)))
|
|
54
|
+
: null;
|
|
55
|
+
|
|
56
|
+
// A route with a `loading.tsx` keys its boundary per URL *and* navigation epoch, so its fallback
|
|
57
|
+
// shows even inside the transition, on first nav and on an in-place revalidate of the same URL
|
|
58
|
+
// (the epoch bumps). A route without one keeps a stable boundary so the transition holds the old
|
|
59
|
+
// page. The loader cache key stays the bare URL, so the boundary remount still reuses cached data.
|
|
60
|
+
let content: ReactNode = (
|
|
61
|
+
<Suspense
|
|
62
|
+
key={matched.loading ? `${dataKey}:${String(epoch)}` : undefined}
|
|
63
|
+
fallback={fallback}>
|
|
64
|
+
<RoutePage
|
|
65
|
+
route={matched}
|
|
66
|
+
params={params}
|
|
67
|
+
dataKey={dataKey}
|
|
68
|
+
epoch={epoch}
|
|
69
|
+
/>
|
|
70
|
+
</Suspense>
|
|
71
|
+
);
|
|
72
|
+
// Templates wrap inside the layouts and re-mount on every navigation (keyed by URL).
|
|
73
|
+
const templates = matched.templates ?? [];
|
|
74
|
+
for (let i = templates.length - 1; i >= 0; i--) {
|
|
75
|
+
const Template = nestedLayout(templates[i]);
|
|
76
|
+
content = (
|
|
77
|
+
<Suspense
|
|
78
|
+
key={`${keyPrefix}${pathname}:${String(i)}`}
|
|
79
|
+
fallback={null}>
|
|
80
|
+
<Template>{content}</Template>
|
|
81
|
+
</Suspense>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
// Nested layouts, deepest first so the shallowest ends up outermost.
|
|
85
|
+
const chain = matched.layouts ?? [];
|
|
86
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
87
|
+
const NestedLayout = nestedLayout(chain[i]);
|
|
88
|
+
content = (
|
|
89
|
+
<Suspense fallback={null}>
|
|
90
|
+
<NestedLayout>{content}</NestedLayout>
|
|
91
|
+
</Suspense>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (matched.errorComponent) {
|
|
95
|
+
content = <ErrorBoundary fallback={errorComponent(matched.errorComponent)}>{content}</ErrorBoundary>;
|
|
96
|
+
}
|
|
97
|
+
return content;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Finds the first route (already specificity-sorted) matching `pathname`. Intercepting routes are
|
|
102
|
+
* skipped unless `allowIntercept`, they only apply on soft navigation.
|
|
103
|
+
*/
|
|
104
|
+
function match(
|
|
105
|
+
routes: RouteDef[],
|
|
106
|
+
pathname: string,
|
|
107
|
+
allowIntercept = true,
|
|
108
|
+
): { route: RouteDef; params: RouteParams } | null {
|
|
109
|
+
for (const route of routes) {
|
|
110
|
+
if (route.intercept && !allowIntercept) continue;
|
|
111
|
+
const params = matchRoute(route.pattern, pathname);
|
|
112
|
+
if (params) return { route, params };
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
25
117
|
/** Matches the current location to a route and renders it, optionally wrapped in the root layout. */
|
|
26
118
|
export function Router(props: {
|
|
27
119
|
routes: RouteDef[];
|
|
28
120
|
layout?: LayoutLoader;
|
|
29
121
|
notFound?: NotFoundLoader;
|
|
30
122
|
globalError?: ErrorComponentLoader;
|
|
123
|
+
slots?: Record<string, RouteDef[]>;
|
|
31
124
|
}): ReactNode {
|
|
32
|
-
const { routes, layout = null, notFound = null, globalError = null } = props;
|
|
125
|
+
const { routes, layout = null, notFound = null, globalError = null, slots = {} } = props;
|
|
33
126
|
const pathname = useLocation();
|
|
34
127
|
|
|
35
128
|
// After each navigation commits, apply the planned scroll (top / restore / #hash) and mark the
|
|
@@ -39,64 +132,33 @@ export function Router(props: {
|
|
|
39
132
|
settleNavigation();
|
|
40
133
|
});
|
|
41
134
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
135
|
+
const epoch = navigationEpoch();
|
|
136
|
+
const soft = isSoftNavigation();
|
|
137
|
+
|
|
138
|
+
// Parallel slots: each `@slot` tree matches the current URL independently (intercepting routes
|
|
139
|
+
// only on soft navigation). Each match is exposed by name via SlotContext and rendered wherever a
|
|
140
|
+
// layout/page places a `Slot`. If an intercepting route matches, the main view holds the previous
|
|
141
|
+
// page (the backdrop) while the slot shows the intercepted route, i.e. a modal overlay.
|
|
142
|
+
const slotElements: Record<string, ReactNode> = {};
|
|
143
|
+
let intercepting = false;
|
|
144
|
+
for (const [name, defs] of Object.entries(slots)) {
|
|
145
|
+
const slotMatch = match(defs, pathname, soft);
|
|
146
|
+
if (!slotMatch) continue;
|
|
147
|
+
if (slotMatch.route.intercept) intercepting = true;
|
|
148
|
+
slotElements[name] = (
|
|
149
|
+
<ParamsContext.Provider value={slotMatch.params}>
|
|
150
|
+
{renderMatched(slotMatch.route, slotMatch.params, pathname, epoch, `@${name} `)}
|
|
151
|
+
</ParamsContext.Provider>
|
|
152
|
+
);
|
|
51
153
|
}
|
|
52
154
|
|
|
155
|
+
const mainPath = intercepting ? previousPathname() : pathname;
|
|
156
|
+
const matched = match(routes, mainPath);
|
|
157
|
+
const params: RouteParams = matched?.params ?? {};
|
|
158
|
+
|
|
53
159
|
let content: ReactNode;
|
|
54
160
|
if (matched) {
|
|
55
|
-
|
|
56
|
-
? createElement(Suspense, { fallback: null }, createElement(loadingComponent(matched.loading)))
|
|
57
|
-
: null;
|
|
58
|
-
const search = typeof window === 'undefined' ? '' : window.location.search;
|
|
59
|
-
const dataKey = `${String(navigationEpoch())}:${pathname}${search}`;
|
|
60
|
-
content = (
|
|
61
|
-
<Suspense fallback={fallback}>
|
|
62
|
-
<RoutePage
|
|
63
|
-
route={matched}
|
|
64
|
-
params={params}
|
|
65
|
-
dataKey={dataKey}
|
|
66
|
-
/>
|
|
67
|
-
</Suspense>
|
|
68
|
-
);
|
|
69
|
-
// Wrap in templates, deepest first so the shallowest ends up outermost. Templates sit
|
|
70
|
-
// inside the layouts and are keyed by pathname so they re-mount on every navigation
|
|
71
|
-
// (resetting their state), unlike layouts which persist across navigations.
|
|
72
|
-
const templates = matched.templates ?? [];
|
|
73
|
-
for (let i = templates.length - 1; i >= 0; i--) {
|
|
74
|
-
const Template = nestedLayout(templates[i]);
|
|
75
|
-
content = (
|
|
76
|
-
<Suspense
|
|
77
|
-
key={`${pathname}:${String(i)}`}
|
|
78
|
-
fallback={null}>
|
|
79
|
-
<Template>{content}</Template>
|
|
80
|
-
</Suspense>
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
// Wrap in nested layouts, deepest first so the shallowest ends up outermost.
|
|
84
|
-
const chain = matched.layouts ?? [];
|
|
85
|
-
for (let i = chain.length - 1; i >= 0; i--) {
|
|
86
|
-
const NestedLayout = nestedLayout(chain[i]);
|
|
87
|
-
content = (
|
|
88
|
-
<Suspense fallback={null}>
|
|
89
|
-
<NestedLayout>{content}</NestedLayout>
|
|
90
|
-
</Suspense>
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
if (matched.errorComponent) {
|
|
94
|
-
content = (
|
|
95
|
-
<ErrorBoundary fallback={errorComponent(matched.errorComponent)}>
|
|
96
|
-
{content}
|
|
97
|
-
</ErrorBoundary>
|
|
98
|
-
);
|
|
99
|
-
}
|
|
161
|
+
content = renderMatched(matched.route, matched.params, mainPath, epoch, '');
|
|
100
162
|
} else if (notFound) {
|
|
101
163
|
const NotFound = resolveNotFound(notFound);
|
|
102
164
|
content = (
|
|
@@ -105,7 +167,7 @@ export function Router(props: {
|
|
|
105
167
|
</Suspense>
|
|
106
168
|
);
|
|
107
169
|
} else {
|
|
108
|
-
content = <div style={{ padding: 24, fontFamily: 'system-ui' }}>404
|
|
170
|
+
content = <div style={{ padding: 24, fontFamily: 'system-ui' }}>404, Not found</div>;
|
|
109
171
|
}
|
|
110
172
|
|
|
111
173
|
if (layout) {
|
|
@@ -118,10 +180,14 @@ export function Router(props: {
|
|
|
118
180
|
}
|
|
119
181
|
|
|
120
182
|
// The root error boundary (global-error.tsx) sits outside the root layout, so it catches
|
|
121
|
-
// errors thrown by the layout itself
|
|
183
|
+
// errors thrown by the layout itself, the last line of defense before a blank screen.
|
|
122
184
|
if (globalError) {
|
|
123
185
|
content = <ErrorBoundary fallback={errorComponent(globalError)}>{content}</ErrorBoundary>;
|
|
124
186
|
}
|
|
125
187
|
|
|
126
|
-
return
|
|
188
|
+
return (
|
|
189
|
+
<ParamsContext.Provider value={params}>
|
|
190
|
+
<SlotContext.Provider value={slotElements}>{content}</SlotContext.Provider>
|
|
191
|
+
</ParamsContext.Provider>
|
|
192
|
+
);
|
|
127
193
|
}
|
|
@@ -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
|
+
}
|
|
@@ -12,7 +12,7 @@ interface ErrorBoundaryState {
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Catches render errors in its subtree and shows the route's `error.tsx` (with a `reset` to retry).
|
|
15
|
-
* Error boundaries must be class components
|
|
15
|
+
* Error boundaries must be class components, React has no hook equivalent.
|
|
16
16
|
*/
|
|
17
17
|
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
18
18
|
state: ErrorBoundaryState = { error: null };
|
|
@@ -2,14 +2,7 @@
|
|
|
2
2
|
* Router hooks for user route components: read the params / pathname / search params, navigate
|
|
3
3
|
* imperatively, and grab a router handle.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
6
|
-
startTransition,
|
|
7
|
-
useContext,
|
|
8
|
-
useEffect,
|
|
9
|
-
useMemo,
|
|
10
|
-
useReducer,
|
|
11
|
-
useSyncExternalStore,
|
|
12
|
-
} from 'react';
|
|
5
|
+
import { useContext, useEffect, useMemo, useReducer, useSyncExternalStore } from 'react';
|
|
13
6
|
|
|
14
7
|
import type { RouteParams } from './match.js';
|
|
15
8
|
import {
|
|
@@ -22,7 +15,7 @@ import {
|
|
|
22
15
|
subscribePending,
|
|
23
16
|
type NavigateOptions,
|
|
24
17
|
} from '../navigation/navigation.js';
|
|
25
|
-
import { clearLoaderData } from './loader.js';
|
|
18
|
+
import { clearLoaderData, revalidate as revalidateData } from './loader.js';
|
|
26
19
|
import { ParamsContext } from './params-context.js';
|
|
27
20
|
import { prefetch } from '../navigation/prefetch.js';
|
|
28
21
|
import type { Href } from '../types.js';
|
|
@@ -37,8 +30,13 @@ export interface RouterInstance {
|
|
|
37
30
|
back(): void;
|
|
38
31
|
/** Go forward one history entry. */
|
|
39
32
|
forward(): void;
|
|
40
|
-
/** Re-render the current route and re-run its loader. */
|
|
33
|
+
/** Re-render the current route and re-run its loader (clears all cached loader data). */
|
|
41
34
|
refresh(): void;
|
|
35
|
+
/**
|
|
36
|
+
* Invalidate cached loader data and re-render so it refetches. No argument refetches the active
|
|
37
|
+
* route; pass an `href` to target a specific route. Use after a mutation.
|
|
38
|
+
*/
|
|
39
|
+
revalidate(href?: Href): void;
|
|
42
40
|
/** Prefetch a route's chunk ahead of navigation. */
|
|
43
41
|
prefetch(href: Href): void;
|
|
44
42
|
}
|
|
@@ -56,6 +54,9 @@ const ROUTER: RouterInstance = {
|
|
|
56
54
|
clearLoaderData();
|
|
57
55
|
refresh();
|
|
58
56
|
},
|
|
57
|
+
revalidate: (href) => {
|
|
58
|
+
revalidateData(href);
|
|
59
|
+
},
|
|
59
60
|
prefetch,
|
|
60
61
|
};
|
|
61
62
|
|
|
@@ -75,21 +76,14 @@ export function useRouter(): RouterInstance {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
/**
|
|
78
|
-
* Subscribes to location changes
|
|
79
|
-
*
|
|
80
|
-
*
|
|
79
|
+
* Subscribes to location changes and reads the live `window.location` on render. Re-renders on any
|
|
80
|
+
* pathname, search, or hash change. The re-render is orchestrated by `navigate`/`notify` (wrapped in
|
|
81
|
+
* `startTransition` for smooth nav, or `document.startViewTransition` when enabled), so the listener
|
|
82
|
+
* itself is a plain force-update.
|
|
81
83
|
*/
|
|
82
84
|
function useLocationSubscription(): void {
|
|
83
85
|
const [, forceUpdate] = useReducer((n: number): number => n + 1, 0);
|
|
84
|
-
useEffect(
|
|
85
|
-
() =>
|
|
86
|
-
subscribeLocation(() => {
|
|
87
|
-
startTransition(() => {
|
|
88
|
-
forceUpdate();
|
|
89
|
-
});
|
|
90
|
-
}),
|
|
91
|
-
[],
|
|
92
|
-
);
|
|
86
|
+
useEffect(() => subscribeLocation(forceUpdate), []);
|
|
93
87
|
}
|
|
94
88
|
|
|
95
89
|
/** Subscribes to and returns the current `location.pathname`. */
|
|
@@ -110,7 +104,7 @@ export function useSearchParams(): URLSearchParams {
|
|
|
110
104
|
return useMemo(() => new URLSearchParams(search), [search]);
|
|
111
105
|
}
|
|
112
106
|
|
|
113
|
-
/** True while a navigation is in flight (started but not yet committed)
|
|
107
|
+
/** True while a navigation is in flight (started but not yet committed), e.g. for a loading bar. */
|
|
114
108
|
export function useNavigationPending(): boolean {
|
|
115
109
|
return useSyncExternalStore(
|
|
116
110
|
subscribePending,
|