toiljs 0.0.8 → 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.js +5 -5
- package/build/cli/create.js +4 -4
- package/build/client/.tsbuildinfo +1 -1
- 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 +5 -1
- package/build/client/index.js +3 -1
- 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 +55 -33
- package/build/client/routing/hooks.js +2 -6
- package/build/client/routing/loader.d.ts +2 -0
- package/build/client/routing/loader.js +9 -1
- 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 +8 -0
- package/build/compiler/config.js +4 -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 +65 -32
- 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 +5 -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/global-error.tsx +1 -1
- package/examples/basic/client/routes/about.tsx +8 -0
- package/examples/basic/client/routes/get-started.tsx +1 -1
- package/examples/basic/client/routes/io.tsx +1 -1
- package/examples/basic/client/routes/loader-demo/index.tsx +7 -2
- package/package.json +1 -1
- package/presets/eslint.js +7 -4
- package/presets/tsconfig.json +1 -1
- package/src/backend/index.ts +1 -1
- package/src/cli/configure.ts +7 -7
- package/src/cli/create.ts +7 -7
- package/src/cli/features.ts +2 -2
- package/src/cli/index.ts +1 -1
- package/src/cli/ui.ts +1 -1
- package/src/cli/validate.ts +1 -1
- package/src/client/components/Form.tsx +2 -2
- package/src/client/components/Image.tsx +2 -2
- package/src/client/components/Script.tsx +3 -3
- 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 +5 -1
- 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 +121 -67
- package/src/client/routing/action.ts +4 -4
- package/src/client/routing/error-boundary.tsx +1 -1
- package/src/client/routing/hooks.ts +6 -25
- package/src/client/routing/loader.ts +20 -8
- 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 +31 -3
- package/src/compiler/docs.ts +26 -26
- package/src/compiler/fonts.ts +87 -0
- package/src/compiler/generate.ts +66 -31
- package/src/compiler/image-report.ts +1 -1
- 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 +9 -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/error-overlay.test.tsx +44 -0
- package/test/dom/revalidate.test.tsx +38 -0
- package/test/dom/route-head.test.tsx +34 -0
- package/test/dom/slot.test.tsx +109 -0
- package/test/dom/view-transitions.test.tsx +51 -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
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
* the per-entry history keys used for scroll restoration. Consumed by `useLocation` (to re-render),
|
|
4
4
|
* `Link` / `navigate` (to change location), and `Router` (which calls `applyScroll` after commit).
|
|
5
5
|
*/
|
|
6
|
+
import { startTransition } from 'react';
|
|
7
|
+
import { flushSync } from 'react-dom';
|
|
8
|
+
|
|
6
9
|
import {
|
|
7
10
|
enableManualScrollRestoration,
|
|
8
11
|
planScroll,
|
|
@@ -13,6 +16,26 @@ import type { Href } from '../types.js';
|
|
|
13
16
|
const listeners = new Set<() => void>();
|
|
14
17
|
let popstateBound = false;
|
|
15
18
|
|
|
19
|
+
/** `document.startViewTransition`, present only where the View Transitions API is supported. */
|
|
20
|
+
interface ViewTransitionDocument {
|
|
21
|
+
startViewTransition?: (callback: () => void) => unknown;
|
|
22
|
+
}
|
|
23
|
+
let viewTransitions = false;
|
|
24
|
+
|
|
25
|
+
/** Enables animated View Transitions for navigation. Called once by `mount` from `client.viewTransitions`. */
|
|
26
|
+
export function setViewTransitions(enabled: boolean): void {
|
|
27
|
+
viewTransitions = enabled;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Whether the current navigation should animate via the View Transitions API. */
|
|
31
|
+
function shouldViewTransition(): boolean {
|
|
32
|
+
if (!viewTransitions || typeof document === 'undefined' || typeof window === 'undefined') {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (typeof (document as ViewTransitionDocument).startViewTransition !== 'function') return false;
|
|
36
|
+
return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
37
|
+
}
|
|
38
|
+
|
|
16
39
|
interface ToilHistoryState {
|
|
17
40
|
__toilKey?: string;
|
|
18
41
|
}
|
|
@@ -23,11 +46,51 @@ function nextKey(): string {
|
|
|
23
46
|
return `t${String(keyCounter)}`;
|
|
24
47
|
}
|
|
25
48
|
|
|
26
|
-
|
|
27
|
-
function notify(): void {
|
|
49
|
+
function runListeners(): void {
|
|
28
50
|
for (const listener of listeners) listener();
|
|
29
51
|
}
|
|
30
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Re-renders subscribers for a location change. Normally wrapped in `startTransition` (smooth: the
|
|
55
|
+
* current page stays while the next route loads). When View Transitions are enabled and supported,
|
|
56
|
+
* the commit runs synchronously inside `document.startViewTransition` so the browser animates the
|
|
57
|
+
* old and new DOM (a crossfade, or shared-element transitions via `view-transition-name`).
|
|
58
|
+
*/
|
|
59
|
+
function notify(): void {
|
|
60
|
+
if (shouldViewTransition()) {
|
|
61
|
+
(document as ViewTransitionDocument).startViewTransition?.(() => {
|
|
62
|
+
flushSync(runListeners);
|
|
63
|
+
});
|
|
64
|
+
} else {
|
|
65
|
+
startTransition(runListeners);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Soft vs hard navigation, for intercepting routes. The initial page load (and any full refresh) is
|
|
70
|
+
// "hard"; client navigations (`navigate` / back / forward) are "soft". `previousPath` is the path we
|
|
71
|
+
// were on before the latest soft navigation, the route the main view keeps showing while an
|
|
72
|
+
// intercepting route fills a slot (the modal overlay).
|
|
73
|
+
let softNav = false;
|
|
74
|
+
let currentPath = typeof window === 'undefined' ? '/' : window.location.pathname;
|
|
75
|
+
let previousPath = currentPath;
|
|
76
|
+
|
|
77
|
+
/** Records a transition to the live location; `soft` is false only for the initial load. */
|
|
78
|
+
function recordTransition(soft: boolean): void {
|
|
79
|
+
previousPath = currentPath;
|
|
80
|
+
currentPath = typeof window === 'undefined' ? '/' : window.location.pathname;
|
|
81
|
+
softNav = soft;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether the current location was reached by a client navigation (not an initial load / refresh). */
|
|
85
|
+
export function isSoftNavigation(): boolean {
|
|
86
|
+
return softNav;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The path the app was on before the latest navigation (what the main view keeps during an intercept). */
|
|
90
|
+
export function previousPathname(): string {
|
|
91
|
+
return previousPath;
|
|
92
|
+
}
|
|
93
|
+
|
|
31
94
|
// Navigation-pending tracking: a navigation is "pending" from when it starts until the new route
|
|
32
95
|
// commits. Drives useNavigationPending() (e.g. a top loading bar).
|
|
33
96
|
let startedTick = 0;
|
|
@@ -54,7 +117,7 @@ export function isNavigationPending(): boolean {
|
|
|
54
117
|
return startedTick !== committedTick;
|
|
55
118
|
}
|
|
56
119
|
|
|
57
|
-
/** Monotonic id incremented on each navigation
|
|
120
|
+
/** Monotonic id incremented on each navigation, used to key/revalidate per-navigation route data. */
|
|
58
121
|
export function navigationEpoch(): number {
|
|
59
122
|
return startedTick;
|
|
60
123
|
}
|
|
@@ -103,6 +166,7 @@ export function navigate(href: Href, options?: NavigateOptions): void {
|
|
|
103
166
|
currentKey = nextKey();
|
|
104
167
|
window.history.pushState({ __toilKey: currentKey }, '', href);
|
|
105
168
|
}
|
|
169
|
+
recordTransition(true);
|
|
106
170
|
planScroll({ hash, toTop: options?.scroll !== false });
|
|
107
171
|
notify();
|
|
108
172
|
}
|
|
@@ -117,8 +181,13 @@ export function forward(): void {
|
|
|
117
181
|
window.history.forward();
|
|
118
182
|
}
|
|
119
183
|
|
|
120
|
-
/**
|
|
184
|
+
/**
|
|
185
|
+
* Re-renders the current route, bumping the navigation epoch so a revalidation of the *same* URL
|
|
186
|
+
* re-keys its Suspense boundary (its `loading.tsx` shows while the loader re-runs) and
|
|
187
|
+
* `useNavigationPending` reports the in-flight refetch, instead of silently freezing the old page.
|
|
188
|
+
*/
|
|
121
189
|
export function refresh(): void {
|
|
190
|
+
beginNavigation();
|
|
122
191
|
notify();
|
|
123
192
|
}
|
|
124
193
|
|
|
@@ -128,6 +197,7 @@ function handlePopState(event: PopStateEvent): void {
|
|
|
128
197
|
rememberScroll(currentKey);
|
|
129
198
|
const state = event.state as ToilHistoryState | null;
|
|
130
199
|
currentKey = state?.__toilKey ?? 'initial';
|
|
200
|
+
recordTransition(true);
|
|
131
201
|
planScroll({ restoreKey: currentKey, hash: window.location.hash, toTop: false });
|
|
132
202
|
notify();
|
|
133
203
|
}
|
|
@@ -45,7 +45,7 @@ function warm(route: RouteDef): void {
|
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
47
|
* Prefetches the route chunk for an internal `href` so a later navigation resolves instantly.
|
|
48
|
-
* No-op for external, unknown, or already-prefetched targets
|
|
48
|
+
* No-op for external, unknown, or already-prefetched targets, safe to call from anywhere,
|
|
49
49
|
* including before an imperative {@link navigate} (e.g. `prefetch('/dashboard')` on hover/intent).
|
|
50
50
|
*/
|
|
51
51
|
export function prefetch(href: string): void {
|
|
@@ -83,7 +83,7 @@ function shouldSkipForConnection(): boolean {
|
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
85
|
* Starts idle-time prefetching of internal links. As each `<a>` pointing at a known route scrolls
|
|
86
|
-
* into view (or near it
|
|
86
|
+
* into view (or near it, 200px margin) its chunk is warmed once; links added later by client
|
|
87
87
|
* navigation are picked up via a MutationObserver. Called by {@link mount}; runs once per app.
|
|
88
88
|
*/
|
|
89
89
|
export function startPrefetcher(routes: RouteDef[]): void {
|
|
@@ -11,8 +11,15 @@ import {
|
|
|
11
11
|
} from './lazy.js';
|
|
12
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
|
|
|
@@ -23,18 +30,99 @@ function RoutePage(props: {
|
|
|
23
30
|
dataKey: string;
|
|
24
31
|
epoch: number;
|
|
25
32
|
}): ReactNode {
|
|
26
|
-
const { Component, data } = readRouteData(props.route, props.params, props.dataKey, props.epoch);
|
|
33
|
+
const { Component, data, head } = readRouteData(props.route, props.params, props.dataKey, props.epoch);
|
|
34
|
+
useRouteHead(head);
|
|
27
35
|
return <LoaderDataContext.Provider value={data}>{createElement(Component)}</LoaderDataContext.Provider>;
|
|
28
36
|
}
|
|
29
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
|
+
|
|
30
117
|
/** Matches the current location to a route and renders it, optionally wrapped in the root layout. */
|
|
31
118
|
export function Router(props: {
|
|
32
119
|
routes: RouteDef[];
|
|
33
120
|
layout?: LayoutLoader;
|
|
34
121
|
notFound?: NotFoundLoader;
|
|
35
122
|
globalError?: ErrorComponentLoader;
|
|
123
|
+
slots?: Record<string, RouteDef[]>;
|
|
36
124
|
}): ReactNode {
|
|
37
|
-
const { routes, layout = null, notFound = null, globalError = null } = props;
|
|
125
|
+
const { routes, layout = null, notFound = null, globalError = null, slots = {} } = props;
|
|
38
126
|
const pathname = useLocation();
|
|
39
127
|
|
|
40
128
|
// After each navigation commits, apply the planned scroll (top / restore / #hash) and mark the
|
|
@@ -44,71 +132,33 @@ export function Router(props: {
|
|
|
44
132
|
settleNavigation();
|
|
45
133
|
});
|
|
46
134
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
+
);
|
|
56
153
|
}
|
|
57
154
|
|
|
155
|
+
const mainPath = intercepting ? previousPathname() : pathname;
|
|
156
|
+
const matched = match(routes, mainPath);
|
|
157
|
+
const params: RouteParams = matched?.params ?? {};
|
|
158
|
+
|
|
58
159
|
let content: ReactNode;
|
|
59
160
|
if (matched) {
|
|
60
|
-
|
|
61
|
-
? createElement(Suspense, { fallback: null }, createElement(loadingComponent(matched.loading)))
|
|
62
|
-
: null;
|
|
63
|
-
const search = typeof window === 'undefined' ? '' : window.location.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.
|
|
69
|
-
content = (
|
|
70
|
-
<Suspense
|
|
71
|
-
key={matched.loading ? dataKey : undefined}
|
|
72
|
-
fallback={fallback}>
|
|
73
|
-
<RoutePage
|
|
74
|
-
route={matched}
|
|
75
|
-
params={params}
|
|
76
|
-
dataKey={dataKey}
|
|
77
|
-
epoch={navigationEpoch()}
|
|
78
|
-
/>
|
|
79
|
-
</Suspense>
|
|
80
|
-
);
|
|
81
|
-
// Wrap in templates, deepest first so the shallowest ends up outermost. Templates sit
|
|
82
|
-
// inside the layouts and are keyed by pathname so they re-mount on every navigation
|
|
83
|
-
// (resetting their state), unlike layouts which persist across navigations.
|
|
84
|
-
const templates = matched.templates ?? [];
|
|
85
|
-
for (let i = templates.length - 1; i >= 0; i--) {
|
|
86
|
-
const Template = nestedLayout(templates[i]);
|
|
87
|
-
content = (
|
|
88
|
-
<Suspense
|
|
89
|
-
key={`${pathname}:${String(i)}`}
|
|
90
|
-
fallback={null}>
|
|
91
|
-
<Template>{content}</Template>
|
|
92
|
-
</Suspense>
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
// Wrap in nested layouts, deepest first so the shallowest ends up outermost.
|
|
96
|
-
const chain = matched.layouts ?? [];
|
|
97
|
-
for (let i = chain.length - 1; i >= 0; i--) {
|
|
98
|
-
const NestedLayout = nestedLayout(chain[i]);
|
|
99
|
-
content = (
|
|
100
|
-
<Suspense fallback={null}>
|
|
101
|
-
<NestedLayout>{content}</NestedLayout>
|
|
102
|
-
</Suspense>
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
if (matched.errorComponent) {
|
|
106
|
-
content = (
|
|
107
|
-
<ErrorBoundary fallback={errorComponent(matched.errorComponent)}>
|
|
108
|
-
{content}
|
|
109
|
-
</ErrorBoundary>
|
|
110
|
-
);
|
|
111
|
-
}
|
|
161
|
+
content = renderMatched(matched.route, matched.params, mainPath, epoch, '');
|
|
112
162
|
} else if (notFound) {
|
|
113
163
|
const NotFound = resolveNotFound(notFound);
|
|
114
164
|
content = (
|
|
@@ -117,7 +167,7 @@ export function Router(props: {
|
|
|
117
167
|
</Suspense>
|
|
118
168
|
);
|
|
119
169
|
} else {
|
|
120
|
-
content = <div style={{ padding: 24, fontFamily: 'system-ui' }}>404
|
|
170
|
+
content = <div style={{ padding: 24, fontFamily: 'system-ui' }}>404, Not found</div>;
|
|
121
171
|
}
|
|
122
172
|
|
|
123
173
|
if (layout) {
|
|
@@ -130,10 +180,14 @@ export function Router(props: {
|
|
|
130
180
|
}
|
|
131
181
|
|
|
132
182
|
// The root error boundary (global-error.tsx) sits outside the root layout, so it catches
|
|
133
|
-
// errors thrown by the layout itself
|
|
183
|
+
// errors thrown by the layout itself, the last line of defense before a blank screen.
|
|
134
184
|
if (globalError) {
|
|
135
185
|
content = <ErrorBoundary fallback={errorComponent(globalError)}>{content}</ErrorBoundary>;
|
|
136
186
|
}
|
|
137
187
|
|
|
138
|
-
return
|
|
188
|
+
return (
|
|
189
|
+
<ParamsContext.Provider value={params}>
|
|
190
|
+
<SlotContext.Provider value={slotElements}>{content}</SlotContext.Provider>
|
|
191
|
+
</ParamsContext.Provider>
|
|
192
|
+
);
|
|
139
193
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Mutations (writes)
|
|
2
|
+
* Mutations (writes), the counterpart to loaders (reads). A loader fetches data on navigation;
|
|
3
3
|
* an action performs a write (save, delete, a server/WASM call) on demand, then revalidates the
|
|
4
4
|
* affected loader data so the UI reflects the change. `useAction` tracks pending/error/result state;
|
|
5
5
|
* `<Form>` is sugar over it for the form case.
|
|
@@ -12,9 +12,9 @@ import type { Href } from '../types.js';
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Which loader data to refetch after an action succeeds:
|
|
15
|
-
* - `true` (default)
|
|
16
|
-
* - an `Href` (or array)
|
|
17
|
-
* - `false
|
|
15
|
+
* - `true` (default), the current route.
|
|
16
|
+
* - an `Href` (or array), those specific routes.
|
|
17
|
+
* - `false`, nothing.
|
|
18
18
|
*/
|
|
19
19
|
export type RevalidateTarget = boolean | Href | readonly Href[];
|
|
20
20
|
|
|
@@ -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 {
|
|
@@ -84,25 +77,13 @@ export function useRouter(): RouterInstance {
|
|
|
84
77
|
|
|
85
78
|
/**
|
|
86
79
|
* Subscribes to location changes and reads the live `window.location` on render. Re-renders on any
|
|
87
|
-
* pathname, search, or hash change.
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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.
|
|
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.
|
|
94
83
|
*/
|
|
95
84
|
function useLocationSubscription(): void {
|
|
96
85
|
const [, forceUpdate] = useReducer((n: number): number => n + 1, 0);
|
|
97
|
-
useEffect(
|
|
98
|
-
() =>
|
|
99
|
-
subscribeLocation(() => {
|
|
100
|
-
startTransition(() => {
|
|
101
|
-
forceUpdate();
|
|
102
|
-
});
|
|
103
|
-
}),
|
|
104
|
-
[],
|
|
105
|
-
);
|
|
86
|
+
useEffect(() => subscribeLocation(forceUpdate), []);
|
|
106
87
|
}
|
|
107
88
|
|
|
108
89
|
/** Subscribes to and returns the current `location.pathname`. */
|
|
@@ -123,7 +104,7 @@ export function useSearchParams(): URLSearchParams {
|
|
|
123
104
|
return useMemo(() => new URLSearchParams(search), [search]);
|
|
124
105
|
}
|
|
125
106
|
|
|
126
|
-
/** 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. */
|
|
127
108
|
export function useNavigationPending(): boolean {
|
|
128
109
|
return useSyncExternalStore(
|
|
129
110
|
subscribePending,
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { createContext, useContext, type ComponentType } from 'react';
|
|
14
14
|
|
|
15
|
+
import type { HeadSpec } from '../head/head.js';
|
|
16
|
+
import { resolveMetadata, type GenerateMetadata, type Metadata } from '../head/metadata.js';
|
|
15
17
|
import { refresh as rerender } from '../navigation/navigation.js';
|
|
16
18
|
import type { RouteDef } from '../types.js';
|
|
17
19
|
import type { RouteParams } from './match.js';
|
|
@@ -40,10 +42,14 @@ interface RouteModule {
|
|
|
40
42
|
default: ComponentType;
|
|
41
43
|
loader?: LoaderFunction;
|
|
42
44
|
revalidate?: Revalidate;
|
|
45
|
+
metadata?: Metadata;
|
|
46
|
+
generateMetadata?: GenerateMetadata;
|
|
43
47
|
}
|
|
44
48
|
interface RouteData {
|
|
45
49
|
Component: ComponentType;
|
|
46
50
|
data: unknown;
|
|
51
|
+
/** Resolved baseline head from the route's `metadata` / `generateMetadata`, if any. */
|
|
52
|
+
head?: HeadSpec;
|
|
47
53
|
}
|
|
48
54
|
interface Entry {
|
|
49
55
|
status: 'pending' | 'done' | 'error';
|
|
@@ -56,14 +62,14 @@ interface Entry {
|
|
|
56
62
|
revalidate: Revalidate;
|
|
57
63
|
/** Navigation epoch at which this entry was (re)fetched. */
|
|
58
64
|
epoch: number;
|
|
59
|
-
/** Whether the route exports a `loader
|
|
65
|
+
/** Whether the route exports a `loader`, a route without one has no data that can change. */
|
|
60
66
|
hasLoader: boolean;
|
|
61
67
|
}
|
|
62
68
|
|
|
63
69
|
const cache = new Map<string, Entry>();
|
|
64
70
|
const MAX_ENTRIES = 32;
|
|
65
71
|
|
|
66
|
-
/** Cache key for a URL: path + query (hash is ignored
|
|
72
|
+
/** Cache key for a URL: path + query (hash is ignored, it never changes loader data). */
|
|
67
73
|
export function loaderKey(pathname: string, search: string): string {
|
|
68
74
|
return `${pathname}${search}`;
|
|
69
75
|
}
|
|
@@ -78,8 +84,14 @@ async function loadRoute(
|
|
|
78
84
|
typeof window === 'undefined' ? '' : window.location.search,
|
|
79
85
|
);
|
|
80
86
|
const data = mod.loader ? await mod.loader({ params, searchParams }) : undefined;
|
|
87
|
+
let head: HeadSpec | undefined;
|
|
88
|
+
if (mod.generateMetadata) {
|
|
89
|
+
head = resolveMetadata(await mod.generateMetadata({ params, searchParams, data }));
|
|
90
|
+
} else if (mod.metadata) {
|
|
91
|
+
head = resolveMetadata(mod.metadata);
|
|
92
|
+
}
|
|
81
93
|
return {
|
|
82
|
-
data: { Component: mod.default, data },
|
|
94
|
+
data: { Component: mod.default, data, head },
|
|
83
95
|
revalidate: mod.revalidate ?? 0,
|
|
84
96
|
hasLoader: mod.loader != null,
|
|
85
97
|
};
|
|
@@ -88,7 +100,7 @@ async function loadRoute(
|
|
|
88
100
|
/** Whether a settled entry must be refetched for the current navigation. */
|
|
89
101
|
function isStale(entry: Entry, epoch: number): boolean {
|
|
90
102
|
if (entry.status === 'error') return true; // always retry a failed load
|
|
91
|
-
// A route with no loader has no data that can change
|
|
103
|
+
// A route with no loader has no data that can change, keep it cached so repeat navigations
|
|
92
104
|
// render synchronously (instant) instead of re-suspending and remounting on every switch.
|
|
93
105
|
if (!entry.hasLoader) return false;
|
|
94
106
|
if (entry.revalidate === false) return false; // cache forever
|
|
@@ -170,7 +182,7 @@ function keyForHref(href: string): string | undefined {
|
|
|
170
182
|
/**
|
|
171
183
|
* Invalidates cached loader data so it refetches on the next render. With no argument, clears every
|
|
172
184
|
* route; with an `href`, clears just that route's entry. Pair with a re-render (the active route
|
|
173
|
-
* refetches and suspends)
|
|
185
|
+
* refetches and suspends), see {@link revalidate}.
|
|
174
186
|
*/
|
|
175
187
|
export function invalidateLoaderData(href?: string): void {
|
|
176
188
|
if (href === undefined) {
|
|
@@ -197,14 +209,14 @@ export const LoaderDataContext = createContext<unknown>(undefined);
|
|
|
197
209
|
/**
|
|
198
210
|
* The data returned by the active route's `loader`. Three ways to type it, easiest first:
|
|
199
211
|
*
|
|
200
|
-
* 1. **Pass the loader
|
|
212
|
+
* 1. **Pass the loader**, zero generics, fully inferred from your loader's return:
|
|
201
213
|
* `const data = useLoaderData(loader);`
|
|
202
214
|
* 2. Pass `typeof loader` as a type argument: `useLoaderData<typeof loader>();`
|
|
203
215
|
* 3. Pass an explicit shape: `useLoaderData<Post>();`
|
|
204
216
|
*
|
|
205
|
-
* With no argument and no type, it returns `unknown` (never `any`)
|
|
217
|
+
* With no argument and no type, it returns `unknown` (never `any`), so the data is there at runtime,
|
|
206
218
|
* 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
|
|
219
|
+
* TypeScript can't tell which file (and so which `loader`) the call belongs to, hence option 1.
|
|
208
220
|
*/
|
|
209
221
|
export function useLoaderData<L extends LoaderFunction>(loader: L): Awaited<ReturnType<L>>;
|
|
210
222
|
export function useLoaderData<T = unknown>(): LoaderData<T>;
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { createRoot } from 'react-dom/client';
|
|
2
2
|
|
|
3
|
+
import {
|
|
4
|
+
DevErrorBoundary,
|
|
5
|
+
DevErrorOverlay,
|
|
6
|
+
initDevErrorOverlay,
|
|
7
|
+
isDevMode,
|
|
8
|
+
} from '../dev/error-overlay.js';
|
|
3
9
|
import { initNavigation } from '../navigation/navigation.js';
|
|
4
10
|
import { startPrefetcher } from '../navigation/prefetch.js';
|
|
5
11
|
import { Router } from './Router.js';
|
|
@@ -14,17 +20,33 @@ export function mount(
|
|
|
14
20
|
layout: LayoutLoader = null,
|
|
15
21
|
notFound: NotFoundLoader = null,
|
|
16
22
|
globalError: ErrorComponentLoader = null,
|
|
23
|
+
slots: Record<string, RouteDef[]> = {},
|
|
17
24
|
): void {
|
|
18
25
|
const el = document.getElementById('root');
|
|
19
26
|
if (!el) throw new Error('toil: #root element not found');
|
|
20
27
|
initNavigation();
|
|
21
|
-
|
|
28
|
+
const app = (
|
|
22
29
|
<Router
|
|
23
30
|
routes={routes}
|
|
24
31
|
layout={layout}
|
|
25
32
|
notFound={notFound}
|
|
26
33
|
globalError={globalError}
|
|
27
|
-
|
|
34
|
+
slots={slots}
|
|
35
|
+
/>
|
|
28
36
|
);
|
|
29
|
-
|
|
37
|
+
// In dev, wrap the app in the error overlay so uncaught render/async errors surface on screen
|
|
38
|
+
// (not a blank page). In production it's omitted entirely.
|
|
39
|
+
if (isDevMode()) {
|
|
40
|
+
initDevErrorOverlay();
|
|
41
|
+
createRoot(el).render(
|
|
42
|
+
<>
|
|
43
|
+
<DevErrorBoundary>{app}</DevErrorBoundary>
|
|
44
|
+
<DevErrorOverlay />
|
|
45
|
+
</>,
|
|
46
|
+
);
|
|
47
|
+
} else {
|
|
48
|
+
createRoot(el).render(app);
|
|
49
|
+
}
|
|
50
|
+
// Prefetch across the main tree and every slot tree (one prefetcher owns the whole table).
|
|
51
|
+
startPrefetcher([...routes, ...Object.values(slots).flat()]);
|
|
30
52
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context carrying the rendered element for each parallel-route slot (`@slot`), keyed by name.
|
|
3
|
+
* Provided by the Router for the current URL, consumed by {@link Slot}.
|
|
4
|
+
*/
|
|
5
|
+
import { createContext, type ReactNode } from 'react';
|
|
6
|
+
|
|
7
|
+
export const SlotContext = createContext<Record<string, ReactNode>>({});
|
package/src/client/types.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { ComponentType, ReactNode } from 'react';
|
|
|
12
12
|
export interface Register {}
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Union of the project's route paths
|
|
15
|
+
* Union of the project's route paths, static routes as literals, dynamic/catch-all as
|
|
16
16
|
* `` `…/${string}` `` templates. Falls back to `string` before the types are generated.
|
|
17
17
|
*/
|
|
18
18
|
export type RoutePath = Register extends { routePath: infer P }
|
|
@@ -56,12 +56,14 @@ export interface RouteDef {
|
|
|
56
56
|
readonly pattern: string;
|
|
57
57
|
readonly load: () => Promise<{ default: ComponentType }>;
|
|
58
58
|
readonly layouts?: readonly LayoutComponentLoader[];
|
|
59
|
-
/** `template.tsx` chain (root → nested)
|
|
59
|
+
/** `template.tsx` chain (root → nested), like layouts, but re-mounted on each navigation. */
|
|
60
60
|
readonly templates?: readonly LayoutComponentLoader[];
|
|
61
|
-
/** Nearest `loading.tsx
|
|
61
|
+
/** Nearest `loading.tsx`, shown as the Suspense fallback while this route loads. */
|
|
62
62
|
readonly loading?: () => Promise<{ default: ComponentType }>;
|
|
63
|
-
/** Nearest `error.tsx
|
|
63
|
+
/** Nearest `error.tsx`, rendered by an error boundary around this route. */
|
|
64
64
|
readonly errorComponent?: () => Promise<{ default: ComponentType<RouteErrorProps> }>;
|
|
65
|
+
/** Intercepting route (`(.)`/`(..)`/`(...)`), matched in its slot only on soft navigation. */
|
|
66
|
+
readonly intercept?: boolean;
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
/** Optional root layout loader (wraps every page). `null` when the project defines no layout. */
|