@jk2908/solas 0.11.1 → 0.13.0
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 +22 -0
- package/README.md +18 -2
- package/dist/index.js +16 -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 +117 -10
- 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/dist/internal/postbuild.js +8 -0
- package/dist/internal/prerender.d.ts +11 -0
- package/dist/internal/prerender.js +13 -0
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.0
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
### Fixes
|
|
10
|
+
|
|
11
|
+
- Endpoint handlers with default or rest parameters now receive the request. Previously Solas passed it only when `Function.length` was non-zero.
|
|
12
|
+
- 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.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- The route finder caches import paths, and the dev watcher ignores `dist`, so large route graphs and long dev sessions do less repeated work.
|
|
17
|
+
|
|
18
|
+
## 0.12.0
|
|
19
|
+
|
|
20
|
+
### Features
|
|
21
|
+
|
|
22
|
+
- The static adapter supports client-side navigation. The build emits each fully prerendered route's RSC payload as `<route>.rsc.txt` next to its HTML, and the browser router fetches it, so a static host needs no server to answer navigations.
|
|
23
|
+
- The active adapter's name is exposed as `import.meta.env.SOLAS_ADAPTER`, so apps can branch on the platform.
|
|
24
|
+
|
|
3
25
|
## 0.11.1
|
|
4
26
|
|
|
5
27
|
### Fixes
|
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`.
|
|
@@ -229,6 +241,8 @@ Solas resolves the origin in this order:
|
|
|
229
241
|
|
|
230
242
|
The resolved origin is exposed to your app as `import.meta.env.VITE_APP_URL` and is used for build-time prerender requests and `sitemap` URLs. When `url` is set, it is also used as a trusted origin for CSRF.
|
|
231
243
|
|
|
244
|
+
The active adapter's name is exposed as `import.meta.env.SOLAS_ADAPTER`, so your app can branch on the platform it is deployed to.
|
|
245
|
+
|
|
232
246
|
You usually only need `url` in production, where the Vite dev server origin is meaningless. For example, to generate a `sitemap.xml` with real URLs:
|
|
233
247
|
|
|
234
248
|
```ts
|
|
@@ -538,7 +552,7 @@ Valid values are `debug`, `info`, `warn`, `error`, and `fatal`.
|
|
|
538
552
|
- `error`: only errors
|
|
539
553
|
- `fatal`: only fatal errors
|
|
540
554
|
|
|
541
|
-
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.
|
|
542
556
|
|
|
543
557
|
```ts
|
|
544
558
|
export default defineConfig({
|
|
@@ -707,7 +721,9 @@ export default defineConfig({
|
|
|
707
721
|
})
|
|
708
722
|
```
|
|
709
723
|
|
|
710
|
-
`vite build` writes the site to `build/` (override with `outDir`): the client assets, each route as `<route>.html` (or `<route>/index.html` under `trailingSlash: 'always'`), plus `sitemap.xml` and `robots.txt`. Serve that directory with anything — `npx serve build`, GitHub Pages, Netlify, an S3 bucket, nginx.
|
|
724
|
+
`vite build` writes the site to `build/` (override with `outDir`): the client assets, each route as `<route>.html` (or `<route>/index.html` under `trailingSlash: 'always'`), its RSC payload as `<route>.rsc.txt`, plus `sitemap.xml` and `robots.txt`. Serve that directory with anything — `npx serve build`, GitHub Pages, Netlify, an S3 bucket, nginx.
|
|
725
|
+
|
|
726
|
+
Client-side navigation works with no server: the browser router fetches each route's `<route>.rsc.txt` payload instead of asking a server for `text/x-component`.
|
|
711
727
|
|
|
712
728
|
Because it forces full prerendering, routes that need a server are not a fit: per-route PPR or SSR opt-ins and dynamic routes without `params()`.
|
|
713
729
|
|
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) {
|
|
@@ -261,6 +275,7 @@ function solas(c) {
|
|
|
261
275
|
viteConfig.define ??= {};
|
|
262
276
|
viteConfig.define['import.meta.env.VITE_APP_URL'] = JSON.stringify(resolvedUrl);
|
|
263
277
|
viteConfig.define['import.meta.env.SOLAS_VERSION'] = JSON.stringify(pkg.version);
|
|
278
|
+
viteConfig.define['import.meta.env.SOLAS_ADAPTER'] = JSON.stringify(config.adapter?.name ?? '');
|
|
264
279
|
viteConfig.optimizeDeps ??= {};
|
|
265
280
|
viteConfig.optimizeDeps.exclude = [
|
|
266
281
|
...new Set([
|
|
@@ -335,6 +350,7 @@ function solas(c) {
|
|
|
335
350
|
precompress: config.precompress,
|
|
336
351
|
trailingSlash: config.trailingSlash,
|
|
337
352
|
url: resolvedUrl,
|
|
353
|
+
adapter: config.adapter?.name ?? '',
|
|
338
354
|
}));
|
|
339
355
|
},
|
|
340
356
|
buildApp: {
|
|
@@ -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)
|
|
@@ -28,6 +28,81 @@ const DEFAULT_GO_CONFIG = {
|
|
|
28
28
|
};
|
|
29
29
|
const logger = new Logger();
|
|
30
30
|
const responseCache = new ResponseCache();
|
|
31
|
+
const STATIC = import.meta.env.SOLAS_ADAPTER === 'static';
|
|
32
|
+
/**
|
|
33
|
+
* The URL to fetch the response for a navigation. On a static host that is the
|
|
34
|
+
* pre-generated RSC payload; otherwise the path itself carries the RSC response
|
|
35
|
+
* for an `Accept: text/x-component` request.
|
|
36
|
+
*/
|
|
37
|
+
function getPayloadUrl(path) {
|
|
38
|
+
const url = new URL(path, window.location.origin);
|
|
39
|
+
url.pathname = url.pathname.endsWith('/')
|
|
40
|
+
? `${url.pathname}index.rsc.txt`
|
|
41
|
+
: `${url.pathname}.rsc.txt`;
|
|
42
|
+
url.search = '';
|
|
43
|
+
return url;
|
|
44
|
+
}
|
|
45
|
+
function performNavigationFetch(path, init) {
|
|
46
|
+
return STATIC
|
|
47
|
+
? fetch(getPayloadUrl(path), init)
|
|
48
|
+
: fetch(path, { ...init, headers: { ...init?.headers, accept: 'text/x-component' } });
|
|
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
|
+
}
|
|
31
106
|
export function BrowserRouterProvider({ children, setPayload, isNavigating = false, url, }) {
|
|
32
107
|
const id = useRef(0);
|
|
33
108
|
const controller = useRef(null);
|
|
@@ -41,6 +116,9 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
41
116
|
* @returns The final path navigated to after any redirects, or the original path if navigation failed.
|
|
42
117
|
*/
|
|
43
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';
|
|
44
122
|
id.current += 1;
|
|
45
123
|
const navigationId = id.current;
|
|
46
124
|
const currentPath = window.location.pathname + window.location.search;
|
|
@@ -62,6 +140,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
62
140
|
throw new Error('[router.go]: external URLs are not supported. Use <a> instead');
|
|
63
141
|
}
|
|
64
142
|
const url = new URL(target, window.location.origin);
|
|
143
|
+
const hash = url.hash;
|
|
65
144
|
const key = ResponseCache.toCacheKey(url.toString(), window.location.origin);
|
|
66
145
|
if (!key)
|
|
67
146
|
throw new Error('Invalid navigation url');
|
|
@@ -79,10 +158,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
79
158
|
if (!promise) {
|
|
80
159
|
const ctrl = new AbortController();
|
|
81
160
|
controller.current = ctrl;
|
|
82
|
-
promise =
|
|
83
|
-
headers: { accept: 'text/x-component' },
|
|
84
|
-
signal: ctrl.signal,
|
|
85
|
-
});
|
|
161
|
+
promise = performNavigationFetch(path, { signal: ctrl.signal });
|
|
86
162
|
responseCache.set(path, promise);
|
|
87
163
|
}
|
|
88
164
|
if (navigationId !== id.current)
|
|
@@ -91,13 +167,18 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
91
167
|
promise,
|
|
92
168
|
createFromFetch(promise),
|
|
93
169
|
]);
|
|
94
|
-
|
|
170
|
+
// on a static host the payload comes from `<path>.rsc.txt`, so the
|
|
171
|
+
// response URL is not the page path
|
|
172
|
+
const resolvedPath = STATIC
|
|
173
|
+
? path
|
|
174
|
+
: (ResponseCache.toCacheKey(res.url, window.location.origin) ?? path);
|
|
95
175
|
if (navigationId !== id.current)
|
|
96
176
|
return resolvedPath;
|
|
97
177
|
if (resolvedPath !== path) {
|
|
98
178
|
history.current.replaceState(resolvedPath);
|
|
99
179
|
}
|
|
100
180
|
setPayload?.(payload);
|
|
181
|
+
applyScroll(scrollIntent, hash);
|
|
101
182
|
window.dispatchEvent(new CustomEvent(Events.names.NAVIGATION, {
|
|
102
183
|
detail: { path: resolvedPath },
|
|
103
184
|
}));
|
|
@@ -136,7 +217,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
136
217
|
return;
|
|
137
218
|
if (responseCache.has(key))
|
|
138
219
|
return;
|
|
139
|
-
responseCache.set(key,
|
|
220
|
+
responseCache.set(key, performNavigationFetch(key));
|
|
140
221
|
}, []);
|
|
141
222
|
/**
|
|
142
223
|
* Refreshes the current page by re-fetching the RSC response for the current path and updating the
|
|
@@ -150,16 +231,17 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
150
231
|
return Promise.resolve(currentPath);
|
|
151
232
|
if (responseCache.has(key))
|
|
152
233
|
responseCache.remove(key);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
});
|
|
234
|
+
// a refresh stays exactly where the user is
|
|
235
|
+
return go(currentPath, { replace: true, scroll: 'none' });
|
|
156
236
|
}, [go]);
|
|
157
237
|
useEffect(() => {
|
|
158
238
|
const h = history.current;
|
|
159
239
|
function handler() {
|
|
160
|
-
h.onPopState();
|
|
240
|
+
const entry = h.onPopState();
|
|
241
|
+
// back/forward restores the position the entry was left at
|
|
161
242
|
void go(BrowserRouter.toTarget(window.location.pathname + window.location.search), {
|
|
162
243
|
replace: true,
|
|
244
|
+
scroll: { restore: entry?.scrollY ?? 0 },
|
|
163
245
|
});
|
|
164
246
|
}
|
|
165
247
|
window.addEventListener('popstate', handler);
|
|
@@ -169,6 +251,31 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
169
251
|
window.removeEventListener('popstate', handler);
|
|
170
252
|
};
|
|
171
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
|
+
}, []);
|
|
172
279
|
const value = useMemo(() => ({
|
|
173
280
|
go,
|
|
174
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.
|
|
@@ -114,6 +114,14 @@ export async function postbuild(cwd = process.cwd()) {
|
|
|
114
114
|
const staticPath = path.join(staticRoot, Prerender.staticRoutePath(route, staticMode));
|
|
115
115
|
await fs.mkdir(path.dirname(staticPath), { recursive: true });
|
|
116
116
|
await fs.writeFile(staticPath, artifact.html);
|
|
117
|
+
// a static host has no server to answer an RSC navigation, so emit
|
|
118
|
+
// the payload next to the HTML for the client router to fetch
|
|
119
|
+
if (manifest.adapter === 'static') {
|
|
120
|
+
await fs.writeFile(staticPath.replace(/\.html$/, '.rsc.txt'), await Prerender.getRscPayload(app, route, {
|
|
121
|
+
origin: manifest.url,
|
|
122
|
+
base: manifest.base,
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
117
125
|
artfifactManifest[route] = {
|
|
118
126
|
mode: artifact.mode,
|
|
119
127
|
files: ['html'],
|
|
@@ -122,6 +122,17 @@ export declare function get(app: {
|
|
|
122
122
|
route: string;
|
|
123
123
|
artifact: any;
|
|
124
124
|
}>;
|
|
125
|
+
/**
|
|
126
|
+
* Fetch the RSC payload for a route, the response a client would get for an RSC
|
|
127
|
+
* navigation. Written alongside a fully prerendered route's HTML so a static
|
|
128
|
+
* host can serve client-side navigation without a server.
|
|
129
|
+
*/
|
|
130
|
+
export declare function getRscPayload(app: {
|
|
131
|
+
fetch: (req: Request) => Promise<Response>;
|
|
132
|
+
}, route: string, opts: {
|
|
133
|
+
origin?: string;
|
|
134
|
+
base?: string;
|
|
135
|
+
}): Promise<string>;
|
|
125
136
|
/**
|
|
126
137
|
* Run the prerendering process for a list of routes with a specified concurrency limit,
|
|
127
138
|
* by calling the 'get' function for each route and yielding the results as they become available.
|
|
@@ -331,6 +331,19 @@ export async function get(app, route, opts) {
|
|
|
331
331
|
return { route, status: res.status };
|
|
332
332
|
return { route, artifact: await res.json() };
|
|
333
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* Fetch the RSC payload for a route, the response a client would get for an RSC
|
|
336
|
+
* navigation. Written alongside a fully prerendered route's HTML so a static
|
|
337
|
+
* host can serve client-side navigation without a server.
|
|
338
|
+
*/
|
|
339
|
+
export async function getRscPayload(app, route, opts) {
|
|
340
|
+
const url = `${opts.origin ?? `http://${Config.SLUG}.local`}${applyBasePath(route, opts.base)}`;
|
|
341
|
+
const res = await app.fetch(new Request(url, { headers: { Accept: 'text/x-component' } }));
|
|
342
|
+
if (!(res instanceof Response) || !res.ok) {
|
|
343
|
+
throw new Error(`[prerender] RSC payload for ${route} was not served`);
|
|
344
|
+
}
|
|
345
|
+
return res.text();
|
|
346
|
+
}
|
|
334
347
|
/**
|
|
335
348
|
* Run the prerendering process for a list of routes with a specified concurrency limit,
|
|
336
349
|
* by calling the 'get' function for each route and yielding the results as they become available.
|
package/dist/types.d.ts
CHANGED
package/package.json
CHANGED