@jk2908/solas 0.12.0 → 0.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +13 -1
- package/dist/adapters/node.js +62 -10
- package/dist/index.js +14 -0
- package/dist/internal/browser-router/history.d.ts +5 -1
- package/dist/internal/browser-router/history.js +35 -12
- package/dist/internal/browser-router/link.js +2 -2
- package/dist/internal/browser-router/router.js +91 -4
- package/dist/internal/browser-router/shared.d.ts +18 -0
- package/dist/internal/build.js +10 -1
- package/dist/internal/env/rsc.js +8 -12
- package/dist/internal/http-router/create-http-router.js +3 -3
- package/dist/internal/http-router/router.d.ts +1 -1
- package/dist/internal/http-router/router.js +22 -16
- package/dist/internal/http-router/utils.d.ts +14 -0
- package/dist/internal/http-router/utils.js +23 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.2
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- The Node adapter's response-stream loop and close handlers use declarations and `while (true)` for readability. No behaviour change.
|
|
8
|
+
|
|
9
|
+
## 0.13.1
|
|
10
|
+
|
|
11
|
+
### Fixes
|
|
12
|
+
|
|
13
|
+
- The Node adapter now aborts `request.signal` when the client disconnects, so streaming endpoints such as SSE can stop their producers.
|
|
14
|
+
- The Node adapter flushes response headers before streaming the body, so a delayed first chunk no longer leaves the client waiting with no response.
|
|
15
|
+
- The Node adapter cancels the response stream when the client disconnects, instead of continuing to write into a dead socket.
|
|
16
|
+
|
|
17
|
+
## 0.13.0
|
|
18
|
+
|
|
19
|
+
### Features
|
|
20
|
+
|
|
21
|
+
- Client navigation now scrolls to the top of a route on push, scrolls to a hash target when the URL has one, and restores the previous position on back and forward. `router.go(...)` and `Link` accept a `scroll` option (`'top'`, `'none'`, or `{ restore: y }`) to control this.
|
|
22
|
+
|
|
23
|
+
### Fixes
|
|
24
|
+
|
|
25
|
+
- Endpoint handlers with default or rest parameters now receive the request. Previously Solas passed it only when `Function.length` was non-zero.
|
|
26
|
+
- Request handling no longer resolves the base path and trailing slash twice per request: the dispatch layer passes its already-normalised path to the router.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- The route finder caches import paths, and the dev watcher ignores `dist`, so large route graphs and long dev sessions do less repeated work.
|
|
31
|
+
|
|
3
32
|
## 0.12.0
|
|
4
33
|
|
|
5
34
|
### Features
|
package/README.md
CHANGED
|
@@ -213,6 +213,18 @@ That keeps your route params aligned across links, imperative navigation, metada
|
|
|
213
213
|
|
|
214
214
|
`router.refresh()` clears the cached RSC response for the current path and fetches a fresh payload, so it is most useful for routes that render request-time data. `router.isNavigating` exposes pending client-side navigation state.
|
|
215
215
|
|
|
216
|
+
Navigation manages scroll for you: `Link` and `router.go(...)` scroll to the top of the new route, or to the target element when the URL has a hash. Back and forward restore the position each history entry was left at. Override this with the `scroll` option:
|
|
217
|
+
|
|
218
|
+
```tsx
|
|
219
|
+
await router.go('/search', { query: { q }, scroll: 'none' })
|
|
220
|
+
|
|
221
|
+
<Link href="/results" scroll="none">
|
|
222
|
+
Keep position
|
|
223
|
+
</Link>
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`scroll` accepts `'top'` (the default), `'none'` to leave the position untouched, or `{ restore: y }` to restore a specific offset.
|
|
227
|
+
|
|
216
228
|
## Config
|
|
217
229
|
|
|
218
230
|
All Solas options are passed to `solas()` inside `defineConfig`.
|
|
@@ -540,7 +552,7 @@ Valid values are `debug`, `info`, `warn`, `error`, and `fatal`.
|
|
|
540
552
|
- `error`: only errors
|
|
541
553
|
- `fatal`: only fatal errors
|
|
542
554
|
|
|
543
|
-
This is mainly useful when debugging framework behaviour such as routing and prerendering. It is for Solas internals, not your app's general-purpose logging, and it does not control top-level
|
|
555
|
+
This is mainly useful when debugging framework behaviour such as routing and prerendering. It is for Solas internals, not your app's general-purpose logging, and it does not control top-level build and preview status output.
|
|
544
556
|
|
|
545
557
|
```ts
|
|
546
558
|
export default defineConfig({
|
package/dist/adapters/node.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { once } from 'node:events';
|
|
2
1
|
import http from 'node:http';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import { pathToFileURL } from 'node:url';
|
|
@@ -38,26 +37,59 @@ function nodeToWeb(req) {
|
|
|
38
37
|
*/
|
|
39
38
|
async function webToNode(stream, res) {
|
|
40
39
|
const reader = stream.getReader();
|
|
40
|
+
let closed = false;
|
|
41
|
+
// a client that goes away leaves nothing to write to; cancel the source so
|
|
42
|
+
// its producer stops instead of buffering into a dead socket
|
|
43
|
+
function onClose() {
|
|
44
|
+
closed = true;
|
|
45
|
+
void reader.cancel().catch(() => { });
|
|
46
|
+
}
|
|
47
|
+
res.once('close', onClose);
|
|
41
48
|
try {
|
|
42
49
|
while (true) {
|
|
50
|
+
if (closed)
|
|
51
|
+
break;
|
|
43
52
|
const { done, value } = await reader.read();
|
|
44
53
|
if (done)
|
|
45
54
|
break;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
if (closed || res.destroyed || res.writableEnded)
|
|
56
|
+
break;
|
|
57
|
+
try {
|
|
58
|
+
// `write` returns false when the socket buffer is full. That is not an
|
|
59
|
+
// error: wait for the `'drain'` event before writing more, otherwise a
|
|
60
|
+
// fast producer and a slow client would buffer the whole body in memory
|
|
61
|
+
if (!res.write(value)) {
|
|
62
|
+
await new Promise(resolve => {
|
|
63
|
+
function settle() {
|
|
64
|
+
res.off('drain', settle);
|
|
65
|
+
res.off('close', settle);
|
|
66
|
+
resolve();
|
|
67
|
+
}
|
|
68
|
+
res.on('drain', settle);
|
|
69
|
+
res.on('close', settle);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// the socket died between the checks above
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
51
77
|
}
|
|
52
78
|
}
|
|
53
79
|
finally {
|
|
54
|
-
|
|
80
|
+
res.off('close', onClose);
|
|
81
|
+
try {
|
|
82
|
+
reader.releaseLock();
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// a pending read can outlive a cancel by a tick
|
|
86
|
+
}
|
|
55
87
|
}
|
|
56
88
|
}
|
|
57
89
|
/**
|
|
58
90
|
* Convert a Node request into a fetch `Request`, streaming the body when present.
|
|
59
91
|
*/
|
|
60
|
-
function toRequest(req) {
|
|
92
|
+
function toRequest(req, signal) {
|
|
61
93
|
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
62
94
|
const headers = new Headers();
|
|
63
95
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
@@ -77,6 +109,7 @@ function toRequest(req) {
|
|
|
77
109
|
headers,
|
|
78
110
|
body: hasBody ? nodeToWeb(req) : undefined,
|
|
79
111
|
duplex: 'half',
|
|
112
|
+
signal,
|
|
80
113
|
};
|
|
81
114
|
return new Request(url, init);
|
|
82
115
|
}
|
|
@@ -99,8 +132,12 @@ async function sendResponse(res, response) {
|
|
|
99
132
|
res.end();
|
|
100
133
|
return;
|
|
101
134
|
}
|
|
135
|
+
// send headers now so a delayed first chunk (SSE, slow streams) does not
|
|
136
|
+
// leave the client waiting with no response
|
|
137
|
+
res.flushHeaders();
|
|
102
138
|
await webToNode(response.body, res);
|
|
103
|
-
res.
|
|
139
|
+
if (!res.destroyed)
|
|
140
|
+
res.end();
|
|
104
141
|
}
|
|
105
142
|
/**
|
|
106
143
|
* Start a production HTTP server for a Node (or Bun) deployment.
|
|
@@ -121,16 +158,31 @@ async function adapter(options = {}) {
|
|
|
121
158
|
const host = options.host ?? '0.0.0.0';
|
|
122
159
|
const handler = await loadHandler(entry);
|
|
123
160
|
const server = http.createServer(async (req, res) => {
|
|
161
|
+
const abort = new AbortController();
|
|
162
|
+
// `close` before the response has finished means the client went away;
|
|
163
|
+
// surface that through `req.signal` so long-lived work can stop
|
|
164
|
+
function onClose() {
|
|
165
|
+
if (!res.writableEnded)
|
|
166
|
+
abort.abort(new Error('client disconnected'));
|
|
167
|
+
}
|
|
168
|
+
res.once('close', onClose);
|
|
124
169
|
try {
|
|
125
|
-
const response = await handler.fetch(toRequest(req));
|
|
170
|
+
const response = await handler.fetch(toRequest(req, abort.signal));
|
|
126
171
|
await sendResponse(res, response);
|
|
127
172
|
}
|
|
128
173
|
catch (err) {
|
|
129
174
|
console.error('[solas] request failed', err);
|
|
175
|
+
if (res.headersSent) {
|
|
176
|
+
res.destroy();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
130
179
|
res.statusCode = 500;
|
|
131
180
|
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
132
181
|
res.end('Internal Server Error');
|
|
133
182
|
}
|
|
183
|
+
finally {
|
|
184
|
+
res.off('close', onClose);
|
|
185
|
+
}
|
|
134
186
|
});
|
|
135
187
|
await new Promise(resolve => server.listen(port, host, resolve));
|
|
136
188
|
options.onListen?.({ port, host });
|
package/dist/index.js
CHANGED
|
@@ -249,6 +249,20 @@ function solas(c) {
|
|
|
249
249
|
viteConfig.publicDir ??= Config.PUBLIC_DIR;
|
|
250
250
|
viteConfig.server ??= {};
|
|
251
251
|
viteConfig.server.port ??= 8787;
|
|
252
|
+
// build output can be large and is rewritten during a session; keep it
|
|
253
|
+
// out of the watcher so route-change filtering does not process it.
|
|
254
|
+
// `.solas` stays watched on purpose: Vite needs those generated-file
|
|
255
|
+
// events to reload the route graph during dev
|
|
256
|
+
viteConfig.server.watch ??= {};
|
|
257
|
+
const watchIgnored = viteConfig.server.watch.ignored;
|
|
258
|
+
viteConfig.server.watch.ignored = [
|
|
259
|
+
...(Array.isArray(watchIgnored)
|
|
260
|
+
? watchIgnored
|
|
261
|
+
: watchIgnored
|
|
262
|
+
? [watchIgnored]
|
|
263
|
+
: []),
|
|
264
|
+
`**/${Config.OUT_DIR}/**`,
|
|
265
|
+
];
|
|
252
266
|
// derive the public url from vite's dev server settings when no
|
|
253
267
|
// origin was set explicitly via the url option or VITE_APP_URL
|
|
254
268
|
if (resolvedUrl === undefined) {
|
|
@@ -28,13 +28,17 @@ export declare class BrowserRouterHistory {
|
|
|
28
28
|
*/
|
|
29
29
|
get current(): BrowserRouter.HistoryEntry;
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
31
|
+
* Push a new history entry and advances the index.
|
|
32
32
|
*/
|
|
33
33
|
pushState(path: string): BrowserRouter.HistoryEntry;
|
|
34
34
|
/**
|
|
35
35
|
* Replaces the current history entry without changing length.
|
|
36
36
|
*/
|
|
37
37
|
replaceState(path: string): BrowserRouter.HistoryEntry;
|
|
38
|
+
/**
|
|
39
|
+
* Store the current window scroll position on the active entry.
|
|
40
|
+
*/
|
|
41
|
+
saveScroll(): void;
|
|
38
42
|
/**
|
|
39
43
|
* Applies relative history movement and updates local index.
|
|
40
44
|
*/
|
|
@@ -11,6 +11,7 @@ export class BrowserRouterHistory {
|
|
|
11
11
|
pathname: url.pathname,
|
|
12
12
|
search: url.search ?? '',
|
|
13
13
|
hash: url.hash ?? '',
|
|
14
|
+
scrollY: 0,
|
|
14
15
|
};
|
|
15
16
|
this.#entries = [entry];
|
|
16
17
|
this.#index = 0;
|
|
@@ -40,9 +41,12 @@ export class BrowserRouterHistory {
|
|
|
40
41
|
return this.#entries[this.#index] ?? null;
|
|
41
42
|
}
|
|
42
43
|
/**
|
|
43
|
-
*
|
|
44
|
+
* Push a new history entry and advances the index.
|
|
44
45
|
*/
|
|
45
46
|
pushState(path) {
|
|
47
|
+
// remember where the user was before leaving the current entry so a
|
|
48
|
+
// later back navigation can restore it
|
|
49
|
+
this.saveScroll();
|
|
46
50
|
if (this.#index < this.#entries.length - 1) {
|
|
47
51
|
// drop forward entries when pushing from the middle
|
|
48
52
|
this.#entries = this.#entries.slice(0, this.#index + 1);
|
|
@@ -52,6 +56,7 @@ export class BrowserRouterHistory {
|
|
|
52
56
|
pathname: window.location.pathname,
|
|
53
57
|
search: window.location.search,
|
|
54
58
|
hash: window.location.hash,
|
|
59
|
+
scrollY: 0,
|
|
55
60
|
};
|
|
56
61
|
this.#entries.push(entry);
|
|
57
62
|
this.#index++;
|
|
@@ -66,6 +71,8 @@ export class BrowserRouterHistory {
|
|
|
66
71
|
pathname: window.location.pathname,
|
|
67
72
|
search: window.location.search,
|
|
68
73
|
hash: window.location.hash,
|
|
74
|
+
// replacing the url does not move the page, so keep the position
|
|
75
|
+
scrollY: this.#entries[this.#index]?.scrollY ?? 0,
|
|
69
76
|
};
|
|
70
77
|
if (this.#entries.length === 0) {
|
|
71
78
|
this.#entries = [entry];
|
|
@@ -75,6 +82,15 @@ export class BrowserRouterHistory {
|
|
|
75
82
|
this.#entries[this.#index] = entry;
|
|
76
83
|
return entry;
|
|
77
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Store the current window scroll position on the active entry.
|
|
87
|
+
*/
|
|
88
|
+
saveScroll() {
|
|
89
|
+
const current = this.#entries[this.#index];
|
|
90
|
+
if (!current)
|
|
91
|
+
return;
|
|
92
|
+
current.scrollY = window.scrollY;
|
|
93
|
+
}
|
|
78
94
|
/**
|
|
79
95
|
* Applies relative history movement and updates local index.
|
|
80
96
|
*/
|
|
@@ -82,6 +98,8 @@ export class BrowserRouterHistory {
|
|
|
82
98
|
if (!Number.isInteger(delta) || delta === 0) {
|
|
83
99
|
return this.current;
|
|
84
100
|
}
|
|
101
|
+
// capture the outgoing position before the browser moves
|
|
102
|
+
this.saveScroll();
|
|
85
103
|
const nextIndex = Math.max(0, Math.min(this.#index + delta, this.#entries.length - 1));
|
|
86
104
|
if (nextIndex !== this.#index)
|
|
87
105
|
this.#index = nextIndex;
|
|
@@ -96,31 +114,30 @@ export class BrowserRouterHistory {
|
|
|
96
114
|
pathname: window.location.pathname,
|
|
97
115
|
search: window.location.search,
|
|
98
116
|
hash: window.location.hash,
|
|
117
|
+
// unknown entries start at the top
|
|
118
|
+
scrollY: 0,
|
|
99
119
|
};
|
|
100
120
|
const prev = this.#entries[this.#index - 1];
|
|
101
121
|
const next = this.#entries[this.#index + 1];
|
|
102
|
-
if (prev &&
|
|
103
|
-
prev.pathname === current.pathname &&
|
|
104
|
-
prev.search === current.search &&
|
|
105
|
-
prev.hash === current.hash) {
|
|
122
|
+
if (prev && matches(prev, current)) {
|
|
106
123
|
this.#index--;
|
|
124
|
+
// restore the position this entry had when it was left
|
|
125
|
+
current.scrollY = prev.scrollY;
|
|
107
126
|
this.#entries[this.#index] = current;
|
|
108
127
|
return current;
|
|
109
128
|
}
|
|
110
|
-
if (next &&
|
|
111
|
-
next.pathname === current.pathname &&
|
|
112
|
-
next.search === current.search &&
|
|
113
|
-
next.hash === current.hash) {
|
|
129
|
+
if (next && matches(next, current)) {
|
|
114
130
|
this.#index++;
|
|
131
|
+
current.scrollY = next.scrollY;
|
|
115
132
|
this.#entries[this.#index] = current;
|
|
116
133
|
return current;
|
|
117
134
|
}
|
|
118
135
|
// fallback: locate an existing matching entry
|
|
119
|
-
const index = this.#entries.findIndex(entry => entry
|
|
120
|
-
entry.search === current.search &&
|
|
121
|
-
entry.hash === current.hash);
|
|
136
|
+
const index = this.#entries.findIndex(entry => matches(entry, current));
|
|
122
137
|
if (index >= 0) {
|
|
138
|
+
const entry = this.#entries[index];
|
|
123
139
|
this.#index = index;
|
|
140
|
+
current.scrollY = entry?.scrollY ?? 0;
|
|
124
141
|
this.#entries[index] = current;
|
|
125
142
|
return current;
|
|
126
143
|
}
|
|
@@ -129,3 +146,9 @@ export class BrowserRouterHistory {
|
|
|
129
146
|
return current;
|
|
130
147
|
}
|
|
131
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Whether two history entries point at the same location.
|
|
151
|
+
*/
|
|
152
|
+
function matches(a, b) {
|
|
153
|
+
return a.pathname === b.pathname && a.search === b.search && a.hash === b.hash;
|
|
154
|
+
}
|
|
@@ -13,7 +13,7 @@ function guard(path, prefetcher) {
|
|
|
13
13
|
return;
|
|
14
14
|
prefetcher(path);
|
|
15
15
|
}
|
|
16
|
-
export function Link({ children, href, params, prefetch = 'none', query, ...rest }) {
|
|
16
|
+
export function Link({ children, href, params, prefetch = 'none', query, scroll, ...rest }) {
|
|
17
17
|
const { go, prefetch: prefetcher } = useRouter();
|
|
18
18
|
const timer = useRef(null);
|
|
19
19
|
// track whether the link is meant to be handled by the router, to avoid
|
|
@@ -46,7 +46,7 @@ export function Link({ children, href, params, prefetch = 'none', query, ...rest
|
|
|
46
46
|
if (handled.current)
|
|
47
47
|
return;
|
|
48
48
|
e.preventDefault();
|
|
49
|
-
go(href, { params, query });
|
|
49
|
+
go(href, { params, query, scroll });
|
|
50
50
|
}, onFocus: e => {
|
|
51
51
|
rest.onFocus?.(e);
|
|
52
52
|
if (e.defaultPrevented)
|
|
@@ -47,6 +47,62 @@ function performNavigationFetch(path, init) {
|
|
|
47
47
|
? fetch(getPayloadUrl(path), init)
|
|
48
48
|
: fetch(path, { ...init, headers: { ...init?.headers, accept: 'text/x-component' } });
|
|
49
49
|
}
|
|
50
|
+
const MAX_HASH_ATTEMPTS = 20;
|
|
51
|
+
function nextFrame(callback) {
|
|
52
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
53
|
+
requestAnimationFrame(callback);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
setTimeout(callback, 0);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Scroll to the element a hash points at, retrying for a few frames because the
|
|
60
|
+
* incoming tree may still be committing when the navigation resolves.
|
|
61
|
+
*/
|
|
62
|
+
function scrollToHash(hash, attempts = MAX_HASH_ATTEMPTS) {
|
|
63
|
+
let id;
|
|
64
|
+
try {
|
|
65
|
+
id = decodeURIComponent(hash.replace(/^#/, ''));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
id = hash.replace(/^#/, '');
|
|
69
|
+
}
|
|
70
|
+
if (!id) {
|
|
71
|
+
window.scrollTo(0, 0);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const element = document.getElementById(id) ?? document.getElementsByName(id)[0] ?? null;
|
|
75
|
+
if (element) {
|
|
76
|
+
element.scrollIntoView();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (attempts <= 0)
|
|
80
|
+
return;
|
|
81
|
+
nextFrame(() => scrollToHash(hash, attempts - 1));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Apply the scroll position for a completed navigation. Push navigations go to
|
|
85
|
+
* the top (or the target hash), back/forward restore the saved position, and
|
|
86
|
+
* refreshes leave the page where it is.
|
|
87
|
+
*/
|
|
88
|
+
function applyScroll(intent, hash) {
|
|
89
|
+
if (typeof window === 'undefined')
|
|
90
|
+
return;
|
|
91
|
+
if (intent === 'none')
|
|
92
|
+
return;
|
|
93
|
+
if (typeof window.scrollTo !== 'function')
|
|
94
|
+
return;
|
|
95
|
+
if (typeof intent === 'object') {
|
|
96
|
+
// wait for the restored tree to commit so it has its full height
|
|
97
|
+
nextFrame(() => nextFrame(() => window.scrollTo(0, intent.restore)));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (hash) {
|
|
101
|
+
scrollToHash(hash);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
window.scrollTo(0, 0);
|
|
105
|
+
}
|
|
50
106
|
export function BrowserRouterProvider({ children, setPayload, isNavigating = false, url, }) {
|
|
51
107
|
const id = useRef(0);
|
|
52
108
|
const controller = useRef(null);
|
|
@@ -60,6 +116,9 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
60
116
|
* @returns The final path navigated to after any redirects, or the original path if navigation failed.
|
|
61
117
|
*/
|
|
62
118
|
const go = useCallback(async (to, opts = {}) => {
|
|
119
|
+
// capture before the first await so a mutated options object cannot
|
|
120
|
+
// change how this navigation scrolls
|
|
121
|
+
const scrollIntent = opts.scroll ?? 'top';
|
|
63
122
|
id.current += 1;
|
|
64
123
|
const navigationId = id.current;
|
|
65
124
|
const currentPath = window.location.pathname + window.location.search;
|
|
@@ -81,6 +140,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
81
140
|
throw new Error('[router.go]: external URLs are not supported. Use <a> instead');
|
|
82
141
|
}
|
|
83
142
|
const url = new URL(target, window.location.origin);
|
|
143
|
+
const hash = url.hash;
|
|
84
144
|
const key = ResponseCache.toCacheKey(url.toString(), window.location.origin);
|
|
85
145
|
if (!key)
|
|
86
146
|
throw new Error('Invalid navigation url');
|
|
@@ -118,6 +178,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
118
178
|
history.current.replaceState(resolvedPath);
|
|
119
179
|
}
|
|
120
180
|
setPayload?.(payload);
|
|
181
|
+
applyScroll(scrollIntent, hash);
|
|
121
182
|
window.dispatchEvent(new CustomEvent(Events.names.NAVIGATION, {
|
|
122
183
|
detail: { path: resolvedPath },
|
|
123
184
|
}));
|
|
@@ -170,16 +231,17 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
170
231
|
return Promise.resolve(currentPath);
|
|
171
232
|
if (responseCache.has(key))
|
|
172
233
|
responseCache.remove(key);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
});
|
|
234
|
+
// a refresh stays exactly where the user is
|
|
235
|
+
return go(currentPath, { replace: true, scroll: 'none' });
|
|
176
236
|
}, [go]);
|
|
177
237
|
useEffect(() => {
|
|
178
238
|
const h = history.current;
|
|
179
239
|
function handler() {
|
|
180
|
-
h.onPopState();
|
|
240
|
+
const entry = h.onPopState();
|
|
241
|
+
// back/forward restores the position the entry was left at
|
|
181
242
|
void go(BrowserRouter.toTarget(window.location.pathname + window.location.search), {
|
|
182
243
|
replace: true,
|
|
244
|
+
scroll: { restore: entry?.scrollY ?? 0 },
|
|
183
245
|
});
|
|
184
246
|
}
|
|
185
247
|
window.addEventListener('popstate', handler);
|
|
@@ -189,6 +251,31 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
189
251
|
window.removeEventListener('popstate', handler);
|
|
190
252
|
};
|
|
191
253
|
}, [go]);
|
|
254
|
+
// take over scroll restoration so back/forward can restore saved positions
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
// remember the browser's own setting so an unmount can hand it back
|
|
257
|
+
const previous = 'scrollRestoration' in window.history ? window.history.scrollRestoration : undefined;
|
|
258
|
+
if (previous !== undefined) {
|
|
259
|
+
window.history.scrollRestoration = 'manual';
|
|
260
|
+
}
|
|
261
|
+
let pending = false;
|
|
262
|
+
function onScroll() {
|
|
263
|
+
if (pending)
|
|
264
|
+
return;
|
|
265
|
+
pending = true;
|
|
266
|
+
nextFrame(() => {
|
|
267
|
+
pending = false;
|
|
268
|
+
history.current.saveScroll();
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
272
|
+
return () => {
|
|
273
|
+
window.removeEventListener('scroll', onScroll);
|
|
274
|
+
if (previous !== undefined) {
|
|
275
|
+
window.history.scrollRestoration = previous;
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}, []);
|
|
192
279
|
const value = useMemo(() => ({
|
|
193
280
|
go,
|
|
194
281
|
prefetch,
|
|
@@ -5,10 +5,25 @@ export type Path = keyof Solas.Routes & string;
|
|
|
5
5
|
type Replace = {
|
|
6
6
|
replace?: boolean;
|
|
7
7
|
};
|
|
8
|
+
/**
|
|
9
|
+
* Where to position the page after a navigation.
|
|
10
|
+
*
|
|
11
|
+
* - `'top'`: scroll to the target hash when the URL has one, otherwise the top.
|
|
12
|
+
* - `'none'`: leave the scroll position untouched.
|
|
13
|
+
* - `{ restore: number }`: restore a specific offset. Used by the router for
|
|
14
|
+
* back and forward.
|
|
15
|
+
*/
|
|
16
|
+
export type ScrollIntent = 'top' | 'none' | {
|
|
17
|
+
restore: number;
|
|
18
|
+
};
|
|
8
19
|
export type GoOptions = {
|
|
9
20
|
replace?: boolean;
|
|
10
21
|
query?: Query;
|
|
11
22
|
params?: Params;
|
|
23
|
+
/**
|
|
24
|
+
* Where to position the page after navigating. Defaults to `'top'`.
|
|
25
|
+
*/
|
|
26
|
+
scroll?: ScrollIntent;
|
|
12
27
|
};
|
|
13
28
|
export type Context = {
|
|
14
29
|
go: Go;
|
|
@@ -29,6 +44,7 @@ export type HistoryEntry = {
|
|
|
29
44
|
pathname: string;
|
|
30
45
|
search: string;
|
|
31
46
|
hash: string;
|
|
47
|
+
scrollY: number;
|
|
32
48
|
};
|
|
33
49
|
/**
|
|
34
50
|
* These targets are used as-is. They are not matched against the route table,
|
|
@@ -97,6 +113,7 @@ export type Target = ExternalTarget | TargetSuffix<ResolvedPath<Path>>;
|
|
|
97
113
|
type TargetConfig = {
|
|
98
114
|
params?: never;
|
|
99
115
|
query?: Query;
|
|
116
|
+
scroll?: ScrollIntent;
|
|
100
117
|
};
|
|
101
118
|
/**
|
|
102
119
|
* Extra options for callers who pass a route pattern and params separately.
|
|
@@ -119,6 +136,7 @@ type TargetConfig = {
|
|
|
119
136
|
*/
|
|
120
137
|
type PatternConfig<TPath extends Path> = {
|
|
121
138
|
query?: Query;
|
|
139
|
+
scroll?: ScrollIntent;
|
|
122
140
|
} & (Solas.Routes[TPath] extends {
|
|
123
141
|
params: infer TParams extends Params;
|
|
124
142
|
} ? {
|
package/dist/internal/build.js
CHANGED
|
@@ -104,6 +104,7 @@ function buildSegment(dir, page, chains) {
|
|
|
104
104
|
export class Finder {
|
|
105
105
|
buildContext;
|
|
106
106
|
config;
|
|
107
|
+
static #importPaths = new Map();
|
|
107
108
|
constructor(buildContext, config) {
|
|
108
109
|
this.buildContext = buildContext;
|
|
109
110
|
this.config = config;
|
|
@@ -143,11 +144,19 @@ export class Finder {
|
|
|
143
144
|
*/
|
|
144
145
|
static getImportPath(file) {
|
|
145
146
|
const cwd = process.cwd();
|
|
147
|
+
const key = `${cwd}\0${file}`;
|
|
148
|
+
const cached = Finder.#importPaths.get(key);
|
|
149
|
+
if (cached !== undefined)
|
|
150
|
+
return cached;
|
|
146
151
|
const generatedDir = path.join(cwd, Config.GENERATED_DIR);
|
|
147
|
-
|
|
152
|
+
const result = path
|
|
148
153
|
.relative(generatedDir, path.resolve(cwd, file))
|
|
149
154
|
.replace(/\\/g, '/')
|
|
150
155
|
.replace(/\.(t|j)sx?$/, '');
|
|
156
|
+
// shared shells, layouts and boundaries are resolved once per segment, so
|
|
157
|
+
// cache the result for the rest of the build
|
|
158
|
+
Finder.#importPaths.set(key, result);
|
|
159
|
+
return result;
|
|
151
160
|
}
|
|
152
161
|
/**
|
|
153
162
|
* Entry point: scan the app directory then process the result
|
package/dist/internal/env/rsc.js
CHANGED
|
@@ -6,7 +6,7 @@ import * as Config from '../../config.js';
|
|
|
6
6
|
import * as RuntimeManifest from '../../manifest.js';
|
|
7
7
|
import { createHttpRouter } from '../http-router/create-http-router.js';
|
|
8
8
|
import { HttpRouter } from '../http-router/router.js';
|
|
9
|
-
import { isRscRequest, normaliseTrailingSlash } from '../http-router/utils.js';
|
|
9
|
+
import { isRscRequest, normaliseTrailingSlash, resolveRequestPath, } from '../http-router/utils.js';
|
|
10
10
|
import { Metadata } from '../metadata.js';
|
|
11
11
|
import { HttpException, isHttpException, toHttpException, toHttpExceptionLike, } from '../navigation/http-exception.js';
|
|
12
12
|
import { isRedirect, toRedirect } from '../navigation/redirect.js';
|
|
@@ -230,8 +230,8 @@ export function createHandler(config, manifest, importMap, options = {}) {
|
|
|
230
230
|
req[Config.REQUEST_META_KEY].error = undefined;
|
|
231
231
|
}
|
|
232
232
|
}
|
|
233
|
-
// prerender artifact requests bypass the normal document path so the
|
|
234
|
-
// gets structured JSON instead of a rendered html response
|
|
233
|
+
// prerender artifact requests bypass the normal document path so the
|
|
234
|
+
// prerenderer gets structured JSON instead of a rendered html response
|
|
235
235
|
if (req.headers.get(`x-${Config.SLUG}-prerender`) === '1' &&
|
|
236
236
|
req.headers.get(`x-${Config.SLUG}-prerender-artifact`) === '1') {
|
|
237
237
|
try {
|
|
@@ -391,16 +391,9 @@ export function createHandler(config, manifest, importMap, options = {}) {
|
|
|
391
391
|
// fast path
|
|
392
392
|
if (method !== 'GET' && method !== 'HEAD')
|
|
393
393
|
return httpRouter.fetch(req);
|
|
394
|
-
const routedPath =
|
|
395
|
-
const canonicalPath = routedPath == null
|
|
396
|
-
? null
|
|
397
|
-
: config.trailingSlash === 'ignore'
|
|
398
|
-
? routedPath
|
|
399
|
-
: normaliseTrailingSlash(routedPath, config.trailingSlash);
|
|
400
|
-
const canonicalPathname = canonicalPath == null ? null : applyBasePath(canonicalPath, BASE_PATH);
|
|
394
|
+
const { routedPath, path: canonicalPath, pathname: canonicalPathname, } = resolveRequestPath(url.pathname, BASE_PATH, config.trailingSlash);
|
|
401
395
|
if (canonicalPathname != null &&
|
|
402
396
|
(method === 'GET' || method === 'HEAD') &&
|
|
403
|
-
config.trailingSlash !== 'ignore' &&
|
|
404
397
|
canonicalPathname !== url.pathname) {
|
|
405
398
|
url.pathname = canonicalPathname;
|
|
406
399
|
return Response.redirect(url.toString(), 308);
|
|
@@ -426,7 +419,10 @@ export function createHandler(config, manifest, importMap, options = {}) {
|
|
|
426
419
|
return asset;
|
|
427
420
|
return HttpRouter.serveStatic(assets, asset, req, config.precompress);
|
|
428
421
|
}
|
|
429
|
-
|
|
422
|
+
// the path is already canonical here: a mismatched shape redirected above,
|
|
423
|
+
// so hand the resolved path to the router rather than letting it recompute
|
|
424
|
+
// the base path and trailing slash a second time
|
|
425
|
+
return httpRouter.fetch(req, canonicalPath);
|
|
430
426
|
}
|
|
431
427
|
// vite-plugin-rsc entrypoint
|
|
432
428
|
return {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { HttpRouter } from './router.js';
|
|
2
2
|
import { isRscRequest } from './utils.js';
|
|
3
3
|
function callEndpoint(fn, req) {
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
// always pass the request; handlers that do not need it simply ignore the
|
|
5
|
+
// argument. relying on `fn.length` silently dropped the request for
|
|
6
|
+
// default/rest parameters and compiled handlers
|
|
7
7
|
return fn(req);
|
|
8
8
|
}
|
|
9
9
|
function createHandlerGroups(manifest) {
|
|
@@ -62,7 +62,7 @@ export declare class HttpRouter {
|
|
|
62
62
|
/**
|
|
63
63
|
* Handle an incoming request.
|
|
64
64
|
*/
|
|
65
|
-
fetch(req: Request): Promise<Response>;
|
|
65
|
+
fetch(req: Request, resolvedPath?: string | null): Promise<Response>;
|
|
66
66
|
/**
|
|
67
67
|
* Serve a file with optional compression content negotiation.
|
|
68
68
|
*/
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { match as createMatch } from 'path-to-regexp';
|
|
2
|
-
import {
|
|
2
|
+
import { normaliseBasePath } from '../../utils/base-path.js';
|
|
3
3
|
import * as Config from '../../config.js';
|
|
4
4
|
import { HttpException } from '../navigation/http-exception.js';
|
|
5
5
|
import { maybeAction } from '../server/actions.js';
|
|
6
6
|
import { enforce } from '../server/csrf.js';
|
|
7
|
-
import { getAlternatePathname, normaliseTrailingSlash, toPathPattern } from './utils.js';
|
|
7
|
+
import { getAlternatePathname, normaliseTrailingSlash, resolveRequestPath, toPathPattern, } from './utils.js';
|
|
8
8
|
const BASE_PATH = normaliseBasePath(import.meta.env.BASE_URL);
|
|
9
9
|
/**
|
|
10
10
|
* Handle routing and matching for server requests.
|
|
@@ -162,20 +162,18 @@ export class HttpRouter {
|
|
|
162
162
|
/**
|
|
163
163
|
* Handle an incoming request.
|
|
164
164
|
*/
|
|
165
|
-
async fetch(req) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
let action = false;
|
|
176
|
-
try {
|
|
165
|
+
async fetch(req, resolvedPath) {
|
|
166
|
+
// callers that have already applied the base-path and trailing-slash
|
|
167
|
+
// policy (the RSC dispatch layer) pass the resolved path in, so the
|
|
168
|
+
// request does not pay for that normalisation twice
|
|
169
|
+
let path = resolvedPath;
|
|
170
|
+
if (path === undefined) {
|
|
171
|
+
const url = new URL(req.url);
|
|
172
|
+
const trailingSlash = this.opts.trailingSlash ?? 'never';
|
|
173
|
+
const resolved = resolveRequestPath(url.pathname, BASE_PATH, trailingSlash);
|
|
174
|
+
path = resolved.path;
|
|
177
175
|
const method = req.method.toUpperCase();
|
|
178
|
-
const canonicalPathname =
|
|
176
|
+
const canonicalPathname = resolved.pathname;
|
|
179
177
|
if (canonicalPathname != null &&
|
|
180
178
|
(method === 'GET' || method === 'HEAD') &&
|
|
181
179
|
canonicalPathname !== url.pathname) {
|
|
@@ -187,6 +185,11 @@ export class HttpRouter {
|
|
|
187
185
|
url.pathname = canonicalPathname;
|
|
188
186
|
req = new Request(url.toString(), req);
|
|
189
187
|
}
|
|
188
|
+
}
|
|
189
|
+
let match = null;
|
|
190
|
+
let action = false;
|
|
191
|
+
try {
|
|
192
|
+
const method = req.method.toUpperCase();
|
|
190
193
|
const { action: isAction, formData: parsedFormData } = await maybeAction(req);
|
|
191
194
|
action = isAction;
|
|
192
195
|
// action requests stay on the same path, but we match them like GET so
|
|
@@ -265,7 +268,10 @@ export class HttpRouter {
|
|
|
265
268
|
}
|
|
266
269
|
}
|
|
267
270
|
}
|
|
268
|
-
|
|
271
|
+
// a precompressed variant is only emitted for an asset that existed, so a
|
|
272
|
+
// brotli hit above already proves the file is there. skipping the second
|
|
273
|
+
// lookup saves a round trip on stores where `exists` is a remote call
|
|
274
|
+
if (encoding === null && !(await assets.exists(resolved))) {
|
|
269
275
|
return new Response('Not found', { status: 404 });
|
|
270
276
|
}
|
|
271
277
|
// get mime type from original path, not compressed variant
|
|
@@ -11,6 +11,20 @@ export type PathPattern = {
|
|
|
11
11
|
path: string;
|
|
12
12
|
wildcardNames: Set<string>;
|
|
13
13
|
};
|
|
14
|
+
/**
|
|
15
|
+
* Resolve an incoming request pathname against the base path and trailing-slash
|
|
16
|
+
* policy. Both the dispatch layer and the router use this so the two never
|
|
17
|
+
* disagree about what a canonical path looks like.
|
|
18
|
+
*
|
|
19
|
+
* `routedPath` is the raw app-relative path, `path` is what the router matches,
|
|
20
|
+
* and `pathname` is the canonical URL pathname including the base path, so the
|
|
21
|
+
* caller can tell whether a redirect is needed.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveRequestPath(pathname: string, base: string | null | undefined, trailingSlash?: PluginConfig['trailingSlash']): {
|
|
24
|
+
routedPath: string | null;
|
|
25
|
+
path: string | null;
|
|
26
|
+
pathname: string | null;
|
|
27
|
+
};
|
|
14
28
|
/**
|
|
15
29
|
* Convert an internal route string into a path-to-regexp pattern and collect
|
|
16
30
|
* the wildcard param names used in that pattern.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { applyBasePath, stripBasePath } from '../../utils/base-path.js';
|
|
1
2
|
/**
|
|
2
3
|
* Whether a request wants the RSC payload rather than a document.
|
|
3
4
|
*
|
|
@@ -9,6 +10,28 @@ export function isRscRequest(req) {
|
|
|
9
10
|
const accept = req.headers.get('accept') ?? '';
|
|
10
11
|
return accept.includes('text/x-component') && !accept.includes('text/html');
|
|
11
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Resolve an incoming request pathname against the base path and trailing-slash
|
|
15
|
+
* policy. Both the dispatch layer and the router use this so the two never
|
|
16
|
+
* disagree about what a canonical path looks like.
|
|
17
|
+
*
|
|
18
|
+
* `routedPath` is the raw app-relative path, `path` is what the router matches,
|
|
19
|
+
* and `pathname` is the canonical URL pathname including the base path, so the
|
|
20
|
+
* caller can tell whether a redirect is needed.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveRequestPath(pathname, base, trailingSlash = 'never') {
|
|
23
|
+
const routedPath = stripBasePath(pathname, base);
|
|
24
|
+
const path = routedPath == null
|
|
25
|
+
? null
|
|
26
|
+
: trailingSlash === 'ignore'
|
|
27
|
+
? routedPath
|
|
28
|
+
: normaliseTrailingSlash(routedPath, trailingSlash);
|
|
29
|
+
return {
|
|
30
|
+
routedPath,
|
|
31
|
+
path,
|
|
32
|
+
pathname: path == null ? null : applyBasePath(path, base),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
12
35
|
/**
|
|
13
36
|
* Escape a literal path segment so it can be used safely in a
|
|
14
37
|
* path-to-regexp pattern.
|
package/package.json
CHANGED