@kasperrt/smolter 0.2.0 → 0.3.1

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 (270) hide show
  1. package/.generated/client/deployment.ts +161 -0
  2. package/{src/internal → .generated}/client/index.ts +3 -0
  3. package/.generated/core/environment.ts +43 -0
  4. package/{src/internal → .generated}/core/errors.ts +1 -1
  5. package/{src/internal → .generated}/core/index.ts +1 -0
  6. package/.generated/core/navigation-router.ts +107 -0
  7. package/.generated/core/navigation.ts +147 -0
  8. package/.generated/core/presentation.ts +108 -0
  9. package/.generated/core/resource-types.ts +44 -0
  10. package/.generated/core/resources.ts +444 -0
  11. package/.generated/core/responses.ts +86 -0
  12. package/{src/internal → .generated}/core/router.ts +136 -63
  13. package/{src/internal → .generated}/core/serialize.ts +7 -2
  14. package/.generated/core/store.ts +25 -0
  15. package/.generated/core/submission.ts +116 -0
  16. package/{src/internal → .generated}/core/types.ts +18 -1
  17. package/.generated/core/url.ts +29 -0
  18. package/.generated/core/utils.ts +5 -0
  19. package/.generated/preact/await.tsx +41 -0
  20. package/.generated/preact/client.tsx +93 -0
  21. package/.generated/preact/context.ts +59 -0
  22. package/.generated/preact/document.tsx +198 -0
  23. package/{src/internal → .generated}/preact/fetcher.ts +43 -23
  24. package/.generated/preact/form.ts +58 -0
  25. package/{src/internal → .generated}/preact/hooks.ts +25 -28
  26. package/.generated/preact/index.ts +34 -0
  27. package/.generated/preact/lazy-route.ts +100 -0
  28. package/.generated/preact/navigation-state.ts +9 -0
  29. package/.generated/preact/navigation.tsx +147 -0
  30. package/.generated/preact/root-context.ts +21 -0
  31. package/.generated/preact/root-data.ts +45 -0
  32. package/.generated/preact/route-hooks.tsx +170 -0
  33. package/.generated/preact/route-types.ts +76 -0
  34. package/.generated/preact/router.tsx +107 -0
  35. package/{src/internal → .generated}/preact/routes.ts +17 -9
  36. package/.generated/react/context.ts +59 -0
  37. package/.generated/react/data.ts +25 -0
  38. package/{src/internal → .generated}/react/fetcher.ts +13 -11
  39. package/.generated/react/form.ts +58 -0
  40. package/{src/internal → .generated}/react/hooks.ts +9 -14
  41. package/{src/internal/preact → .generated/react}/index.ts +5 -1
  42. package/{src/internal → .generated}/react/link.ts +13 -21
  43. package/.generated/react/resources/components.tsx +404 -0
  44. package/{src/internal → .generated}/react/routes.ts +17 -9
  45. package/.generated/server/application.tsx +185 -0
  46. package/.generated/server/assets.ts +19 -0
  47. package/.generated/server/handler.ts +69 -0
  48. package/.generated/server/http.ts +55 -0
  49. package/{src/internal → .generated}/server/index.ts +3 -3
  50. package/.generated/server/node.ts +4 -0
  51. package/.generated/server/preact.ts +7 -0
  52. package/.generated/vite/application/browser-boundary.ts +42 -0
  53. package/.generated/vite/application/lazy-files.ts +138 -0
  54. package/.generated/vite/application/vite.ts +144 -0
  55. package/.generated/vite/deployment.ts +46 -0
  56. package/{src/internal → .generated}/vite/index.ts +11 -4
  57. package/{src/preact-vite.ts → .generated/vite/preact.ts} +14 -3
  58. package/{src/react-vite.ts → .generated/vite/react.ts} +8 -3
  59. package/CHANGELOG.md +63 -0
  60. package/README.md +277 -35
  61. package/dist/client/deployment.d.ts +12 -0
  62. package/dist/client/deployment.js +102 -0
  63. package/dist/client/deserialize.js +10 -0
  64. package/dist/{internal/client → client}/index.d.ts +2 -0
  65. package/dist/client/index.js +47 -0
  66. package/dist/core/environment.d.ts +12 -0
  67. package/dist/core/environment.js +23 -0
  68. package/dist/core/errors.js +7 -0
  69. package/dist/{internal/core → core}/index.d.ts +1 -0
  70. package/dist/core/match.js +33 -0
  71. package/dist/core/navigation-router.d.ts +15 -0
  72. package/dist/core/navigation-router.js +64 -0
  73. package/dist/core/navigation.d.ts +15 -0
  74. package/dist/core/navigation.js +92 -0
  75. package/dist/core/presentation.d.ts +5 -0
  76. package/dist/core/presentation.js +69 -0
  77. package/dist/core/resource-types.d.ts +37 -0
  78. package/dist/core/resources.d.ts +19 -0
  79. package/dist/core/resources.js +345 -0
  80. package/dist/core/responses.d.ts +15 -0
  81. package/dist/core/responses.js +48 -0
  82. package/dist/{internal/core → core}/router.d.ts +1 -1
  83. package/dist/core/router.js +204 -0
  84. package/dist/{internal/core → core}/serialize.d.ts +2 -0
  85. package/dist/core/serialize.js +17 -0
  86. package/dist/core/store.d.ts +6 -0
  87. package/dist/core/store.js +21 -0
  88. package/dist/core/submission.d.ts +30 -0
  89. package/dist/core/submission.js +41 -0
  90. package/dist/{internal/core → core}/types.d.ts +16 -1
  91. package/dist/core/url.d.ts +7 -0
  92. package/dist/core/url.js +12 -0
  93. package/dist/core/utils.d.ts +5 -0
  94. package/dist/core/utils.js +5 -0
  95. package/dist/files/index.js +64 -0
  96. package/dist/preact/await.d.ts +7 -0
  97. package/dist/preact/await.js +19 -0
  98. package/dist/preact/client.d.ts +7 -0
  99. package/dist/preact/client.js +68 -0
  100. package/dist/preact/context.d.ts +18 -0
  101. package/dist/preact/context.js +23 -0
  102. package/dist/preact/document.d.ts +15 -0
  103. package/dist/preact/document.js +155 -0
  104. package/dist/{internal/preact → preact}/fetcher.d.ts +9 -5
  105. package/dist/preact/fetcher.js +45 -0
  106. package/dist/{internal/preact → preact}/form.d.ts +5 -6
  107. package/dist/preact/form.js +21 -0
  108. package/dist/{internal/preact → preact}/hooks.d.ts +7 -6
  109. package/dist/preact/hooks.js +25 -0
  110. package/dist/preact/index.d.ts +18 -0
  111. package/dist/preact/index.js +15 -0
  112. package/dist/preact/lazy-route.d.ts +19 -0
  113. package/dist/preact/lazy-route.js +51 -0
  114. package/dist/preact/navigation-state.d.ts +2 -0
  115. package/dist/preact/navigation-state.js +9 -0
  116. package/dist/preact/navigation.d.ts +28 -0
  117. package/dist/preact/navigation.js +60 -0
  118. package/dist/preact/root-context.d.ts +13 -0
  119. package/dist/preact/root-context.js +9 -0
  120. package/dist/preact/root-data.d.ts +8 -0
  121. package/dist/preact/root-data.js +28 -0
  122. package/dist/preact/route-hooks.d.ts +32 -0
  123. package/dist/preact/route-hooks.js +94 -0
  124. package/dist/preact/route-types.d.ts +54 -0
  125. package/dist/preact/router.d.ts +16 -0
  126. package/dist/preact/router.js +63 -0
  127. package/dist/{internal/preact → preact}/routes.d.ts +1 -1
  128. package/dist/preact/routes.js +12 -0
  129. package/dist/{internal/preact → preact}/runtime.js +1 -1
  130. package/dist/react/context.d.ts +18 -0
  131. package/dist/react/context.js +23 -0
  132. package/dist/react/data.d.ts +5 -0
  133. package/dist/react/data.js +3 -0
  134. package/dist/{internal/react → react}/fetcher.d.ts +2 -2
  135. package/dist/react/fetcher.js +34 -0
  136. package/dist/{internal/react → react}/form.d.ts +5 -6
  137. package/dist/react/form.js +21 -0
  138. package/dist/react/hooks.js +25 -0
  139. package/dist/{internal/react → react}/index.d.ts +1 -1
  140. package/dist/react/index.js +10 -0
  141. package/dist/{internal/react → react}/link.d.ts +2 -2
  142. package/dist/react/link.js +27 -0
  143. package/dist/react/resources/components.d.ts +64 -0
  144. package/dist/react/resources/components.js +239 -0
  145. package/dist/{internal/react → react}/routes.d.ts +1 -1
  146. package/dist/react/routes.js +12 -0
  147. package/dist/react/runtime.js +1 -0
  148. package/dist/server/application.d.ts +4 -0
  149. package/dist/server/application.js +119 -0
  150. package/dist/server/assets.d.ts +5 -0
  151. package/dist/server/assets.js +7 -0
  152. package/dist/server/handler.d.ts +19 -0
  153. package/dist/server/handler.js +22 -0
  154. package/dist/server/http.d.ts +4 -0
  155. package/dist/server/http.js +27 -0
  156. package/dist/server/index.js +76 -0
  157. package/dist/server/node.d.ts +3 -0
  158. package/dist/server/node.js +3 -0
  159. package/dist/server/preact.d.ts +3 -0
  160. package/dist/server/preact.js +4 -0
  161. package/dist/vite/application/browser-boundary.d.ts +3 -0
  162. package/dist/vite/application/browser-boundary.js +35 -0
  163. package/dist/vite/application/lazy-files.d.ts +2 -0
  164. package/dist/vite/application/lazy-files.js +88 -0
  165. package/dist/vite/application/vite.d.ts +9 -0
  166. package/dist/vite/application/vite.js +101 -0
  167. package/dist/vite/deployment.d.ts +8 -0
  168. package/dist/vite/deployment.js +36 -0
  169. package/dist/vite/index.js +109 -0
  170. package/dist/{preact-vite.d.ts → vite/preact.d.ts} +6 -3
  171. package/dist/vite/preact.js +16 -0
  172. package/dist/{react-vite.d.ts → vite/react.d.ts} +4 -3
  173. package/dist/vite/react.js +15 -0
  174. package/dist/vite/transport.js +35 -0
  175. package/jsr.json +28 -16
  176. package/package.json +115 -35
  177. package/dist/client.d.ts +0 -2
  178. package/dist/client.js +0 -2
  179. package/dist/core.d.ts +0 -2
  180. package/dist/core.js +0 -2
  181. package/dist/files.d.ts +0 -2
  182. package/dist/files.js +0 -2
  183. package/dist/internal/client/deserialize.js +0 -11
  184. package/dist/internal/client/index.js +0 -44
  185. package/dist/internal/core/errors.js +0 -6
  186. package/dist/internal/core/index.js +0 -7
  187. package/dist/internal/core/match.js +0 -27
  188. package/dist/internal/core/router.js +0 -189
  189. package/dist/internal/core/serialize.js +0 -13
  190. package/dist/internal/core/types.js +0 -5
  191. package/dist/internal/core/url.d.ts +0 -2
  192. package/dist/internal/core/url.js +0 -6
  193. package/dist/internal/files/index.js +0 -77
  194. package/dist/internal/preact/context.d.ts +0 -18
  195. package/dist/internal/preact/context.js +0 -20
  196. package/dist/internal/preact/fetcher.js +0 -28
  197. package/dist/internal/preact/form.js +0 -41
  198. package/dist/internal/preact/hooks.js +0 -35
  199. package/dist/internal/preact/index.d.ts +0 -9
  200. package/dist/internal/preact/index.js +0 -7
  201. package/dist/internal/preact/link.d.ts +0 -12
  202. package/dist/internal/preact/link.js +0 -28
  203. package/dist/internal/preact/routes.js +0 -22
  204. package/dist/internal/react/context.d.ts +0 -18
  205. package/dist/internal/react/context.js +0 -20
  206. package/dist/internal/react/fetcher.js +0 -28
  207. package/dist/internal/react/form.js +0 -41
  208. package/dist/internal/react/hooks.js +0 -35
  209. package/dist/internal/react/index.js +0 -7
  210. package/dist/internal/react/link.js +0 -28
  211. package/dist/internal/react/routes.js +0 -22
  212. package/dist/internal/react/runtime.js +0 -2
  213. package/dist/internal/server/index.js +0 -69
  214. package/dist/internal/vite/index.js +0 -132
  215. package/dist/internal/vite/transport.js +0 -42
  216. package/dist/preact-client.d.ts +0 -2
  217. package/dist/preact-client.js +0 -2
  218. package/dist/preact-server.d.ts +0 -2
  219. package/dist/preact-server.js +0 -2
  220. package/dist/preact-vite.js +0 -9
  221. package/dist/preact.d.ts +0 -2
  222. package/dist/preact.js +0 -2
  223. package/dist/react-client.d.ts +0 -2
  224. package/dist/react-client.js +0 -2
  225. package/dist/react-server.d.ts +0 -2
  226. package/dist/react-server.js +0 -2
  227. package/dist/react-vite.js +0 -9
  228. package/dist/react.d.ts +0 -2
  229. package/dist/react.js +0 -2
  230. package/dist/server.d.ts +0 -2
  231. package/dist/server.js +0 -2
  232. package/dist/utils.d.ts +0 -5
  233. package/dist/utils.js +0 -5
  234. package/dist/vite.d.ts +0 -2
  235. package/dist/vite.js +0 -2
  236. package/src/client.ts +0 -2
  237. package/src/core.ts +0 -2
  238. package/src/files.ts +0 -2
  239. package/src/internal/core/url.ts +0 -6
  240. package/src/internal/preact/context.ts +0 -46
  241. package/src/internal/preact/form.ts +0 -79
  242. package/src/internal/preact/link.ts +0 -46
  243. package/src/internal/react/context.ts +0 -46
  244. package/src/internal/react/form.ts +0 -79
  245. package/src/internal/react/index.ts +0 -13
  246. package/src/preact-client.ts +0 -2
  247. package/src/preact-server.ts +0 -2
  248. package/src/preact.ts +0 -2
  249. package/src/react-client.ts +0 -2
  250. package/src/react-server.ts +0 -2
  251. package/src/react.ts +0 -2
  252. package/src/server.ts +0 -2
  253. package/src/utils.ts +0 -5
  254. package/src/vite.ts +0 -2
  255. /package/{src/internal → .generated}/client/deserialize.ts +0 -0
  256. /package/{src/internal → .generated}/core/match.ts +0 -0
  257. /package/{src/internal → .generated}/files/index.ts +0 -0
  258. /package/{src/internal → .generated}/preact/runtime.ts +0 -0
  259. /package/{src/internal → .generated}/react/runtime.ts +0 -0
  260. /package/{src/internal → .generated}/vite/transport.ts +0 -0
  261. /package/dist/{internal/client → client}/deserialize.d.ts +0 -0
  262. /package/dist/{internal/core → core}/errors.d.ts +0 -0
  263. /package/dist/{internal/core → core}/match.d.ts +0 -0
  264. /package/dist/{internal/files → files}/index.d.ts +0 -0
  265. /package/dist/{internal/preact → preact}/runtime.d.ts +0 -0
  266. /package/dist/{internal/react → react}/hooks.d.ts +0 -0
  267. /package/dist/{internal/react → react}/runtime.d.ts +0 -0
  268. /package/dist/{internal/server → server}/index.d.ts +0 -0
  269. /package/dist/{internal/vite → vite}/index.d.ts +0 -0
  270. /package/dist/{internal/vite → vite}/transport.d.ts +0 -0
@@ -0,0 +1,161 @@
1
+ import type { NavigationRouter } from "../core/index.ts";
2
+
3
+ /** Build identity and the fresh, non-cacheable manifest URL emitted by deploymentPlugin. */
4
+ export interface DeploymentOptions {
5
+ version: string;
6
+ manifestURL: string;
7
+ /** Maximum time to wait for the update check before continuing navigation. */
8
+ timeout?: number;
9
+ /** Override document navigation, for example to ask about unsaved changes. */
10
+ reload?: (destination: URL) => void;
11
+ }
12
+
13
+ /** Check for deployment changes before navigation. Returns a cleanup function. */
14
+ export function watchDeployment(
15
+ router: NavigationRouter,
16
+ options: DeploymentOptions | null,
17
+ ): () => void {
18
+ if (!options || typeof window === "undefined") return () => {};
19
+
20
+ const { version, manifestURL, timeout = 2000 } = options;
21
+ const navigate = router.navigate;
22
+ const dispose = router.dispose;
23
+ const manifest = new URL(manifestURL, location.href);
24
+ const key = `smolter:deployment:${manifest.href}:${version}`;
25
+ let sequence = 0;
26
+ let stopped = false;
27
+ let replay = false;
28
+ let destination = new URL(location.href);
29
+ let pending: Promise<boolean> | undefined;
30
+ let controller: AbortController | undefined;
31
+
32
+ function changed() {
33
+ if (pending) return pending;
34
+
35
+ controller = new AbortController();
36
+ const timer = setTimeout(() => controller?.abort(), timeout);
37
+ pending = fetch(manifest, { cache: "no-store", signal: controller.signal })
38
+ .then(async (response) => {
39
+ if (!response.ok) return false;
40
+
41
+ const data: unknown = await response.json();
42
+
43
+ return (
44
+ typeof data === "object" &&
45
+ data !== null &&
46
+ "version" in data &&
47
+ typeof data.version === "string" &&
48
+ data.version.length > 0 &&
49
+ data.version !== version
50
+ );
51
+ })
52
+ .catch(() => false)
53
+ .finally(() => {
54
+ clearTimeout(timer);
55
+ pending = undefined;
56
+ });
57
+
58
+ return pending;
59
+ }
60
+
61
+ async function reload(target: URL) {
62
+ // If storage is unavailable, do not risk reloading the same cached document forever.
63
+ const allowed = await Promise.resolve()
64
+ .then(() => {
65
+ if (sessionStorage.getItem(key)) return false;
66
+
67
+ sessionStorage.setItem(key, "1");
68
+
69
+ return true;
70
+ })
71
+ .catch(() => false);
72
+
73
+ if (allowed && !stopped) {
74
+ if (options?.reload) options.reload(target);
75
+ if (!options?.reload) {
76
+ if (target.href === location.href) location.reload();
77
+ if (target.href !== location.href) location.assign(target.href);
78
+ }
79
+ }
80
+
81
+ return allowed && !stopped;
82
+ }
83
+
84
+ const guarded: NavigationRouter["navigate"] = async (href, operation) => {
85
+ const target = new URL(href, router.getSnapshot().url);
86
+ const currentURL = new URL(router.getSnapshot().url);
87
+ const current = ++sequence;
88
+ destination = target;
89
+
90
+ if (
91
+ target.origin === location.origin &&
92
+ (target.pathname !== currentURL.pathname ||
93
+ target.search !== currentURL.search) &&
94
+ (await changed())
95
+ ) {
96
+ if (current !== sequence || stopped || (await reload(target))) return;
97
+ }
98
+
99
+ if (current === sequence && !stopped) await navigate(href, operation);
100
+ };
101
+
102
+ const pop = (event: PopStateEvent) => {
103
+ if (replay) return;
104
+
105
+ const target = new URL(location.href);
106
+ const previous = new URL(router.getSnapshot().url);
107
+
108
+ if (
109
+ target.pathname === previous.pathname &&
110
+ target.search === previous.search
111
+ )
112
+ return;
113
+
114
+ event.stopImmediatePropagation();
115
+ const current = ++sequence;
116
+ destination = target;
117
+ void changed().then(async (updated) => {
118
+ if (stopped || current !== sequence) return;
119
+ if (updated && (await reload(target))) return;
120
+
121
+ replay = true;
122
+ window.dispatchEvent(
123
+ new PopStateEvent("popstate", { state: event.state }),
124
+ );
125
+ replay = false;
126
+ });
127
+ };
128
+
129
+ const preloadError = () => {
130
+ if (router.getSnapshot().state === "submitting") return;
131
+
132
+ const current = sequence;
133
+ const target = destination;
134
+ void changed().then((updated) => {
135
+ if (updated && current === sequence && !stopped) return reload(target);
136
+ });
137
+ };
138
+
139
+ function cleanup() {
140
+ stopped = true;
141
+ sequence++;
142
+ controller?.abort();
143
+ window.removeEventListener("popstate", pop, true);
144
+ window.removeEventListener("vite:preloadError", preloadError);
145
+
146
+ if (router.navigate === guarded) router.navigate = navigate;
147
+ if (router.dispose === guardedDispose) router.dispose = dispose;
148
+ }
149
+
150
+ function guardedDispose() {
151
+ cleanup();
152
+ dispose();
153
+ }
154
+
155
+ router.navigate = guarded;
156
+ router.dispose = guardedDispose;
157
+ window.addEventListener("popstate", pop, true);
158
+ window.addEventListener("vite:preloadError", preloadError);
159
+
160
+ return cleanup;
161
+ }
@@ -16,6 +16,7 @@ export { deserialize } from "./deserialize.ts";
16
16
 
17
17
  /** Browser-safe route exports, excluding server implementation code. */
18
18
  export interface ClientRouteModule {
19
+ id?: string;
19
20
  default?: unknown;
20
21
  HydrateFallback?: unknown;
21
22
  ErrorBoundary?: unknown;
@@ -82,3 +83,5 @@ export function serverRoute<M extends RouteModule = RouteModule>(
82
83
  }),
83
84
  } as Route<M>;
84
85
  }
86
+
87
+ export { type DeploymentOptions, watchDeployment } from "./deployment.ts";
@@ -0,0 +1,43 @@
1
+ export interface ApplicationEnvironment {
2
+ resolveURL?: (url: URL) => URL;
3
+ isDocumentHandoff?: (from: URL, to: URL) => boolean;
4
+ leaveDocument?: (destination: URL) => void;
5
+ entryScript?: string;
6
+ }
7
+
8
+ // An absent configuration lets bundlers remove unused deployment callbacks.
9
+ export let applicationEnvironment: ApplicationEnvironment | undefined;
10
+
11
+ /** Configure deployment-specific navigation before rendering or mounting. */
12
+ export function configureApplication(
13
+ environment: ApplicationEnvironment,
14
+ ): void {
15
+ applicationEnvironment = environment;
16
+ }
17
+
18
+ export function resolveApplicationURL(url: URL): URL {
19
+ return applicationEnvironment?.resolveURL?.(url) ?? url;
20
+ }
21
+
22
+ export function handoffDocument(event: MouseEvent, destination: URL): void {
23
+ if (
24
+ !applicationEnvironment?.isDocumentHandoff?.(
25
+ new URL(location.href),
26
+ destination,
27
+ )
28
+ )
29
+ return;
30
+
31
+ event.preventDefault();
32
+ leaveDocument(destination);
33
+ }
34
+
35
+ export function leaveDocument(destination: URL): void {
36
+ if (applicationEnvironment?.leaveDocument) {
37
+ applicationEnvironment.leaveDocument(destination);
38
+
39
+ return;
40
+ }
41
+
42
+ location.assign(destination.href);
43
+ }
@@ -8,6 +8,6 @@ export function safeWrapAsync<T>(
8
8
  .then(work)
9
9
  .then(
10
10
  (data) => (data instanceof Error ? { error: data } : { data }),
11
- (cause: unknown) => ({ error: new Error(String(cause), { cause }) }),
11
+ (cause: unknown) => ({ error: Error(String(cause), { cause }) }),
12
12
  );
13
13
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  export * from "./errors.ts";
4
4
  export * from "./match.ts";
5
+ export * from "./navigation-router.ts";
5
6
  export * from "./router.ts";
6
7
  export * from "./serialize.ts";
7
8
  export * from "./types.ts";
@@ -0,0 +1,107 @@
1
+ import { matchRoutes } from "./match.ts";
2
+ import type {
3
+ NavigateOptions,
4
+ Route,
5
+ Router,
6
+ RouterOptions,
7
+ Snapshot,
8
+ } from "./types.ts";
9
+ import { internalURL } from "./url.ts";
10
+
11
+ /** Navigation and subscriptions without the loader/action engine. */
12
+ export type NavigationRouter = Pick<
13
+ Router,
14
+ "routes" | "getSnapshot" | "subscribe" | "start" | "navigate" | "dispose"
15
+ >;
16
+
17
+ /** Route modules for navigation only; data handlers require createDataRouter. */
18
+ export type NavigationRoute = Route & {
19
+ loader?: never;
20
+ action?: never;
21
+ clientLoader?: never;
22
+ clientAction?: never;
23
+ shouldRevalidate?: never;
24
+ };
25
+
26
+ /** Initial URL and optional browser history for navigation-only routing. */
27
+ export type NavigationRouterOptions = Pick<RouterOptions, "url" | "browser">;
28
+
29
+ /** Create a route matcher and history store without data requests or fetchers. */
30
+ export function createRouter(
31
+ routes: readonly NavigationRoute[],
32
+ options: NavigationRouterOptions = {},
33
+ ): NavigationRouter {
34
+ const browser = options.browser && typeof window !== "undefined";
35
+ const listeners = new Set<() => void>();
36
+ let users = 0;
37
+ const resolve = (url: URL): Snapshot => {
38
+ const match = matchRoutes(routes, url.pathname);
39
+
40
+ return {
41
+ url: url.href,
42
+ path: match?.route.path,
43
+ params: match?.params ?? {},
44
+ state: "idle",
45
+ loaded: true,
46
+ };
47
+ };
48
+ let snapshot = resolve(
49
+ new URL(options.url ?? (browser ? location.href : "/"), "http://localhost"),
50
+ );
51
+ const update = (url: URL) => {
52
+ if (url.href === snapshot.url) return;
53
+
54
+ snapshot = resolve(url);
55
+
56
+ for (const listener of listeners) listener();
57
+ };
58
+ const pop = () => update(new URL(location.href));
59
+
60
+ return {
61
+ routes,
62
+ getSnapshot: () => snapshot,
63
+ subscribe(listener) {
64
+ listeners.add(listener);
65
+
66
+ return () => {
67
+ listeners.delete(listener);
68
+ };
69
+ },
70
+ start() {
71
+ if (!users++ && browser) {
72
+ addEventListener("popstate", pop);
73
+ addEventListener("hashchange", pop);
74
+ pop();
75
+ }
76
+
77
+ return () => {
78
+ if (!--users && browser) {
79
+ removeEventListener("popstate", pop);
80
+ removeEventListener("hashchange", pop);
81
+ }
82
+ };
83
+ },
84
+ async navigate(href: string, operation?: NavigateOptions) {
85
+ const url = internalURL(snapshot.url, href);
86
+
87
+ if (!url) return;
88
+
89
+ if (browser)
90
+ history[operation?.replace ? "replaceState" : "pushState"](
91
+ operation?.state ?? null,
92
+ "",
93
+ url,
94
+ );
95
+
96
+ update(url);
97
+ },
98
+ dispose() {
99
+ listeners.clear();
100
+
101
+ if (browser) {
102
+ removeEventListener("popstate", pop);
103
+ removeEventListener("hashchange", pop);
104
+ }
105
+ },
106
+ };
107
+ }
@@ -0,0 +1,147 @@
1
+ import {
2
+ handoffDocument,
3
+ leaveDocument,
4
+ resolveApplicationURL,
5
+ } from "./environment.ts";
6
+ import { createStore } from "./store.ts";
7
+ import type { NavigateOptions, Router } from "./types.ts";
8
+ import { navigationClick } from "./url.ts";
9
+
10
+ export type NavigationType = "POP" | "PUSH" | "REPLACE";
11
+
12
+ export interface NavigationSnapshot {
13
+ type: NavigationType;
14
+ state: unknown;
15
+ key: string;
16
+ preventScrollReset: boolean;
17
+ }
18
+
19
+ const scrollHistory = new WeakMap<Router, Map<string, [number, number]>>();
20
+
21
+ export function scrollPositions(router: Router) {
22
+ let positions = scrollHistory.get(router);
23
+
24
+ if (!positions) {
25
+ positions = new Map();
26
+ scrollHistory.set(router, positions);
27
+ }
28
+
29
+ return positions;
30
+ }
31
+
32
+ const stores = new WeakMap<Router, ReturnType<typeof createNavigationStore>>();
33
+
34
+ function createNavigationStore() {
35
+ return createStore<NavigationSnapshot>({
36
+ type: "POP",
37
+ state: typeof history === "undefined" ? null : history.state?.state,
38
+ key:
39
+ typeof history === "undefined"
40
+ ? "initial"
41
+ : (history.state?.key ?? "initial"),
42
+ preventScrollReset: false,
43
+ });
44
+ }
45
+
46
+ export function navigationStore(router: Router) {
47
+ let store = stores.get(router);
48
+
49
+ if (!store) {
50
+ store = createNavigationStore();
51
+ stores.set(router, store);
52
+ }
53
+
54
+ return store;
55
+ }
56
+
57
+ export function connectNavigation(router: Router) {
58
+ const store = navigationStore(router);
59
+ const navigate = router.navigate;
60
+ let sequence = 0;
61
+ let pending: NavigationSnapshot | undefined;
62
+ const committed = () => {
63
+ if (!pending || router.getSnapshot().state !== "idle") return;
64
+
65
+ const next = pending;
66
+ pending = undefined;
67
+
68
+ if (next.type !== "POP") history.replaceState(next, "", location.href);
69
+
70
+ store.update(next);
71
+ };
72
+ const unsubscribe = router.subscribe(committed);
73
+
74
+ const rememberScroll = () => {
75
+ scrollPositions(router).set(store.getSnapshot().key, [
76
+ window.scrollX,
77
+ window.scrollY,
78
+ ]);
79
+ };
80
+
81
+ router.navigate = async (href, options: NavigateOptions = {}) => {
82
+ const destination = resolveApplicationURL(
83
+ new URL(href, router.getSnapshot().url),
84
+ );
85
+
86
+ if (destination.origin !== location.origin) {
87
+ leaveDocument(destination);
88
+
89
+ return;
90
+ }
91
+
92
+ rememberScroll();
93
+ const current = ++sequence;
94
+ pending = {
95
+ type: options.replace ? "REPLACE" : "PUSH",
96
+ state: options.state ?? null,
97
+ key: `${Date.now().toString(36)}-${current}`,
98
+ preventScrollReset: options.preventScrollReset ?? false,
99
+ };
100
+ // Retained views may commit after navigate resolves; the subscription waits
101
+ // for that final idle snapshot rather than starting another listener.
102
+ await navigate(href, options);
103
+ committed();
104
+ };
105
+
106
+ const pop = () => {
107
+ rememberScroll();
108
+ pending = {
109
+ ...history.state,
110
+ type: "POP",
111
+ key: history.state?.key ?? "initial",
112
+ preventScrollReset: false,
113
+ };
114
+ committed();
115
+ };
116
+
117
+ const handoff = (event: MouseEvent) => {
118
+ const anchor =
119
+ event.target instanceof Element ? event.target.closest("a[href]") : null;
120
+
121
+ if (
122
+ !(anchor instanceof HTMLAnchorElement) ||
123
+ !navigationClick(event, anchor)
124
+ )
125
+ return;
126
+
127
+ const target = resolveApplicationURL(new URL(anchor.href));
128
+
129
+ handoffDocument(event, target);
130
+ };
131
+
132
+ if (typeof document !== "undefined")
133
+ document.addEventListener("click", handoff);
134
+
135
+ window.addEventListener("popstate", pop);
136
+ const dispose = router.dispose;
137
+ router.dispose = () => {
138
+ pending = undefined;
139
+ unsubscribe();
140
+ window.removeEventListener("popstate", pop);
141
+
142
+ if (typeof document !== "undefined")
143
+ document.removeEventListener("click", handoff);
144
+
145
+ dispose();
146
+ };
147
+ }
@@ -0,0 +1,108 @@
1
+ import type { NavigateOptions, Route, Router, Snapshot } from "./types.ts";
2
+
3
+ interface ResolvableRoute extends Route {
4
+ resolveComponent?: () => unknown;
5
+ }
6
+
7
+ type CommitView = (update: () => void) => void;
8
+
9
+ const pending = new WeakMap<Router, () => Snapshot>();
10
+
11
+ export function pendingLocation(router: Router) {
12
+ const snapshot = pending.get(router)?.();
13
+
14
+ return snapshot?.state !== "idle" && snapshot?.url
15
+ ? new URL(snapshot.url)
16
+ : undefined;
17
+ }
18
+
19
+ // Keep the committed route's view and data together while navigation loads.
20
+ // The underlying router still owns cancellation, redirects and fetcher state.
21
+ export function retainRoute(router: Router, commitView: CommitView) {
22
+ const read = router.getSnapshot;
23
+ pending.set(router, read);
24
+ let viewTransition: boolean | undefined;
25
+ const navigate = router.navigate;
26
+ router.navigate = (href, options: NavigateOptions = {}) => {
27
+ viewTransition = options.viewTransition;
28
+
29
+ return navigate(href, options);
30
+ };
31
+ let visible = read();
32
+ let revision = 0;
33
+ const listeners = new Set<() => void>();
34
+
35
+ const publish = () => {
36
+ for (const listener of listeners) listener();
37
+ };
38
+
39
+ const unsubscribe = router.subscribe(() => {
40
+ const next = read();
41
+ const current = ++revision;
42
+
43
+ if (next.state !== "idle" && visible.loaded) {
44
+ visible = { ...visible, state: next.state };
45
+ publish();
46
+
47
+ return;
48
+ }
49
+
50
+ const commit = () => {
51
+ if (current !== revision) return;
52
+
53
+ commitView(() => {
54
+ visible = next;
55
+ publish();
56
+ });
57
+ };
58
+
59
+ const previousURL = new URL(visible.url);
60
+ const nextURL = new URL(next.url);
61
+ const changed =
62
+ previousURL.pathname + previousURL.search !==
63
+ nextURL.pathname + nextURL.search;
64
+ const component = (path: string | undefined) => {
65
+ const route = router.routes.find((item) => item.path === path) as
66
+ | ResolvableRoute
67
+ | undefined;
68
+
69
+ return route?.resolveComponent?.() ?? route?.default;
70
+ };
71
+ const previousComponent = component(visible.path);
72
+ const sharedView =
73
+ previousComponent && previousComponent === component(next.path);
74
+ const animate =
75
+ changed &&
76
+ visible.loaded &&
77
+ viewTransition !== false &&
78
+ !sharedView &&
79
+ previousURL.pathname !== nextURL.pathname &&
80
+ typeof document !== "undefined" &&
81
+ typeof document.startViewTransition === "function" &&
82
+ !matchMedia("(prefers-reduced-motion: reduce)").matches;
83
+
84
+ if (animate) {
85
+ const transition = document.startViewTransition(commit);
86
+ void transition.ready.catch(() => {});
87
+
88
+ return;
89
+ }
90
+
91
+ commit();
92
+ });
93
+
94
+ router.getSnapshot = () => visible;
95
+ router.subscribe = (listener) => {
96
+ listeners.add(listener);
97
+
98
+ return () => listeners.delete(listener);
99
+ };
100
+
101
+ const dispose = router.dispose;
102
+ router.dispose = () => {
103
+ unsubscribe();
104
+ pending.delete(router);
105
+ listeners.clear();
106
+ dispose();
107
+ };
108
+ }
@@ -0,0 +1,44 @@
1
+ export type Params = Readonly<Record<string, string>>;
2
+
3
+ export interface LoaderFunctionArgs {
4
+ params: Params;
5
+ signal: AbortSignal;
6
+ }
7
+
8
+ export interface ActionFunctionArgs<Input = unknown> {
9
+ input: Input;
10
+ params: Params;
11
+ signal: AbortSignal;
12
+ }
13
+
14
+ export interface DataResult<Data> {
15
+ data: Data | null;
16
+ error: string;
17
+ unexpected?: true;
18
+ }
19
+
20
+ export interface ResourceModule {
21
+ action?: (args: ActionFunctionArgs) => Promise<DataResult<unknown>>;
22
+ loader?: (args: LoaderFunctionArgs) => Promise<DataResult<unknown>>;
23
+ shouldRevalidate?: (args: ShouldRevalidateFunctionArgs) => boolean;
24
+ }
25
+
26
+ export interface ShouldRevalidateFunctionArgs {
27
+ actionResult: DataResult<unknown>;
28
+ defaultShouldRevalidate: boolean;
29
+ params: Params;
30
+ }
31
+
32
+ export type NavigationState = "idle" | "loading" | "submitting";
33
+
34
+ export interface RouteState {
35
+ actionData: unknown;
36
+ actionError: string;
37
+ actionState: "idle" | "submitting";
38
+ loaderData: unknown;
39
+ loaderError: string;
40
+ loaderInitialized: boolean;
41
+ loaderState: "idle" | "loading";
42
+ navigationState: NavigationState;
43
+ unexpectedError: boolean;
44
+ }