arcway 0.1.11 → 0.1.13
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/client/provider.js +32 -7
- package/client/router.js +146 -75
- package/package.json +4 -2
- package/server/pages/build.js +103 -56
- package/server/pages/hmr.js +19 -13
- package/server/pages/ssr.js +3 -2
- package/server/pages/watcher.js +4 -3
- package/client/router.jsx +0 -303
package/client/provider.js
CHANGED
|
@@ -3,7 +3,7 @@ import { WsManager } from './ws.js';
|
|
|
3
3
|
|
|
4
4
|
const SOLO_CTX_KEY = '__provider_context__';
|
|
5
5
|
const WS_CTX_KEY = '__ws_context__';
|
|
6
|
-
const DEFAULT_CONFIG = { pathPrefix: '' };
|
|
6
|
+
const DEFAULT_CONFIG = { pathPrefix: '/api' };
|
|
7
7
|
|
|
8
8
|
const ApiContext = (globalThis[SOLO_CTX_KEY] ??= createContext(DEFAULT_CONFIG));
|
|
9
9
|
const WsContext = (globalThis[WS_CTX_KEY] ??= createContext(null));
|
|
@@ -18,8 +18,25 @@ function useWsManager() {
|
|
|
18
18
|
|
|
19
19
|
const useSoloContext = useApiContext;
|
|
20
20
|
|
|
21
|
-
function
|
|
22
|
-
const
|
|
21
|
+
function toWsProtocol(loc, wsPath) {
|
|
22
|
+
const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
23
|
+
return `${proto}//${loc.host}${wsPath}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveWsUrl(input) {
|
|
27
|
+
if (typeof window === 'undefined') return null;
|
|
28
|
+
if (!input) return toWsProtocol(window.location, '/ws');
|
|
29
|
+
if (/^wss?:\/\//.test(input)) return input;
|
|
30
|
+
if (input.startsWith('/')) return toWsProtocol(window.location, input);
|
|
31
|
+
return input;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ApiProvider({ children, pathPrefix = '/api', headers, wsUrl }) {
|
|
35
|
+
const resolvedWsUrl = useMemo(() => resolveWsUrl(wsUrl), [wsUrl]);
|
|
36
|
+
const config = useMemo(
|
|
37
|
+
() => ({ pathPrefix, headers, wsUrl: resolvedWsUrl }),
|
|
38
|
+
[pathPrefix, headers, resolvedWsUrl],
|
|
39
|
+
);
|
|
23
40
|
|
|
24
41
|
const wsManagerRef = useRef(null);
|
|
25
42
|
const wsManager = useMemo(() => {
|
|
@@ -27,11 +44,11 @@ function ApiProvider({ children, pathPrefix = '', headers, wsUrl }) {
|
|
|
27
44
|
wsManagerRef.current.disconnect();
|
|
28
45
|
wsManagerRef.current = null;
|
|
29
46
|
}
|
|
30
|
-
if (!
|
|
31
|
-
const manager = new WsManager({ url:
|
|
47
|
+
if (!resolvedWsUrl) return null;
|
|
48
|
+
const manager = new WsManager({ url: resolvedWsUrl });
|
|
32
49
|
wsManagerRef.current = manager;
|
|
33
50
|
return manager;
|
|
34
|
-
}, [
|
|
51
|
+
}, [resolvedWsUrl]);
|
|
35
52
|
|
|
36
53
|
useEffect(() => {
|
|
37
54
|
if (wsManager) {
|
|
@@ -50,4 +67,12 @@ function ApiProvider({ children, pathPrefix = '', headers, wsUrl }) {
|
|
|
50
67
|
const Provider = ApiProvider;
|
|
51
68
|
const SoloProvider = ApiProvider;
|
|
52
69
|
|
|
53
|
-
export {
|
|
70
|
+
export {
|
|
71
|
+
ApiProvider,
|
|
72
|
+
Provider,
|
|
73
|
+
SoloProvider,
|
|
74
|
+
resolveWsUrl,
|
|
75
|
+
useApiContext,
|
|
76
|
+
useSoloContext,
|
|
77
|
+
useWsManager,
|
|
78
|
+
};
|
package/client/router.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
-
import React from "react";
|
|
3
1
|
import {
|
|
4
2
|
createContext,
|
|
5
3
|
useContext,
|
|
@@ -9,86 +7,102 @@ import {
|
|
|
9
7
|
useMemo,
|
|
10
8
|
useRef,
|
|
11
9
|
useTransition,
|
|
12
|
-
createElement
|
|
13
|
-
} from
|
|
10
|
+
createElement,
|
|
11
|
+
} from 'react';
|
|
14
12
|
import {
|
|
15
13
|
readClientManifest,
|
|
16
14
|
loadPage,
|
|
17
15
|
matchClientRoute,
|
|
18
16
|
loadLoadingComponents,
|
|
19
|
-
prefetchRoute
|
|
20
|
-
} from
|
|
21
|
-
import { parseQuery } from
|
|
22
|
-
|
|
23
|
-
const
|
|
17
|
+
prefetchRoute,
|
|
18
|
+
} from './page-loader.js';
|
|
19
|
+
import { parseQuery } from './query.js';
|
|
20
|
+
|
|
21
|
+
const ROUTER_CTX_KEY = '__router_context__';
|
|
22
|
+
const RouterContext = (globalThis[ROUTER_CTX_KEY] ??= createContext(null));
|
|
23
|
+
|
|
24
24
|
function wrapInLayouts(element, layouts) {
|
|
25
25
|
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
26
26
|
element = createElement(layouts[i], null, element);
|
|
27
27
|
}
|
|
28
28
|
return element;
|
|
29
29
|
}
|
|
30
|
+
|
|
30
31
|
function useRouter() {
|
|
31
32
|
const ctx = useContext(RouterContext);
|
|
32
33
|
if (!ctx) {
|
|
33
|
-
throw new Error(
|
|
34
|
+
throw new Error('useRouter must be used within a <Router> provider');
|
|
34
35
|
}
|
|
36
|
+
|
|
35
37
|
return useMemo(
|
|
36
38
|
() => ({
|
|
37
39
|
pathname: ctx.pathname,
|
|
38
40
|
params: ctx.params,
|
|
39
|
-
query: typeof window !==
|
|
41
|
+
query: typeof window !== 'undefined' ? parseQuery(window.location.search) : {},
|
|
40
42
|
push: (to, options) => ctx.navigate(to, { ...options, replace: false }),
|
|
41
43
|
replace: (to, options) => ctx.navigate(to, { ...options, replace: true }),
|
|
42
44
|
back: () => {
|
|
43
|
-
if (typeof window !==
|
|
45
|
+
if (typeof window !== 'undefined') window.history.back();
|
|
44
46
|
},
|
|
45
47
|
forward: () => {
|
|
46
|
-
if (typeof window !==
|
|
48
|
+
if (typeof window !== 'undefined') window.history.forward();
|
|
47
49
|
},
|
|
48
50
|
refresh: () => {
|
|
49
|
-
if (typeof window !==
|
|
50
|
-
}
|
|
51
|
+
if (typeof window !== 'undefined') window.location.reload();
|
|
52
|
+
},
|
|
51
53
|
}),
|
|
52
|
-
|
|
54
|
+
// queryVersion bumps on every query-only navigation or same-path popstate,
|
|
55
|
+
// so memoised consumers (notably `useSearchParams`) re-read the URL.
|
|
56
|
+
[ctx.pathname, ctx.params, ctx.navigate, ctx.queryVersion],
|
|
53
57
|
);
|
|
54
58
|
}
|
|
59
|
+
|
|
55
60
|
function usePathname() {
|
|
56
61
|
return useRouter().pathname;
|
|
57
62
|
}
|
|
63
|
+
|
|
58
64
|
function useParams() {
|
|
59
65
|
return useRouter().params;
|
|
60
66
|
}
|
|
67
|
+
|
|
61
68
|
function useSearchParams() {
|
|
62
69
|
return useRouter().query;
|
|
63
70
|
}
|
|
71
|
+
|
|
64
72
|
function Router({
|
|
65
73
|
initialPath,
|
|
66
74
|
initialParams,
|
|
67
75
|
initialComponent,
|
|
68
76
|
initialLayouts,
|
|
69
77
|
initialLoadings,
|
|
70
|
-
children
|
|
78
|
+
children,
|
|
71
79
|
}) {
|
|
72
80
|
const [pathname, setPathname] = useState(
|
|
73
|
-
() => initialPath ?? (typeof window !==
|
|
81
|
+
() => initialPath ?? (typeof window !== 'undefined' ? window.location.pathname : '/'),
|
|
74
82
|
);
|
|
75
83
|
const [pageState, setPageState] = useState({
|
|
76
84
|
component: initialComponent ?? null,
|
|
77
85
|
layouts: initialLayouts ?? [],
|
|
78
86
|
loadings: initialLoadings ?? [],
|
|
79
|
-
params: initialParams ?? {}
|
|
87
|
+
params: initialParams ?? {},
|
|
80
88
|
});
|
|
81
89
|
const [isNavigating, setIsNavigating] = useState(false);
|
|
82
90
|
const [isPending, startTransition] = useTransition();
|
|
91
|
+
// Bumped on query-only navigations (both push/replace and popstate) so that
|
|
92
|
+
// `useRouter`/`useSearchParams` consumers re-render even though pathname and
|
|
93
|
+
// params are unchanged.
|
|
94
|
+
const [queryVersion, setQueryVersion] = useState(0);
|
|
83
95
|
const manifestRef = useRef(null);
|
|
96
|
+
|
|
84
97
|
useEffect(() => {
|
|
85
98
|
manifestRef.current = readClientManifest();
|
|
86
99
|
const onManifestUpdate = () => {
|
|
87
100
|
manifestRef.current = readClientManifest();
|
|
88
101
|
};
|
|
89
|
-
window.addEventListener(
|
|
90
|
-
return () => window.removeEventListener(
|
|
102
|
+
window.addEventListener('manifest-update', onManifestUpdate);
|
|
103
|
+
return () => window.removeEventListener('manifest-update', onManifestUpdate);
|
|
91
104
|
}, []);
|
|
105
|
+
|
|
92
106
|
const applyLoaded = useCallback((loaded, newPath) => {
|
|
93
107
|
startTransition(() => {
|
|
94
108
|
setPathname(newPath);
|
|
@@ -96,71 +110,114 @@ function Router({
|
|
|
96
110
|
component: loaded.component,
|
|
97
111
|
layouts: loaded.layouts,
|
|
98
112
|
loadings: loaded.loadings,
|
|
99
|
-
params: loaded.params
|
|
113
|
+
params: loaded.params,
|
|
100
114
|
});
|
|
101
115
|
setIsNavigating(false);
|
|
102
116
|
});
|
|
103
117
|
}, []);
|
|
118
|
+
|
|
104
119
|
const navigateToPage = useCallback(
|
|
105
120
|
async (to, options) => {
|
|
106
121
|
const scroll = options?.scroll !== false;
|
|
107
122
|
const replace = options?.replace === true;
|
|
108
|
-
|
|
123
|
+
|
|
124
|
+
// `matchClientRoute` only knows how to match pathnames, so any query
|
|
125
|
+
// string has to be stripped before the lookup. Splitting also lets us
|
|
126
|
+
// special-case same-path/different-query navigations as a history-only
|
|
127
|
+
// update — no bundle loading, no scroll reset, no full reload.
|
|
128
|
+
const qIdx = to.indexOf('?');
|
|
129
|
+
const pathOnly = qIdx === -1 ? to : to.slice(0, qIdx);
|
|
130
|
+
const search = qIdx === -1 ? '' : to.slice(qIdx);
|
|
131
|
+
const currentSearch =
|
|
132
|
+
typeof window !== 'undefined' ? window.location.search : '';
|
|
133
|
+
|
|
134
|
+
if (pathOnly === pathname && search === currentSearch) return;
|
|
135
|
+
|
|
136
|
+
if (pathOnly === pathname) {
|
|
137
|
+
if (replace) {
|
|
138
|
+
window.history.replaceState(null, '', to);
|
|
139
|
+
} else {
|
|
140
|
+
window.history.pushState(null, '', to);
|
|
141
|
+
}
|
|
142
|
+
setQueryVersion((v) => v + 1);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
109
146
|
const manifest = manifestRef.current;
|
|
110
147
|
if (!manifest) {
|
|
111
148
|
window.location.href = to;
|
|
112
149
|
return;
|
|
113
150
|
}
|
|
114
|
-
|
|
151
|
+
|
|
152
|
+
const matched = matchClientRoute(manifest, pathOnly);
|
|
115
153
|
if (!matched) {
|
|
116
154
|
window.location.href = to;
|
|
117
155
|
return;
|
|
118
156
|
}
|
|
157
|
+
|
|
119
158
|
setIsNavigating(true);
|
|
120
159
|
if (replace) {
|
|
121
|
-
window.history.replaceState(null,
|
|
160
|
+
window.history.replaceState(null, '', to);
|
|
122
161
|
} else {
|
|
123
|
-
window.history.pushState(null,
|
|
162
|
+
window.history.pushState(null, '', to);
|
|
124
163
|
}
|
|
164
|
+
|
|
125
165
|
const targetLoadings = await loadLoadingComponents(matched.route);
|
|
126
166
|
if (targetLoadings.length > 0) {
|
|
127
|
-
setPathname(
|
|
167
|
+
setPathname(pathOnly);
|
|
128
168
|
setPageState((prev) => ({
|
|
129
169
|
...prev,
|
|
130
170
|
loadings: targetLoadings,
|
|
131
|
-
params: matched.params
|
|
171
|
+
params: matched.params,
|
|
132
172
|
}));
|
|
133
173
|
}
|
|
174
|
+
|
|
134
175
|
try {
|
|
135
|
-
const loaded = await loadPage(manifest,
|
|
176
|
+
const loaded = await loadPage(manifest, pathOnly);
|
|
136
177
|
if (!loaded) {
|
|
137
178
|
window.location.href = to;
|
|
138
179
|
return;
|
|
139
180
|
}
|
|
140
|
-
|
|
181
|
+
|
|
182
|
+
applyLoaded(loaded, pathOnly);
|
|
183
|
+
if (search !== currentSearch) setQueryVersion((v) => v + 1);
|
|
184
|
+
|
|
141
185
|
if (scroll) {
|
|
142
186
|
window.scrollTo(0, 0);
|
|
143
187
|
}
|
|
144
188
|
} catch (err) {
|
|
145
|
-
console.error(
|
|
189
|
+
console.error('Client navigation failed, falling back to full page load:', err);
|
|
146
190
|
window.location.href = to;
|
|
147
191
|
}
|
|
148
192
|
},
|
|
149
|
-
[pathname, applyLoaded]
|
|
193
|
+
[pathname, applyLoaded],
|
|
150
194
|
);
|
|
195
|
+
|
|
151
196
|
useEffect(() => {
|
|
152
197
|
async function onPopState() {
|
|
153
198
|
const newPath = window.location.pathname;
|
|
199
|
+
|
|
200
|
+
// Same-path back/forward is a query-only history step (or identical URL);
|
|
201
|
+
// skip the page load and just force re-render of search-param readers.
|
|
202
|
+
if (newPath === pathname) {
|
|
203
|
+
setQueryVersion((v) => v + 1);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
154
207
|
const manifest = manifestRef.current;
|
|
208
|
+
|
|
155
209
|
if (!manifest) {
|
|
156
210
|
setPathname(newPath);
|
|
157
211
|
setPageState((prev) => ({ ...prev, params: {} }));
|
|
212
|
+
setQueryVersion((v) => v + 1);
|
|
158
213
|
return;
|
|
159
214
|
}
|
|
215
|
+
|
|
160
216
|
try {
|
|
161
217
|
const loaded = await loadPage(manifest, newPath);
|
|
162
218
|
if (loaded) {
|
|
163
219
|
applyLoaded(loaded, newPath);
|
|
220
|
+
setQueryVersion((v) => v + 1);
|
|
164
221
|
} else {
|
|
165
222
|
window.location.reload();
|
|
166
223
|
}
|
|
@@ -168,60 +225,72 @@ function Router({
|
|
|
168
225
|
window.location.reload();
|
|
169
226
|
}
|
|
170
227
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
228
|
+
|
|
229
|
+
window.addEventListener('popstate', onPopState);
|
|
230
|
+
return () => window.removeEventListener('popstate', onPopState);
|
|
231
|
+
}, [pathname, applyLoaded]);
|
|
232
|
+
|
|
174
233
|
const { component: PageComponent, layouts, loadings, params } = pageState;
|
|
234
|
+
|
|
175
235
|
let content;
|
|
176
236
|
if (PageComponent) {
|
|
177
|
-
const inner =
|
|
237
|
+
const inner =
|
|
238
|
+
isNavigating && loadings.length > 0
|
|
239
|
+
? createElement(loadings.at(-1))
|
|
240
|
+
: createElement(PageComponent, params);
|
|
178
241
|
content = wrapInLayouts(inner, layouts);
|
|
179
242
|
} else {
|
|
180
243
|
content = children;
|
|
181
244
|
}
|
|
182
|
-
|
|
245
|
+
|
|
246
|
+
const progressBar =
|
|
247
|
+
isNavigating && loadings.length === 0 && !isPending
|
|
248
|
+
? createElement('div', {
|
|
249
|
+
style: {
|
|
250
|
+
position: 'fixed',
|
|
251
|
+
top: 0,
|
|
252
|
+
left: 0,
|
|
253
|
+
width: '100%',
|
|
254
|
+
height: '2px',
|
|
255
|
+
backgroundColor: '#0070f3',
|
|
256
|
+
zIndex: 99999,
|
|
257
|
+
animation: 'nav-progress 1s ease-in-out infinite',
|
|
258
|
+
},
|
|
259
|
+
})
|
|
260
|
+
: null;
|
|
261
|
+
|
|
262
|
+
return createElement(
|
|
183
263
|
RouterContext.Provider,
|
|
184
264
|
{
|
|
185
265
|
value: {
|
|
186
266
|
pathname,
|
|
187
267
|
params,
|
|
188
|
-
navigate: navigateToPage
|
|
268
|
+
navigate: navigateToPage,
|
|
269
|
+
queryVersion,
|
|
189
270
|
},
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
"div",
|
|
194
|
-
{
|
|
195
|
-
style: {
|
|
196
|
-
position: "fixed",
|
|
197
|
-
top: 0,
|
|
198
|
-
left: 0,
|
|
199
|
-
width: "100%",
|
|
200
|
-
height: "2px",
|
|
201
|
-
backgroundColor: "#0070f3",
|
|
202
|
-
zIndex: 99999,
|
|
203
|
-
animation: "nav-progress 1s ease-in-out infinite"
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
)
|
|
207
|
-
]
|
|
208
|
-
}
|
|
271
|
+
},
|
|
272
|
+
content,
|
|
273
|
+
progressBar,
|
|
209
274
|
);
|
|
210
275
|
}
|
|
211
|
-
|
|
276
|
+
|
|
277
|
+
function Link({ href, children, onClick, scroll, replace, prefetch = 'hover', ...rest }) {
|
|
212
278
|
const router = useContext(RouterContext);
|
|
213
279
|
const linkRef = useRef(null);
|
|
280
|
+
|
|
214
281
|
const handleMouseEnter = useCallback(() => {
|
|
215
|
-
if (prefetch !==
|
|
282
|
+
if (prefetch !== 'hover') return;
|
|
216
283
|
const manifest = readClientManifest();
|
|
217
284
|
if (manifest) {
|
|
218
285
|
prefetchRoute(manifest, href);
|
|
219
286
|
}
|
|
220
287
|
}, [href, prefetch]);
|
|
288
|
+
|
|
221
289
|
useEffect(() => {
|
|
222
|
-
if (prefetch !==
|
|
290
|
+
if (prefetch !== 'viewport') return;
|
|
223
291
|
const el = linkRef.current;
|
|
224
|
-
if (!el || typeof IntersectionObserver ===
|
|
292
|
+
if (!el || typeof IntersectionObserver === 'undefined') return;
|
|
293
|
+
|
|
225
294
|
const observer = new IntersectionObserver(
|
|
226
295
|
(entries) => {
|
|
227
296
|
for (const entry of entries) {
|
|
@@ -235,40 +304,42 @@ function Link({ href, children, onClick, scroll, replace, prefetch = "hover", ..
|
|
|
235
304
|
}
|
|
236
305
|
}
|
|
237
306
|
},
|
|
238
|
-
{ rootMargin:
|
|
307
|
+
{ rootMargin: '200px' },
|
|
239
308
|
);
|
|
309
|
+
|
|
240
310
|
observer.observe(el);
|
|
241
311
|
return () => observer.disconnect();
|
|
242
312
|
}, [href, prefetch]);
|
|
313
|
+
|
|
243
314
|
function handleClick(e) {
|
|
244
315
|
if (onClick) onClick(e);
|
|
245
316
|
if (e.defaultPrevented) return;
|
|
246
317
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
247
318
|
if (e.button !== 0) return;
|
|
248
|
-
if (rest.target ===
|
|
319
|
+
if (rest.target === '_blank' || rest.download !== undefined) return;
|
|
320
|
+
|
|
249
321
|
try {
|
|
250
322
|
const url = new URL(href, window.location.origin);
|
|
251
323
|
if (url.origin !== window.location.origin) return;
|
|
252
324
|
} catch {
|
|
253
325
|
return;
|
|
254
326
|
}
|
|
327
|
+
|
|
255
328
|
e.preventDefault();
|
|
256
329
|
if (router) {
|
|
257
330
|
router.navigate(href, { scroll, replace });
|
|
258
331
|
} else {
|
|
259
|
-
window.history.pushState(null,
|
|
260
|
-
window.dispatchEvent(new PopStateEvent(
|
|
332
|
+
window.history.pushState(null, '', href);
|
|
333
|
+
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
261
334
|
}
|
|
262
335
|
}
|
|
263
|
-
|
|
336
|
+
|
|
337
|
+
return createElement(
|
|
338
|
+
'a',
|
|
339
|
+
{ ref: linkRef, href, onClick: handleClick, onMouseEnter: handleMouseEnter, ...rest },
|
|
340
|
+
children,
|
|
341
|
+
);
|
|
264
342
|
}
|
|
343
|
+
|
|
265
344
|
const SoloRouter = Router;
|
|
266
|
-
export {
|
|
267
|
-
Link,
|
|
268
|
-
Router,
|
|
269
|
-
SoloRouter,
|
|
270
|
-
useParams,
|
|
271
|
-
usePathname,
|
|
272
|
-
useRouter,
|
|
273
|
-
useSearchParams
|
|
274
|
-
};
|
|
345
|
+
export { Link, Router, SoloRouter, useParams, usePathname, useRouter, useSearchParams };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arcway",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -29,8 +29,10 @@
|
|
|
29
29
|
"./ui/style-mira.css": "./client/ui/style-mira.css"
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
|
-
"prepare": "esbuild client/router.jsx --outfile=client/router.js --format=esm --jsx=automatic",
|
|
33
32
|
"test": "vitest run --project=unit",
|
|
33
|
+
"test:watch": "VITEST_MAX_WORKERS=2 vitest --project=unit",
|
|
34
|
+
"test:serial": "VITEST_MAX_WORKERS=1 vitest run --project=unit",
|
|
35
|
+
"test:coverage": "vitest run --project=unit --coverage",
|
|
34
36
|
"test:storybook": "TMPDIR=~/tmp PLAYWRIGHT_BROWSERS_PATH=~/.cache/playwright vitest run --project=storybook",
|
|
35
37
|
"test:all": "TMPDIR=~/tmp PLAYWRIGHT_BROWSERS_PATH=~/.cache/playwright vitest run",
|
|
36
38
|
"format": "prettier --write 'server/**/*.js' 'client/**/*.js' 'tests/**/*.js'",
|
package/server/pages/build.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
3
4
|
import {
|
|
4
5
|
discoverPages,
|
|
5
6
|
discoverLayouts,
|
|
@@ -38,65 +39,111 @@ async function buildPages(options) {
|
|
|
38
39
|
if (pages.length === 0) {
|
|
39
40
|
return { pageCount: 0, outDir, manifest: { entries: [], sharedChunks: [] } };
|
|
40
41
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
42
|
+
// Build into a uniquely-suffixed staging directory and atomically swap into place
|
|
43
|
+
// only after every step succeeds. If the build fails partway through, the previous
|
|
44
|
+
// `outDir` is left intact so the server keeps serving the old pages instead of
|
|
45
|
+
// falling back to 404s. The random suffix keeps concurrent callers against the same
|
|
46
|
+
// `outDir` (e.g. parallel vitest workers) from racing on the same staging path.
|
|
47
|
+
const buildDir = outDir + '-next-' + crypto.randomBytes(6).toString('hex');
|
|
48
|
+
await fs.mkdir(path.join(buildDir, 'server'), { recursive: true });
|
|
49
|
+
await fs.mkdir(path.join(buildDir, 'client'), { recursive: true });
|
|
50
|
+
const builds = [];
|
|
51
|
+
try {
|
|
52
|
+
let fontFaceCss;
|
|
53
|
+
let fontPreloadHtml;
|
|
54
|
+
if (options.fonts && options.fonts.length > 0) {
|
|
55
|
+
const publicDir = path.join(rootDir, 'public');
|
|
56
|
+
const fontResult = await resolveFonts(options.fonts, publicDir);
|
|
57
|
+
if (fontResult.fontFaceCss) fontFaceCss = fontResult.fontFaceCss;
|
|
58
|
+
if (fontResult.preloadHtml) fontPreloadHtml = fontResult.preloadHtml;
|
|
59
|
+
}
|
|
60
|
+
builds.push(
|
|
61
|
+
buildServerBundles(pages, buildDir, serverTarget),
|
|
62
|
+
buildLayoutServerBundles(layouts, buildDir, serverTarget),
|
|
63
|
+
buildMiddlewareServerBundles(middlewares, buildDir, serverTarget),
|
|
64
|
+
buildClientBundles(
|
|
65
|
+
pages,
|
|
66
|
+
layouts,
|
|
67
|
+
buildDir,
|
|
68
|
+
clientTarget,
|
|
69
|
+
minify,
|
|
70
|
+
rootDir,
|
|
71
|
+
loadings,
|
|
72
|
+
minify ? 'production' : 'development',
|
|
73
|
+
devMode,
|
|
74
|
+
),
|
|
75
|
+
buildErrorPageBundles(errorPages, buildDir, serverTarget),
|
|
76
|
+
buildCssBundle(pagesDir, buildDir, minify, stylesPath, fontFaceCss),
|
|
77
|
+
);
|
|
78
|
+
if (devMode) builds.push(buildHmrRuntimeBundle(buildDir));
|
|
79
|
+
// Fail fast: `Promise.all` rejects the moment any build throws, so a broken
|
|
80
|
+
// page produces a prompt error instead of waiting for slower sibling builds
|
|
81
|
+
// to finish. Cleanup of the staging dir is scheduled in the catch block.
|
|
82
|
+
const [
|
|
83
|
+
serverResult,
|
|
84
|
+
layoutServerResult,
|
|
85
|
+
middlewareServerResult,
|
|
86
|
+
clientResult,
|
|
87
|
+
errorBundles,
|
|
88
|
+
cssBundle,
|
|
89
|
+
] = await Promise.all(builds);
|
|
90
|
+
const manifest = generateManifest(
|
|
57
91
|
pages,
|
|
58
92
|
layouts,
|
|
59
|
-
outDir,
|
|
60
|
-
clientTarget,
|
|
61
|
-
minify,
|
|
62
|
-
rootDir,
|
|
63
93
|
loadings,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
middlewares,
|
|
95
|
+
serverResult,
|
|
96
|
+
layoutServerResult,
|
|
97
|
+
middlewareServerResult,
|
|
98
|
+
clientResult,
|
|
99
|
+
);
|
|
100
|
+
if (errorBundles.error) manifest.errorBundle = errorBundles.error;
|
|
101
|
+
if (errorBundles.notFound) manifest.notFoundBundle = errorBundles.notFound;
|
|
102
|
+
if (cssBundle) manifest.cssBundle = cssBundle;
|
|
103
|
+
if (fontPreloadHtml) manifest.fontPreloadHtml = fontPreloadHtml;
|
|
104
|
+
await fs.writeFile(
|
|
105
|
+
path.join(buildDir, 'pages-manifest.json'),
|
|
106
|
+
JSON.stringify(manifest, null, 2),
|
|
107
|
+
);
|
|
108
|
+
await fs.rm(outDir, { recursive: true, force: true });
|
|
109
|
+
await fs.rename(buildDir, outDir);
|
|
110
|
+
const clientMetafile =
|
|
111
|
+
devMode && clientResult.metafile
|
|
112
|
+
? rewriteMetafilePaths(clientResult.metafile, buildDir, outDir)
|
|
113
|
+
: undefined;
|
|
114
|
+
return {
|
|
115
|
+
pageCount: pages.length,
|
|
116
|
+
outDir,
|
|
117
|
+
manifest,
|
|
118
|
+
...(clientMetafile ? { clientMetafile } : {}),
|
|
119
|
+
};
|
|
120
|
+
} catch (err) {
|
|
121
|
+
// esbuild has no cancellation API, so sibling builds may still be writing
|
|
122
|
+
// into `buildDir` when we land here. Wait for every pending build to settle
|
|
123
|
+
// before removing the directory — otherwise `fs.rm` races the writes and
|
|
124
|
+
// fails with ENOTEMPTY. The cleanup is fire-and-forget so the caller sees
|
|
125
|
+
// the original error immediately; the unique staging-dir suffix means any
|
|
126
|
+
// lingering files can't pollute a subsequent `buildPages` call.
|
|
127
|
+
void Promise.allSettled(builds).then(() =>
|
|
128
|
+
fs.rm(buildDir, { recursive: true, force: true }).catch(() => {}),
|
|
129
|
+
);
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// esbuild's metafile keys reference the outdir it was given. After the staging
|
|
134
|
+
// directory is swapped into place, rewrite those keys to reference the final
|
|
135
|
+
// outDir so HMR diffs across successive builds compare like-for-like paths.
|
|
136
|
+
function rewriteMetafilePaths(metafile, fromDir, toDir) {
|
|
137
|
+
const outputs = {};
|
|
138
|
+
for (const [key, value] of Object.entries(metafile.outputs)) {
|
|
139
|
+
const abs = path.resolve(key);
|
|
140
|
+
const newKey =
|
|
141
|
+
abs === fromDir || abs.startsWith(fromDir + path.sep)
|
|
142
|
+
? toDir + abs.slice(fromDir.length)
|
|
143
|
+
: abs;
|
|
144
|
+
outputs[newKey] = value;
|
|
145
|
+
}
|
|
146
|
+
return { ...metafile, outputs };
|
|
100
147
|
}
|
|
101
148
|
import { patternToFileName, layoutDirToFileName } from './build-server.js';
|
|
102
149
|
import { generateHydrationEntry } from './build-client.js';
|
package/server/pages/hmr.js
CHANGED
|
@@ -123,7 +123,12 @@ function diffClientMetafiles(oldMeta, newMeta, outDir) {
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
if (newFiles.length === 0) {
|
|
126
|
-
return {
|
|
126
|
+
return {
|
|
127
|
+
changedNavBundles: [],
|
|
128
|
+
changedHydrationBundles: [],
|
|
129
|
+
changedCssBundle: null,
|
|
130
|
+
needsFullReload: false,
|
|
131
|
+
};
|
|
127
132
|
}
|
|
128
133
|
const getEntryNames = (meta) => {
|
|
129
134
|
const names = new Set();
|
|
@@ -149,28 +154,29 @@ function diffClientMetafiles(oldMeta, newMeta, outDir) {
|
|
|
149
154
|
}
|
|
150
155
|
}
|
|
151
156
|
const changedNavBundles = [];
|
|
157
|
+
const changedHydrationBundles = [];
|
|
152
158
|
let changedCssBundle = null;
|
|
153
159
|
for (const f of newFiles) {
|
|
154
160
|
const rel = path.relative(outDir, f).replace(/\\/g, '/');
|
|
155
|
-
if (/^client\/nav_
|
|
161
|
+
if (/^client\/nav_.*\.js$/.test(rel)) {
|
|
156
162
|
changedNavBundles.push(rel);
|
|
157
163
|
} else if (rel.endsWith('.css')) {
|
|
158
164
|
changedCssBundle = rel;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
const hasChangedHydrationBundles = newFiles.some((f) => {
|
|
162
|
-
const rel = path.relative(outDir, f).replace(/\\/g, '/');
|
|
163
|
-
return (
|
|
165
|
+
} else if (
|
|
164
166
|
rel.startsWith('client/') &&
|
|
165
|
-
!rel.startsWith('client/nav_') &&
|
|
166
167
|
!rel.startsWith('client/chunks/') &&
|
|
167
|
-
!rel.endsWith('.css') &&
|
|
168
168
|
!rel.startsWith('client/hmr/')
|
|
169
|
-
)
|
|
170
|
-
|
|
171
|
-
|
|
169
|
+
) {
|
|
170
|
+
changedHydrationBundles.push(rel);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (
|
|
174
|
+
changedNavBundles.length === 0 &&
|
|
175
|
+
changedHydrationBundles.length === 0 &&
|
|
176
|
+
!changedCssBundle
|
|
177
|
+
) {
|
|
172
178
|
needsFullReload = true;
|
|
173
179
|
}
|
|
174
|
-
return { changedNavBundles, changedCssBundle, needsFullReload };
|
|
180
|
+
return { changedNavBundles, changedHydrationBundles, changedCssBundle, needsFullReload };
|
|
175
181
|
}
|
|
176
182
|
export { buildHmrRuntimeBundle, buildHmrScript, diffClientMetafiles, reactRefreshPlugin };
|
package/server/pages/ssr.js
CHANGED
|
@@ -63,7 +63,7 @@ function wrapWithProviders(createElement, element, pathname, params) {
|
|
|
63
63
|
}
|
|
64
64
|
const providerCtx = globalThis[PROVIDER_CTX_KEY];
|
|
65
65
|
if (providerCtx) {
|
|
66
|
-
const apiValue = { pathPrefix: '' };
|
|
66
|
+
const apiValue = { pathPrefix: '/api' };
|
|
67
67
|
element = createElement(providerCtx.Provider, { value: apiValue }, element);
|
|
68
68
|
}
|
|
69
69
|
return element;
|
|
@@ -94,7 +94,7 @@ function buildHtmlShell({ headHtml, fontPreloadTags, cssLinkTag, bodysuffix }) {
|
|
|
94
94
|
<html>
|
|
95
95
|
<head>
|
|
96
96
|
<meta charset="utf-8" />
|
|
97
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
97
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
98
98
|
${fontPreloadTags}
|
|
99
99
|
${cssLinkTag}
|
|
100
100
|
${headHtml}
|
|
@@ -266,6 +266,7 @@ async function renderErrorPage(
|
|
|
266
266
|
export {
|
|
267
267
|
buildCssLinkTag,
|
|
268
268
|
buildFontPreloadTags,
|
|
269
|
+
buildHtmlShell,
|
|
269
270
|
buildLiveReloadScript,
|
|
270
271
|
buildScriptTags,
|
|
271
272
|
loadComponent,
|
package/server/pages/watcher.js
CHANGED
|
@@ -24,19 +24,20 @@ function createPagesWatcher(options) {
|
|
|
24
24
|
const elapsed = Date.now() - start;
|
|
25
25
|
if (handler.emitEvent && prevClientMetafile && result.clientMetafile && !isStructuralChange) {
|
|
26
26
|
const diff = diffClientMetafiles(prevClientMetafile, result.clientMetafile, outDir);
|
|
27
|
+
const hmrModules = [...diff.changedNavBundles, ...diff.changedHydrationBundles];
|
|
27
28
|
if (diff.needsFullReload) {
|
|
28
29
|
handler.emitEvent({ type: 'reload' });
|
|
29
30
|
log.info(`Pages rebuilt in ${elapsed}ms (full reload)`);
|
|
30
|
-
} else if (
|
|
31
|
+
} else if (hmrModules.length > 0) {
|
|
31
32
|
const manifestJson = handler.getClientManifestJson?.();
|
|
32
33
|
handler.emitEvent({
|
|
33
34
|
type: 'hmr-update',
|
|
34
|
-
modules:
|
|
35
|
+
modules: hmrModules,
|
|
35
36
|
timestamp: Date.now(),
|
|
36
37
|
...(manifestJson ? { manifest: manifestJson } : {}),
|
|
37
38
|
});
|
|
38
39
|
log.info(
|
|
39
|
-
`Pages rebuilt in ${elapsed}ms (HMR: ${
|
|
40
|
+
`Pages rebuilt in ${elapsed}ms (HMR: ${hmrModules.length} module(s))`,
|
|
40
41
|
);
|
|
41
42
|
} else if (diff.changedCssBundle) {
|
|
42
43
|
handler.emitEvent({
|
package/client/router.jsx
DELETED
|
@@ -1,303 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import {
|
|
3
|
-
createContext,
|
|
4
|
-
useContext,
|
|
5
|
-
useState,
|
|
6
|
-
useEffect,
|
|
7
|
-
useCallback,
|
|
8
|
-
useMemo,
|
|
9
|
-
useRef,
|
|
10
|
-
useTransition,
|
|
11
|
-
createElement,
|
|
12
|
-
} from 'react';
|
|
13
|
-
import {
|
|
14
|
-
readClientManifest,
|
|
15
|
-
loadPage,
|
|
16
|
-
matchClientRoute,
|
|
17
|
-
loadLoadingComponents,
|
|
18
|
-
prefetchRoute,
|
|
19
|
-
} from './page-loader.js';
|
|
20
|
-
import { parseQuery } from './query.js';
|
|
21
|
-
|
|
22
|
-
const ROUTER_CTX_KEY = '__router_context__';
|
|
23
|
-
const RouterContext = (globalThis[ROUTER_CTX_KEY] ??= createContext(null));
|
|
24
|
-
|
|
25
|
-
function wrapInLayouts(element, layouts) {
|
|
26
|
-
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
27
|
-
element = createElement(layouts[i], null, element);
|
|
28
|
-
}
|
|
29
|
-
return element;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function useRouter() {
|
|
33
|
-
const ctx = useContext(RouterContext);
|
|
34
|
-
if (!ctx) {
|
|
35
|
-
throw new Error('useRouter must be used within a <Router> provider');
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return useMemo(
|
|
39
|
-
() => ({
|
|
40
|
-
pathname: ctx.pathname,
|
|
41
|
-
params: ctx.params,
|
|
42
|
-
query: typeof window !== 'undefined' ? parseQuery(window.location.search) : {},
|
|
43
|
-
push: (to, options) => ctx.navigate(to, { ...options, replace: false }),
|
|
44
|
-
replace: (to, options) => ctx.navigate(to, { ...options, replace: true }),
|
|
45
|
-
back: () => {
|
|
46
|
-
if (typeof window !== 'undefined') window.history.back();
|
|
47
|
-
},
|
|
48
|
-
forward: () => {
|
|
49
|
-
if (typeof window !== 'undefined') window.history.forward();
|
|
50
|
-
},
|
|
51
|
-
refresh: () => {
|
|
52
|
-
if (typeof window !== 'undefined') window.location.reload();
|
|
53
|
-
},
|
|
54
|
-
}),
|
|
55
|
-
[ctx.pathname, ctx.params, ctx.navigate],
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function usePathname() {
|
|
60
|
-
return useRouter().pathname;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function useParams() {
|
|
64
|
-
return useRouter().params;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function useSearchParams() {
|
|
68
|
-
return useRouter().query;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function Router({
|
|
72
|
-
initialPath,
|
|
73
|
-
initialParams,
|
|
74
|
-
initialComponent,
|
|
75
|
-
initialLayouts,
|
|
76
|
-
initialLoadings,
|
|
77
|
-
children,
|
|
78
|
-
}) {
|
|
79
|
-
const [pathname, setPathname] = useState(
|
|
80
|
-
() => initialPath ?? (typeof window !== 'undefined' ? window.location.pathname : '/'),
|
|
81
|
-
);
|
|
82
|
-
const [pageState, setPageState] = useState({
|
|
83
|
-
component: initialComponent ?? null,
|
|
84
|
-
layouts: initialLayouts ?? [],
|
|
85
|
-
loadings: initialLoadings ?? [],
|
|
86
|
-
params: initialParams ?? {},
|
|
87
|
-
});
|
|
88
|
-
const [isNavigating, setIsNavigating] = useState(false);
|
|
89
|
-
const [isPending, startTransition] = useTransition();
|
|
90
|
-
const manifestRef = useRef(null);
|
|
91
|
-
|
|
92
|
-
useEffect(() => {
|
|
93
|
-
manifestRef.current = readClientManifest();
|
|
94
|
-
const onManifestUpdate = () => {
|
|
95
|
-
manifestRef.current = readClientManifest();
|
|
96
|
-
};
|
|
97
|
-
window.addEventListener('manifest-update', onManifestUpdate);
|
|
98
|
-
return () => window.removeEventListener('manifest-update', onManifestUpdate);
|
|
99
|
-
}, []);
|
|
100
|
-
|
|
101
|
-
const applyLoaded = useCallback((loaded, newPath) => {
|
|
102
|
-
startTransition(() => {
|
|
103
|
-
setPathname(newPath);
|
|
104
|
-
setPageState({
|
|
105
|
-
component: loaded.component,
|
|
106
|
-
layouts: loaded.layouts,
|
|
107
|
-
loadings: loaded.loadings,
|
|
108
|
-
params: loaded.params,
|
|
109
|
-
});
|
|
110
|
-
setIsNavigating(false);
|
|
111
|
-
});
|
|
112
|
-
}, []);
|
|
113
|
-
|
|
114
|
-
const navigateToPage = useCallback(
|
|
115
|
-
async (to, options) => {
|
|
116
|
-
const scroll = options?.scroll !== false;
|
|
117
|
-
const replace = options?.replace === true;
|
|
118
|
-
if (to === pathname) return;
|
|
119
|
-
|
|
120
|
-
const manifest = manifestRef.current;
|
|
121
|
-
if (!manifest) {
|
|
122
|
-
window.location.href = to;
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const matched = matchClientRoute(manifest, to);
|
|
127
|
-
if (!matched) {
|
|
128
|
-
window.location.href = to;
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
setIsNavigating(true);
|
|
133
|
-
if (replace) {
|
|
134
|
-
window.history.replaceState(null, '', to);
|
|
135
|
-
} else {
|
|
136
|
-
window.history.pushState(null, '', to);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const targetLoadings = await loadLoadingComponents(matched.route);
|
|
140
|
-
if (targetLoadings.length > 0) {
|
|
141
|
-
setPathname(to);
|
|
142
|
-
setPageState((prev) => ({
|
|
143
|
-
...prev,
|
|
144
|
-
loadings: targetLoadings,
|
|
145
|
-
params: matched.params,
|
|
146
|
-
}));
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
try {
|
|
150
|
-
const loaded = await loadPage(manifest, to);
|
|
151
|
-
if (!loaded) {
|
|
152
|
-
window.location.href = to;
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
applyLoaded(loaded, to);
|
|
157
|
-
|
|
158
|
-
if (scroll) {
|
|
159
|
-
window.scrollTo(0, 0);
|
|
160
|
-
}
|
|
161
|
-
} catch (err) {
|
|
162
|
-
console.error('Client navigation failed, falling back to full page load:', err);
|
|
163
|
-
window.location.href = to;
|
|
164
|
-
}
|
|
165
|
-
},
|
|
166
|
-
[pathname, applyLoaded],
|
|
167
|
-
);
|
|
168
|
-
|
|
169
|
-
useEffect(() => {
|
|
170
|
-
async function onPopState() {
|
|
171
|
-
const newPath = window.location.pathname;
|
|
172
|
-
const manifest = manifestRef.current;
|
|
173
|
-
|
|
174
|
-
if (!manifest) {
|
|
175
|
-
setPathname(newPath);
|
|
176
|
-
setPageState((prev) => ({ ...prev, params: {} }));
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
try {
|
|
181
|
-
const loaded = await loadPage(manifest, newPath);
|
|
182
|
-
if (loaded) {
|
|
183
|
-
applyLoaded(loaded, newPath);
|
|
184
|
-
} else {
|
|
185
|
-
window.location.reload();
|
|
186
|
-
}
|
|
187
|
-
} catch {
|
|
188
|
-
window.location.reload();
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
window.addEventListener('popstate', onPopState);
|
|
193
|
-
return () => window.removeEventListener('popstate', onPopState);
|
|
194
|
-
}, [applyLoaded]);
|
|
195
|
-
|
|
196
|
-
const { component: PageComponent, layouts, loadings, params } = pageState;
|
|
197
|
-
|
|
198
|
-
let content;
|
|
199
|
-
if (PageComponent) {
|
|
200
|
-
const inner = isNavigating && loadings.length > 0
|
|
201
|
-
? createElement(loadings.at(-1))
|
|
202
|
-
: createElement(PageComponent, params);
|
|
203
|
-
content = wrapInLayouts(inner, layouts);
|
|
204
|
-
} else {
|
|
205
|
-
content = children;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
return (
|
|
209
|
-
<RouterContext.Provider
|
|
210
|
-
value={{
|
|
211
|
-
pathname,
|
|
212
|
-
params,
|
|
213
|
-
navigate: navigateToPage,
|
|
214
|
-
}}
|
|
215
|
-
>
|
|
216
|
-
{content}
|
|
217
|
-
{isNavigating && loadings.length === 0 && !isPending && (
|
|
218
|
-
<div
|
|
219
|
-
style={{
|
|
220
|
-
position: 'fixed',
|
|
221
|
-
top: 0,
|
|
222
|
-
left: 0,
|
|
223
|
-
width: '100%',
|
|
224
|
-
height: '2px',
|
|
225
|
-
backgroundColor: '#0070f3',
|
|
226
|
-
zIndex: 99999,
|
|
227
|
-
animation: 'nav-progress 1s ease-in-out infinite',
|
|
228
|
-
}}
|
|
229
|
-
/>
|
|
230
|
-
)}
|
|
231
|
-
</RouterContext.Provider>
|
|
232
|
-
);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function Link({ href, children, onClick, scroll, replace, prefetch = 'hover', ...rest }) {
|
|
236
|
-
const router = useContext(RouterContext);
|
|
237
|
-
const linkRef = useRef(null);
|
|
238
|
-
|
|
239
|
-
const handleMouseEnter = useCallback(() => {
|
|
240
|
-
if (prefetch !== 'hover') return;
|
|
241
|
-
const manifest = readClientManifest();
|
|
242
|
-
if (manifest) {
|
|
243
|
-
prefetchRoute(manifest, href);
|
|
244
|
-
}
|
|
245
|
-
}, [href, prefetch]);
|
|
246
|
-
|
|
247
|
-
useEffect(() => {
|
|
248
|
-
if (prefetch !== 'viewport') return;
|
|
249
|
-
const el = linkRef.current;
|
|
250
|
-
if (!el || typeof IntersectionObserver === 'undefined') return;
|
|
251
|
-
|
|
252
|
-
const observer = new IntersectionObserver(
|
|
253
|
-
(entries) => {
|
|
254
|
-
for (const entry of entries) {
|
|
255
|
-
if (entry.isIntersecting) {
|
|
256
|
-
const manifest = readClientManifest();
|
|
257
|
-
if (manifest) {
|
|
258
|
-
prefetchRoute(manifest, href);
|
|
259
|
-
}
|
|
260
|
-
observer.unobserve(el);
|
|
261
|
-
break;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
},
|
|
265
|
-
{ rootMargin: '200px' },
|
|
266
|
-
);
|
|
267
|
-
|
|
268
|
-
observer.observe(el);
|
|
269
|
-
return () => observer.disconnect();
|
|
270
|
-
}, [href, prefetch]);
|
|
271
|
-
|
|
272
|
-
function handleClick(e) {
|
|
273
|
-
if (onClick) onClick(e);
|
|
274
|
-
if (e.defaultPrevented) return;
|
|
275
|
-
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
276
|
-
if (e.button !== 0) return;
|
|
277
|
-
if (rest.target === '_blank' || rest.download !== undefined) return;
|
|
278
|
-
|
|
279
|
-
try {
|
|
280
|
-
const url = new URL(href, window.location.origin);
|
|
281
|
-
if (url.origin !== window.location.origin) return;
|
|
282
|
-
} catch {
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
e.preventDefault();
|
|
287
|
-
if (router) {
|
|
288
|
-
router.navigate(href, { scroll, replace });
|
|
289
|
-
} else {
|
|
290
|
-
window.history.pushState(null, '', href);
|
|
291
|
-
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
return (
|
|
296
|
-
<a ref={linkRef} href={href} onClick={handleClick} onMouseEnter={handleMouseEnter} {...rest}>
|
|
297
|
-
{children}
|
|
298
|
-
</a>
|
|
299
|
-
);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const SoloRouter = Router;
|
|
303
|
-
export { Link, Router, SoloRouter, useParams, usePathname, useRouter, useSearchParams };
|