netjes 0.0.0-stage → 0.0.2

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 (222) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -2
  3. package/bin/netjes.mjs +13 -0
  4. package/cli/agents.mjs +66 -0
  5. package/cli/commands.mjs +467 -0
  6. package/cli/help.mjs +89 -0
  7. package/cli/info.mjs +454 -0
  8. package/cli/main.mjs +167 -0
  9. package/cli/packages.mjs +19 -0
  10. package/cli/process-io.mjs +163 -0
  11. package/cli/routes.mjs +31 -0
  12. package/cli/scaffold.mjs +193 -0
  13. package/cli/suggest.mjs +50 -0
  14. package/cli/terminal.mjs +40 -0
  15. package/dist/actions.d.ts +98 -0
  16. package/dist/actions.d.ts.map +1 -0
  17. package/dist/actions.js +176 -0
  18. package/dist/actions.js.map +1 -0
  19. package/dist/client-entry.d.ts +2 -0
  20. package/dist/client-entry.d.ts.map +1 -0
  21. package/dist/client-entry.js +126 -0
  22. package/dist/client-entry.js.map +1 -0
  23. package/dist/deferred.d.ts +39 -0
  24. package/dist/deferred.d.ts.map +1 -0
  25. package/dist/deferred.js +123 -0
  26. package/dist/deferred.js.map +1 -0
  27. package/dist/define-page.d.ts +15 -0
  28. package/dist/define-page.d.ts.map +1 -0
  29. package/dist/define-page.js +27 -0
  30. package/dist/define-page.js.map +1 -0
  31. package/dist/document.d.ts +19 -0
  32. package/dist/document.d.ts.map +1 -0
  33. package/dist/document.js +17 -0
  34. package/dist/document.js.map +1 -0
  35. package/dist/draft-mode.d.ts +5 -0
  36. package/dist/draft-mode.d.ts.map +1 -0
  37. package/dist/draft-mode.js +6 -0
  38. package/dist/draft-mode.js.map +1 -0
  39. package/dist/dynamic.d.ts +20 -0
  40. package/dist/dynamic.d.ts.map +1 -0
  41. package/dist/dynamic.js +37 -0
  42. package/dist/dynamic.js.map +1 -0
  43. package/dist/form-data.d.ts +10 -0
  44. package/dist/form-data.d.ts.map +1 -0
  45. package/dist/form-data.js +10 -0
  46. package/dist/form-data.js.map +1 -0
  47. package/dist/form.d.ts +35 -0
  48. package/dist/form.d.ts.map +1 -0
  49. package/dist/form.js +80 -0
  50. package/dist/form.js.map +1 -0
  51. package/dist/href.d.ts +25 -0
  52. package/dist/href.d.ts.map +1 -0
  53. package/dist/href.js +71 -0
  54. package/dist/href.js.map +1 -0
  55. package/dist/image.d.ts +36 -0
  56. package/dist/image.d.ts.map +1 -0
  57. package/dist/image.js +60 -0
  58. package/dist/image.js.map +1 -0
  59. package/dist/index.d.ts +18 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +14 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/link.d.ts +17 -0
  64. package/dist/link.d.ts.map +1 -0
  65. package/dist/link.js +46 -0
  66. package/dist/link.js.map +1 -0
  67. package/dist/locale.d.ts +3 -0
  68. package/dist/locale.d.ts.map +1 -0
  69. package/dist/locale.js +6 -0
  70. package/dist/locale.js.map +1 -0
  71. package/dist/metadata.d.ts +83 -0
  72. package/dist/metadata.d.ts.map +1 -0
  73. package/dist/metadata.js +100 -0
  74. package/dist/metadata.js.map +1 -0
  75. package/dist/modules.d.ts +74 -0
  76. package/dist/modules.d.ts.map +1 -0
  77. package/dist/modules.js +85 -0
  78. package/dist/modules.js.map +1 -0
  79. package/dist/nonce.d.ts +8 -0
  80. package/dist/nonce.d.ts.map +1 -0
  81. package/dist/nonce.js +12 -0
  82. package/dist/nonce.js.map +1 -0
  83. package/dist/payload.d.ts +171 -0
  84. package/dist/payload.d.ts.map +1 -0
  85. package/dist/payload.js +273 -0
  86. package/dist/payload.js.map +1 -0
  87. package/dist/public-config.d.ts +11 -0
  88. package/dist/public-config.d.ts.map +1 -0
  89. package/dist/public-config.js +10 -0
  90. package/dist/public-config.js.map +1 -0
  91. package/dist/query.d.ts +22 -0
  92. package/dist/query.d.ts.map +1 -0
  93. package/dist/query.js +68 -0
  94. package/dist/query.js.map +1 -0
  95. package/dist/render-cleanup.d.ts +6 -0
  96. package/dist/render-cleanup.d.ts.map +1 -0
  97. package/dist/render-cleanup.js +7 -0
  98. package/dist/render-cleanup.js.map +1 -0
  99. package/dist/root.d.ts +12 -0
  100. package/dist/root.d.ts.map +1 -0
  101. package/dist/root.js +26 -0
  102. package/dist/root.js.map +1 -0
  103. package/dist/route-tree.d.ts +56 -0
  104. package/dist/route-tree.d.ts.map +1 -0
  105. package/dist/route-tree.js +86 -0
  106. package/dist/route-tree.js.map +1 -0
  107. package/dist/router.d.ts +118 -0
  108. package/dist/router.d.ts.map +1 -0
  109. package/dist/router.js +938 -0
  110. package/dist/router.js.map +1 -0
  111. package/dist/schema.d.ts +35 -0
  112. package/dist/schema.d.ts.map +1 -0
  113. package/dist/schema.js +74 -0
  114. package/dist/schema.js.map +1 -0
  115. package/dist/script.d.ts +23 -0
  116. package/dist/script.d.ts.map +1 -0
  117. package/dist/script.js +89 -0
  118. package/dist/script.js.map +1 -0
  119. package/dist/server-entry.d.ts +2 -0
  120. package/dist/server-entry.d.ts.map +1 -0
  121. package/dist/server-entry.js +4 -0
  122. package/dist/server-entry.js.map +1 -0
  123. package/dist/server-render.d.ts +19 -0
  124. package/dist/server-render.d.ts.map +1 -0
  125. package/dist/server-render.js +193 -0
  126. package/dist/server-render.js.map +1 -0
  127. package/dist/testing-setup.d.ts +2 -0
  128. package/dist/testing-setup.d.ts.map +1 -0
  129. package/dist/testing-setup.js +8 -0
  130. package/dist/testing-setup.js.map +1 -0
  131. package/dist/testing.d.ts +76 -0
  132. package/dist/testing.d.ts.map +1 -0
  133. package/dist/testing.js +106 -0
  134. package/dist/testing.js.map +1 -0
  135. package/dist/vitals-config.d.ts +4 -0
  136. package/dist/vitals-config.d.ts.map +1 -0
  137. package/dist/vitals-config.js +5 -0
  138. package/dist/vitals-config.js.map +1 -0
  139. package/dist/vitals.d.ts +13 -0
  140. package/dist/vitals.d.ts.map +1 -0
  141. package/dist/vitals.js +60 -0
  142. package/dist/vitals.js.map +1 -0
  143. package/dist/zod.d.ts +19 -0
  144. package/dist/zod.d.ts.map +1 -0
  145. package/dist/zod.js +65 -0
  146. package/dist/zod.js.map +1 -0
  147. package/package.json +147 -4
  148. package/sidecar/server.mjs +325 -0
  149. package/skills/netjes/SKILL.md +93 -0
  150. package/skills/netjes/references/caching-and-streaming.md +68 -0
  151. package/skills/netjes/references/data-and-actions.md +98 -0
  152. package/skills/netjes/references/deployment.md +57 -0
  153. package/skills/netjes/references/routing.md +65 -0
  154. package/skills/netjes/references/testing.md +57 -0
  155. package/src/actions.tsx +241 -0
  156. package/src/client-entry.tsx +126 -0
  157. package/src/deferred.ts +134 -0
  158. package/src/define-page.tsx +41 -0
  159. package/src/document.tsx +49 -0
  160. package/src/draft-mode.ts +6 -0
  161. package/src/dynamic.tsx +64 -0
  162. package/src/form-data.ts +10 -0
  163. package/src/form.tsx +122 -0
  164. package/src/href.ts +79 -0
  165. package/src/image.tsx +91 -0
  166. package/src/index.ts +40 -0
  167. package/src/link.tsx +73 -0
  168. package/src/locale.ts +6 -0
  169. package/src/metadata.tsx +181 -0
  170. package/src/modules.ts +133 -0
  171. package/src/nonce.tsx +14 -0
  172. package/src/payload.ts +405 -0
  173. package/src/public-config.tsx +19 -0
  174. package/src/query.tsx +82 -0
  175. package/src/render-cleanup.ts +7 -0
  176. package/src/root.tsx +39 -0
  177. package/src/route-tree.tsx +139 -0
  178. package/src/router.tsx +1188 -0
  179. package/src/schema.ts +98 -0
  180. package/src/script.tsx +103 -0
  181. package/src/server-entry.tsx +4 -0
  182. package/src/server-render.tsx +223 -0
  183. package/src/testing-setup.ts +8 -0
  184. package/src/testing.tsx +177 -0
  185. package/src/virtual.d.ts +12 -0
  186. package/src/vitals-config.ts +6 -0
  187. package/src/vitals.tsx +68 -0
  188. package/src/zod.ts +72 -0
  189. package/template/.dockerignore +11 -0
  190. package/template/Dockerfile +58 -0
  191. package/template/Features/Home/HomePage.cs +32 -0
  192. package/template/Program.cs +28 -0
  193. package/template/Properties/launchSettings.json +13 -0
  194. package/template/README.md +24 -0
  195. package/template/SiteProps.cs +4 -0
  196. package/template/_AGENTS.md +64 -0
  197. package/template/_CLAUDE.md +1 -0
  198. package/template/__NAME__.csproj +11 -0
  199. package/template/_gitattributes +2 -0
  200. package/template/_gitignore +11 -0
  201. package/template/app/about/page.tsx +17 -0
  202. package/template/app/error.tsx +15 -0
  203. package/template/app/layout.tsx +33 -0
  204. package/template/app/not-found.tsx +14 -0
  205. package/template/app/page.tsx +14 -0
  206. package/template/appsettings.Development.json +3 -0
  207. package/template/appsettings.json +9 -0
  208. package/template/components/Greeting.test.tsx +25 -0
  209. package/template/components/Greeting.tsx +26 -0
  210. package/template/components/HelloForm.test.tsx +27 -0
  211. package/template/components/HelloForm.tsx +34 -0
  212. package/template/docker-compose.override.yml +15 -0
  213. package/template/docker-compose.prod.yml +26 -0
  214. package/template/docker-compose.yml +16 -0
  215. package/template/generated/netjes.ts +73 -0
  216. package/template/package.json +36 -0
  217. package/template/public/favicon.svg +6 -0
  218. package/template/styles/global.css +7 -0
  219. package/template/tsconfig.json +19 -0
  220. package/template/vite.config.ts +7 -0
  221. package/vite/plugin.d.ts +29 -0
  222. package/vite/plugin.mjs +291 -0
package/dist/router.js ADDED
@@ -0,0 +1,938 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Suspense, createContext, startTransition, use, useContext, useEffect, useLayoutEffect, useMemo, useState, } from 'react';
3
+ import { actionRequest, expectActionClient, fetchAction, setActionClient } from './actions.js';
4
+ import { deferredRegistry, revivePayload } from './deferred.js';
5
+ import { MissingModuleError } from './modules.js';
6
+ import { COOKIES_HEADER, DATA_HEADER, SEGMENTS_HEADER, SERVER_TIME_HEADER, parseAntiforgery, parseNavigationPayload, } from './payload.js';
7
+ import { ErrorBoundary, LayoutDepthContext, RouteTree } from './route-tree.js';
8
+ import { parseLenient, parseWith } from './schema.js';
9
+ function isLoaded(result) {
10
+ return 'payload' in result;
11
+ }
12
+ const IDLE = { state: 'idle' };
13
+ const ANTIFORGERY_URL = '/_netjes/antiforgery';
14
+ const NONE = { kind: 'none' };
15
+ const TOP = { kind: 'top' };
16
+ const STATE_KEY = '__netjes';
17
+ const PREFETCH_TTL_MS = 30_000;
18
+ const MAX_REDIRECTS = 10;
19
+ const MAX_REMEMBERED_ENTRIES = 50;
20
+ /** What a tab posts to the app's other tabs when one of its calls or posts changed cookies. */
21
+ const COOKIES_CHANGED_MESSAGE = 'cookies-changed';
22
+ /**
23
+ * The channel the tabs of one app share: same origin (BroadcastChannel's own scope) and same base path, as cookie
24
+ * authentication scopes its cookie to the base path by default.
25
+ */
26
+ const channelName = (basePath) => `netjes:${basePath || '/'}`;
27
+ let keyCounter = 0;
28
+ const createKey = () => `${Date.now().toString(36)}-${(keyCounter++).toString(36)}`;
29
+ function readKey(state) {
30
+ if (typeof state !== 'object' || state === null || !(STATE_KEY in state))
31
+ return undefined;
32
+ const value = state[STATE_KEY];
33
+ return typeof value === 'string' ? value : undefined;
34
+ }
35
+ /**
36
+ * Records how far the browser clock is from the server's, given a server time for "now": the `X-Netjes-Now` header
37
+ * of a data response, or for the initial document the payload's `renderedAt` (so the browser judges seeded data
38
+ * as fresh as the server did when it rendered, and hydration matches).
39
+ */
40
+ export function withClockOffset(payload, serverNow, now = Date.now()) {
41
+ return serverNow === undefined || !Number.isFinite(serverNow) ? payload : { ...payload, clockOffset: now - serverNow };
42
+ }
43
+ /**
44
+ * Root-relative hrefs (`/about`, not `//host` or `https://…`) are written as if the app were mounted at the root.
45
+ * One that already starts with the base path (built from it on purpose) is left as it is.
46
+ */
47
+ export function withBasePath(href, basePath) {
48
+ if (!basePath || !href.startsWith('/') || href.startsWith('//') || href.startsWith('/\\'))
49
+ return href;
50
+ const alreadyThere = href === basePath || href.startsWith(`${basePath}/`) || href.startsWith(`${basePath}?`) || href.startsWith(`${basePath}#`);
51
+ return alreadyThere ? href : basePath + href;
52
+ }
53
+ function withoutBasePath(pathname, basePath) {
54
+ if (!basePath || !pathname.startsWith(basePath))
55
+ return pathname;
56
+ const rest = pathname.slice(basePath.length);
57
+ return rest === '' ? '/' : rest.startsWith('/') ? rest : pathname;
58
+ }
59
+ const samePage = (a, b) => a.pathname === b.pathname && a.search === b.search;
60
+ /** Only http(s) URLs are ever navigated to; `javascript:` and friends would run script in the page. */
61
+ const isWebUrl = (url) => url.protocol === 'http:' || url.protocol === 'https:';
62
+ /**
63
+ * The URL of a page the server sent (`payload.url`, the request's path and query), on the base's origin. That path
64
+ * can start with `//` (a request for `https://app//evil.example/x`), which `new URL(path, base)` reads as a host.
65
+ */
66
+ function pageUrl(path, base) {
67
+ return path.startsWith('/') ? new URL(new URL(base).origin + path) : new URL(path, base);
68
+ }
69
+ /**
70
+ * The first page's URL. Its url is the path the browser requested, so one on another origin is not where the browser
71
+ * is: the router then goes by the browser's location, which refresh() reloads.
72
+ */
73
+ function initialUrl(path) {
74
+ if (typeof window === 'undefined')
75
+ return pageUrl(path, 'http://localhost');
76
+ const url = pageUrl(path, window.location.href);
77
+ return url.origin === window.location.origin ? url : withHash(new URL(window.location.href), '');
78
+ }
79
+ function withHash(url, hash) {
80
+ const copy = new URL(url);
81
+ copy.hash = hash;
82
+ return copy;
83
+ }
84
+ function attachmentName(disposition) {
85
+ const encoded = /filename\*=UTF-8''([^;]+)/i.exec(disposition)?.[1];
86
+ if (encoded) {
87
+ try {
88
+ return decodeURIComponent(encoded);
89
+ }
90
+ catch {
91
+ // Fall through to the plain filename.
92
+ }
93
+ }
94
+ return /filename="?([^";]+)"?/i.exec(disposition)?.[1] ?? 'download';
95
+ }
96
+ async function fetchPayload(url, request, signal, segmentKeys = [], token, onCookiesChanged) {
97
+ const headers = { [DATA_HEADER]: '1', Accept: 'application/json' };
98
+ if (request.method === 'POST' && token?.headerName && token.requestToken)
99
+ headers[token.headerName] = token.requestToken;
100
+ if (request.method === 'GET' && segmentKeys.length > 0)
101
+ headers[SEGMENTS_HEADER] = segmentKeys.join(',');
102
+ if (request.method === 'POST' && request.contentType)
103
+ headers['Content-Type'] = request.contentType;
104
+ const init = { method: request.method, headers, credentials: 'same-origin', signal };
105
+ if (request.method === 'POST' && request.body !== undefined)
106
+ init.body = request.body;
107
+ const response = await fetch(url.href, init);
108
+ // A form post that signed someone in or out; script cannot read Set-Cookie itself.
109
+ if (request.method === 'POST' && response.headers.get(COOKIES_HEADER) === 'changed')
110
+ onCookiesChanged?.();
111
+ const type = response.headers.get('content-type') ?? '';
112
+ if (type.includes('application/x-ndjson') && response.body) {
113
+ // Page.Defer: the payload first, then a line per deferred value as the server completes it.
114
+ const registry = deferredRegistry();
115
+ const lines = readLines(response.body, signal);
116
+ const first = await lines.next();
117
+ if (first.done)
118
+ throw new Error('netjes: the server sent an empty page stream');
119
+ const payload = parseNavigationPayload(JSON.parse(first.value));
120
+ void (async () => {
121
+ try {
122
+ for await (const line of { [Symbol.asyncIterator]: () => lines }) {
123
+ const item = JSON.parse(line);
124
+ if (typeof item.error === 'string')
125
+ registry.reject(item.id, item.error);
126
+ else
127
+ registry.resolve(item.id, item.value);
128
+ }
129
+ }
130
+ catch (error) {
131
+ if (!signal.aborted)
132
+ console.error('netjes: reading deferred values failed', error);
133
+ }
134
+ finally {
135
+ // Whatever the stream did not deliver will not come: fail it, so its Suspense boundary does not wait forever.
136
+ registry.rejectPending((id) => `netjes: the page stream ended before deferred value ${id} arrived`);
137
+ }
138
+ })();
139
+ if (payload.kind !== 'page')
140
+ return payload;
141
+ const serverNow = Number(response.headers.get(SERVER_TIME_HEADER) ?? Number.NaN);
142
+ return withClockOffset(revivePayload(payload, registry.source), Number.isFinite(serverNow) ? serverNow : undefined);
143
+ }
144
+ if (type.includes('application/json')) {
145
+ const payload = parseNavigationPayload(await response.json());
146
+ if (payload.kind !== 'page')
147
+ return payload;
148
+ // Without the header (a cache in front of UseNetjes) there is no fresh server time: take the timestamps as they
149
+ // are. Falling back to renderedAt would make a cached payload look brand new.
150
+ const serverNow = Number(response.headers.get(SERVER_TIME_HEADER) ?? Number.NaN);
151
+ return withClockOffset(payload, Number.isFinite(serverNow) ? serverNow : undefined);
152
+ }
153
+ // Something other than a Netjes page answered. A GET can simply be loaded by the browser instead.
154
+ if (request.method === 'GET')
155
+ return null;
156
+ // A POST must not be sent again. Show what a native submission would have shown, where that is safe.
157
+ if (!response.ok)
158
+ return { failed: response.status };
159
+ if (type.includes('text/html'))
160
+ return { html: await response.text(), url: response.url || url.href };
161
+ const disposition = response.headers.get('content-disposition') ?? '';
162
+ if (/^\s*attachment/i.test(disposition))
163
+ return { file: await response.blob(), name: attachmentName(disposition) };
164
+ return { failed: response.status };
165
+ }
166
+ /** The lines of a streamed body, as they arrive. */
167
+ async function* readLines(body, signal) {
168
+ const reader = body.getReader();
169
+ const decoder = new TextDecoder();
170
+ let buffered = '';
171
+ try {
172
+ while (!signal.aborted) {
173
+ const { value, done } = await reader.read();
174
+ if (done)
175
+ break;
176
+ buffered += decoder.decode(value, { stream: true });
177
+ let newline = buffered.indexOf('\n');
178
+ while (newline >= 0) {
179
+ const line = buffered.slice(0, newline).trim();
180
+ buffered = buffered.slice(newline + 1);
181
+ if (line)
182
+ yield line;
183
+ newline = buffered.indexOf('\n');
184
+ }
185
+ }
186
+ const rest = (buffered + decoder.decode()).trim();
187
+ if (rest)
188
+ yield rest;
189
+ }
190
+ finally {
191
+ reader.releaseLock();
192
+ }
193
+ }
194
+ /** Client-side navigation: fetches page payloads from the server, keeps history in sync, restores scroll. */
195
+ class Navigator {
196
+ active = null;
197
+ prefetched = new Map();
198
+ entries = new Map();
199
+ scrollPositions = new Map();
200
+ currentKey = createKey();
201
+ /** The page on screen is from before cookies changed: kept for no history entry until one comes from the server. */
202
+ currentIsStale = false;
203
+ /** Shared with the app's other tabs, where the browser has BroadcastChannel; null until started, and once stopped. */
204
+ channel = null;
205
+ deps;
206
+ current;
207
+ constructor(deps, current) {
208
+ this.deps = deps;
209
+ this.current = current;
210
+ }
211
+ start() {
212
+ window.history.scrollRestoration = 'manual';
213
+ const existing = readKey(window.history.state);
214
+ if (existing)
215
+ this.currentKey = existing;
216
+ else
217
+ window.history.replaceState({ ...(window.history.state ?? {}), [STATE_KEY]: this.currentKey }, '');
218
+ this.remember(this.currentKey, this.current);
219
+ let frame = 0;
220
+ const onScroll = () => {
221
+ if (frame)
222
+ return;
223
+ frame = requestAnimationFrame(() => {
224
+ frame = 0;
225
+ this.rememberScroll();
226
+ });
227
+ };
228
+ const onPopState = (event) => this.onPopState(event);
229
+ window.addEventListener('popstate', onPopState);
230
+ window.addEventListener('scroll', onScroll, { passive: true });
231
+ const channel = typeof BroadcastChannel === 'function' ? new BroadcastChannel(channelName(this.deps.basePath)) : null;
232
+ if (channel) {
233
+ channel.onmessage = (event) => {
234
+ if (event.data === COOKIES_CHANGED_MESSAGE)
235
+ this.cookiesChangedInAnotherTab();
236
+ };
237
+ }
238
+ this.channel = channel;
239
+ return () => {
240
+ window.removeEventListener('popstate', onPopState);
241
+ window.removeEventListener('scroll', onScroll);
242
+ cancelAnimationFrame(frame);
243
+ this.stopListeningToOtherTabs();
244
+ };
245
+ }
246
+ stopListeningToOtherTabs() {
247
+ this.channel?.close();
248
+ this.channel = null;
249
+ }
250
+ async navigate(href, history, scroll) {
251
+ const url = new URL(withBasePath(href, this.deps.basePath), window.location.href);
252
+ if (!isWebUrl(url)) {
253
+ console.error(`netjes: refusing to navigate to ${url.protocol} URL`);
254
+ return;
255
+ }
256
+ if (url.origin !== window.location.origin) {
257
+ this.hard(url);
258
+ return;
259
+ }
260
+ if (url.hash && samePage(url, this.current.url)) {
261
+ // Same document, different fragment: no fetch, just history and scroll. It supersedes any pending navigation.
262
+ this.cancelActive();
263
+ this.rememberScroll();
264
+ const key = createKey();
265
+ window.history[history === 'push' ? 'pushState' : 'replaceState']({ [STATE_KEY]: key }, '', url.href);
266
+ this.setCurrent(key, { ...this.current, url });
267
+ this.deps.commit(this.current, scroll ? { kind: 'hash', hash: url.hash } : NONE);
268
+ return;
269
+ }
270
+ await this.go(url, { method: 'GET' }, {
271
+ history,
272
+ scroll: !scroll ? NONE : url.hash ? { kind: 'hash', hash: url.hash } : TOP,
273
+ pending: 'loading',
274
+ usePrefetch: true,
275
+ reuseSegments: true,
276
+ });
277
+ }
278
+ submit(action, body) {
279
+ return this.go(action, { method: 'POST', ...body }, { history: 'auto', scroll: 'auto', pending: 'submitting', usePrefetch: false, reuseSegments: false });
280
+ }
281
+ /**
282
+ * Loads the page on screen again, in place: same history entry, fragment and scroll position. `keepPrefetched` after
283
+ * cookies changed: the prefetched pages left are all from after the change.
284
+ */
285
+ refresh({ keepPrefetched = false } = {}) {
286
+ if (!keepPrefetched)
287
+ this.prefetched.clear();
288
+ // In a modal (an intercepted page), as in Next.js: the modal stays open over the page it opened on.
289
+ const base = this.current.base;
290
+ return this.go(this.current.url, { method: 'GET' }, {
291
+ history: 'replace',
292
+ scroll: NONE,
293
+ pending: 'loading',
294
+ usePrefetch: false,
295
+ reuseSegments: false,
296
+ ...(base ? { interceptOver: withHash(base.url, '') } : {}),
297
+ });
298
+ }
299
+ /** The current page: actions send its token and URL, and refresh it when they revalidate. */
300
+ get page() {
301
+ return this.current;
302
+ }
303
+ tokenRequest = null;
304
+ /**
305
+ * A token for pages that carry none (output-cached pages leave it out so they can be shared): fetched once from
306
+ * the server, which also sets the antiforgery cookie, then reused.
307
+ */
308
+ fetchedToken() {
309
+ this.tokenRequest ??= fetch(withBasePath(ANTIFORGERY_URL, this.deps.basePath), { credentials: 'same-origin', headers: { Accept: 'application/json' } })
310
+ .then(async (response) => (response.ok ? parseAntiforgery(await response.json()) : undefined))
311
+ .catch(() => {
312
+ this.tokenRequest = null;
313
+ return undefined;
314
+ });
315
+ return this.tokenRequest;
316
+ }
317
+ /** Whether a navigation is on its way (fetching or about to commit). */
318
+ get navigating() {
319
+ return this.active !== null;
320
+ }
321
+ /** Drops the fetched token; the next action or post fetches a new one (an action may have signed the user in or out). */
322
+ forgetToken() {
323
+ this.tokenRequest = null;
324
+ }
325
+ /** After a server action: the data behind any page may have changed, so prefetched pages are dropped. */
326
+ forgetPrefetched() {
327
+ this.prefetched.clear();
328
+ }
329
+ /** The page on screen is from before cookies last changed, and no page has come from the server since. */
330
+ get fromBeforeCookieChange() {
331
+ return this.currentIsStale;
332
+ }
333
+ /**
334
+ * An action or post of this tab changed cookies (signed someone in or out). The app's other tabs share the cookies,
335
+ * so they are told, and forget the previous user's pages too.
336
+ */
337
+ cookiesChanged(from) {
338
+ this.forgetPagesBeforeCookieChange(from);
339
+ this.channel?.postMessage(COOKIES_CHANGED_MESSAGE);
340
+ }
341
+ /**
342
+ * Another tab of the app changed cookies: this tab forgets the same as that one did, and loads the page on screen
343
+ * from the server, as that tab does after its call, unless a navigation on its way (started over) shows a page
344
+ * soon. It posts nothing back, so tabs never answer each other's messages.
345
+ */
346
+ cookiesChangedInAnotherTab() {
347
+ this.forgetToken();
348
+ this.forgetPagesBeforeCookieChange();
349
+ if (!this.navigating)
350
+ void this.refresh({ keepPrefetched: true });
351
+ }
352
+ /**
353
+ * After cookies changed (signing in or out, like Next.js's rule for cookies set by an action): the pages kept for
354
+ * back and forward, and prefetched ones, show the app as it was before, so they are loaded from the server again.
355
+ * Requests still on their way went out with the old cookies: a navigation and the prefetches in flight start over.
356
+ * A form post (`from`, when its own response said so) is never sent twice. The page on screen stays until a page
357
+ * comes from the server, and is kept for no history entry, not even one a fragment link on it adds.
358
+ */
359
+ forgetPagesBeforeCookieChange(from) {
360
+ this.entries.clear();
361
+ const loading = [...this.prefetched].filter(([, prefetch]) => prefetch.loading);
362
+ this.prefetched.clear();
363
+ for (const [href, prefetch] of loading) {
364
+ prefetch.controller.abort();
365
+ this.startPrefetch(new URL(href));
366
+ }
367
+ this.currentIsStale = true;
368
+ const active = this.active;
369
+ if (active === from) {
370
+ if (from)
371
+ from.changedCookies = true;
372
+ }
373
+ else if (active?.again) {
374
+ active.again();
375
+ }
376
+ else if (active) {
377
+ active.stale = true;
378
+ }
379
+ for (const listener of this.cookieListeners)
380
+ listener();
381
+ }
382
+ cookieListeners = new Set();
383
+ onCookiesChanged(listener) {
384
+ this.cookieListeners.add(listener);
385
+ return () => this.cookieListeners.delete(listener);
386
+ }
387
+ prefetch(href) {
388
+ const url = new URL(withBasePath(href, this.deps.basePath), window.location.href);
389
+ if (url.origin !== window.location.origin)
390
+ return;
391
+ url.hash = '';
392
+ const now = Date.now();
393
+ for (const [key, entry] of this.prefetched)
394
+ if (now - entry.at >= PREFETCH_TTL_MS)
395
+ this.prefetched.delete(key);
396
+ if (this.prefetched.has(url.href))
397
+ return;
398
+ this.startPrefetch(url);
399
+ }
400
+ startPrefetch(url) {
401
+ const controller = new AbortController();
402
+ const prefetch = { at: Date.now(), result: this.load(url, { method: 'GET' }, controller.signal, true), controller, loading: true };
403
+ prefetch.result.then(() => {
404
+ prefetch.loading = false;
405
+ }, () => {
406
+ prefetch.loading = false;
407
+ // A prefetch started over has taken its place.
408
+ if (this.prefetched.get(url.href) === prefetch)
409
+ this.prefetched.delete(url.href);
410
+ });
411
+ this.prefetched.set(url.href, prefetch);
412
+ }
413
+ onPopState(event) {
414
+ const url = new URL(window.location.href);
415
+ const key = readKey(event.state) ?? createKey();
416
+ const saved = this.scrollPositions.get(key);
417
+ const scroll = saved ? { kind: 'restore', ...saved } : url.hash ? { kind: 'hash', hash: url.hash } : TOP;
418
+ const cached = this.entries.get(key);
419
+ const known = cached && samePage(cached.url, url) ? cached : samePage(url, this.current.url) ? this.current : null;
420
+ if (known) {
421
+ this.active?.controller.abort();
422
+ this.setCurrent(key, { ...known, url });
423
+ this.deps.commit(this.current, scroll);
424
+ return;
425
+ }
426
+ void this.go(url, { method: 'GET' }, { history: 'none', scroll, pending: 'loading', key, usePrefetch: false, reuseSegments: true });
427
+ }
428
+ async go(url, request, options) {
429
+ if (url.origin !== window.location.origin) {
430
+ // navigate() and Form hand other origins to the browser, and every page URL the router keeps is on this one.
431
+ // Refuse one that gets here all the same: a failed load would send the browser to it.
432
+ console.error(`netjes: refusing to load ${url.origin} in the client router`);
433
+ return;
434
+ }
435
+ this.active?.controller.abort();
436
+ const controller = new AbortController();
437
+ const navigation = {
438
+ controller,
439
+ again: request.method === 'GET'
440
+ ? () => {
441
+ navigation.restarted = this.go(url, request, options);
442
+ }
443
+ : null,
444
+ stale: false,
445
+ changedCookies: false,
446
+ };
447
+ this.active = navigation;
448
+ this.deps.setPending({ state: options.pending, location: withoutBasePath(url.pathname, this.deps.basePath) + url.search });
449
+ let committed = false;
450
+ let failure;
451
+ try {
452
+ const prefetched = options.usePrefetch ? this.takePrefetched(url, controller.signal) : undefined;
453
+ const under = options.interceptOver ? this.load(options.interceptOver, { method: 'GET' }, controller.signal, false) : undefined;
454
+ under?.catch(() => { });
455
+ const result = await (prefetched ?? this.load(url, request, controller.signal, options.reuseSegments, () => this.cookiesChanged(navigation)));
456
+ // Superseded by another navigation, or started over because cookies changed: then it ends with the new attempt.
457
+ if (controller.signal.aborted)
458
+ return navigation.restarted;
459
+ // A post may have changed what other pages show; do not serve them from before it. One that changed cookies has
460
+ // dropped them already, and started those in flight over.
461
+ if (request.method === 'POST' && !navigation.changedCookies)
462
+ this.prefetched.clear();
463
+ if ('hard' in result) {
464
+ this.hard(result.hard);
465
+ return;
466
+ }
467
+ if ('document' in result) {
468
+ // The tab belongs to that document: another tab's sign-in or sign-out must not load a page into it.
469
+ this.stopListeningToOtherTabs();
470
+ this.deps.showDocument(result.document, result.url);
471
+ return;
472
+ }
473
+ if ('file' in result) {
474
+ this.deps.download(result.file, result.name);
475
+ return;
476
+ }
477
+ if ('failed' in result) {
478
+ // Keep the page, and what the user typed, as it is; useNavigation() tells the page.
479
+ console.error(`netjes: the form submission failed with status ${result.failed}`);
480
+ failure = { status: result.failed, action: withoutBasePath(url.pathname, this.deps.basePath) + url.search };
481
+ return;
482
+ }
483
+ const base = under ? await under.catch(() => null) : null;
484
+ const shown = options.reuseSegments && request.method === 'GET'
485
+ ? await this.intercepted(result)
486
+ : base && isLoaded(base)
487
+ ? await this.intercepted(result, base)
488
+ : result;
489
+ if (controller.signal.aborted)
490
+ return navigation.restarted;
491
+ const previous = this.current.url;
492
+ const history = options.history === 'auto' ? (samePage(shown.url, previous) ? 'replace' : 'push') : options.history;
493
+ let key = options.key ?? createKey();
494
+ if (history === 'none') {
495
+ if (!options.key)
496
+ window.history.replaceState({ [STATE_KEY]: key }, '');
497
+ }
498
+ else {
499
+ this.rememberScroll();
500
+ key = createKey();
501
+ window.history[history === 'push' ? 'pushState' : 'replaceState']({ [STATE_KEY]: key }, '', shown.url.href);
502
+ }
503
+ this.currentIsStale = navigation.stale;
504
+ this.setCurrent(key, shown);
505
+ this.forgetToken();
506
+ committed = true;
507
+ const scroll = shown.base ? NONE : options.scroll === 'auto' ? (shown.url.pathname === previous.pathname ? NONE : TOP) : options.scroll;
508
+ this.deps.commit(shown, scroll);
509
+ }
510
+ catch (error) {
511
+ if (controller.signal.aborted)
512
+ return navigation.restarted;
513
+ if (request.method === 'POST') {
514
+ // Never turn a failed submission into a GET: keep the page, and what the user typed, as it is.
515
+ console.error('netjes: the form could not be submitted', error);
516
+ failure = { status: null, action: withoutBasePath(url.pathname, this.deps.basePath) + url.search };
517
+ return;
518
+ }
519
+ console.error('netjes: navigation failed, loading the page from the server instead', error);
520
+ this.hard(url);
521
+ }
522
+ finally {
523
+ if (this.active === navigation) {
524
+ this.active = null;
525
+ if (!committed)
526
+ this.deps.setPending(failure ? { state: 'idle', error: failure } : IDLE);
527
+ }
528
+ }
529
+ }
530
+ async load(url, request, signal, reuseSegments, onCookiesChanged = () => this.cookiesChanged()) {
531
+ let target = url;
532
+ let current = request;
533
+ // After a form post everything is loaded again: the post may have changed what any layout shows.
534
+ let keys = reuseSegments && request.method === 'GET' ? this.heldSegmentKeys() : [];
535
+ // Output-cached pages carry no token; a post from one needs a fetched one (as a header).
536
+ const token = request.method === 'POST' && !this.current.payload.antiforgery ? await this.fetchedToken() : undefined;
537
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
538
+ let payload = await fetchPayload(target, current, signal, keys, current.method === 'POST' ? token : undefined, onCookiesChanged);
539
+ if (payload !== null && 'kind' in payload && payload.kind === 'page' && keys.length > 0) {
540
+ const resolved = this.withReusedSegments(payload);
541
+ // The server reused a segment this page no longer has (it changed meanwhile): load it in full.
542
+ payload = resolved ?? (await fetchPayload(target, current, signal));
543
+ }
544
+ if (payload === null)
545
+ return { hard: target };
546
+ if ('html' in payload)
547
+ return { document: payload.html, url: payload.url };
548
+ if ('file' in payload || 'failed' in payload)
549
+ return payload;
550
+ if (payload.kind === 'redirect') {
551
+ target = new URL(payload.location, target);
552
+ if (!isWebUrl(target))
553
+ throw new Error(`netjes: refusing to follow a redirect to a ${target.protocol} URL`);
554
+ if (target.origin !== window.location.origin)
555
+ return { hard: target };
556
+ if (current.method !== 'GET')
557
+ keys = [];
558
+ current = { method: 'GET' };
559
+ continue;
560
+ }
561
+ const finalUrl = pageUrl(payload.url, target);
562
+ if (finalUrl.origin !== window.location.origin)
563
+ throw new Error(`netjes: the page payload names another origin (${finalUrl.origin})`);
564
+ if (hop === 0)
565
+ finalUrl.hash = url.hash;
566
+ if (payload.version !== this.deps.version)
567
+ return { hard: finalUrl };
568
+ try {
569
+ return { payload, modules: await this.deps.loader.loadRoute(payload), url: finalUrl };
570
+ }
571
+ catch (error) {
572
+ if (error instanceof MissingModuleError)
573
+ return { hard: finalUrl };
574
+ throw error;
575
+ }
576
+ }
577
+ throw new Error('netjes: too many redirects');
578
+ }
579
+ /**
580
+ * Intercepting routes: when the new page can be shown in a slot of a segment the current page is inside (a photo
581
+ * opening in a modal over the feed), keep the current page and render the intercepting page in that slot.
582
+ */
583
+ async intercepted(loaded, base = this.current.base ?? this.current) {
584
+ const intercepts = loaded.payload.intercepts;
585
+ if (!intercepts?.length)
586
+ return loaded;
587
+ const intercept = intercepts.find((i) => base.payload.segments.some((s) => s.id === i.segment));
588
+ if (!intercept)
589
+ return loaded;
590
+ const Page = (await this.deps.loader.load(intercept.page));
591
+ const { props } = loaded.payload;
592
+ // The intercepting page's own route values: its parameters can be named differently from the page's.
593
+ const params = intercept.params ?? loaded.payload.params;
594
+ function InterceptedSlot() {
595
+ return _jsx(Page, { ...props, params: params });
596
+ }
597
+ const segments = base.modules.segments.map((segment) => segment.id === intercept.segment ? { ...segment, slots: { ...segment.slots, [intercept.slot]: InterceptedSlot } } : segment);
598
+ return {
599
+ payload: { ...base.payload, url: loaded.payload.url },
600
+ modules: { ...base.modules, segments },
601
+ url: loaded.url,
602
+ pageKey: base.pageKey ?? base.url.pathname,
603
+ base,
604
+ };
605
+ }
606
+ /** Keys of the current page's layout segments whose props the client holds. */
607
+ heldSegmentKeys() {
608
+ return this.current.payload.segments.flatMap((segment) => (segment.key && segment.props && !segment.reused ? [segment.key] : []));
609
+ }
610
+ /** Fills in the props of segments the server marked reused from the current page, or null if one is missing. */
611
+ withReusedSegments(payload) {
612
+ if (!payload.segments.some((segment) => segment.reused))
613
+ return payload;
614
+ const held = this.current.payload.segments;
615
+ const segments = [];
616
+ for (const segment of payload.segments) {
617
+ if (!segment.reused) {
618
+ segments.push(segment);
619
+ continue;
620
+ }
621
+ const known = held.find((h) => h.id === segment.id && h.key === segment.key && h.props);
622
+ if (!known)
623
+ return null;
624
+ segments.push({ ...segment, props: known.props, reused: undefined });
625
+ }
626
+ return { ...payload, segments };
627
+ }
628
+ hard(url) {
629
+ if (isWebUrl(url))
630
+ this.deps.hardNavigate(url.href);
631
+ else
632
+ console.error(`netjes: refusing to navigate to ${url.protocol} URL`);
633
+ }
634
+ cancelActive() {
635
+ if (!this.active)
636
+ return;
637
+ this.active.controller.abort();
638
+ this.active = null;
639
+ this.deps.setPending(IDLE);
640
+ }
641
+ takePrefetched(url, signal) {
642
+ const key = withHash(url, '').href;
643
+ const hit = this.prefetched.get(key);
644
+ if (!hit)
645
+ return undefined;
646
+ this.prefetched.delete(key);
647
+ if (Date.now() - hit.at >= PREFETCH_TTL_MS)
648
+ return undefined;
649
+ return hit.result.then((result) => 'payload' in result ? { ...result, url: samePage(result.url, url) ? withHash(result.url, url.hash) : result.url } : result, () => this.load(url, { method: 'GET' }, signal, true));
650
+ }
651
+ setCurrent(key, loaded) {
652
+ this.currentKey = key;
653
+ this.current = loaded;
654
+ if (!this.currentIsStale)
655
+ this.remember(key, loaded);
656
+ }
657
+ remember(key, loaded) {
658
+ this.entries.delete(key);
659
+ this.entries.set(key, loaded);
660
+ while (this.entries.size > MAX_REMEMBERED_ENTRIES) {
661
+ const oldest = this.entries.keys().next().value;
662
+ if (oldest === undefined)
663
+ break;
664
+ this.entries.delete(oldest);
665
+ }
666
+ }
667
+ rememberScroll() {
668
+ this.scrollPositions.set(this.currentKey, { x: window.scrollX, y: window.scrollY });
669
+ }
670
+ }
671
+ function elementForFragment(fragment) {
672
+ let decoded = fragment;
673
+ try {
674
+ decoded = decodeURIComponent(fragment);
675
+ }
676
+ catch {
677
+ // Not valid percent-encoding (e.g. "#100%"): look the fragment up as written.
678
+ }
679
+ return document.getElementById(decoded) ?? document.getElementById(fragment);
680
+ }
681
+ function applyScroll(intent) {
682
+ switch (intent.kind) {
683
+ case 'none':
684
+ return;
685
+ case 'top':
686
+ window.scrollTo(0, 0);
687
+ return;
688
+ case 'restore':
689
+ window.scrollTo(intent.x, intent.y);
690
+ return;
691
+ case 'hash': {
692
+ const target = elementForFragment(intent.hash.slice(1));
693
+ if (target)
694
+ target.scrollIntoView();
695
+ else
696
+ window.scrollTo(0, 0);
697
+ return;
698
+ }
699
+ default: {
700
+ const exhaustive = intent;
701
+ return exhaustive;
702
+ }
703
+ }
704
+ }
705
+ const RouterContext = createContext(null);
706
+ const ViewContext = createContext(null);
707
+ const NavigationContext = createContext(IDLE);
708
+ function PassThrough({ children }) {
709
+ return children;
710
+ }
711
+ const defaultHardNavigate = (href) => window.location.assign(href);
712
+ /** Shows an HTML response to a form post the way the browser would have after a native submission. */
713
+ function defaultShowDocument(html, url) {
714
+ window.history.pushState(null, '', url);
715
+ document.open();
716
+ document.write(html);
717
+ document.close();
718
+ // document.open() removed the router's listeners; let the browser load whatever back/forward lands on.
719
+ window.addEventListener('popstate', () => window.location.reload());
720
+ }
721
+ function defaultDownload(file, name) {
722
+ const href = URL.createObjectURL(file);
723
+ const link = document.createElement('a');
724
+ link.href = href;
725
+ link.download = name;
726
+ document.body.append(link);
727
+ link.click();
728
+ link.remove();
729
+ setTimeout(() => URL.revokeObjectURL(href), 10_000);
730
+ }
731
+ export function RouterProvider({ initial, initialModules, loader, errorPage, hardNavigate = defaultHardNavigate, showDocument = defaultShowDocument, download = defaultDownload, shell: Shell = PassThrough, }) {
732
+ const [view, setView] = useState(() => ({
733
+ loaded: {
734
+ payload: initial,
735
+ modules: initialModules,
736
+ url: initialUrl(initial.url),
737
+ },
738
+ id: 0,
739
+ scroll: NONE,
740
+ }));
741
+ const [navigation, setNavigation] = useState(IDLE);
742
+ const [navigator] = useState(() => {
743
+ // Server actions called before the effect below registers this router wait for it (the server runs no effects).
744
+ if (typeof document !== 'undefined')
745
+ expectActionClient();
746
+ return new Navigator({
747
+ loader,
748
+ version: initial.version,
749
+ basePath: initial.basePath ?? '',
750
+ hardNavigate,
751
+ showDocument,
752
+ download,
753
+ setPending: setNavigation,
754
+ commit: (loaded, scroll) => startTransition(() => {
755
+ setView((previous) => ({ loaded, id: previous.id + 1, scroll }));
756
+ setNavigation(IDLE);
757
+ }),
758
+ }, view.loaded);
759
+ });
760
+ // In the commit that shows the page, before the browser dispatches the clicks that came in while React hydrated it:
761
+ // a fragment link followed then must find this history entry keyed, to come back to.
762
+ useLayoutEffect(() => navigator.start(), [navigator]);
763
+ const [actionListeners] = useState(() => new Set());
764
+ useEffect(() => setActionClient({
765
+ tokenField: () => navigator.page.payload.antiforgery?.formFieldName ?? '__RequestVerificationToken',
766
+ async invoke(name, url, input) {
767
+ const page = navigator.page;
768
+ const returnUrl = page.url.pathname + page.url.search;
769
+ const token = page.payload.antiforgery ?? (await navigator.fetchedToken());
770
+ const actionUrl = withBasePath(url, page.payload.basePath ?? '');
771
+ let cookiesChanged = false;
772
+ const onCookiesChanged = () => {
773
+ cookiesChanged = true;
774
+ navigator.cookiesChanged();
775
+ };
776
+ let call = await fetchAction(name, actionUrl, actionRequest(input, token, returnUrl), onCookiesChanged);
777
+ navigator.forgetToken();
778
+ // Tokens belong to the signed-in user: after signing in or out (on any tab) the page's one is stale. Only a
779
+ // call the server marks as refused for its token did not run, so only that one is sent again, once, with a
780
+ // fresh token. Any other 400 may come from an action that changed data first.
781
+ if (call.tokenRefused) {
782
+ const fresh = await navigator.fetchedToken();
783
+ if (fresh?.requestToken && fresh.requestToken !== token?.requestToken) {
784
+ call = await fetchAction(name, actionUrl, actionRequest(input, fresh, returnUrl), onCookiesChanged);
785
+ }
786
+ navigator.forgetToken();
787
+ }
788
+ const { response } = call;
789
+ // Changed cookies dropped the prefetched pages already, and started those in flight over.
790
+ if (!cookiesChanged)
791
+ navigator.forgetPrefetched();
792
+ for (const listener of actionListeners)
793
+ listener(response);
794
+ // The user may have moved on while the action ran: then neither pull them back (redirect) nor reload the
795
+ // page they left (refresh, which would also cancel their navigation).
796
+ const movedOn = navigator.page !== page || navigator.navigating;
797
+ if (!movedOn && response.redirect)
798
+ await navigator.navigate(response.redirect, 'push', true);
799
+ else if (!movedOn && response.revalidate)
800
+ await navigator.refresh();
801
+ // After signing in or out (an action that set or deleted a cookie), the page on screen shows whoever was
802
+ // signed in before until a page comes from the server: refresh it, like Next.js. That is the action's page
803
+ // when it neither redirected nor revalidated, or redirected to a fragment of itself (which loads nothing),
804
+ // or one the user moved on to that loaded with the old cookies while the action ran. A navigation still on
805
+ // its way has started over already. A redirect that left the page as it was went to another site (to sign
806
+ // in there, say), which the browser loads instead, or ended in a download: neither refreshes it.
807
+ const handedOver = !movedOn && Boolean(response.redirect) && navigator.page === page;
808
+ if (navigator.fromBeforeCookieChange && !navigator.navigating && !handedOver) {
809
+ await navigator.refresh({ keepPrefetched: cookiesChanged });
810
+ }
811
+ return response;
812
+ },
813
+ }), [navigator, actionListeners]);
814
+ useLayoutEffect(() => {
815
+ if (view.id > 0)
816
+ applyScroll(view.scroll);
817
+ }, [view]);
818
+ const router = useMemo(() => ({
819
+ push: (href, options) => navigator.navigate(href, 'push', options?.scroll ?? true),
820
+ replace: (href, options) => navigator.navigate(href, 'replace', options?.scroll ?? true),
821
+ prefetch: (href) => navigator.prefetch(href),
822
+ refresh: () => navigator.refresh(),
823
+ back: () => window.history.back(),
824
+ forward: () => window.history.forward(),
825
+ submit: (action, body) => navigator.submit(action, body),
826
+ onActionResponse: (listener) => {
827
+ actionListeners.add(listener);
828
+ return () => actionListeners.delete(listener);
829
+ },
830
+ onCookiesChanged: (listener) => navigator.onCookiesChanged(listener),
831
+ }), [navigator, actionListeners]);
832
+ const { payload, modules, url } = view.loaded;
833
+ const viewValue = useMemo(() => ({ payload, url }), [payload, url]);
834
+ return (_jsx(RouterContext, { value: router, children: _jsx(ViewContext, { value: viewValue, children: _jsx(NavigationContext, { value: navigation, children: _jsx(Shell, { children: _jsx(ErrorBoundary, { resetKey: view.id, fallback: (error) => _jsx(ErrorFallback, { error: error, errorPage: errorPage, loader: loader, payload: payload }), children: _jsx(RouteTree, { modules: modules, segments: payload.segments, props: payload.props, params: payload.params, status: payload.status, pageKind: payload.pageKind, pageKey: view.loaded.pageKey ?? url.pathname, retry: () => void router.refresh(), metadata: payload.metadata }) }) }) }) }) }));
835
+ }
836
+ const errorPageModules = new WeakMap();
837
+ function ErrorFallback({ error, errorPage, loader, payload }) {
838
+ const props = { status: 500, message: error instanceof Error ? error.message : 'Something went wrong' };
839
+ const builtIn = _jsx(RouteTree, { modules: { page: null, segments: [] }, props: props, params: payload.params, status: 500, pageKey: "error" });
840
+ if (!errorPage)
841
+ return builtIn;
842
+ let modules = errorPageModules.get(loader);
843
+ if (!modules) {
844
+ modules = loader.loadRoute(errorPage);
845
+ errorPageModules.set(loader, modules);
846
+ }
847
+ return (_jsx(ErrorBoundary, { resetKey: 0, fallback: () => builtIn, children: _jsx(Suspense, { fallback: null, children: _jsx(LoadedErrorPage, { modules: modules, props: props, payload: payload }) }) }));
848
+ }
849
+ function LoadedErrorPage({ modules, props, payload }) {
850
+ return _jsx(RouteTree, { modules: use(modules), props: props, params: payload.params, status: 500, pageKind: "error", pageKey: "error" });
851
+ }
852
+ /** The router's contexts without the router: for the test harness (netjes/testing), which fakes navigation. */
853
+ export function RouterContexts({ router, payload, url, navigation, children, }) {
854
+ const view = useMemo(() => ({ payload, url }), [payload, url]);
855
+ return (_jsx(RouterContext, { value: router, children: _jsx(ViewContext, { value: view, children: _jsx(NavigationContext, { value: navigation, children: children }) }) }));
856
+ }
857
+ function useView() {
858
+ const view = useContext(ViewContext);
859
+ if (!view)
860
+ throw new Error('netjes: this hook must be used inside a Netjes page');
861
+ return view;
862
+ }
863
+ export function useInternalRouter() {
864
+ const router = useContext(RouterContext);
865
+ if (!router)
866
+ throw new Error('netjes: this hook must be used inside a Netjes page');
867
+ return router;
868
+ }
869
+ /** The client router: `push`, `replace`, `prefetch`, `refresh`, `back` and `forward`. Throws outside a Netjes page. */
870
+ export function useRouter() {
871
+ return useInternalRouter();
872
+ }
873
+ /** The path without the base path, as the app's routes see it (like Next.js's `usePathname` with `basePath`). */
874
+ export function usePathname() {
875
+ const { url, payload } = useView();
876
+ return withoutBasePath(url.pathname, payload.basePath ?? '');
877
+ }
878
+ /** An href for a plain `<a>` or attribute, under the base path: what `<Link>` does for its own. */
879
+ export function useHref(href) {
880
+ return withBasePath(href, useBasePath());
881
+ }
882
+ /** `UsePathBase`'s base path for this page, or '' when the app is mounted at the root. */
883
+ export function useBasePath() {
884
+ return useView().payload.basePath ?? '';
885
+ }
886
+ /** Search values as Next.js passes them: a string, or an array of strings when the key repeats. */
887
+ function searchObject(search) {
888
+ const result = {};
889
+ for (const key of new Set(search.keys())) {
890
+ const values = search.getAll(key);
891
+ result[key] = values.length === 1 ? values[0] : values;
892
+ }
893
+ return result;
894
+ }
895
+ export function useSearchParams(schema) {
896
+ const { search } = useView().url;
897
+ return useMemo(() => {
898
+ const params = new URLSearchParams(search);
899
+ return schema ? parseLenient(schema, searchObject(params), 'search params') : params;
900
+ }, [search, schema]);
901
+ }
902
+ export function useParams(schema) {
903
+ const { params } = useView().payload;
904
+ return useMemo(() => (schema ? parseWith(schema, params, 'route params') : params), [params, schema]);
905
+ }
906
+ export function useSharedProps(schema) {
907
+ const { shared } = useView().payload;
908
+ return useMemo(() => (schema ? parseWith(schema, shared, 'shared props') : shared), [shared, schema]);
909
+ }
910
+ function decodeSegment(segment) {
911
+ try {
912
+ return decodeURIComponent(segment);
913
+ }
914
+ catch {
915
+ return segment;
916
+ }
917
+ }
918
+ /**
919
+ * In a layout: the URL segments below it, decoded (route groups do not appear in URLs). Next.js
920
+ * `useSelectedLayoutSegments`. Outside layouts, the whole path.
921
+ */
922
+ export function useSelectedLayoutSegments() {
923
+ const depth = useContext(LayoutDepthContext);
924
+ const pathname = usePathname();
925
+ return useMemo(() => pathname.split('/').filter(Boolean).slice(depth).map(decodeSegment), [pathname, depth]);
926
+ }
927
+ /** In a layout: the active segment one level below it (e.g. the selected tab), or null at the layout's own page. */
928
+ export function useSelectedLayoutSegment() {
929
+ return useSelectedLayoutSegments()[0] ?? null;
930
+ }
931
+ export function usePagePayload() {
932
+ return useView().payload;
933
+ }
934
+ /** The router's state: `loading` during a navigation, `submitting` during a form post, else `idle` (with `error` after a post that produced no page). */
935
+ export function useNavigation() {
936
+ return useContext(NavigationContext);
937
+ }
938
+ //# sourceMappingURL=router.js.map