@rshono/core 1.0.0-rc.16 → 1.0.0-rc.18

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 (73) hide show
  1. package/README.md +29 -5
  2. package/dist/builder/env-shadow-loader.cjs +58 -2
  3. package/dist/builder/rspack-config.d.ts.map +1 -1
  4. package/dist/builder/rspack-config.js +46 -6
  5. package/dist/builder/rspack-config.js.map +1 -1
  6. package/dist/cli/build.d.ts.map +1 -1
  7. package/dist/cli/build.js +24 -1
  8. package/dist/cli/build.js.map +1 -1
  9. package/dist/cli/dev.d.ts.map +1 -1
  10. package/dist/cli/dev.js +4 -0
  11. package/dist/cli/dev.js.map +1 -1
  12. package/dist/cli/index.js +14 -8
  13. package/dist/cli/index.js.map +1 -1
  14. package/dist/config.d.ts +10 -0
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js.map +1 -1
  17. package/dist/deploy/cloudflare/runtime.d.ts.map +1 -1
  18. package/dist/deploy/cloudflare/runtime.js +31 -5
  19. package/dist/deploy/cloudflare/runtime.js.map +1 -1
  20. package/dist/deploy/contract.d.ts +6 -2
  21. package/dist/deploy/contract.d.ts.map +1 -1
  22. package/dist/deploy/contract.js.map +1 -1
  23. package/dist/deploy/filesystem.d.ts.map +1 -1
  24. package/dist/deploy/filesystem.js +2 -1
  25. package/dist/deploy/filesystem.js.map +1 -1
  26. package/dist/deploy/node/runtime.d.ts.map +1 -1
  27. package/dist/deploy/node/runtime.js +3 -4
  28. package/dist/deploy/node/runtime.js.map +1 -1
  29. package/dist/index.d.ts +3 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +3 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/router.d.ts +60 -7
  34. package/dist/router.d.ts.map +1 -1
  35. package/dist/router.js.map +1 -1
  36. package/dist/runtime/context.d.ts +51 -7
  37. package/dist/runtime/context.d.ts.map +1 -1
  38. package/dist/runtime/context.js +49 -5
  39. package/dist/runtime/context.js.map +1 -1
  40. package/dist/runtime/entry.client.js +230 -277
  41. package/dist/runtime/entry.client.js.map +1 -1
  42. package/dist/runtime/entry.rsc.d.ts.map +1 -1
  43. package/dist/runtime/entry.rsc.js +187 -39
  44. package/dist/runtime/entry.rsc.js.map +1 -1
  45. package/dist/runtime/flight-inject.d.ts +1 -1
  46. package/dist/runtime/flight-inject.d.ts.map +1 -1
  47. package/dist/runtime/flight-inject.js +207 -40
  48. package/dist/runtime/flight-inject.js.map +1 -1
  49. package/dist/runtime/navigation.d.ts +4 -0
  50. package/dist/runtime/navigation.d.ts.map +1 -1
  51. package/dist/runtime/navigation.js.map +1 -1
  52. package/dist/runtime/request.d.ts.map +1 -1
  53. package/dist/runtime/request.js +10 -0
  54. package/dist/runtime/request.js.map +1 -1
  55. package/dist/runtime/validate-entries.d.ts +33 -0
  56. package/dist/runtime/validate-entries.d.ts.map +1 -0
  57. package/dist/runtime/validate-entries.js +185 -0
  58. package/dist/runtime/validate-entries.js.map +1 -0
  59. package/dist/server/prerendered.d.ts +30 -6
  60. package/dist/server/prerendered.d.ts.map +1 -1
  61. package/dist/server/prerendered.js +71 -13
  62. package/dist/server/prerendered.js.map +1 -1
  63. package/dist/server/server-config.d.ts +12 -0
  64. package/dist/server/server-config.d.ts.map +1 -1
  65. package/dist/server/server-config.js +25 -0
  66. package/dist/server/server-config.js.map +1 -1
  67. package/dist/server/ssg.d.ts.map +1 -1
  68. package/dist/server/ssg.js +151 -25
  69. package/dist/server/ssg.js.map +1 -1
  70. package/dist/server/static.d.ts.map +1 -1
  71. package/dist/server/static.js +3 -1
  72. package/dist/server/static.js.map +1 -1
  73. package/package.json +7 -6
@@ -93,12 +93,213 @@ function showFatal(error, componentStack) {
93
93
  host.appendChild(box);
94
94
  }, 0);
95
95
  }
96
+ /** What every flight response is typed as. The charset and any other parameters follow it. */
97
+ const FLIGHT_CONTENT_TYPE = 'text/x-component';
98
+ /**
99
+ * Fetches a payload, refusing a response that is not one.
100
+ *
101
+ * The status cannot be the gate: a payload legitimately arrives as a 404 from the `notFound` page and as a
102
+ * 500 from an action that threw, and both carry a real payload the caller has to see. The content type is.
103
+ *
104
+ * What this catches is the response that is not a payload at all — a `bodyLimit()` 413, a proxy's error page,
105
+ * a 502 mid-deploy. Handed to the flight parser those all surface as `Error: Connection closed.`, with the
106
+ * status and the body nowhere in sight; here they become an error that says what arrived.
107
+ */
108
+ async function payloadResponse(request) {
109
+ const response = await fetch(request);
110
+ const contentType = response.headers.get('content-type');
111
+ if (contentType?.startsWith(FLIGHT_CONTENT_TYPE))
112
+ return response;
113
+ // Read for the message: a plain-text refusal says what it refused only in its body, and HTTP/2 has no
114
+ // `statusText` at all. Bounded, because this is an error path and the body is not ours to trust.
115
+ const body = await response.text().then((text) => text.trim().slice(0, 200), () => '');
116
+ const status = `${response.status}${response.statusText ? ` ${response.statusText}` : ''}`;
117
+ throw new Error(`[rshono] the server answered ${status} (${contentType ?? 'no content type'}) instead of a payload${body ? `: ${body}` : ''}`);
118
+ }
96
119
  /**
97
120
  * Asks a URL for its flight payload. Deliberately uncached — a payload can never be staler than the click
98
121
  * that wanted it, and the browser's own HTTP cache is what makes a repeat visit cheap.
99
122
  */
100
123
  function requestPayload(href, signal) {
101
- return createFromFetch(fetch(createRscRequest(new URL(href, location.href).href, undefined, signal)));
124
+ return createFromFetch(payloadResponse(createRscRequest(new URL(href, location.href).href, undefined, signal)));
125
+ }
126
+ /**
127
+ * Whether the browser hands us its navigations. Gated on `sourceElement` rather than on `navigation` itself:
128
+ * Chrome shipped the event in 102 and that property only in 135, and without it a `data-native` link cannot
129
+ * be told from any other — so the older window would soft-navigate the very links that asked not to be.
130
+ *
131
+ * Where this is false there is no interception at all and every navigation is a real browser load, which a
132
+ * server-rendered app answers correctly on its own. Only the soft part is missing.
133
+ *
134
+ * Both globals are tested, and neither is touched before: this runs at module scope, where a ReferenceError
135
+ * would take the whole client runtime down with it rather than degrading anything.
136
+ */
137
+ const canSoftNavigate = typeof navigation !== 'undefined' && typeof NavigateEvent !== 'undefined' && 'sourceElement' in NavigateEvent.prototype;
138
+ /**
139
+ * Drops a navigation's result promises. Both reject when a navigation is superseded or cancelled — routine
140
+ * here, since a second click is meant to abandon the first — and unhandled they would be reported as faults.
141
+ */
142
+ function settle(result) {
143
+ const ignore = () => { };
144
+ void result.committed?.catch(ignore);
145
+ void result.finished?.catch(ignore);
146
+ }
147
+ // The imperative actions behind `useNavigation().router`. Each one only *asks*: the browser turns it into a
148
+ // `navigate` event, which is where `listenNavigation` answers it — so a `router.push` and a link click reach
149
+ // the same code by the same route, and inherit the same fetch, scroll and `pending` flag.
150
+ function push(href) {
151
+ if (canSoftNavigate)
152
+ settle(navigation.navigate(href, { history: 'push' }));
153
+ else
154
+ window.location.assign(href);
155
+ }
156
+ function replace(href) {
157
+ if (canSoftNavigate)
158
+ settle(navigation.navigate(href, { history: 'replace' }));
159
+ else
160
+ window.location.replace(href);
161
+ }
162
+ // A traversal is the browser's to perform either way — `navigation` only hands it back as an interceptable
163
+ // event first. Nothing to go back to is a rejection there and a no-op here; both amount to the same thing.
164
+ function back() {
165
+ if (canSoftNavigate)
166
+ settle(navigation.back());
167
+ else
168
+ window.history.back();
169
+ }
170
+ function forward() {
171
+ if (canSoftNavigate)
172
+ settle(navigation.forward());
173
+ else
174
+ window.history.forward();
175
+ }
176
+ // A refresh keeps the URL, and is still a navigation: it arrives as `navigationType: 'reload'`, which is what
177
+ // tells the listener to leave scroll and focus where the user left them.
178
+ function refresh() {
179
+ if (canSoftNavigate)
180
+ settle(navigation.reload());
181
+ else
182
+ window.location.reload();
183
+ }
184
+ /**
185
+ * Turns a control-signal digest — how `redirect()` / `notFound()` reach the browser — into a real
186
+ * navigation. Returns false for anything else, so callers fall through to their own handling.
187
+ *
188
+ * `hard` forces a full document load, for signals that surfaced *through React*: it unmounts the root on
189
+ * an uncaught error, leaving no live tree to soft-navigate with.
190
+ */
191
+ function handleControlDigest(error, { hard = false } = {}) {
192
+ const digest = error?.digest;
193
+ if (!isControlDigest(digest))
194
+ return false;
195
+ const redirect = parseRedirectDigest(digest);
196
+ if (!redirect) {
197
+ window.location.reload();
198
+ }
199
+ else if (hard) {
200
+ window.location.assign(new URL(redirect.location, window.location.href).href);
201
+ }
202
+ else {
203
+ push(redirect.location);
204
+ }
205
+ return true;
206
+ }
207
+ /**
208
+ * Puts a payload on screen, resolving once React has committed it. Replaced by `BrowserRoot`'s own on mount;
209
+ * the default covers the window before hydration, where `setServerCallback` is already registered but there
210
+ * is no root to update — a reload is the honest answer, and nothing after it needs to run.
211
+ */
212
+ let setPayload = () => {
213
+ window.location.reload();
214
+ return new Promise(() => { });
215
+ };
216
+ /** Runs work inside the nav transition so `useNavigation().pending` stays true across the round-trip. */
217
+ let startNav = (run) => {
218
+ void run();
219
+ };
220
+ /**
221
+ * Fetches the payload for `url` and puts it on screen.
222
+ *
223
+ * Resolves once React has **committed** it rather than when the fetch lands: an intercepted navigation
224
+ * scrolls and moves focus when this promise settles, and a `#hash` target does not exist until the new tree
225
+ * does. Rejects only on a genuine failure — being superseded is not one, and resolves quietly, because the
226
+ * navigation that replaced this one owns the screen from then on.
227
+ */
228
+ function loadPayload(url, signal) {
229
+ // Deliberately not awaited inside the transition: the scope ends once the payload is handed to React, and
230
+ // React holds `pending` until the update it scheduled commits. Awaiting the commit *inside* the scope would
231
+ // work too, but only because React happens not to gate a commit on its async scope settling — an internal
232
+ // this has no reason to depend on across the whole `^19.1.0` peer range.
233
+ let committed;
234
+ const run = async () => {
235
+ const payload = await requestPayload(url, signal);
236
+ // The browser aborts a navigation the moment a newer one starts. Checked again after the await because
237
+ // the fetch may already have resolved by then, and applying it would repaint a page the user has left.
238
+ if (signal?.aborted)
239
+ return;
240
+ if (payload.redirect) {
241
+ push(payload.redirect);
242
+ return;
243
+ }
244
+ committed = setPayload(payload);
245
+ };
246
+ // `startTransition` runs the work but hands nothing back, so the promise carrying a failure is caught here
247
+ // instead. Assigned synchronously: React invokes the callback before `startNav` returns.
248
+ let work;
249
+ startNav(() => (work = run()));
250
+ return work.then(
251
+ // Undefined whenever nothing was applied — an abort, or a redirect — and there is then nothing to wait for.
252
+ () => committed, (error) => {
253
+ // Checked before the error is read: an abort is this navigation being replaced, and the one that
254
+ // replaced it owns the outcome.
255
+ if (signal?.aborted || handleControlDigest(error))
256
+ return;
257
+ throw error;
258
+ });
259
+ }
260
+ /**
261
+ * Navigations the browser can hand over but shouldn't:
262
+ *
263
+ * - a fragment jump, which is same-document already and needs no payload — the browser's own jump is the one
264
+ * that honours `scroll-padding-top`, and re-rendering would pull the target out from under it;
265
+ * - a download, which is not a navigation of this page at all;
266
+ * - a `POST` form, which is a submission and the server's to answer (a `GET` form carries its fields in the
267
+ * URL, has no `formData`, and soft-navigates like any other link);
268
+ * - a link marked `data-native`, the documented opt-out.
269
+ */
270
+ function leaveToBrowser(event) {
271
+ return event.hashChange || event.downloadRequest !== null || event.formData !== null || event.sourceElement?.hasAttribute('data-native') === true;
272
+ }
273
+ /**
274
+ * The whole router, in one listener.
275
+ *
276
+ * Every navigation the page can make arrives as a `navigate` event — a link click, a `GET` form, a
277
+ * `history.pushState`, the back button, `navigation.reload()` — already filtered by the browser: it does not
278
+ * fire for a middle-click, a modified click or a new tab, and reports `canIntercept: false` for anything
279
+ * cross-origin, or for a traversal that leaves the app. Those need no handling here; they are left alone, and
280
+ * the browser performs them as it always would.
281
+ */
282
+ function listenNavigation() {
283
+ if (!canSoftNavigate)
284
+ return () => { };
285
+ const onNavigate = (event) => {
286
+ if (!event.canIntercept || leaveToBrowser(event))
287
+ return;
288
+ // A push or a traversal lands on a new page, so the browser resets the scroll offset — or restores the
289
+ // one it remembers — and moves focus, which is what makes a soft navigation announce itself to a screen
290
+ // reader. A replace or a refresh stays where it is, so neither should move. Both wait on the handler,
291
+ // which is the point of resolving it at commit rather than at fetch.
292
+ const inPlace = event.navigationType === 'replace' || event.navigationType === 'reload';
293
+ event.intercept({
294
+ scroll: inPlace ? 'manual' : 'after-transition',
295
+ focusReset: inPlace ? 'manual' : 'after-transition',
296
+ // The URL commits before the handler runs, so a failure leaves the address bar describing a page the
297
+ // document is not showing. A real load is the only way back to agreement.
298
+ handler: () => loadPayload(event.destination.url, event.signal).catch(() => window.location.reload()),
299
+ });
300
+ };
301
+ navigation.addEventListener('navigate', onNavigate);
302
+ return () => navigation.removeEventListener('navigate', onNavigate);
102
303
  }
103
304
  async function main() {
104
305
  // The assertion is load-bearing under the compiler that builds this: TypeScript 7 declares `nonce` on
@@ -108,149 +309,32 @@ async function main() {
108
309
  const cspMeta = document.querySelector('meta[property="csp-nonce"]');
109
310
  if (cspMeta?.nonce)
110
311
  __webpack_nonce__ = cspMeta.nonce;
111
- // Both are replaced by BrowserRoot's own on mount. The defaults cover the window before hydration, where
112
- // `setServerCallback` is already registered but there is no root to update — a reload is the honest answer.
113
- let setPayload = () => {
114
- window.location.reload();
115
- };
116
- // Runs work inside the nav transition so useNavigation().pending stays true across the round-trip.
117
- let startNav = (run) => {
118
- void run();
119
- };
120
312
  const initialPayload = await createFromReadableStream(flightStream);
121
- function push(href) {
122
- const target = new URL(href, window.location.href);
123
- if (target.origin !== window.location.origin) {
124
- window.location.assign(target.href);
125
- return;
126
- }
127
- window.history.pushState(null, '', target.href);
128
- }
129
- function replace(href) {
130
- const target = new URL(href, window.location.href);
131
- if (target.origin !== window.location.origin) {
132
- window.location.replace(target.href);
133
- return;
134
- }
135
- window.history.replaceState(null, '', target.href);
136
- }
137
- // A traversal is the browser's to perform: it moves the entry itself and fires `popstate`, which is where
138
- // `listenNavigation` picks the new document up — so these need no more than to ask, and inherit the same
139
- // fetch, scroll restoration and `pending` flag a back-button press already got.
140
- const back = () => window.history.back();
141
- const forward = () => window.history.forward();
142
- // A refresh keeps the URL, so it can't ride the history patch like push/replace and drives the re-fetch itself.
143
- const refresh = () => startNav(async () => {
144
- try {
145
- await fetchRscPayload();
146
- }
147
- catch {
148
- window.location.reload();
149
- }
150
- });
151
- /**
152
- * Turns a control-signal digest — how `redirect()` / `notFound()` reach the browser — into a real
153
- * navigation. Returns false for anything else, so callers fall through to their own handling.
154
- *
155
- * `hard` forces a full document load, for signals that surfaced *through React*: it unmounts the root on
156
- * an uncaught error, leaving no live tree to soft-navigate with.
157
- */
158
- function handleControlDigest(error, { hard = false } = {}) {
159
- const digest = error?.digest;
160
- if (!isControlDigest(digest))
161
- return false;
162
- const redirect = parseRedirectDigest(digest);
163
- if (!redirect) {
164
- window.location.reload();
165
- }
166
- else if (hard) {
167
- window.location.assign(new URL(redirect.location, window.location.href).href);
168
- }
169
- else {
170
- push(redirect.location);
171
- }
172
- return true;
173
- }
174
- /**
175
- * The navigation whose payload the screen is allowed to show. React runs async transitions concurrently, so
176
- * two overlapping navigations are two live fetches with no ordering between them — without this, a slow
177
- * first response landing after a fast second one renders the page the user already left while the address
178
- * bar shows the one they asked for.
179
- */
180
- let currentNavigation = 0;
181
- /** The in-flight navigation's fetch, so a newer one can stop paying for it. */
182
- let navigationFetch = null;
183
- /**
184
- * Fetches the payload for the current URL and applies it, unless a newer navigation started meanwhile.
185
- *
186
- * @returns `true` when this navigation is the one that settled the screen, `false` when it was superseded.
187
- * The distinction is what keeps a stale response from scrolling a page it is no longer rendering — and
188
- * why being superseded resolves rather than throws: both callers answer a rejection with a full reload,
189
- * so surfacing the abort would turn every fast second click into one.
190
- */
191
- async function fetchRscPayload() {
192
- const navigation = ++currentNavigation;
193
- navigationFetch?.abort();
194
- const controller = (navigationFetch = new AbortController());
195
- const superseded = () => navigation !== currentNavigation;
196
- let payload;
197
- try {
198
- payload = await requestPayload(window.location.href, controller.signal);
199
- }
200
- catch (error) {
201
- // Checked before the error is read: an abort is this navigation being replaced, and the one that
202
- // replaced it owns the outcome.
203
- if (superseded())
204
- return false;
205
- if (handleControlDigest(error))
206
- return true;
207
- throw error;
208
- }
209
- if (superseded())
210
- return false;
211
- if (payload.redirect) {
212
- push(payload.redirect);
213
- return true;
214
- }
215
- setPayload(payload);
216
- return true;
217
- }
218
313
  function BrowserRoot() {
219
314
  const [payload, setPayloadState] = React.useState(initialPayload);
220
315
  const [pending, startTransition] = React.useTransition();
221
- // The scroll a fetched navigation still owes, held until its payload is on screen.
222
- const pendingScroll = React.useRef(null);
316
+ // The resolver the payload on screen still owes — see {@link loadPayload}.
317
+ const pendingCommit = React.useRef(null);
223
318
  React.useEffect(() => {
224
- setPayload = (v) => setPayloadState(v);
319
+ setPayload = (next) => new Promise((resolve) => {
320
+ // A payload replaced before it ever painted still has a navigation waiting on it. React commits
321
+ // only the newest, so the effect below never runs for the one it skipped: release it here.
322
+ pendingCommit.current?.();
323
+ pendingCommit.current = resolve;
324
+ setPayloadState(next);
325
+ });
225
326
  startNav = (run) => startTransition(run);
226
327
  }, [startTransition]);
227
328
  /**
228
- * Scrolls where the navigation asked, once React has put its payload in the DOM — a `#hash` target does
229
- * not exist until the new tree does. A layout effect, so the pre-scroll position is never painted.
329
+ * Releases the navigation waiting on this payload, which is what lets the browser scroll and move focus
330
+ * now that their target exists. A layout effect, so the pre-scroll position is never painted.
230
331
  */
231
332
  React.useLayoutEffect(() => {
232
- const scroll = pendingScroll.current;
233
- pendingScroll.current = null;
234
- scroll?.();
333
+ const commit = pendingCommit.current;
334
+ pendingCommit.current = null;
335
+ commit?.();
235
336
  }, [payload]);
236
- React.useEffect(() => {
237
- const stopNavigating = listenNavigation((afterRender) => startNav(async () => {
238
- try {
239
- // Only the navigation that settled the screen owes a scroll — a superseded one would move the
240
- // page the navigation that replaced it is about to render.
241
- if (await fetchRscPayload())
242
- pendingScroll.current = afterRender;
243
- }
244
- catch {
245
- window.location.reload();
246
- }
247
- }));
248
- const stopUpgradingLinks = listenLinks();
249
- return () => {
250
- stopUpgradingLinks();
251
- stopNavigating();
252
- };
253
- }, []);
337
+ React.useEffect(() => listenNavigation(), []);
254
338
  const router = React.useMemo(() => ({ push, replace, back, forward, refresh, pending }), [pending]);
255
339
  return _jsx(RouterContext.Provider, { value: router, children: payload.root });
256
340
  }
@@ -267,7 +351,7 @@ async function main() {
267
351
  });
268
352
  let payload;
269
353
  try {
270
- payload = await createFromFetch(fetch(request), { temporaryReferences });
354
+ payload = await createFromFetch(payloadResponse(request), { temporaryReferences });
271
355
  }
272
356
  catch (error) {
273
357
  if (handleControlDigest(error))
@@ -279,10 +363,17 @@ async function main() {
279
363
  return undefined;
280
364
  }
281
365
  if (documentUrl() === calledFrom)
282
- React.startTransition(() => setPayload(payload));
366
+ React.startTransition(() => void setPayload(payload));
283
367
  if (payload.notFound)
284
368
  return undefined;
285
369
  const result = payload.returnValue;
370
+ if (!result) {
371
+ // A payload that is not this action's own reply: the server rendered a page in its place. An action
372
+ // that had already run has its result carried across (see `actionResults` in entry.rsc.tsx), so
373
+ // reaching here means the request failed before it ran at all — an undecodable body, most likely.
374
+ // Reading `.ok` off it used to hand the caller `Cannot read properties of undefined`.
375
+ 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.');
376
+ }
286
377
  if (!result.ok)
287
378
  throw result.error;
288
379
  return result.value;
@@ -309,145 +400,9 @@ async function main() {
309
400
  },
310
401
  });
311
402
  if (import.meta.webpackHot) {
312
- initDevRefresh(fetchRscPayload);
403
+ initDevRefresh();
313
404
  }
314
405
  }
315
- /** Runs teardown in reverse and empties the list, so a second call is a no-op. */
316
- function disposeAll(undo) {
317
- for (const dispose of undo.splice(0).reverse())
318
- dispose();
319
- }
320
- // An `<a>` we intercept for soft navigation: same-origin, same tab, not a download,
321
- // and not explicitly opted out with `data-native` (which forces a full browser navigation).
322
- function isRouterLink(link) {
323
- return (!!link.href &&
324
- (!link.target || link.target === '_self') &&
325
- link.origin === location.origin &&
326
- !link.hasAttribute('download') &&
327
- !link.hasAttribute('data-native'));
328
- }
329
- /**
330
- * Upgrades the app's anchors: a plain left-click becomes a soft navigation. It shares no state with
331
- * `listenNavigation` — a click only calls `history.pushState`, which is where that picks the navigation up.
332
- */
333
- function listenLinks() {
334
- const undo = [];
335
- function onClick(e) {
336
- const link = e.target.closest('a');
337
- if (link &&
338
- link instanceof HTMLAnchorElement &&
339
- isRouterLink(link) &&
340
- e.button === 0 &&
341
- !e.metaKey &&
342
- !e.ctrlKey &&
343
- !e.altKey &&
344
- !e.shiftKey &&
345
- !e.defaultPrevented) {
346
- if (link.hash && link.pathname === location.pathname && link.search === location.search)
347
- return;
348
- e.preventDefault();
349
- history.pushState(null, '', link.href);
350
- }
351
- }
352
- document.addEventListener('click', onClick);
353
- undo.push(() => document.removeEventListener('click', onClick));
354
- return () => disposeAll(undo);
355
- }
356
- /**
357
- * The element the current `#fragment` names, if it is on the page. A fragment is percent-encoded and an `id`
358
- * is not, so it is decoded first — and taken literally when a hand-written `%` makes that throw.
359
- */
360
- function fragmentTarget() {
361
- const fragment = location.hash.slice(1);
362
- if (!fragment)
363
- return null;
364
- let id = fragment;
365
- try {
366
- id = decodeURIComponent(fragment);
367
- }
368
- catch {
369
- // Malformed escape — the literal fragment is the better guess at the id than nothing.
370
- }
371
- return document.getElementById(id);
372
- }
373
- function listenNavigation(onNavigation) {
374
- const undo = [];
375
- // Set explicitly as a statement of intent: the browser remembers a traversal's offset, and nothing here
376
- // tracks one.
377
- const prevRestoration = window.history.scrollRestoration;
378
- try {
379
- window.history.scrollRestoration = 'auto';
380
- }
381
- catch {
382
- // Not settable in every browser, and only a preference — the navigation still works without it.
383
- }
384
- undo.push(() => {
385
- try {
386
- window.history.scrollRestoration = prevRestoration;
387
- }
388
- catch {
389
- // As above: if it could not be set, it cannot be put back either.
390
- }
391
- });
392
- /**
393
- * A push is not a real navigation to the browser, so nothing resets the scroll offset. A `#hash` names
394
- * where to land instead; `replace` keeps its position, and a traversal is the browser's to restore.
395
- *
396
- * `scrollIntoView` is the algorithm a browser's own fragment jump uses, so `scroll-padding-top` still
397
- * applies. Neither call passes a `behavior`, leaving `scroll-behavior: smooth` the app's to ask for.
398
- */
399
- const afterRenderFor = (type) => () => {
400
- if (type !== 'push')
401
- return;
402
- const target = fragmentTarget();
403
- if (target)
404
- target.scrollIntoView();
405
- else
406
- window.scrollTo(0, 0);
407
- };
408
- // What the payload on screen was rendered for. See {@link documentUrl}.
409
- let renderedUrl = documentUrl();
410
- /**
411
- * A navigation that moves only the fragment leaves the document unchanged, so the payload on screen is
412
- * already the right one — fetching another would re-render the page out from under the jump.
413
- * `router.refresh()` is unaffected, and remains the way to ask for fresh data at an unchanged URL.
414
- */
415
- const notify = (type) => {
416
- const afterRender = afterRenderFor(type);
417
- if (documentUrl() === renderedUrl) {
418
- afterRender();
419
- return;
420
- }
421
- renderedUrl = documentUrl();
422
- onNavigation(afterRender);
423
- };
424
- const onPopState = () => notify('pop');
425
- window.addEventListener('popstate', onPopState);
426
- undo.push(() => window.removeEventListener('popstate', onPopState));
427
- // Saved unbound on purpose, and called back with `.call(this, …)` below — patching `history` is the only
428
- // way to see a navigation the app makes itself, and the receiver is restored at every call site.
429
- // eslint-disable-next-line @typescript-eslint/unbound-method
430
- const oldPushState = window.history.pushState;
431
- window.history.pushState = function (state, unused, url) {
432
- const res = oldPushState.call(this, state, unused, url);
433
- notify('push');
434
- return res;
435
- };
436
- undo.push(() => {
437
- window.history.pushState = oldPushState;
438
- });
439
- // eslint-disable-next-line @typescript-eslint/unbound-method -- as with `pushState` above.
440
- const oldReplaceState = window.history.replaceState;
441
- window.history.replaceState = function (state, unused, url) {
442
- const res = oldReplaceState.call(this, state, unused, url);
443
- notify('replace');
444
- return res;
445
- };
446
- undo.push(() => {
447
- window.history.replaceState = oldReplaceState;
448
- });
449
- return () => disposeAll(undo);
450
- }
451
406
  /**
452
407
  * Dev-only refresh client, listening to the CLI's SSE endpoint:
453
408
  *
@@ -455,9 +410,7 @@ function listenNavigation(onNavigation) {
455
410
  * rsc-update → server component code changed: re-fetch the flight payload, state preserved.
456
411
  * hello → sent on (re)connect with the latest build hash; a mismatch means a missed event.
457
412
  */
458
- // `Promise<unknown>`: the payload fetch reports whether its navigation was superseded, which matters to a
459
- // click and not to a rebuild — here only settling or rejecting does.
460
- function initDevRefresh(fetchRscPayload) {
413
+ function initDevRefresh() {
461
414
  const hot = import.meta.webpackHot;
462
415
  let connectedOnce = false;
463
416
  /** The newest build the dev server has announced — what {@link applyClientUpdate} walks towards. */
@@ -477,7 +430,7 @@ function initDevRefresh(fetchRscPayload) {
477
430
  targetHash = message.hash ?? targetHash;
478
431
  if (connectedOnce) {
479
432
  await applyClientUpdate();
480
- await fetchRscPayload().catch(() => window.location.reload());
433
+ await loadPayload(window.location.href).catch(() => window.location.reload());
481
434
  }
482
435
  connectedOnce = true;
483
436
  break;
@@ -487,7 +440,7 @@ function initDevRefresh(fetchRscPayload) {
487
440
  break;
488
441
  case 'rsc-update':
489
442
  console.log('[rshono] server components updated');
490
- await fetchRscPayload().catch(() => window.location.reload());
443
+ await loadPayload(window.location.href).catch(() => window.location.reload());
491
444
  break;
492
445
  }
493
446
  }