@rshono/core 1.0.0-rc.2 → 1.0.0-rc.21

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 (208) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +348 -158
  3. package/bin/rshono.mjs +3 -4
  4. package/dist/builder/env-shadow-loader.cjs +123 -8
  5. package/dist/builder/page-entry-loader.cjs +28 -1
  6. package/dist/builder/page-files.d.ts +0 -1
  7. package/dist/builder/page-files.js +7 -3
  8. package/dist/builder/page-files.js.map +1 -1
  9. package/dist/builder/public-env.d.ts +7 -1
  10. package/dist/builder/public-env.js +7 -0
  11. package/dist/builder/public-env.js.map +1 -1
  12. package/dist/builder/react-versions.d.ts +12 -0
  13. package/dist/builder/react-versions.js +74 -0
  14. package/dist/builder/react-versions.js.map +1 -0
  15. package/dist/builder/rspack-config.d.ts +13 -4
  16. package/dist/builder/rspack-config.js +136 -53
  17. package/dist/builder/rspack-config.js.map +1 -1
  18. package/dist/builder/server-only-imports.d.ts +32 -0
  19. package/dist/builder/server-only-imports.js +51 -0
  20. package/dist/builder/server-only-imports.js.map +1 -0
  21. package/dist/cli/build.d.ts +2 -3
  22. package/dist/cli/build.js +51 -13
  23. package/dist/cli/build.js.map +1 -1
  24. package/dist/cli/dev.d.ts +2 -3
  25. package/dist/cli/dev.js +149 -45
  26. package/dist/cli/dev.js.map +1 -1
  27. package/dist/cli/exit.d.ts +12 -0
  28. package/dist/cli/exit.js +16 -0
  29. package/dist/cli/exit.js.map +1 -0
  30. package/dist/cli/index.d.ts +0 -1
  31. package/dist/cli/index.js +91 -30
  32. package/dist/cli/index.js.map +1 -1
  33. package/dist/cli/start.d.ts +0 -1
  34. package/dist/cli/start.js +15 -18
  35. package/dist/cli/start.js.map +1 -1
  36. package/dist/config.d.ts +72 -78
  37. package/dist/config.js +17 -1
  38. package/dist/config.js.map +1 -1
  39. package/dist/deploy/aws-lambda/runtime.d.ts +4 -7
  40. package/dist/deploy/aws-lambda/runtime.js +5 -8
  41. package/dist/deploy/aws-lambda/runtime.js.map +1 -1
  42. package/dist/deploy/build-marker.d.ts +9 -6
  43. package/dist/deploy/build-marker.js +8 -4
  44. package/dist/deploy/build-marker.js.map +1 -1
  45. package/dist/deploy/cloudflare/build.d.ts +0 -1
  46. package/dist/deploy/cloudflare/build.js +17 -13
  47. package/dist/deploy/cloudflare/build.js.map +1 -1
  48. package/dist/deploy/cloudflare/runtime.d.ts +2 -6
  49. package/dist/deploy/cloudflare/runtime.js +73 -68
  50. package/dist/deploy/cloudflare/runtime.js.map +1 -1
  51. package/dist/deploy/contract.d.ts +57 -41
  52. package/dist/deploy/contract.js.map +1 -1
  53. package/dist/deploy/filesystem.d.ts +3 -6
  54. package/dist/deploy/filesystem.js +23 -20
  55. package/dist/deploy/filesystem.js.map +1 -1
  56. package/dist/deploy/node/runtime.d.ts +5 -3
  57. package/dist/deploy/node/runtime.js +23 -9
  58. package/dist/deploy/node/runtime.js.map +1 -1
  59. package/dist/deploy/presets.d.ts +34 -30
  60. package/dist/deploy/presets.js +56 -44
  61. package/dist/deploy/presets.js.map +1 -1
  62. package/dist/deploy/public-paths.d.ts +42 -0
  63. package/dist/deploy/public-paths.js +66 -0
  64. package/dist/deploy/public-paths.js.map +1 -0
  65. package/dist/deploy/vercel/build.d.ts +0 -1
  66. package/dist/deploy/vercel/build.js +25 -16
  67. package/dist/deploy/vercel/build.js.map +1 -1
  68. package/dist/deploy/vercel/runtime.d.ts +10 -7
  69. package/dist/deploy/vercel/runtime.js +48 -11
  70. package/dist/deploy/vercel/runtime.js.map +1 -1
  71. package/dist/index.d.ts +19 -17
  72. package/dist/index.js +20 -10
  73. package/dist/index.js.map +1 -1
  74. package/dist/router.d.ts +212 -120
  75. package/dist/router.js +3 -8
  76. package/dist/router.js.map +1 -1
  77. package/dist/runtime/boundaries.d.ts +62 -48
  78. package/dist/runtime/boundaries.js +32 -34
  79. package/dist/runtime/boundaries.js.map +1 -1
  80. package/dist/runtime/client.d.ts +20 -11
  81. package/dist/runtime/client.js +20 -10
  82. package/dist/runtime/client.js.map +1 -1
  83. package/dist/runtime/context.d.ts +304 -123
  84. package/dist/runtime/context.js +377 -149
  85. package/dist/runtime/context.js.map +1 -1
  86. package/dist/runtime/control.d.ts +15 -1
  87. package/dist/runtime/control.js +24 -0
  88. package/dist/runtime/control.js.map +1 -1
  89. package/dist/runtime/dev-protocol.d.ts +4 -9
  90. package/dist/runtime/dev-protocol.js.map +1 -1
  91. package/dist/runtime/empty-server-app.d.ts +0 -1
  92. package/dist/runtime/entry.client.d.ts +4 -1
  93. package/dist/runtime/entry.client.js +455 -313
  94. package/dist/runtime/entry.client.js.map +1 -1
  95. package/dist/runtime/entry.rsc.d.ts +15 -7
  96. package/dist/runtime/entry.rsc.js +811 -252
  97. package/dist/runtime/entry.rsc.js.map +1 -1
  98. package/dist/runtime/entry.ssr.d.ts +27 -10
  99. package/dist/runtime/entry.ssr.js +30 -48
  100. package/dist/runtime/entry.ssr.js.map +1 -1
  101. package/dist/runtime/failure-document.d.ts +11 -0
  102. package/dist/runtime/failure-document.js +35 -0
  103. package/dist/runtime/failure-document.js.map +1 -0
  104. package/dist/runtime/flight-inject.d.ts +23 -0
  105. package/dist/runtime/flight-inject.js +403 -0
  106. package/dist/runtime/flight-inject.js.map +1 -0
  107. package/dist/runtime/hot-update.d.ts +44 -0
  108. package/dist/runtime/hot-update.js +44 -0
  109. package/dist/runtime/hot-update.js.map +1 -0
  110. package/dist/runtime/navigation.d.ts +46 -54
  111. package/dist/runtime/navigation.js +22 -63
  112. package/dist/runtime/navigation.js.map +1 -1
  113. package/dist/runtime/request.d.ts +42 -8
  114. package/dist/runtime/request.js +84 -7
  115. package/dist/runtime/request.js.map +1 -1
  116. package/dist/runtime/server.d.ts +20 -18
  117. package/dist/runtime/server.js +22 -17
  118. package/dist/runtime/server.js.map +1 -1
  119. package/dist/runtime/validate-entries.d.ts +77 -0
  120. package/dist/runtime/validate-entries.js +405 -0
  121. package/dist/runtime/validate-entries.js.map +1 -0
  122. package/dist/server/headers.d.ts +41 -15
  123. package/dist/server/headers.js +53 -24
  124. package/dist/server/headers.js.map +1 -1
  125. package/dist/server/load-config.d.ts +4 -5
  126. package/dist/server/load-config.js +22 -14
  127. package/dist/server/load-config.js.map +1 -1
  128. package/dist/server/load-env.d.ts +0 -1
  129. package/dist/server/prerendered.d.ts +88 -34
  130. package/dist/server/prerendered.js +184 -24
  131. package/dist/server/prerendered.js.map +1 -1
  132. package/dist/server/server-config.d.ts +35 -50
  133. package/dist/server/server-config.js +27 -74
  134. package/dist/server/server-config.js.map +1 -1
  135. package/dist/server/shutdown.d.ts +5 -4
  136. package/dist/server/shutdown.js +5 -3
  137. package/dist/server/shutdown.js.map +1 -1
  138. package/dist/server/ssg.d.ts +9 -7
  139. package/dist/server/ssg.js +209 -65
  140. package/dist/server/ssg.js.map +1 -1
  141. package/dist/server/static.d.ts +0 -1
  142. package/dist/server/static.js +7 -1
  143. package/dist/server/static.js.map +1 -1
  144. package/package.json +21 -23
  145. package/dist/builder/page-files.d.ts.map +0 -1
  146. package/dist/builder/public-env.d.ts.map +0 -1
  147. package/dist/builder/rspack-config.d.ts.map +0 -1
  148. package/dist/cli/build.d.ts.map +0 -1
  149. package/dist/cli/dev.d.ts.map +0 -1
  150. package/dist/cli/index.d.ts.map +0 -1
  151. package/dist/cli/start.d.ts.map +0 -1
  152. package/dist/config.d.ts.map +0 -1
  153. package/dist/deploy/aws-lambda/runtime.d.ts.map +0 -1
  154. package/dist/deploy/build-marker.d.ts.map +0 -1
  155. package/dist/deploy/bun/runtime.d.ts +0 -11
  156. package/dist/deploy/bun/runtime.d.ts.map +0 -1
  157. package/dist/deploy/bun/runtime.js +0 -22
  158. package/dist/deploy/bun/runtime.js.map +0 -1
  159. package/dist/deploy/cloudflare/build.d.ts.map +0 -1
  160. package/dist/deploy/cloudflare/runtime.d.ts.map +0 -1
  161. package/dist/deploy/contract.d.ts.map +0 -1
  162. package/dist/deploy/deno/runtime.d.ts +0 -11
  163. package/dist/deploy/deno/runtime.d.ts.map +0 -1
  164. package/dist/deploy/deno/runtime.js +0 -16
  165. package/dist/deploy/deno/runtime.js.map +0 -1
  166. package/dist/deploy/filesystem.d.ts.map +0 -1
  167. package/dist/deploy/listen.d.ts +0 -20
  168. package/dist/deploy/listen.d.ts.map +0 -1
  169. package/dist/deploy/listen.js +0 -24
  170. package/dist/deploy/listen.js.map +0 -1
  171. package/dist/deploy/netlify/build.d.ts +0 -8
  172. package/dist/deploy/netlify/build.d.ts.map +0 -1
  173. package/dist/deploy/netlify/build.js +0 -52
  174. package/dist/deploy/netlify/build.js.map +0 -1
  175. package/dist/deploy/netlify/runtime.d.ts +0 -13
  176. package/dist/deploy/netlify/runtime.d.ts.map +0 -1
  177. package/dist/deploy/netlify/runtime.js +0 -24
  178. package/dist/deploy/netlify/runtime.js.map +0 -1
  179. package/dist/deploy/node/runtime.d.ts.map +0 -1
  180. package/dist/deploy/presets.d.ts.map +0 -1
  181. package/dist/deploy/vercel/build.d.ts.map +0 -1
  182. package/dist/deploy/vercel/runtime.d.ts.map +0 -1
  183. package/dist/index.d.ts.map +0 -1
  184. package/dist/router.d.ts.map +0 -1
  185. package/dist/runtime/boundaries.d.ts.map +0 -1
  186. package/dist/runtime/client.d.ts.map +0 -1
  187. package/dist/runtime/context.d.ts.map +0 -1
  188. package/dist/runtime/control.d.ts.map +0 -1
  189. package/dist/runtime/dev-protocol.d.ts.map +0 -1
  190. package/dist/runtime/empty-server-app.d.ts.map +0 -1
  191. package/dist/runtime/entry.client.d.ts.map +0 -1
  192. package/dist/runtime/entry.rsc.d.ts.map +0 -1
  193. package/dist/runtime/entry.ssr.d.ts.map +0 -1
  194. package/dist/runtime/navigation.d.ts.map +0 -1
  195. package/dist/runtime/request.d.ts.map +0 -1
  196. package/dist/runtime/server.d.ts.map +0 -1
  197. package/dist/server/compress.d.ts +0 -15
  198. package/dist/server/compress.d.ts.map +0 -1
  199. package/dist/server/compress.js +0 -76
  200. package/dist/server/compress.js.map +0 -1
  201. package/dist/server/headers.d.ts.map +0 -1
  202. package/dist/server/load-config.d.ts.map +0 -1
  203. package/dist/server/load-env.d.ts.map +0 -1
  204. package/dist/server/prerendered.d.ts.map +0 -1
  205. package/dist/server/server-config.d.ts.map +0 -1
  206. package/dist/server/shutdown.d.ts.map +0 -1
  207. package/dist/server/ssg.d.ts.map +0 -1
  208. package/dist/server/static.d.ts.map +0 -1
@@ -2,15 +2,45 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import React from 'react';
3
3
  import { hydrateRoot } from 'react-dom/client';
4
4
  import { createFromFetch, createFromReadableStream, createTemporaryReferenceSet, encodeReply, setServerCallback, } from 'react-server-dom-rspack/client.browser';
5
- import { rscStream } from 'rsc-html-stream/client';
6
5
  import { isControlDigest, parseRedirectDigest } from './control.js';
6
+ // Dev-only: its one caller sits behind `import.meta.webpackHot`, which a production build compiles to
7
+ // `false` — so this module is dropped there.
8
+ import { walkHotUpdates } from './hot-update.js';
7
9
  import { RouterContext } from './navigation.js';
8
10
  import { createRscRequest } from './request.js';
9
11
  const isDev = process.env.NODE_ENV === 'development';
12
+ /** The flight payload the document carried, read back out of `__FLIGHT_DATA` — see `flight-inject.ts`. */
13
+ function readFlightPayload() {
14
+ const encoder = new TextEncoder();
15
+ // Assigned synchronously by `start`, which `new ReadableStream` runs before it returns.
16
+ let controller;
17
+ const stream = new ReadableStream({
18
+ start: (c) => void (controller = c),
19
+ });
20
+ const enqueue = (chunk) => controller.enqueue(typeof chunk === 'string' ? encoder.encode(chunk) : chunk);
21
+ // Payload scripts interleave with the document: the ones that already ran are in the array, the rest
22
+ // arrive through `push`.
23
+ const data = (self.__FLIGHT_DATA ??= []);
24
+ for (const chunk of data)
25
+ enqueue(chunk);
26
+ data.push = enqueue;
27
+ // The last payload script lands before parsing finishes, so that is what closes the stream.
28
+ if (document.readyState === 'loading') {
29
+ document.addEventListener('DOMContentLoaded', () => controller.close(), { once: true });
30
+ }
31
+ else {
32
+ controller.close();
33
+ }
34
+ return stream;
35
+ }
36
+ /** Created at module evaluation, not inside `main()`, so no chunk can be pushed before it is watching. */
37
+ const flightStream = readFlightPayload();
10
38
  /**
11
- * Guarantees somewhere to attach the fatal overlay. React's root container is the whole `document`,
12
- * so by the time an uncaught error has torn the tree down, `<body>` — or even `<html>` — may be gone.
39
+ * The part of the location a payload is rendered for the document, without the fragment, which the server
40
+ * never sees. Two URLs that differ only by `#hash` describe the same payload.
13
41
  */
42
+ const documentUrl = () => location.pathname + location.search;
43
+ /** Guarantees somewhere to attach the fatal overlay: the root container is `document`, so a teardown can take `<body>` with it. */
14
44
  function overlayHost() {
15
45
  if (!document.documentElement)
16
46
  document.appendChild(document.createElement('html'));
@@ -19,32 +49,40 @@ function overlayHost() {
19
49
  return document.body;
20
50
  }
21
51
  /**
22
- * Replaces the white screen of death with something readable.
23
- *
24
- * Because the root container is `document`, an uncaught render error leaves a genuinely blank page
25
- * with the reason only in the console — so this paints the reason over it instead. In development
26
- * that's the full stack; in production it's a generic notice plus a reload button, since the tree is
27
- * unrecoverable and reloading is the only way forward.
52
+ * Paints a full-viewport panel over whatever is on screen, and returns the box for the caller to fill.
28
53
  *
29
- * Written with DOM calls rather than React (the renderer is what just failed) and `textContent`
30
- * rather than `innerHTML` (an error message is untrusted input).
54
+ * DOM calls rather than React (one caller runs because the renderer just failed), and `textContent` rather
55
+ * than `innerHTML` (an error message is untrusted input). Queued on a macrotask: React's teardown runs after
56
+ * the callback that reaches here returns, and would remove a node appended inline.
31
57
  */
32
- function showFatal(error, componentStack) {
33
- // Queued rather than run inline: React's teardown happens after this callback returns, and would
34
- // remove a node appended synchronously along with the rest of the tree.
58
+ function paintOverlay(fill) {
35
59
  setTimeout(() => {
36
60
  const host = overlayHost();
37
61
  host.querySelector('[data-rshono-fatal]')?.remove();
38
62
  const box = document.createElement('div');
39
63
  box.setAttribute('data-rshono-fatal', '');
40
- box.setAttribute('role', 'alert'); // the page is gone; announce it rather than leaving silence
64
+ box.setAttribute('role', 'alert');
41
65
  box.style.cssText =
42
66
  'position:fixed;inset:0;z-index:2147483647;overflow:auto;padding:1.5rem;background:#18181b;color:#f4f4f5;' +
43
67
  'font:14px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;text-align:left';
44
- const title = document.createElement('div');
45
- title.textContent = isDev ? 'Unhandled error' : 'Something went wrong';
46
- title.style.cssText = 'font-size:1.0625rem;font-weight:700;color:#f87171;margin:0 0 0.75rem';
47
- box.appendChild(title);
68
+ fill(box);
69
+ host.appendChild(box);
70
+ }, 0);
71
+ }
72
+ /** The overlay's heading. */
73
+ function overlayTitle(text) {
74
+ const title = document.createElement('div');
75
+ title.textContent = text;
76
+ title.style.cssText = 'font-size:1.0625rem;font-weight:700;color:#f87171;margin:0 0 0.75rem';
77
+ return title;
78
+ }
79
+ /**
80
+ * Paints the reason for an uncaught render error over the blank page it leaves behind — the full stack in
81
+ * dev, a generic notice and a reload button in production.
82
+ */
83
+ function showFatal(error, componentStack) {
84
+ paintOverlay((box) => {
85
+ box.appendChild(overlayTitle(isDev ? 'Unhandled error' : 'Something went wrong'));
48
86
  if (isDev) {
49
87
  const detail = document.createElement('pre');
50
88
  detail.style.cssText = 'margin:0;white-space:pre-wrap;word-break:break-word';
@@ -63,165 +101,380 @@ function showFatal(error, componentStack) {
63
101
  reload.textContent = 'Reload page';
64
102
  reload.style.cssText =
65
103
  'margin-top:1.25rem;padding:0.5rem 1rem;font:inherit;color:#18181b;background:#f4f4f5;border:0;border-radius:4px;cursor:pointer';
66
- reload.addEventListener('click', () => window.location.reload());
104
+ reload.addEventListener('click', () => loadOutsideRouter(() => window.location.reload()));
67
105
  box.appendChild(reload);
68
- host.appendChild(box);
69
- }, 0);
106
+ });
107
+ }
108
+ /**
109
+ * The end of the line for a `notFound()` that arrived too late to be a 404 and did not survive a reload.
110
+ *
111
+ * No reload button, unlike {@link showFatal}: the reload has already been spent, and the second identical
112
+ * response is what brought us here. "Page not found" is what the server was trying to say, so it is what the
113
+ * visitor is told; the reason it could not say it properly is a message for whoever wrote the page, and dev is
114
+ * where they are.
115
+ */
116
+ function showLateNotFound() {
117
+ paintOverlay((box) => {
118
+ box.appendChild(overlayTitle('Page not found'));
119
+ const message = document.createElement('p');
120
+ message.textContent = isDev
121
+ ? 'notFound() was raised from a boundary that resolved after the page shell had been sent, so the response ' +
122
+ 'could not be a 404 — and reloading rendered the same page again. Decide before the render starts ' +
123
+ 'streaming: in Hono middleware, or in the page component body above the boundary.'
124
+ : 'This page is not available.';
125
+ message.style.cssText = 'margin:0;color:#d4d4d8';
126
+ box.appendChild(message);
127
+ });
128
+ }
129
+ /** What every flight response is typed as. The charset and any other parameters follow it. */
130
+ const FLIGHT_CONTENT_TYPE = 'text/x-component';
131
+ /**
132
+ * Fetches a payload, refusing a response that is not one.
133
+ *
134
+ * The status cannot be the gate: a payload legitimately arrives as a 404 from the `notFound` page and as a
135
+ * 500 from an action that threw, and both carry a real payload the caller has to see. The content type is.
136
+ *
137
+ * What this catches is the response that is not a payload at all — a `bodyLimit()` 413, a proxy's error page,
138
+ * a 502 mid-deploy. Handed to the flight parser those all surface as `Error: Connection closed.`, with the
139
+ * status and the body nowhere in sight; here they become an error that says what arrived.
140
+ */
141
+ async function payloadResponse(request) {
142
+ const response = await fetch(request);
143
+ const contentType = response.headers.get('content-type');
144
+ if (contentType?.startsWith(FLIGHT_CONTENT_TYPE))
145
+ return response;
146
+ // Read for the message: a plain-text refusal says what it refused only in its body, and HTTP/2 has no
147
+ // `statusText` at all. Bounded, because this is an error path and the body is not ours to trust.
148
+ const body = await response.text().then((text) => text.trim().slice(0, 200), () => '');
149
+ const status = `${response.status}${response.statusText ? ` ${response.statusText}` : ''}`;
150
+ throw new Error(`[rshono] the server answered ${status} (${contentType ?? 'no content type'}) instead of a payload${body ? `: ${body}` : ''}`);
151
+ }
152
+ /**
153
+ * Asks a URL for its flight payload. Deliberately uncached — a payload can never be staler than the click
154
+ * that wanted it, and the browser's own HTTP cache is what makes a repeat visit cheap.
155
+ */
156
+ function requestPayload(href, signal) {
157
+ return createFromFetch(payloadResponse(createRscRequest(new URL(href, location.href).href, undefined, signal)));
158
+ }
159
+ /**
160
+ * Whether the browser hands us its navigations. Gated on `sourceElement` rather than on `navigation` itself:
161
+ * Chrome shipped the event in 102 and that property only in 135, and without it a `data-native` link cannot
162
+ * be told from any other — so the older window would soft-navigate the very links that asked not to be.
163
+ *
164
+ * Where this is false there is no interception at all and every navigation is a real browser load, which a
165
+ * server-rendered app answers correctly on its own. Only the soft part is missing.
166
+ *
167
+ * Both globals are tested, and neither is touched before: this runs at module scope, where a ReferenceError
168
+ * would take the whole client runtime down with it rather than degrading anything.
169
+ */
170
+ const canSoftNavigate = typeof navigation !== 'undefined' && typeof NavigateEvent !== 'undefined' && 'sourceElement' in NavigateEvent.prototype;
171
+ /**
172
+ * Drops a navigation's result promises. Both reject when a navigation is superseded or cancelled — routine
173
+ * here, since a second click is meant to abandon the first — and unhandled they would be reported as faults.
174
+ */
175
+ function settle(result) {
176
+ const ignore = () => { };
177
+ void result.committed?.catch(ignore);
178
+ void result.finished?.catch(ignore);
179
+ }
180
+ /** Set by {@link loadOutsideRouter}, read and cleared by the `navigate` listener. */
181
+ let bypassRouter = false;
182
+ /**
183
+ * Performs a navigation the router below must **not** intercept, and returns having asked for it.
184
+ *
185
+ * `location.reload()` and `location.assign()` fire a `navigate` event like any other navigation, and
186
+ * `listenNavigation` intercepts a `reload` on purpose — that is what `router.refresh()` is. Every caller here
187
+ * is reaching for a *new document* precisely because the current one cannot be repaired: the React root a
188
+ * soft load would render into is the thing that just failed, or is about to be torn down. Intercepted, the
189
+ * escape hatch becomes a payload fetch that lands nowhere — which is how a late `notFound()` left the tab on
190
+ * its Suspense fallback with no second document ever arriving, and how a late `redirect()` moved the address
191
+ * bar to a page it then failed to render.
192
+ *
193
+ * One-shot: the listener clears the flag on the next event it sees. If that event never comes — a navigation
194
+ * the browser refuses — the cost is that the *next* navigation is a full load rather than a soft one, on a
195
+ * document that was on its way out anyway.
196
+ */
197
+ function loadOutsideRouter(navigate) {
198
+ bypassRouter = true;
199
+ navigate();
200
+ }
201
+ // The imperative actions behind `useNavigation().router`. Each one only *asks*: the browser turns it into a
202
+ // `navigate` event, which is where `listenNavigation` answers it — so a `router.push` and a link click reach
203
+ // the same code by the same route, and inherit the same fetch, scroll and `pending` flag.
204
+ function push(href) {
205
+ if (canSoftNavigate)
206
+ settle(navigation.navigate(href, { history: 'push' }));
207
+ else
208
+ window.location.assign(href);
70
209
  }
71
- // In-memory flight-payload cache keyed by same-origin path+search. `data-prefetch`
72
- // links warm it on hover/focus; a navigation to a warmed URL resolves instantly and
73
- // clears the entry (a prefetch is used at most once, so re-visits always re-fetch).
74
- const payloadCache = new Map();
75
- // Bounded so a long session over a link-dense app can't grow it without limit. Insertion-ordered,
76
- // so the first key is the least recently warmed.
77
- const MAX_WARMED_PAYLOADS = 8;
78
- function cacheKey(href) {
79
- const url = new URL(href, location.href);
80
- if (url.origin !== location.origin)
81
- return null;
82
- return url.pathname + url.search;
210
+ function replace(href) {
211
+ if (canSoftNavigate)
212
+ settle(navigation.navigate(href, { history: 'replace' }));
213
+ else
214
+ window.location.replace(href);
83
215
  }
84
- function requestPayload(href) {
85
- return createFromFetch(fetch(createRscRequest(new URL(href, location.href).href)));
216
+ // A traversal is the browser's to perform either way — `navigation` only hands it back as an interceptable
217
+ // event first. Nothing to go back to is a rejection there and a no-op here; both amount to the same thing.
218
+ function back() {
219
+ if (canSoftNavigate)
220
+ settle(navigation.back());
221
+ else
222
+ window.history.back();
86
223
  }
87
- function warmPayload(href) {
88
- const key = cacheKey(href);
89
- if (!key || key === cacheKey(location.href) || payloadCache.has(key))
224
+ function forward() {
225
+ if (canSoftNavigate)
226
+ settle(navigation.forward());
227
+ else
228
+ window.history.forward();
229
+ }
230
+ // A refresh keeps the URL, and is still a navigation: it arrives as `navigationType: 'reload'`, which is what
231
+ // tells the listener to leave scroll and focus where the user left them.
232
+ function refresh() {
233
+ if (canSoftNavigate)
234
+ settle(navigation.reload());
235
+ else
236
+ window.location.reload();
237
+ }
238
+ /** How long the recovery reload is given to replace this document before the panel is painted instead. */
239
+ const RELOAD_GRACE_MS = 2000;
240
+ /** The `sessionStorage` key bounding the recovery reload for one URL. Spent here, released in {@link main}. */
241
+ const lateNotFoundKey = () => `rshono:late-not-found:${documentUrl()}`;
242
+ /**
243
+ * Spends the one reload a late `notFound()` gets, or paints if it has already been spent for this URL.
244
+ *
245
+ * `redirect()` is terminal on the client — there is somewhere to navigate to — and `notFound()` is not: the
246
+ * response is already committed as a 200, so the only recovery left is asking for the page again and hoping
247
+ * the signal comes early enough this time to be a real 404. That works where the lateness was incidental, a
248
+ * boundary that happened to resolve after the shell on a slow request. Where it is structural — a page that
249
+ * always signals from a late boundary — the reload gets a byte-identical response and reloads again, and the
250
+ * tab spins until the visitor leaves. In production nothing is logged, because the warning that explains this
251
+ * is `isDev`-only.
252
+ *
253
+ * So it is bounded: one reload per URL per tab, then {@link showLateNotFound}. `sessionStorage` because the
254
+ * value has to outlive the document it is written in and must not outlive the tab, and keyed by URL so a
255
+ * second page's late signal still gets its own attempt.
256
+ */
257
+ function reloadOnceForLateNotFound() {
258
+ const key = lateNotFoundKey();
259
+ let spent;
260
+ try {
261
+ spent = sessionStorage.getItem(key) !== null;
262
+ if (!spent)
263
+ sessionStorage.setItem(key, '1');
264
+ }
265
+ catch {
266
+ // Storage can throw outright where site data is blocked, and a page that cannot count its reloads has
267
+ // to pick a side. It picks the terminating one: a message on a page that might have recovered is a
268
+ // worse outcome than a reload loop only by a lot less.
269
+ spent = true;
270
+ }
271
+ if (spent) {
272
+ showLateNotFound();
90
273
  return;
91
- const promise = requestPayload(href);
92
- payloadCache.set(key, promise);
93
- // One entry in means at most one out, and the first key is the oldest.
94
- if (payloadCache.size > MAX_WARMED_PAYLOADS)
95
- payloadCache.delete(payloadCache.keys().next().value);
96
- // Don't cache failures, and swallow the rejection until (or unless) a nav awaits it.
97
- promise.catch(() => {
98
- if (payloadCache.get(key) === promise)
99
- payloadCache.delete(key);
274
+ }
275
+ loadOutsideRouter(() => window.location.reload());
276
+ // The reload wins this race whenever it happens at all: the document goes away and takes the timer with
277
+ // it. What this covers is a reload that does not happen — swallowed by an interceptor, refused by the
278
+ // browser, held by a `beforeunload` — which used to leave the visitor on a Suspense fallback with nothing
279
+ // coming and nothing said. The panel is the honest answer in that case too.
280
+ setTimeout(() => {
281
+ if (!document.querySelector('[data-rshono-fatal]'))
282
+ showLateNotFound();
283
+ }, RELOAD_GRACE_MS);
284
+ }
285
+ /**
286
+ * Turns a control-signal digest — how `redirect()` / `notFound()` reach the browser — into a real
287
+ * navigation. Returns false for anything else, so callers fall through to their own handling.
288
+ *
289
+ * `hard` forces a full document load, for signals that surfaced *through React*: it unmounts the root on
290
+ * an uncaught error, leaving no live tree to soft-navigate with.
291
+ */
292
+ function handleControlDigest(error, { hard = false } = {}) {
293
+ const digest = error?.digest;
294
+ if (!isControlDigest(digest))
295
+ return false;
296
+ const redirect = parseRedirectDigest(digest);
297
+ if (!redirect) {
298
+ reloadOnceForLateNotFound();
299
+ }
300
+ else if (hard) {
301
+ loadOutsideRouter(() => window.location.assign(new URL(redirect.location, window.location.href).href));
302
+ }
303
+ else {
304
+ push(redirect.location);
305
+ }
306
+ return true;
307
+ }
308
+ /**
309
+ * Puts a payload on screen, resolving once React has committed it. Replaced by `BrowserRoot`'s own on mount;
310
+ * the default covers the window before hydration, where `setServerCallback` is already registered but there
311
+ * is no root to update — a reload is the honest answer, and nothing after it needs to run.
312
+ */
313
+ let setPayload = () => {
314
+ window.location.reload();
315
+ return new Promise(() => { });
316
+ };
317
+ /** Runs work inside the nav transition so `useNavigation().pending` stays true across the round-trip. */
318
+ let startNav = (run) => {
319
+ void run();
320
+ };
321
+ /**
322
+ * Fetches the payload for `url` and puts it on screen.
323
+ *
324
+ * Resolves once React has **committed** it rather than when the fetch lands: an intercepted navigation
325
+ * scrolls and moves focus when this promise settles, and a `#hash` target does not exist until the new tree
326
+ * does. Rejects only on a genuine failure — being superseded is not one, and resolves quietly, because the
327
+ * navigation that replaced this one owns the screen from then on.
328
+ */
329
+ function loadPayload(url, signal) {
330
+ // Deliberately not awaited inside the transition: the scope ends once the payload is handed to React, and
331
+ // React holds `pending` until the update it scheduled commits. Awaiting the commit *inside* the scope would
332
+ // work too, but only because React happens not to gate a commit on its async scope settling — an internal
333
+ // this has no reason to depend on across the whole `^19.1.0` peer range.
334
+ let committed;
335
+ const run = async () => {
336
+ const payload = await requestPayload(url, signal);
337
+ // The browser aborts a navigation the moment a newer one starts. Checked again after the await because
338
+ // the fetch may already have resolved by then, and applying it would repaint a page the user has left.
339
+ if (signal?.aborted)
340
+ return;
341
+ if (payload.redirect) {
342
+ push(payload.redirect);
343
+ return;
344
+ }
345
+ committed = setPayload(payload);
346
+ };
347
+ // `startTransition` runs the work but hands nothing back, so the promise carrying a failure is caught here
348
+ // instead. Assigned synchronously: React invokes the callback before `startNav` returns.
349
+ let work;
350
+ startNav(() => (work = run()));
351
+ return work.then(
352
+ // Undefined whenever nothing was applied — an abort, or a redirect — and there is then nothing to wait for.
353
+ () => committed, (error) => {
354
+ // Checked before the error is read: an abort is this navigation being replaced, and the one that
355
+ // replaced it owns the outcome.
356
+ if (signal?.aborted || handleControlDigest(error))
357
+ return;
358
+ throw error;
100
359
  });
101
360
  }
102
- function takePayload(href) {
103
- const key = cacheKey(href);
104
- if (key) {
105
- const cached = payloadCache.get(key);
106
- if (cached) {
107
- payloadCache.delete(key);
108
- return cached;
361
+ /**
362
+ * Navigations the browser can hand over but shouldn't:
363
+ *
364
+ * - a fragment jump, which is same-document already and needs no payload — the browser's own jump is the one
365
+ * that honours `scroll-padding-top`, and re-rendering would pull the target out from under it;
366
+ * - a download, which is not a navigation of this page at all;
367
+ * - a `POST` form, which is a submission and the server's to answer (a `GET` form carries its fields in the
368
+ * URL, has no `formData`, and soft-navigates like any other link);
369
+ * - a link marked `data-native`, the documented opt-out.
370
+ */
371
+ function leaveToBrowser(event) {
372
+ return event.hashChange || event.downloadRequest !== null || event.formData !== null || event.sourceElement?.hasAttribute('data-native') === true;
373
+ }
374
+ /**
375
+ * The whole router, in one listener.
376
+ *
377
+ * Every navigation the page can make arrives as a `navigate` event — a link click, a `GET` form, a
378
+ * `history.pushState`, the back button, `navigation.reload()` — already filtered by the browser: it does not
379
+ * fire for a middle-click, a modified click or a new tab, and reports `canIntercept: false` for anything
380
+ * cross-origin, or for a traversal that leaves the app. Those need no handling here; they are left alone, and
381
+ * the browser performs them as it always would.
382
+ */
383
+ function listenNavigation() {
384
+ if (!canSoftNavigate)
385
+ return () => { };
386
+ const onNavigate = (event) => {
387
+ // Cleared as it is consumed, whatever this event turns out to be: the flag names one navigation, and the
388
+ // one it named is the one that just arrived.
389
+ if (bypassRouter) {
390
+ bypassRouter = false;
391
+ return;
109
392
  }
110
- }
111
- return requestPayload(href);
393
+ if (!event.canIntercept || leaveToBrowser(event))
394
+ return;
395
+ // A push or a traversal lands on a new page, so the browser resets the scroll offset — or restores the
396
+ // one it remembers — and moves focus, which is what makes a soft navigation announce itself to a screen
397
+ // reader. A replace or a refresh stays where it is, so neither should move. Both wait on the handler,
398
+ // which is the point of resolving it at commit rather than at fetch.
399
+ const inPlace = event.navigationType === 'replace' || event.navigationType === 'reload';
400
+ event.intercept({
401
+ scroll: inPlace ? 'manual' : 'after-transition',
402
+ focusReset: inPlace ? 'manual' : 'after-transition',
403
+ // The URL commits before the handler runs, so a failure leaves the address bar describing a page the
404
+ // document is not showing. A real load is the only way back to agreement.
405
+ handler: () => loadPayload(event.destination.url, event.signal).catch(() => loadOutsideRouter(() => window.location.reload())),
406
+ });
407
+ };
408
+ navigation.addEventListener('navigate', onNavigate);
409
+ return () => navigation.removeEventListener('navigate', onNavigate);
112
410
  }
113
411
  async function main() {
412
+ // The assertion is load-bearing under the compiler that builds this: TypeScript 7 declares `nonce` on
413
+ // HTMLElement, 6 declares it on Element. ESLint runs the older lib — where the narrowing is redundant —
414
+ // so it reports an assertion that `tsc` requires. Believe `typecheck`, not the rule.
415
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
114
416
  const cspMeta = document.querySelector('meta[property="csp-nonce"]');
115
417
  if (cspMeta?.nonce)
116
418
  __webpack_nonce__ = cspMeta.nonce;
117
- // Both are replaced by BrowserRoot's own on mount. The defaults matter: `setServerCallback` is
118
- // registered before hydration, so an action or refresh firing in that window would otherwise call
119
- // an unassigned binding. Until there's a root to update, a full reload is the honest fallback.
120
- let setPayload = () => {
121
- window.location.reload();
122
- };
123
- // Runs work inside the nav transition so useNavigation().pending stays true across the round-trip.
124
- let startNav = (run) => {
125
- void run();
126
- };
127
- const initialPayload = await createFromReadableStream(rscStream);
128
- function push(href) {
129
- const target = new URL(href, window.location.href);
130
- if (target.origin !== window.location.origin) {
131
- window.location.assign(target.href);
132
- return;
133
- }
134
- window.history.pushState(null, '', target.href);
135
- }
136
- function replace(href) {
137
- const target = new URL(href, window.location.href);
138
- if (target.origin !== window.location.origin) {
139
- window.location.replace(target.href);
140
- return;
141
- }
142
- window.history.replaceState(null, '', target.href);
143
- }
144
- const back = () => window.history.back();
145
- const forward = () => window.history.forward();
146
- // A refresh keeps the URL, so it can't ride the history patch like push/replace — it drives the flight re-fetch directly (bypassing any warmed cache to get fresh data).
147
- const refresh = () => startNav(async () => {
419
+ const initialPayload = await createFromReadableStream(flightStream);
420
+ // The recovery reload landed: this document *is* the `notFound` page, so the signal that could only be a
421
+ // digest on the `RSC: 1` request became a real 404 on the document request. The one-reload bound was spent
422
+ // on a recovery that worked and has to be released — otherwise the *next* soft navigation to this URL sees a
423
+ // spent key and paints {@link showLateNotFound} over a page the app can still render.
424
+ //
425
+ // This is the discriminator the bound was missing. A structurally late `notFound()` commits its 200 before
426
+ // it signals, so its reloaded document is the page itself and carries no `notFound`: the key survives and
427
+ // that loop stays bounded at one reload, which is the whole reason the bound exists.
428
+ if (initialPayload.notFound) {
148
429
  try {
149
- await fetchRscPayload(true);
430
+ sessionStorage.removeItem(lateNotFoundKey());
150
431
  }
151
432
  catch {
152
- window.location.reload();
153
- }
154
- });
155
- /**
156
- * Turns a control-signal digest — how `redirect()` / `notFound()` reach the browser — into a real
157
- * navigation. Returns false for anything else, so callers can fall through to their own handling.
158
- *
159
- * `hard` forces a full document load, for signals that surfaced *through React* (a nested
160
- * component's redirect, reported via the root error handlers). React unmounts the root on an
161
- * uncaught error, so there is no live tree left to soft-navigate with. A signal caught earlier —
162
- * a top-level payload rejection — still swaps the payload in place.
163
- */
164
- function handleControlDigest(error, { hard = false } = {}) {
165
- const digest = error?.digest;
166
- if (!isControlDigest(digest))
167
- return false;
168
- const redirect = parseRedirectDigest(digest);
169
- if (!redirect) {
170
- window.location.reload();
171
- }
172
- else if (hard) {
173
- window.location.assign(new URL(redirect.location, window.location.href).href);
174
- }
175
- else {
176
- push(redirect.location);
177
- }
178
- return true;
179
- }
180
- async function fetchRscPayload(force = false) {
181
- let payload;
182
- try {
183
- payload = await (force ? requestPayload(window.location.href) : takePayload(window.location.href));
433
+ // Blocked site data. `reloadOnceForLateNotFound` already treats that as the terminating case.
184
434
  }
185
- catch (error) {
186
- if (handleControlDigest(error))
187
- return;
188
- throw error;
189
- }
190
- if (payload.redirect)
191
- return push(payload.redirect);
192
- setPayload(payload);
193
435
  }
194
436
  function BrowserRoot() {
195
437
  const [payload, setPayloadState] = React.useState(initialPayload);
196
438
  const [pending, startTransition] = React.useTransition();
439
+ // The resolver the payload on screen still owes — see {@link loadPayload}.
440
+ const pendingCommit = React.useRef(null);
197
441
  React.useEffect(() => {
198
- setPayload = (v) => setPayloadState(v);
442
+ setPayload = (next) => new Promise((resolve) => {
443
+ // A payload replaced before it ever painted still has a navigation waiting on it. React commits
444
+ // only the newest, so the effect below never runs for the one it skipped: release it here.
445
+ pendingCommit.current?.();
446
+ pendingCommit.current = resolve;
447
+ setPayloadState(next);
448
+ });
199
449
  startNav = (run) => startTransition(run);
200
450
  }, [startTransition]);
201
- React.useEffect(() => listenNavigation((restoreScroll) => startNav(async () => {
202
- try {
203
- await fetchRscPayload();
204
- restoreScroll();
205
- }
206
- catch {
207
- window.location.reload();
208
- }
209
- }), warmPayload), []);
451
+ /**
452
+ * Releases the navigation waiting on this payload, which is what lets the browser scroll and move focus
453
+ * now that their target exists. A layout effect, so the pre-scroll position is never painted.
454
+ */
455
+ React.useLayoutEffect(() => {
456
+ const commit = pendingCommit.current;
457
+ pendingCommit.current = null;
458
+ commit?.();
459
+ }, [payload]);
460
+ React.useEffect(() => listenNavigation(), []);
210
461
  const router = React.useMemo(() => ({ push, replace, back, forward, refresh, pending }), [pending]);
211
462
  return _jsx(RouterContext.Provider, { value: router, children: payload.root });
212
463
  }
213
464
  setServerCallback(async (id, args) => {
214
465
  const temporaryReferences = createTemporaryReferenceSet();
466
+ // The document the action is being called from. Every action response carries a fresh payload for that
467
+ // page, so if a navigation has moved on by the time it arrives the payload describes a page the user has
468
+ // left — the return value is still theirs, but painting it is not. Compared without the fragment, which
469
+ // the server never saw.
470
+ const calledFrom = documentUrl();
215
471
  const request = createRscRequest(window.location.href, {
216
472
  id,
217
473
  body: await encodeReply(args, { temporaryReferences }),
218
474
  });
219
- // The action is about to mutate who-knows-what, so anything warmed up to now is pre-mutation
220
- // data. Cleared before the round-trip so it happens even if the action throws.
221
- payloadCache.clear();
222
475
  let payload;
223
476
  try {
224
- payload = await createFromFetch(fetch(request), { temporaryReferences });
477
+ payload = await createFromFetch(payloadResponse(request), { temporaryReferences });
225
478
  }
226
479
  catch (error) {
227
480
  if (handleControlDigest(error))
@@ -232,220 +485,109 @@ async function main() {
232
485
  push(payload.redirect);
233
486
  return undefined;
234
487
  }
235
- React.startTransition(() => setPayload(payload));
488
+ if (documentUrl() === calledFrom)
489
+ React.startTransition(() => void setPayload(payload));
236
490
  if (payload.notFound)
237
491
  return undefined;
238
492
  const result = payload.returnValue;
493
+ if (!result) {
494
+ // A payload that is not this action's own reply: the server rendered a page in its place. An action
495
+ // that had already run has its result carried across (see `actionResults` in entry.rsc.tsx), and the
496
+ // ways a *caller* can get a request wrong are refused ahead of any render — an unknown id or an
497
+ // undecodable body is a `text/plain` 400, which `payloadResponse` turns into an error of its own
498
+ // before this.
499
+ //
500
+ // What is left is the server failing before the action ran, which is answered with the `error` page —
501
+ // a flight payload, so it arrives here rather than at `payloadResponse`, with no `returnValue` in it.
502
+ // A module the deployment no longer holds is the reachable case: `loadServerAction` throws, the
503
+ // framework reports it and 500s, and this is what the caller has to go on. Hence the message: the
504
+ // failure is on the server and its log is where the error is. Below that it is still the defensive
505
+ // floor for a payload shaped by another deployment or replaced by a proxy — reading `.ok` off it used
506
+ // to hand the caller `Cannot read properties of undefined`.
507
+ throw new Error('[rshono] the server action produced no result — the request failed around it and the server answered with a page instead. Its log has the error.');
508
+ }
239
509
  if (!result.ok)
240
510
  throw result.error;
241
511
  return result.value;
242
512
  });
243
- // A `redirect()` / `notFound()` from a component *below* the page root can only reach us through
244
- // React: it rides the flight payload as an error at that component's position, and boundaries
245
- // re-throw it (see boundaries.tsx) so it lands here rather than rendering an error fallback.
513
+ // A `redirect()` / `notFound()` from a component below the page root reaches us through React: it rides the
514
+ // flight payload as an error, and boundaries re-throw it so it lands here rather than in a fallback.
246
515
  //
247
- // Anything that isn't a control signal falls back to what React would have done on its own
248
- // console for a caught error, `reportError` (i.e. window.onerror, so error-reporting tools still
249
- // see it) for an uncaught one. Overriding these hooks means opting out of that default, so it has
250
- // to be put back by hand.
516
+ // Installing these hooks opts out of React's own defaults, so everything that isn't a control signal has to
517
+ // be put back by hand `reportError` rather than a bare log, so error-reporting tools still see it.
251
518
  hydrateRoot(document, _jsx(BrowserRoot, {}), {
252
519
  formState: initialPayload.formState,
253
520
  onCaughtError: (error, errorInfo) => {
254
521
  if (handleControlDigest(error, { hard: true }))
255
522
  return;
256
- // A boundary handled this and the tree is intact, so no overlay: whatever fallback the app
257
- // chose is the right thing to have on screen.
523
+ // A boundary handled it and the tree is intact, so no overlay over the app's own fallback.
258
524
  console.error(error, errorInfo.componentStack ?? '');
259
525
  },
260
526
  onUncaughtError: (error, errorInfo) => {
261
527
  if (handleControlDigest(error, { hard: true }))
262
528
  return;
263
- // Nothing caught it, so React tears the root down — and the root is `document`. This is the
264
- // white screen; paint the reason over it.
529
+ // Nothing caught it, so React tears the root down — and the root is `document`.
265
530
  globalThis.reportError(error);
266
531
  showFatal(error, errorInfo.componentStack);
267
532
  },
268
533
  });
269
534
  if (import.meta.webpackHot) {
270
- // Server code may have changed, so drop any warmed payloads and re-fetch fresh.
271
- initDevRefresh(() => {
272
- payloadCache.clear();
273
- return fetchRscPayload(true);
274
- });
535
+ initDevRefresh();
275
536
  }
276
537
  }
277
- /** Hover/focus dwell time before a `data-prefetch` link warms its payload. */
278
- const PREFETCH_DELAY_MS = 120;
279
- // An `<a>` we intercept for soft navigation: same-origin, same tab, not a download,
280
- // and not explicitly opted out with `data-native` (which forces a full browser navigation).
281
- function isRouterLink(link) {
282
- return (!!link.href &&
283
- (!link.target || link.target === '_self') &&
284
- link.origin === location.origin &&
285
- !link.hasAttribute('download') &&
286
- !link.hasAttribute('data-native'));
287
- }
288
- function listenNavigation(onNavigation, prefetch) {
289
- // Scroll restoration. We tag each history entry with a stable numeric key in its
290
- // `history.state` and remember scrollY per key, so back/forward restores the exact
291
- // position while push scrolls to the top. `manual` hands restoration to us.
292
- const scrollByKey = new Map();
293
- let seq = 0;
294
- const prevRestoration = window.history.scrollRestoration;
295
- try {
296
- window.history.scrollRestoration = 'manual';
297
- }
298
- catch { }
299
- const keyOf = () => {
300
- const state = window.history.state;
301
- return state && typeof state.__rshonoScroll === 'number' ? state.__rshonoScroll : null;
302
- };
303
- const tag = (state, key) => ({ ...state, __rshonoScroll: key });
304
- if (keyOf() === null) {
305
- window.history.replaceState(tag(window.history.state, seq++), '');
306
- }
307
- let scrollRaf = 0;
308
- const onScroll = () => {
309
- if (scrollRaf)
310
- return;
311
- scrollRaf = requestAnimationFrame(() => {
312
- scrollRaf = 0;
313
- const key = keyOf();
314
- if (key !== null)
315
- scrollByKey.set(key, window.scrollY);
316
- });
317
- };
318
- window.addEventListener('scroll', onScroll, { passive: true });
319
- const restoreScrollFor = (type) => () => {
320
- if (type === 'replace')
321
- return;
322
- const key = keyOf();
323
- requestAnimationFrame(() => {
324
- const y = type === 'pop' && key !== null ? (scrollByKey.get(key) ?? 0) : 0;
325
- window.scrollTo(0, y);
326
- });
327
- };
328
- const notify = (type) => onNavigation(restoreScrollFor(type));
329
- const onPopState = () => notify('pop');
330
- window.addEventListener('popstate', onPopState);
331
- const oldPushState = window.history.pushState;
332
- window.history.pushState = function (state, unused, url) {
333
- const res = oldPushState.call(this, tag(state, seq++), unused, url);
334
- notify('push');
335
- return res;
336
- };
337
- const oldReplaceState = window.history.replaceState;
338
- window.history.replaceState = function (state, unused, url) {
339
- const res = oldReplaceState.call(this, tag(state, keyOf() ?? seq++), unused, url);
340
- notify('replace');
341
- return res;
342
- };
343
- function onClick(e) {
344
- const link = e.target.closest('a');
345
- if (link &&
346
- link instanceof HTMLAnchorElement &&
347
- isRouterLink(link) &&
348
- e.button === 0 &&
349
- !e.metaKey &&
350
- !e.ctrlKey &&
351
- !e.altKey &&
352
- !e.shiftKey &&
353
- !e.defaultPrevented) {
354
- if (link.hash && link.pathname === location.pathname && link.search === location.search)
355
- return;
356
- e.preventDefault();
357
- history.pushState(null, '', link.href);
358
- }
359
- }
360
- document.addEventListener('click', onClick);
361
- // A prefetch is a full server render, so it waits out a short dwell time on one shared timer:
362
- // sweeping the cursor across a list of prefetch links costs one request (for the link the pointer
363
- // settled on), not one per link.
364
- let prefetchTimer;
365
- function onPrefetch(e) {
366
- const target = e.target;
367
- if (!(target instanceof Element))
368
- return;
369
- const link = target.closest('a[data-prefetch]');
370
- if (!(link instanceof HTMLAnchorElement) || !isRouterLink(link))
371
- return;
372
- const { href } = link;
373
- clearTimeout(prefetchTimer);
374
- prefetchTimer = setTimeout(() => prefetch(href), PREFETCH_DELAY_MS);
375
- }
376
- document.addEventListener('pointerover', onPrefetch);
377
- document.addEventListener('focusin', onPrefetch);
378
- return () => {
379
- clearTimeout(prefetchTimer);
380
- document.removeEventListener('click', onClick);
381
- document.removeEventListener('pointerover', onPrefetch);
382
- document.removeEventListener('focusin', onPrefetch);
383
- window.removeEventListener('popstate', onPopState);
384
- window.removeEventListener('scroll', onScroll);
385
- window.history.pushState = oldPushState;
386
- window.history.replaceState = oldReplaceState;
387
- try {
388
- window.history.scrollRestoration = prevRestoration;
389
- }
390
- catch { }
391
- };
392
- }
393
538
  /**
394
- * Dev-only refresh client (stripped from prod bundles: the whole call is
395
- * guarded by import.meta.webpackHot). Listens to the CLI's SSE endpoint:
539
+ * Dev-only refresh client, listening to the CLI's SSE endpoint:
396
540
  *
397
- * client-built → hot-apply the waiting updates (react-refresh keeps
398
- * component state); any failure falls back to reload.
399
- * rsc-update server component code changed: re-fetch the flight
400
- * payload for the current URL, state preserved.
401
- * hello → sent on (re)connect with the latest build hash; a
402
- * mismatch means events were missed — resync.
541
+ * client-built → hot-apply the waiting updates; anything the page can't be patched up to reloads.
542
+ * rsc-update → server component code changed: re-fetch the flight payload, state preserved.
543
+ * hello sent on (re)connect with the latest build hash; a mismatch means a missed event.
403
544
  */
404
- function initDevRefresh(fetchRscPayload) {
545
+ function initDevRefresh() {
546
+ const hot = import.meta.webpackHot;
405
547
  let connectedOnce = false;
406
- async function applyClientUpdate(hash) {
407
- const hot = import.meta.webpackHot;
408
- if (hash === __webpack_hash__)
409
- return;
410
- if (hot.status() !== 'idle') {
411
- window.location.reload();
412
- return;
413
- }
414
- try {
415
- await hot.check(true);
416
- if (hash !== __webpack_hash__)
417
- await applyClientUpdate(hash);
418
- }
419
- catch (error) {
420
- console.warn('[rshono] hot update failed, reloading:', error);
421
- window.location.reload();
422
- }
548
+ /** The newest build the dev server has announced — what {@link applyClientUpdate} walks towards. */
549
+ let targetHash;
550
+ function reload(reason, error) {
551
+ console.warn(`[rshono] ${reason} — reloading`, ...(error === undefined ? [] : [error]));
552
+ loadOutsideRouter(() => window.location.reload());
423
553
  }
424
- const source = new EventSource('/_rshono/hmr');
425
- source.onmessage = async (event) => {
426
- const message = JSON.parse(event.data);
554
+ async function applyClientUpdate() {
555
+ const giveUp = await walkHotUpdates(hot, () => __webpack_hash__, () => targetHash);
556
+ if (giveUp)
557
+ reload(giveUp.reason, giveUp.error);
558
+ }
559
+ async function handle(message) {
427
560
  switch (message.type) {
428
561
  case 'hello':
562
+ targetHash = message.hash ?? targetHash;
429
563
  if (connectedOnce) {
430
- if (message.hash && message.hash !== __webpack_hash__)
431
- await applyClientUpdate(message.hash);
432
- await fetchRscPayload().catch(() => window.location.reload());
564
+ await applyClientUpdate();
565
+ await loadPayload(window.location.href).catch(() => loadOutsideRouter(() => window.location.reload()));
433
566
  }
434
567
  connectedOnce = true;
435
568
  break;
436
569
  case 'client-built':
437
- if (message.hash)
438
- await applyClientUpdate(message.hash);
570
+ targetHash = message.hash;
571
+ await applyClientUpdate();
439
572
  break;
440
573
  case 'rsc-update':
441
574
  console.log('[rshono] server components updated');
442
- await fetchRscPayload().catch(() => window.location.reload());
575
+ await loadPayload(window.location.href).catch(() => loadOutsideRouter(() => window.location.reload()));
443
576
  break;
444
577
  }
578
+ }
579
+ const source = new EventSource('/_rshono/hmr');
580
+ // Chained rather than handled as they arrive: `hot.check` may only run from `idle`, and a burst of saves
581
+ // puts several frames on the wire inside the time one takes. Queueing drops nothing, because `targetHash`
582
+ // is shared — whichever handler runs next walks to the newest build.
583
+ let queue = Promise.resolve();
584
+ source.onmessage = (event) => {
585
+ const message = JSON.parse(event.data);
586
+ queue = queue.then(() => handle(message)).catch((error) => reload('the dev client failed', error));
445
587
  };
446
588
  }
447
- // Bootstrap failures (a truncated or malformed initial flight payload, most likely) would otherwise
448
- // be an unhandled rejection: nothing hydrates, nothing is reported, and the page just sits there.
589
+ // A bootstrap failure — a truncated initial payload, most likely would otherwise be an unhandled
590
+ // rejection: nothing hydrates, nothing is reported, and the page just sits there.
449
591
  main().catch((error) => {
450
592
  console.error('[rshono] the client runtime failed to start:', error);
451
593
  showFatal(error);