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.
Files changed (135) hide show
  1. package/build/backend/.tsbuildinfo +1 -1
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/cli/configure.d.ts +1 -0
  4. package/build/cli/configure.js +85 -20
  5. package/build/cli/create.d.ts +1 -0
  6. package/build/cli/create.js +18 -7
  7. package/build/cli/features.d.ts +2 -0
  8. package/build/cli/features.js +22 -0
  9. package/build/cli/index.js +8 -0
  10. package/build/client/.tsbuildinfo +1 -1
  11. package/build/client/components/Form.d.ts +12 -0
  12. package/build/client/components/Form.js +23 -0
  13. package/build/client/components/Image.d.ts +13 -0
  14. package/build/client/components/Image.js +22 -0
  15. package/build/client/components/Script.d.ts +13 -0
  16. package/build/client/components/Script.js +68 -0
  17. package/build/client/components/Slot.d.ts +6 -0
  18. package/build/client/components/Slot.js +6 -0
  19. package/build/client/dev/error-overlay.d.ts +20 -0
  20. package/build/client/dev/error-overlay.js +123 -0
  21. package/build/client/head/head.d.ts +2 -0
  22. package/build/client/head/head.js +17 -2
  23. package/build/client/head/metadata.d.ts +29 -0
  24. package/build/client/head/metadata.js +38 -0
  25. package/build/client/index.d.ts +15 -3
  26. package/build/client/index.js +8 -2
  27. package/build/client/navigation/navigation.d.ts +3 -0
  28. package/build/client/navigation/navigation.js +42 -1
  29. package/build/client/routing/Router.d.ts +1 -0
  30. package/build/client/routing/Router.js +56 -34
  31. package/build/client/routing/action.d.ts +17 -0
  32. package/build/client/routing/action.js +55 -0
  33. package/build/client/routing/hooks.d.ts +1 -0
  34. package/build/client/routing/hooks.js +6 -7
  35. package/build/client/routing/loader.d.ts +10 -2
  36. package/build/client/routing/loader.js +83 -24
  37. package/build/client/routing/mount.d.ts +1 -1
  38. package/build/client/routing/mount.js +12 -4
  39. package/build/client/routing/slot-context.d.ts +2 -0
  40. package/build/client/routing/slot-context.js +2 -0
  41. package/build/client/types.d.ts +1 -0
  42. package/build/compiler/.tsbuildinfo +1 -1
  43. package/build/compiler/config.d.ts +10 -0
  44. package/build/compiler/config.js +5 -1
  45. package/build/compiler/docs.js +26 -26
  46. package/build/compiler/fonts.d.ts +4 -0
  47. package/build/compiler/fonts.js +64 -0
  48. package/build/compiler/generate.js +67 -32
  49. package/build/compiler/image-report.d.ts +2 -0
  50. package/build/compiler/image-report.js +62 -0
  51. package/build/compiler/plugin.js +1 -1
  52. package/build/compiler/prerender.d.ts +7 -0
  53. package/build/compiler/prerender.js +111 -0
  54. package/build/compiler/routes.d.ts +3 -0
  55. package/build/compiler/routes.js +50 -5
  56. package/build/compiler/seo.d.ts +70 -0
  57. package/build/compiler/seo.js +221 -0
  58. package/build/compiler/vite.js +13 -1
  59. package/build/io/.tsbuildinfo +1 -1
  60. package/build/shared/.tsbuildinfo +1 -1
  61. package/examples/basic/client/404.tsx +1 -1
  62. package/examples/basic/client/components/Header.tsx +38 -0
  63. package/examples/basic/client/components/HoneycombBackground.tsx +86 -18
  64. package/examples/basic/client/global-error.tsx +3 -3
  65. package/examples/basic/client/layout.tsx +2 -33
  66. package/examples/basic/client/public/images/test_image.webp +0 -0
  67. package/examples/basic/client/routes/about.tsx +8 -0
  68. package/examples/basic/client/routes/get-started.tsx +1 -1
  69. package/examples/basic/client/routes/index.tsx +8 -1
  70. package/examples/basic/client/routes/io.tsx +1 -1
  71. package/examples/basic/client/routes/loader-demo/index.tsx +29 -1
  72. package/examples/basic/client/routes/test.tsx +8 -0
  73. package/examples/basic/client/styles/main.css +48 -1
  74. package/package.json +8 -6
  75. package/presets/eslint.js +7 -4
  76. package/presets/tsconfig.json +1 -1
  77. package/src/backend/index.ts +1 -1
  78. package/src/cli/configure.ts +102 -21
  79. package/src/cli/create.ts +25 -9
  80. package/src/cli/features.ts +33 -1
  81. package/src/cli/index.ts +10 -1
  82. package/src/cli/ui.ts +1 -1
  83. package/src/cli/validate.ts +1 -1
  84. package/src/client/components/Form.tsx +65 -0
  85. package/src/client/components/Image.tsx +89 -0
  86. package/src/client/components/Script.tsx +113 -0
  87. package/src/client/components/Slot.tsx +21 -0
  88. package/src/client/dev/error-overlay.tsx +197 -0
  89. package/src/client/head/head.ts +28 -3
  90. package/src/client/head/metadata.ts +92 -0
  91. package/src/client/index.ts +20 -3
  92. package/src/client/navigation/Link.tsx +1 -1
  93. package/src/client/navigation/navigation.ts +74 -4
  94. package/src/client/navigation/prefetch.ts +2 -2
  95. package/src/client/routing/Router.tsx +128 -62
  96. package/src/client/routing/action.ts +122 -0
  97. package/src/client/routing/error-boundary.tsx +1 -1
  98. package/src/client/routing/hooks.ts +17 -23
  99. package/src/client/routing/loader.ts +158 -35
  100. package/src/client/routing/mount.tsx +25 -3
  101. package/src/client/routing/slot-context.ts +7 -0
  102. package/src/client/types.ts +6 -4
  103. package/src/compiler/config.ts +40 -3
  104. package/src/compiler/docs.ts +26 -26
  105. package/src/compiler/fonts.ts +87 -0
  106. package/src/compiler/generate.ts +69 -31
  107. package/src/compiler/image-report.ts +85 -0
  108. package/src/compiler/plugin.ts +2 -2
  109. package/src/compiler/prerender.ts +130 -0
  110. package/src/compiler/routes.ts +62 -7
  111. package/src/compiler/seo.ts +356 -0
  112. package/src/compiler/vite.ts +21 -4
  113. package/src/io/FastSet.ts +1 -1
  114. package/src/io/index.ts +1 -1
  115. package/src/io/types.ts +1 -1
  116. package/src/server/index.ts +1 -1
  117. package/src/server/main.ts +1 -1
  118. package/src/shared/index.ts +1 -1
  119. package/test/dom/Image.test.tsx +46 -0
  120. package/test/dom/Script.test.tsx +45 -0
  121. package/test/dom/action.test.tsx +129 -0
  122. package/test/dom/error-overlay.test.tsx +44 -0
  123. package/test/dom/loader.test.tsx +121 -0
  124. package/test/dom/revalidate.test.tsx +38 -0
  125. package/test/dom/route-head.test.tsx +34 -0
  126. package/test/dom/router-loading.test.tsx +44 -0
  127. package/test/dom/slot.test.tsx +109 -0
  128. package/test/dom/view-transitions.test.tsx +51 -0
  129. package/test/features.test.ts +31 -0
  130. package/test/fonts.test.ts +26 -0
  131. package/test/metadata.test.ts +41 -0
  132. package/test/prerender.test.ts +46 -0
  133. package/test/routes.test.ts +20 -1
  134. package/test/seo.test.ts +142 -0
  135. 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 { 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
 
19
26
  /** Loads a matched route's module + loader data (suspending), then renders it with the data in context. */
20
- function RoutePage(props: { route: RouteDef; params: RouteParams; dataKey: string }): ReactNode {
21
- const { Component, data } = readRouteData(props.route, props.params, props.dataKey);
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
- let matched: RouteDef | undefined;
43
- let params: RouteParams = {};
44
- for (const route of routes) {
45
- const result = matchRoute(route.pattern, pathname);
46
- if (result) {
47
- matched = route;
48
- params = result;
49
- break;
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
- const fallback: ReactNode = matched.loading
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 Not found</div>;
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 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.
122
184
  if (globalError) {
123
185
  content = <ErrorBoundary fallback={errorComponent(globalError)}>{content}</ErrorBoundary>;
124
186
  }
125
187
 
126
- 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
+ );
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 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 {
@@ -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 (in a transition, so the current page stays on screen while the
79
- * next chunk loads) and reads the live `window.location` on render. Re-renders on any pathname,
80
- * search, or hash change.
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) 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. */
114
108
  export function useNavigationPending(): boolean {
115
109
  return useSyncExternalStore(
116
110
  subscribePending,