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.
Files changed (105) hide show
  1. package/build/backend/.tsbuildinfo +1 -1
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/cli/configure.js +5 -5
  4. package/build/cli/create.js +4 -4
  5. package/build/client/.tsbuildinfo +1 -1
  6. package/build/client/components/Slot.d.ts +6 -0
  7. package/build/client/components/Slot.js +6 -0
  8. package/build/client/dev/error-overlay.d.ts +20 -0
  9. package/build/client/dev/error-overlay.js +123 -0
  10. package/build/client/head/head.d.ts +2 -0
  11. package/build/client/head/head.js +17 -2
  12. package/build/client/head/metadata.d.ts +29 -0
  13. package/build/client/head/metadata.js +38 -0
  14. package/build/client/index.d.ts +5 -1
  15. package/build/client/index.js +3 -1
  16. package/build/client/navigation/navigation.d.ts +3 -0
  17. package/build/client/navigation/navigation.js +42 -1
  18. package/build/client/routing/Router.d.ts +1 -0
  19. package/build/client/routing/Router.js +55 -33
  20. package/build/client/routing/hooks.js +2 -6
  21. package/build/client/routing/loader.d.ts +2 -0
  22. package/build/client/routing/loader.js +9 -1
  23. package/build/client/routing/mount.d.ts +1 -1
  24. package/build/client/routing/mount.js +12 -4
  25. package/build/client/routing/slot-context.d.ts +2 -0
  26. package/build/client/routing/slot-context.js +2 -0
  27. package/build/client/types.d.ts +1 -0
  28. package/build/compiler/.tsbuildinfo +1 -1
  29. package/build/compiler/config.d.ts +8 -0
  30. package/build/compiler/config.js +4 -1
  31. package/build/compiler/docs.js +26 -26
  32. package/build/compiler/fonts.d.ts +4 -0
  33. package/build/compiler/fonts.js +64 -0
  34. package/build/compiler/generate.js +65 -32
  35. package/build/compiler/plugin.js +1 -1
  36. package/build/compiler/prerender.d.ts +7 -0
  37. package/build/compiler/prerender.js +111 -0
  38. package/build/compiler/routes.d.ts +3 -0
  39. package/build/compiler/routes.js +50 -5
  40. package/build/compiler/seo.d.ts +70 -0
  41. package/build/compiler/seo.js +221 -0
  42. package/build/compiler/vite.js +5 -1
  43. package/build/io/.tsbuildinfo +1 -1
  44. package/build/shared/.tsbuildinfo +1 -1
  45. package/examples/basic/client/404.tsx +1 -1
  46. package/examples/basic/client/global-error.tsx +1 -1
  47. package/examples/basic/client/routes/about.tsx +8 -0
  48. package/examples/basic/client/routes/get-started.tsx +1 -1
  49. package/examples/basic/client/routes/io.tsx +1 -1
  50. package/examples/basic/client/routes/loader-demo/index.tsx +7 -2
  51. package/package.json +1 -1
  52. package/presets/eslint.js +7 -4
  53. package/presets/tsconfig.json +1 -1
  54. package/src/backend/index.ts +1 -1
  55. package/src/cli/configure.ts +7 -7
  56. package/src/cli/create.ts +7 -7
  57. package/src/cli/features.ts +2 -2
  58. package/src/cli/index.ts +1 -1
  59. package/src/cli/ui.ts +1 -1
  60. package/src/cli/validate.ts +1 -1
  61. package/src/client/components/Form.tsx +2 -2
  62. package/src/client/components/Image.tsx +2 -2
  63. package/src/client/components/Script.tsx +3 -3
  64. package/src/client/components/Slot.tsx +21 -0
  65. package/src/client/dev/error-overlay.tsx +197 -0
  66. package/src/client/head/head.ts +28 -3
  67. package/src/client/head/metadata.ts +92 -0
  68. package/src/client/index.ts +5 -1
  69. package/src/client/navigation/Link.tsx +1 -1
  70. package/src/client/navigation/navigation.ts +74 -4
  71. package/src/client/navigation/prefetch.ts +2 -2
  72. package/src/client/routing/Router.tsx +121 -67
  73. package/src/client/routing/action.ts +4 -4
  74. package/src/client/routing/error-boundary.tsx +1 -1
  75. package/src/client/routing/hooks.ts +6 -25
  76. package/src/client/routing/loader.ts +20 -8
  77. package/src/client/routing/mount.tsx +25 -3
  78. package/src/client/routing/slot-context.ts +7 -0
  79. package/src/client/types.ts +6 -4
  80. package/src/compiler/config.ts +31 -3
  81. package/src/compiler/docs.ts +26 -26
  82. package/src/compiler/fonts.ts +87 -0
  83. package/src/compiler/generate.ts +66 -31
  84. package/src/compiler/image-report.ts +1 -1
  85. package/src/compiler/plugin.ts +2 -2
  86. package/src/compiler/prerender.ts +130 -0
  87. package/src/compiler/routes.ts +62 -7
  88. package/src/compiler/seo.ts +356 -0
  89. package/src/compiler/vite.ts +9 -4
  90. package/src/io/FastSet.ts +1 -1
  91. package/src/io/index.ts +1 -1
  92. package/src/io/types.ts +1 -1
  93. package/src/server/index.ts +1 -1
  94. package/src/server/main.ts +1 -1
  95. package/src/shared/index.ts +1 -1
  96. package/test/dom/error-overlay.test.tsx +44 -0
  97. package/test/dom/revalidate.test.tsx +38 -0
  98. package/test/dom/route-head.test.tsx +34 -0
  99. package/test/dom/slot.test.tsx +109 -0
  100. package/test/dom/view-transitions.test.tsx +51 -0
  101. package/test/fonts.test.ts +26 -0
  102. package/test/metadata.test.ts +41 -0
  103. package/test/prerender.test.ts +46 -0
  104. package/test/routes.test.ts +20 -1
  105. 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
- /** Notifies every subscriber that the location may have changed. */
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 used to key/revalidate per-navigation route data. */
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
- /** Re-renders the current route without changing the URL (there is no server data to refetch). */
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 safe to call from anywhere,
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 200px margin) its chunk is warmed once; links added later by client
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 { navigationEpoch, settleNavigation } from '../navigation/navigation.js';
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
- let matched: RouteDef | undefined;
48
- let params: RouteParams = {};
49
- for (const route of routes) {
50
- const result = matchRoute(route.pattern, pathname);
51
- if (result) {
52
- matched = route;
53
- params = result;
54
- break;
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
- const fallback: ReactNode = matched.loading
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 Not found</div>;
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 the last line of defense before a blank screen.
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 <ParamsContext.Provider value={params}>{content}</ParamsContext.Provider>;
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) the counterpart to loaders (reads). A loader fetches data on navigation;
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) the current route.
16
- * - an `Href` (or array) those specific routes.
17
- * - `false` nothing.
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 React has no hook equivalent.
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
- * 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.
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) e.g. for a loading bar. */
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` a route without one has no data that can change. */
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 it never changes loader data). */
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 keep it cached so repeat navigations
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) see {@link revalidate}.
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** zero generics, fully inferred from your loader's return:
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`) so the data is there at runtime,
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 hence option 1.
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
- createRoot(el).render(
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
- startPrefetcher(routes);
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>>({});
@@ -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 static routes as literals, dynamic/catch-all as
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) like layouts, but re-mounted on each navigation. */
59
+ /** `template.tsx` chain (root → nested), like layouts, but re-mounted on each navigation. */
60
60
  readonly templates?: readonly LayoutComponentLoader[];
61
- /** Nearest `loading.tsx` shown as the Suspense fallback while this route loads. */
61
+ /** Nearest `loading.tsx`, shown as the Suspense fallback while this route loads. */
62
62
  readonly loading?: () => Promise<{ default: ComponentType }>;
63
- /** Nearest `error.tsx` rendered by an error boundary around this route. */
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. */