@kasperrt/smolter 0.0.1 → 0.3.0

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 (246) hide show
  1. package/{src/internal → .generated}/client/index.ts +1 -0
  2. package/.generated/core/environment.ts +43 -0
  3. package/{src/internal → .generated}/core/errors.ts +1 -1
  4. package/{src/internal → .generated}/core/index.ts +2 -0
  5. package/.generated/core/navigation-router.ts +107 -0
  6. package/.generated/core/navigation.ts +147 -0
  7. package/.generated/core/presentation.ts +108 -0
  8. package/.generated/core/resource-types.ts +44 -0
  9. package/.generated/core/resources.ts +444 -0
  10. package/.generated/core/responses.ts +86 -0
  11. package/{src/internal → .generated}/core/router.ts +136 -63
  12. package/.generated/core/serialize.ts +20 -0
  13. package/.generated/core/store.ts +25 -0
  14. package/.generated/core/submission.ts +116 -0
  15. package/{src/internal → .generated}/core/types.ts +18 -1
  16. package/.generated/core/url.ts +29 -0
  17. package/.generated/core/utils.ts +5 -0
  18. package/.generated/preact/await.tsx +41 -0
  19. package/.generated/preact/client.tsx +93 -0
  20. package/.generated/preact/context.ts +59 -0
  21. package/.generated/preact/document.tsx +198 -0
  22. package/{src/internal → .generated}/preact/fetcher.ts +43 -23
  23. package/.generated/preact/form.ts +58 -0
  24. package/{src/internal → .generated}/preact/hooks.ts +25 -28
  25. package/.generated/preact/index.ts +34 -0
  26. package/.generated/preact/lazy-route.ts +100 -0
  27. package/.generated/preact/navigation-state.ts +9 -0
  28. package/.generated/preact/navigation.tsx +147 -0
  29. package/.generated/preact/root-context.ts +21 -0
  30. package/.generated/preact/root-data.ts +45 -0
  31. package/.generated/preact/route-hooks.tsx +170 -0
  32. package/.generated/preact/route-types.ts +76 -0
  33. package/.generated/preact/router.tsx +107 -0
  34. package/{src/internal → .generated}/preact/routes.ts +17 -9
  35. package/.generated/react/context.ts +59 -0
  36. package/.generated/react/data.ts +25 -0
  37. package/{src/internal → .generated}/react/fetcher.ts +13 -11
  38. package/.generated/react/form.ts +58 -0
  39. package/{src/internal → .generated}/react/hooks.ts +9 -14
  40. package/{src/internal/preact → .generated/react}/index.ts +5 -1
  41. package/{src/internal → .generated}/react/link.ts +13 -21
  42. package/.generated/react/resources/components.tsx +404 -0
  43. package/{src/internal → .generated}/react/routes.ts +17 -9
  44. package/.generated/server/application.tsx +185 -0
  45. package/.generated/server/assets.ts +19 -0
  46. package/.generated/server/handler.ts +69 -0
  47. package/.generated/server/http.ts +55 -0
  48. package/{src/internal → .generated}/server/index.ts +4 -17
  49. package/.generated/server/node.ts +4 -0
  50. package/.generated/server/preact.ts +7 -0
  51. package/.generated/vite/application/browser-boundary.ts +42 -0
  52. package/.generated/vite/application/lazy-files.ts +136 -0
  53. package/.generated/vite/application/vite.ts +144 -0
  54. package/{src/internal → .generated}/vite/index.ts +4 -2
  55. package/.generated/vite/preact.ts +24 -0
  56. package/.generated/vite/react.ts +18 -0
  57. package/{src/internal → .generated}/vite/transport.ts +2 -1
  58. package/CHANGELOG.md +54 -0
  59. package/README.md +269 -55
  60. package/dist/client/deserialize.js +10 -0
  61. package/dist/{internal/client → client}/index.d.ts +1 -0
  62. package/dist/client/index.js +46 -0
  63. package/dist/core/environment.d.ts +12 -0
  64. package/dist/core/environment.js +23 -0
  65. package/dist/core/errors.js +7 -0
  66. package/dist/{internal/core → core}/index.d.ts +2 -0
  67. package/dist/core/match.js +33 -0
  68. package/dist/core/navigation-router.d.ts +15 -0
  69. package/dist/core/navigation-router.js +64 -0
  70. package/dist/core/navigation.d.ts +15 -0
  71. package/dist/core/navigation.js +92 -0
  72. package/dist/core/presentation.d.ts +5 -0
  73. package/dist/core/presentation.js +69 -0
  74. package/dist/core/resource-types.d.ts +37 -0
  75. package/dist/core/resources.d.ts +19 -0
  76. package/dist/core/resources.js +345 -0
  77. package/dist/core/responses.d.ts +15 -0
  78. package/dist/core/responses.js +48 -0
  79. package/dist/{internal/core → core}/router.d.ts +1 -1
  80. package/dist/core/router.js +204 -0
  81. package/dist/core/serialize.d.ts +5 -0
  82. package/dist/core/serialize.js +17 -0
  83. package/dist/core/store.d.ts +6 -0
  84. package/dist/core/store.js +21 -0
  85. package/dist/core/submission.d.ts +30 -0
  86. package/dist/core/submission.js +41 -0
  87. package/dist/{internal/core → core}/types.d.ts +16 -1
  88. package/dist/core/url.d.ts +7 -0
  89. package/dist/core/url.js +12 -0
  90. package/dist/core/utils.d.ts +5 -0
  91. package/dist/core/utils.js +5 -0
  92. package/dist/files/index.js +64 -0
  93. package/dist/preact/await.d.ts +7 -0
  94. package/dist/preact/await.js +19 -0
  95. package/dist/preact/client.d.ts +7 -0
  96. package/dist/preact/client.js +67 -0
  97. package/dist/preact/context.d.ts +18 -0
  98. package/dist/preact/context.js +23 -0
  99. package/dist/preact/document.d.ts +15 -0
  100. package/dist/preact/document.js +155 -0
  101. package/dist/{internal/preact → preact}/fetcher.d.ts +9 -5
  102. package/dist/preact/fetcher.js +45 -0
  103. package/dist/{internal/preact → preact}/form.d.ts +5 -6
  104. package/dist/preact/form.js +21 -0
  105. package/dist/{internal/preact → preact}/hooks.d.ts +7 -6
  106. package/dist/preact/hooks.js +25 -0
  107. package/dist/preact/index.d.ts +18 -0
  108. package/dist/preact/index.js +15 -0
  109. package/dist/preact/lazy-route.d.ts +19 -0
  110. package/dist/preact/lazy-route.js +51 -0
  111. package/dist/preact/navigation-state.d.ts +2 -0
  112. package/dist/preact/navigation-state.js +9 -0
  113. package/dist/preact/navigation.d.ts +28 -0
  114. package/dist/preact/navigation.js +60 -0
  115. package/dist/preact/root-context.d.ts +13 -0
  116. package/dist/preact/root-context.js +9 -0
  117. package/dist/preact/root-data.d.ts +8 -0
  118. package/dist/preact/root-data.js +28 -0
  119. package/dist/preact/route-hooks.d.ts +32 -0
  120. package/dist/preact/route-hooks.js +94 -0
  121. package/dist/preact/route-types.d.ts +54 -0
  122. package/dist/preact/router.d.ts +16 -0
  123. package/dist/preact/router.js +63 -0
  124. package/dist/{internal/preact → preact}/routes.d.ts +1 -1
  125. package/dist/preact/routes.js +12 -0
  126. package/dist/{internal/preact → preact}/runtime.js +1 -1
  127. package/dist/react/context.d.ts +18 -0
  128. package/dist/react/context.js +23 -0
  129. package/dist/react/data.d.ts +5 -0
  130. package/dist/react/data.js +3 -0
  131. package/dist/{internal/react → react}/fetcher.d.ts +2 -2
  132. package/dist/react/fetcher.js +34 -0
  133. package/dist/{internal/react → react}/form.d.ts +5 -6
  134. package/dist/react/form.js +21 -0
  135. package/dist/react/hooks.js +25 -0
  136. package/dist/{internal/react → react}/index.d.ts +1 -1
  137. package/dist/react/index.js +10 -0
  138. package/dist/{internal/react → react}/link.d.ts +2 -2
  139. package/dist/react/link.js +27 -0
  140. package/dist/react/resources/components.d.ts +64 -0
  141. package/dist/react/resources/components.js +239 -0
  142. package/dist/{internal/react → react}/routes.d.ts +1 -1
  143. package/dist/react/routes.js +12 -0
  144. package/dist/react/runtime.js +1 -0
  145. package/dist/server/application.d.ts +4 -0
  146. package/dist/server/application.js +119 -0
  147. package/dist/server/assets.d.ts +5 -0
  148. package/dist/server/assets.js +7 -0
  149. package/dist/server/handler.d.ts +19 -0
  150. package/dist/server/handler.js +22 -0
  151. package/dist/server/http.d.ts +4 -0
  152. package/dist/server/http.js +27 -0
  153. package/dist/{internal/server → server}/index.d.ts +2 -3
  154. package/dist/server/index.js +76 -0
  155. package/dist/server/node.d.ts +3 -0
  156. package/dist/server/node.js +3 -0
  157. package/dist/server/preact.d.ts +3 -0
  158. package/dist/server/preact.js +4 -0
  159. package/dist/vite/application/browser-boundary.d.ts +3 -0
  160. package/dist/vite/application/browser-boundary.js +35 -0
  161. package/dist/vite/application/lazy-files.d.ts +2 -0
  162. package/dist/vite/application/lazy-files.js +87 -0
  163. package/dist/vite/application/vite.d.ts +9 -0
  164. package/dist/vite/application/vite.js +101 -0
  165. package/dist/{internal/vite → vite}/index.d.ts +2 -1
  166. package/dist/vite/index.js +104 -0
  167. package/dist/vite/preact.d.ts +11 -0
  168. package/dist/vite/preact.js +15 -0
  169. package/dist/vite/react.d.ts +9 -0
  170. package/dist/vite/react.js +14 -0
  171. package/dist/{internal/vite → vite}/transport.d.ts +1 -1
  172. package/dist/vite/transport.js +35 -0
  173. package/jsr.json +28 -11
  174. package/package.json +119 -38
  175. package/dist/client.d.ts +0 -2
  176. package/dist/client.js +0 -2
  177. package/dist/core.d.ts +0 -2
  178. package/dist/core.js +0 -2
  179. package/dist/files.d.ts +0 -2
  180. package/dist/files.js +0 -2
  181. package/dist/internal/client/deserialize.js +0 -11
  182. package/dist/internal/client/index.js +0 -44
  183. package/dist/internal/core/errors.js +0 -6
  184. package/dist/internal/core/index.js +0 -6
  185. package/dist/internal/core/match.js +0 -27
  186. package/dist/internal/core/router.js +0 -189
  187. package/dist/internal/core/types.js +0 -5
  188. package/dist/internal/core/url.d.ts +0 -2
  189. package/dist/internal/core/url.js +0 -6
  190. package/dist/internal/files/index.js +0 -77
  191. package/dist/internal/preact/context.d.ts +0 -18
  192. package/dist/internal/preact/context.js +0 -20
  193. package/dist/internal/preact/fetcher.js +0 -28
  194. package/dist/internal/preact/form.js +0 -41
  195. package/dist/internal/preact/hooks.js +0 -35
  196. package/dist/internal/preact/index.d.ts +0 -9
  197. package/dist/internal/preact/index.js +0 -7
  198. package/dist/internal/preact/link.d.ts +0 -12
  199. package/dist/internal/preact/link.js +0 -28
  200. package/dist/internal/preact/routes.js +0 -22
  201. package/dist/internal/react/context.d.ts +0 -18
  202. package/dist/internal/react/context.js +0 -20
  203. package/dist/internal/react/fetcher.js +0 -28
  204. package/dist/internal/react/form.js +0 -41
  205. package/dist/internal/react/hooks.js +0 -35
  206. package/dist/internal/react/index.js +0 -7
  207. package/dist/internal/react/link.js +0 -28
  208. package/dist/internal/react/routes.js +0 -22
  209. package/dist/internal/react/runtime.js +0 -2
  210. package/dist/internal/server/index.js +0 -81
  211. package/dist/internal/vite/index.js +0 -132
  212. package/dist/internal/vite/transport.js +0 -42
  213. package/dist/preact.d.ts +0 -2
  214. package/dist/preact.js +0 -2
  215. package/dist/react.d.ts +0 -2
  216. package/dist/react.js +0 -2
  217. package/dist/server.d.ts +0 -2
  218. package/dist/server.js +0 -2
  219. package/dist/vite.d.ts +0 -2
  220. package/dist/vite.js +0 -2
  221. package/src/client.ts +0 -2
  222. package/src/core.ts +0 -2
  223. package/src/files.ts +0 -2
  224. package/src/internal/core/url.ts +0 -6
  225. package/src/internal/preact/context.ts +0 -46
  226. package/src/internal/preact/form.ts +0 -79
  227. package/src/internal/preact/link.ts +0 -46
  228. package/src/internal/react/context.ts +0 -46
  229. package/src/internal/react/form.ts +0 -79
  230. package/src/internal/react/index.ts +0 -13
  231. package/src/preact.ts +0 -2
  232. package/src/react.ts +0 -2
  233. package/src/server.ts +0 -2
  234. package/src/vite.ts +0 -2
  235. /package/{src/internal → .generated}/client/deserialize.ts +0 -0
  236. /package/{src/internal → .generated}/core/match.ts +0 -0
  237. /package/{src/internal → .generated}/files/index.ts +0 -0
  238. /package/{src/internal → .generated}/preact/runtime.ts +0 -0
  239. /package/{src/internal → .generated}/react/runtime.ts +0 -0
  240. /package/dist/{internal/client → client}/deserialize.d.ts +0 -0
  241. /package/dist/{internal/core → core}/errors.d.ts +0 -0
  242. /package/dist/{internal/core → core}/match.d.ts +0 -0
  243. /package/dist/{internal/files → files}/index.d.ts +0 -0
  244. /package/dist/{internal/preact → preact}/runtime.d.ts +0 -0
  245. /package/dist/{internal/react → react}/hooks.d.ts +0 -0
  246. /package/dist/{internal/react → react}/runtime.d.ts +0 -0
@@ -4,6 +4,7 @@ import type {
4
4
  DataFunction,
5
5
  NavigateOptions,
6
6
  Result,
7
+ RevalidationArgs,
7
8
  Route,
8
9
  Router,
9
10
  RouterOptions,
@@ -14,19 +15,25 @@ import { internalURL } from "./url.ts";
14
15
 
15
16
  type ChannelSnapshot = Partial<Snapshot> & Pick<Snapshot, "state">;
16
17
 
17
- interface OperationOptions extends SubmitOptions, NavigateOptions {
18
- hops?: number;
18
+ interface OperationOptions extends SubmitOptions, NavigateOptions {}
19
+
20
+ interface RevalidationTask {
21
+ revalidate: (
22
+ args: RevalidationArgs,
23
+ targets?: readonly string[],
24
+ ) => Promise<unknown>;
25
+ dispose?: () => void;
19
26
  }
20
27
 
21
28
  /** Create an isolated router. Use browser: true for browser history, or loadRouter for SSR. */
22
- export function createRouter(
29
+ export function createDataRouter(
23
30
  routes: readonly Route[],
24
31
  options: RouterOptions = {},
25
32
  ): Router {
26
33
  const browser = options.browser && typeof window !== "undefined";
27
- let initialized = options.hydrationData?.state === "idle";
34
+ const hydrated = options.hydrationData?.state === "idle";
28
35
  const initial: Snapshot = {
29
- loaded: initialized,
36
+ loaded: hydrated,
30
37
  ...(options.hydrationData ?? {
31
38
  url: new URL(
32
39
  options.url ?? (browser ? location.href : "/"),
@@ -36,18 +43,15 @@ export function createRouter(
36
43
  state: "idle",
37
44
  }),
38
45
  };
39
- const disposers = new Set<() => void>();
40
- const channel = <S extends ChannelSnapshot>(initial: S, local = false) => {
46
+ const revalidators = new Set<RevalidationTask>();
47
+ const channel = <S extends ChannelSnapshot>(initial: S, local?: boolean) => {
41
48
  let snapshot = initial;
49
+ let loadedURL: string | undefined;
50
+ let submission: FormData | undefined;
42
51
  let controller: AbortController | undefined;
43
52
  const listeners = new Set<() => void>();
44
- const dispose = () => {
45
- disposers.delete(dispose);
46
- controller?.abort();
47
- listeners.clear();
48
- };
49
- const emit = (next: Partial<Snapshot>) => {
50
- snapshot = { ...snapshot, ...next };
53
+ const emit = (state: Snapshot["state"], next?: Partial<Snapshot>) => {
54
+ snapshot = { ...snapshot, ...next, state };
51
55
 
52
56
  for (const listener of listeners) listener();
53
57
  };
@@ -55,16 +59,19 @@ export function createRouter(
55
59
  href: string,
56
60
  body?: FormData,
57
61
  operation: OperationOptions = {},
58
- // true pushes history; false handles popstate; undefined is a data request.
62
+ // Only loader navigations set this: true pushes history; false handles popstate.
59
63
  navigation?: boolean,
64
+ redirects = 10,
60
65
  ): Promise<Result> => {
61
66
  controller?.abort();
62
67
 
63
68
  controller = new AbortController();
64
69
 
65
70
  const { signal } = controller;
66
- disposers.add(dispose);
71
+ const { method = "POST" } = operation;
72
+ revalidators.add(task);
67
73
 
74
+ submission = body;
68
75
  const previous = snapshot;
69
76
  const url = internalURL(main.getSnapshot().url, href);
70
77
  const match = url && matchRoutes(routes, url.pathname);
@@ -81,6 +88,7 @@ export function createRouter(
81
88
  "",
82
89
  url,
83
90
  );
91
+
84
92
  const sameRoute = snapshot.path === match?.route.path;
85
93
 
86
94
  snapshot = {
@@ -89,20 +97,19 @@ export function createRouter(
89
97
  path: match?.route.path,
90
98
  params: match?.params ?? {},
91
99
  actionData: undefined,
92
- data: sameRoute ? snapshot.data : undefined,
93
- loaded: sameRoute && snapshot.loaded,
100
+ ...(!sameRoute && { data: undefined, loaded: false }),
94
101
  };
95
102
  };
96
103
 
97
104
  if (!body) commit();
98
- emit({ state: body ? "submitting" : "loading", error: undefined });
105
+
106
+ emit(body ? "submitting" : "loading", { error: undefined });
99
107
 
100
108
  const result = await safeWrapAsync(() => {
101
- if (!match) return new Error(url ? "No route" : "External URL");
109
+ if (!match) return Error(url ? "No route" : "External URL");
102
110
 
103
111
  if (
104
112
  navigation !== undefined &&
105
- !body &&
106
113
  previous.loaded &&
107
114
  !previous.error &&
108
115
  previous.path === match.route.path &&
@@ -120,12 +127,12 @@ export function createRouter(
120
127
  match.route[body ? "clientAction" : "clientLoader"] ||
121
128
  match.route[body ? "action" : "loader"];
122
129
 
123
- if (!fn) return body && new Error("No action");
130
+ if (!fn) return body && Error("No action");
124
131
 
125
132
  return (fn as DataFunction)({
126
133
  request: new Request(url, {
127
134
  signal,
128
- method: body && (operation.method ?? "POST"),
135
+ method: body && method,
129
136
  body,
130
137
  }),
131
138
  params: match.params,
@@ -138,48 +145,88 @@ export function createRouter(
138
145
  const redirectTo =
139
146
  result.data instanceof Response && result.data.headers.get("Location");
140
147
 
141
- if (redirectTo && (operation.hops ?? 0) < 10) {
148
+ if (redirectTo && redirects) {
142
149
  await main.load(
143
150
  new URL(redirectTo, url!).href,
144
151
  undefined,
145
- {
146
- replace: true,
147
- hops: (operation.hops ?? 0) + 1,
148
- },
152
+ { replace: true },
149
153
  true,
154
+ redirects - 1,
150
155
  );
151
156
 
152
- if (!signal.aborted) emit({ state: "idle" });
157
+ if (!signal.aborted) {
158
+ submission = undefined;
159
+ emit("idle");
160
+ }
153
161
 
154
162
  return result;
155
163
  }
156
164
 
157
- if (redirectTo) result.error = new Error("Redirect loop");
165
+ if (redirectTo) result.error = Error("Redirect loop");
166
+
167
+ if (!body && !result.error) loadedURL = url!.href;
158
168
 
159
169
  if (body && !result.error) commit();
160
170
 
161
- emit({
171
+ emit(body && !result.error ? "loading" : "idle", {
162
172
  [body && !local ? "actionData" : "data"]: result.data,
163
173
  error: result.error,
164
174
  loaded: body ? snapshot.loaded : !result.error,
165
- state: body && !result.error ? "loading" : "idle",
166
175
  });
167
176
 
168
177
  if (body && !result.error) {
169
- await router.revalidate("action", {
170
- formMethod: operation.method ?? "POST",
171
- formAction: url!.href,
172
- formData: body,
173
- actionResult: result.data,
174
- });
178
+ await router
179
+ .revalidate("action", {
180
+ formMethod: method,
181
+ formAction: url!.href,
182
+ formData: body,
183
+ actionResult: result.data,
184
+ })
185
+ .finally(() => {
186
+ if (!signal.aborted) {
187
+ submission = undefined;
188
+ emit("idle");
189
+ }
190
+ });
175
191
 
176
- if (!signal.aborted) emit({ state: "idle" });
192
+ return result;
177
193
  }
178
194
 
195
+ if (!signal.aborted) submission = undefined;
196
+
179
197
  return result;
180
198
  };
199
+ const task = {
200
+ revalidate: async (
201
+ args: RevalidationArgs,
202
+ targets?: readonly string[],
203
+ ) => {
204
+ if (local && (!loadedURL || submission)) return;
205
+
206
+ const url = new URL(local ? loadedURL! : main.getSnapshot().url);
207
+ const route = matchRoutes(routes, url.pathname)?.route;
208
+
209
+ // Targets are strings, so an absent route ID cannot match one.
210
+ if (
211
+ !route ||
212
+ (targets &&
213
+ !targets.includes(route.id!) &&
214
+ !targets.includes(route.path))
215
+ )
216
+ return;
181
217
 
182
- return {
218
+ if (
219
+ route.shouldRevalidate?.({
220
+ ...args,
221
+ url,
222
+ currentUrl: url,
223
+ nextUrl: url,
224
+ }) === false
225
+ )
226
+ return;
227
+
228
+ return run(url.href);
229
+ },
183
230
  getSnapshot: () => snapshot,
184
231
  subscribe(listener: () => void) {
185
232
  listeners.add(listener);
@@ -190,48 +237,74 @@ export function createRouter(
190
237
  },
191
238
  load: run,
192
239
  submit: run,
193
- dispose,
240
+ dispose() {
241
+ controller?.abort();
242
+ listeners.clear();
243
+ revalidators.delete(task);
244
+ },
194
245
  };
246
+ revalidators.add(task);
247
+
248
+ return task;
195
249
  };
196
250
  const main = channel(initial);
197
251
  let started = 0;
198
- const pop = () => {
199
- void main.load(location.href, undefined, undefined, false);
200
- };
252
+ const pop = () => main.load(location.href, undefined, undefined, false);
201
253
  const router: Router = {
202
254
  ...main,
203
255
  routes,
204
256
  start() {
205
257
  if (!started++ && browser) addEventListener("popstate", pop);
206
258
 
207
- if (!initialized) {
208
- initialized = true;
259
+ // A changed snapshot means a page operation has already started.
260
+ if (!hydrated && main.getSnapshot() === initial)
209
261
  void main.load(initial.url);
210
- }
211
262
 
212
263
  return () => {
213
264
  if (!--started && browser) removeEventListener("popstate", pop);
214
265
  };
215
266
  },
216
267
  async navigate(url, operation) {
217
- initialized = true;
218
268
  await main.load(url, undefined, operation, true);
219
269
  },
220
- async revalidate(reason = "manual", context = {}) {
270
+ async revalidate(options = {}, context) {
271
+ const {
272
+ route,
273
+ reason = "manual",
274
+ ...details
275
+ } = typeof options === "string"
276
+ ? { reason: options, ...context }
277
+ : options;
221
278
  const url = new URL(main.getSnapshot().url);
279
+ const args = {
280
+ ...details,
281
+ url,
282
+ currentUrl: url,
283
+ nextUrl: url,
284
+ defaultShouldRevalidate: true,
285
+ reason,
286
+ };
287
+ const targets = typeof route === "string" ? [route] : route;
288
+
289
+ await Promise.all(
290
+ [...revalidators].map((refresh) => refresh.revalidate(args, targets)),
291
+ );
292
+ },
222
293
 
223
- if (
224
- matchRoutes(routes, url.pathname)?.route.shouldRevalidate?.({
225
- ...context,
226
- url,
227
- currentUrl: url,
228
- nextUrl: url,
229
- defaultShouldRevalidate: true,
230
- reason,
231
- }) === false
232
- )
233
- return;
234
- await main.load(url.href);
294
+ registerRevalidator(id, refresh) {
295
+ const task = {
296
+ revalidate: async (
297
+ args: RevalidationArgs,
298
+ targets?: readonly string[],
299
+ ) => {
300
+ if (!targets || targets.includes(id)) return refresh(args);
301
+ },
302
+ };
303
+ revalidators.add(task);
304
+
305
+ return () => {
306
+ revalidators.delete(task);
307
+ };
235
308
  },
236
309
 
237
310
  createFetcher() {
@@ -239,9 +312,9 @@ export function createRouter(
239
312
  },
240
313
 
241
314
  dispose() {
242
- main.dispose();
315
+ for (const refresh of revalidators) refresh.dispose?.();
243
316
 
244
- for (const dispose of disposers) dispose();
317
+ revalidators.clear();
245
318
 
246
319
  if (browser) removeEventListener("popstate", pop);
247
320
  },
@@ -0,0 +1,20 @@
1
+ import type { Snapshot } from "./types.ts";
2
+
3
+ /** Safe inside a script element. Data must be JSON serializable; error stacks and causes are omitted. */
4
+ export function serialize(snapshot: Snapshot): string {
5
+ return serializeData({
6
+ ...snapshot,
7
+ error: snapshot.error && {
8
+ name: snapshot.error.name,
9
+ message: snapshot.error.message,
10
+ },
11
+ });
12
+ }
13
+
14
+ /** Encode JSON for embedding inside an HTML script element. */
15
+ export function serializeData(value: unknown): string {
16
+ return JSON.stringify(value)
17
+ .replace(/</g, "\\u003c")
18
+ .replace(/\u2028/g, "\\u2028")
19
+ .replace(/\u2029/g, "\\u2029");
20
+ }
@@ -0,0 +1,25 @@
1
+ /** Small observable value shared by layout data and navigation state. */
2
+ export function createStore<T>(initial: T): {
3
+ getSnapshot: () => T;
4
+ subscribe(listener: () => void): () => void;
5
+ update(next: T): void;
6
+ } {
7
+ let value = initial;
8
+ const listeners = new Set<() => void>();
9
+
10
+ return {
11
+ getSnapshot: () => value,
12
+ subscribe(listener: () => void) {
13
+ listeners.add(listener);
14
+
15
+ return () => {
16
+ listeners.delete(listener);
17
+ };
18
+ },
19
+ update(next: T) {
20
+ value = next;
21
+
22
+ for (const listener of listeners) listener();
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,116 @@
1
+ import type {
2
+ Fetcher,
3
+ NavigateOptions,
4
+ Router,
5
+ SubmitOptions,
6
+ } from "./types.ts";
7
+ import { internalURL } from "./url.ts";
8
+
9
+ /** Values accepted by programmatic route submissions. */
10
+ export type Submission = FormData | HTMLFormElement | Record<string, unknown>;
11
+
12
+ /** Destination and encoding for a programmatic route submission. */
13
+ export interface SubmissionOptions {
14
+ action?: string;
15
+ encType?: string;
16
+ method?: string;
17
+ replace?: boolean;
18
+ preventScrollReset?: boolean;
19
+ }
20
+
21
+ export function formData(value: Submission): FormData {
22
+ if (value instanceof FormData) return value;
23
+
24
+ if (
25
+ typeof HTMLFormElement !== "undefined" &&
26
+ value instanceof HTMLFormElement
27
+ )
28
+ return new FormData(value);
29
+
30
+ const body = new FormData();
31
+
32
+ for (const [key, field] of Object.entries(value))
33
+ body.append(key, String(field));
34
+
35
+ return body;
36
+ }
37
+
38
+ export function submissionBody(value: Submission, encoding?: string): FormData {
39
+ return formData(
40
+ encoding === "application/json"
41
+ ? { _payload: JSON.stringify(value) }
42
+ : value,
43
+ );
44
+ }
45
+
46
+ /** Encode successful form controls for a GET route request. */
47
+ export function submissionSearch(body: FormData): string {
48
+ const search = new URLSearchParams();
49
+
50
+ for (const [key, value] of body)
51
+ search.append(key, typeof value === "string" ? value : value.name);
52
+
53
+ return String(search);
54
+ }
55
+
56
+ /** Read the JSON payload of a programmatic submission. */
57
+ export async function readSubmission(request: Request): Promise<unknown> {
58
+ if (request.headers.get("Content-Type")?.includes("application/json"))
59
+ return request.json();
60
+
61
+ const body = await request.formData();
62
+
63
+ return JSON.parse(String(body.get("_payload") ?? "{}"));
64
+ }
65
+
66
+ /** Browser form behavior shared by the React and Preact adapters. */
67
+ export interface RouteFormOptions extends NavigateOptions {
68
+ action?: string;
69
+ method?: "get" | "post" | "put" | "patch" | "delete";
70
+ reloadDocument?: boolean;
71
+ }
72
+
73
+ export interface FormSubmissionEvent {
74
+ currentTarget: HTMLFormElement;
75
+ defaultPrevented: boolean;
76
+ preventDefault: () => void;
77
+ }
78
+
79
+ /** Submit an internal form while leaving external actions to the browser. */
80
+ export function submitForm(
81
+ router: Router,
82
+ event: FormSubmissionEvent,
83
+ submitter: HTMLElement | null,
84
+ options: RouteFormOptions,
85
+ fetcher?: Fetcher,
86
+ ): void {
87
+ if (event.defaultPrevented || options.reloadDocument) return;
88
+
89
+ const target = internalURL(
90
+ router.getSnapshot().url,
91
+ submitter?.getAttribute("formaction") ?? options.action,
92
+ );
93
+
94
+ if (!target) return;
95
+
96
+ const data = new FormData(event.currentTarget, submitter);
97
+ const method = (
98
+ submitter?.getAttribute("formmethod") ?? options.method
99
+ )?.toUpperCase();
100
+
101
+ event.preventDefault();
102
+
103
+ if (method === "GET") {
104
+ target.search = submissionSearch(data);
105
+
106
+ void (fetcher
107
+ ? fetcher.load(target.href)
108
+ : router.navigate(target.href, options));
109
+
110
+ return;
111
+ }
112
+
113
+ void (fetcher ?? router).submit(target.href, data, {
114
+ method: method as SubmitOptions["method"],
115
+ });
116
+ }
@@ -55,6 +55,8 @@ export interface RevalidationArgs extends RevalidationContext {
55
55
 
56
56
  /** A route component and its optional server and client data handlers. */
57
57
  export interface RouteModule {
58
+ /** Stable identifier for targeted revalidation; defaults to the path. */
59
+ id?: string;
58
60
  default?: unknown;
59
61
  HydrateFallback?: unknown;
60
62
  ErrorBoundary?: unknown;
@@ -96,7 +98,11 @@ export interface RouterOptions {
96
98
 
97
99
  /** Options for replacing rather than appending a browser history entry. */
98
100
  export interface NavigateOptions {
101
+ state?: unknown;
102
+ viewTransition?: boolean;
99
103
  replace?: boolean;
104
+ /** Preserve scroll in integrations that manage scroll restoration. */
105
+ preventScrollReset?: boolean;
100
106
  }
101
107
 
102
108
  /** HTTP method for a route action submission; defaults to POST. */
@@ -129,6 +135,15 @@ export interface Fetcher {
129
135
  dispose: () => void;
130
136
  }
131
137
 
138
+ /** Refresh active route data without navigation, optionally selecting route IDs or paths. */
139
+ export interface RevalidateOptions extends RevalidationContext {
140
+ route?: string | readonly string[];
141
+ reason?: RevalidationArgs["reason"];
142
+ }
143
+
144
+ /** A registered layout loader's revalidation callback. */
145
+ export type Revalidator = (args: RevalidationArgs) => void | Promise<void>;
146
+
132
147
  /** Route navigation, subscriptions, fetchers, and loader revalidation. */
133
148
  export interface Router {
134
149
  routes: readonly Route[];
@@ -137,9 +152,11 @@ export interface Router {
137
152
  start: () => () => void;
138
153
  navigate: (url: string, options?: NavigateOptions) => Promise<void>;
139
154
  revalidate: (
140
- reason?: RevalidationArgs["reason"],
155
+ options?: RevalidateOptions | RevalidationArgs["reason"],
141
156
  context?: RevalidationContext,
142
157
  ) => Promise<void>;
158
+ /** Register layout data under a route ID. The returned function unregisters it. */
159
+ registerRevalidator: (id: string, revalidate: Revalidator) => () => void;
143
160
  submit: (
144
161
  url: string,
145
162
  body: FormData,
@@ -0,0 +1,29 @@
1
+ /** Resolve an internal URL; external links remain the browser's responsibility. */
2
+ export function internalURL(base: string, href = base): URL | undefined {
3
+ const url = new URL(href, base);
4
+
5
+ if (url.origin === new URL(base).origin) return url;
6
+ }
7
+
8
+ /** Browser and framework event fields used by navigation interception. */
9
+ type NavigationClickEvent = Pick<
10
+ MouseEvent,
11
+ "defaultPrevented" | "button" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey"
12
+ >;
13
+
14
+ /** Whether an anchor click should use router navigation. */
15
+ export function navigationClick(
16
+ event: NavigationClickEvent,
17
+ anchor: HTMLAnchorElement,
18
+ ): boolean {
19
+ return !(
20
+ event.defaultPrevented ||
21
+ event.button ||
22
+ event.metaKey ||
23
+ event.ctrlKey ||
24
+ event.altKey ||
25
+ event.shiftKey ||
26
+ anchor.hasAttribute("download") ||
27
+ (anchor.target && anchor.target !== "_self")
28
+ );
29
+ }
@@ -0,0 +1,5 @@
1
+ /** Framework-independent, browser-safe utilities. @module */
2
+ export * from "./errors.ts";
3
+ export * from "./match.ts";
4
+ export * from "./serialize.ts";
5
+ export * from "./url.ts";
@@ -0,0 +1,41 @@
1
+ import type { ComponentChildren } from "preact";
2
+ import { use } from "preact/compat";
3
+
4
+ export interface AwaitProps<T> {
5
+ resolve: T | Promise<T>;
6
+ children: (value: Awaited<T>) => ComponentChildren;
7
+ errorElement?: ComponentChildren;
8
+ }
9
+
10
+ type Resolution = { value: unknown } | { error: unknown };
11
+
12
+ // Cache only the error-as-data adapter. Preact owns pending and resolved state.
13
+ const resolutions = new WeakMap<Promise<unknown>, Promise<Resolution>>();
14
+
15
+ export function Await<T>({
16
+ resolve,
17
+ children,
18
+ errorElement,
19
+ }: AwaitProps<T>): ComponentChildren {
20
+ if (!(resolve instanceof Promise)) return children(resolve as Awaited<T>);
21
+
22
+ let pending = resolutions.get(resolve);
23
+
24
+ if (!pending) {
25
+ pending = resolve.then(
26
+ (value) => ({ value }),
27
+ (error: unknown) => ({ error }),
28
+ );
29
+ resolutions.set(resolve, pending);
30
+ }
31
+
32
+ const result = use(pending);
33
+
34
+ if ("error" in result) {
35
+ if (errorElement !== undefined) return errorElement;
36
+
37
+ throw result.error;
38
+ }
39
+
40
+ return children(result.value as Awaited<T>);
41
+ }