@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.
- package/README.md +29 -5
- package/dist/builder/env-shadow-loader.cjs +58 -2
- package/dist/builder/rspack-config.d.ts.map +1 -1
- package/dist/builder/rspack-config.js +46 -6
- package/dist/builder/rspack-config.js.map +1 -1
- package/dist/cli/build.d.ts.map +1 -1
- package/dist/cli/build.js +24 -1
- package/dist/cli/build.js.map +1 -1
- package/dist/cli/dev.d.ts.map +1 -1
- package/dist/cli/dev.js +4 -0
- package/dist/cli/dev.js.map +1 -1
- package/dist/cli/index.js +14 -8
- package/dist/cli/index.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/deploy/cloudflare/runtime.d.ts.map +1 -1
- package/dist/deploy/cloudflare/runtime.js +31 -5
- package/dist/deploy/cloudflare/runtime.js.map +1 -1
- package/dist/deploy/contract.d.ts +6 -2
- package/dist/deploy/contract.d.ts.map +1 -1
- package/dist/deploy/contract.js.map +1 -1
- package/dist/deploy/filesystem.d.ts.map +1 -1
- package/dist/deploy/filesystem.js +2 -1
- package/dist/deploy/filesystem.js.map +1 -1
- package/dist/deploy/node/runtime.d.ts.map +1 -1
- package/dist/deploy/node/runtime.js +3 -4
- package/dist/deploy/node/runtime.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/router.d.ts +60 -7
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js.map +1 -1
- package/dist/runtime/context.d.ts +51 -7
- package/dist/runtime/context.d.ts.map +1 -1
- package/dist/runtime/context.js +49 -5
- package/dist/runtime/context.js.map +1 -1
- package/dist/runtime/entry.client.js +230 -277
- package/dist/runtime/entry.client.js.map +1 -1
- package/dist/runtime/entry.rsc.d.ts.map +1 -1
- package/dist/runtime/entry.rsc.js +187 -39
- package/dist/runtime/entry.rsc.js.map +1 -1
- package/dist/runtime/flight-inject.d.ts +1 -1
- package/dist/runtime/flight-inject.d.ts.map +1 -1
- package/dist/runtime/flight-inject.js +207 -40
- package/dist/runtime/flight-inject.js.map +1 -1
- package/dist/runtime/navigation.d.ts +4 -0
- package/dist/runtime/navigation.d.ts.map +1 -1
- package/dist/runtime/navigation.js.map +1 -1
- package/dist/runtime/request.d.ts.map +1 -1
- package/dist/runtime/request.js +10 -0
- package/dist/runtime/request.js.map +1 -1
- package/dist/runtime/validate-entries.d.ts +33 -0
- package/dist/runtime/validate-entries.d.ts.map +1 -0
- package/dist/runtime/validate-entries.js +185 -0
- package/dist/runtime/validate-entries.js.map +1 -0
- package/dist/server/prerendered.d.ts +30 -6
- package/dist/server/prerendered.d.ts.map +1 -1
- package/dist/server/prerendered.js +71 -13
- package/dist/server/prerendered.js.map +1 -1
- package/dist/server/server-config.d.ts +12 -0
- package/dist/server/server-config.d.ts.map +1 -1
- package/dist/server/server-config.js +25 -0
- package/dist/server/server-config.js.map +1 -1
- package/dist/server/ssg.d.ts.map +1 -1
- package/dist/server/ssg.js +151 -25
- package/dist/server/ssg.js.map +1 -1
- package/dist/server/static.d.ts.map +1 -1
- package/dist/server/static.js +3 -1
- package/dist/server/static.js.map +1 -1
- 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(
|
|
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
|
|
222
|
-
const
|
|
316
|
+
// The resolver the payload on screen still owes — see {@link loadPayload}.
|
|
317
|
+
const pendingCommit = React.useRef(null);
|
|
223
318
|
React.useEffect(() => {
|
|
224
|
-
setPayload = (
|
|
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
|
-
*
|
|
229
|
-
*
|
|
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
|
|
233
|
-
|
|
234
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
443
|
+
await loadPayload(window.location.href).catch(() => window.location.reload());
|
|
491
444
|
break;
|
|
492
445
|
}
|
|
493
446
|
}
|