@pracht/core 0.4.0 → 0.6.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/README.md +2 -2
- package/dist/index.d.mts +22 -9
- package/dist/index.mjs +349 -163
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,8 +28,8 @@ Route modules may export the page as a function default export or as a named
|
|
|
28
28
|
`handlePrachtRequest()` sanitizes unexpected 5xx errors by default so raw server
|
|
29
29
|
messages do not leak into SSR HTML or route-state JSON. Explicit
|
|
30
30
|
`PrachtHttpError` 4xx messages are preserved. Pass `debugErrors: true` to expose
|
|
31
|
-
raw details intentionally during debugging;
|
|
32
|
-
|
|
31
|
+
raw details intentionally during debugging; the flag is ignored when
|
|
32
|
+
`NODE_ENV=production`. Debug responses also attach `error.diagnostics`
|
|
33
33
|
metadata for the failure phase and matched framework files when available.
|
|
34
34
|
|
|
35
35
|
### Client
|
package/dist/index.d.mts
CHANGED
|
@@ -78,10 +78,11 @@ interface ApiConfig {
|
|
|
78
78
|
middleware?: string[];
|
|
79
79
|
/**
|
|
80
80
|
* When `true` (the default), state-changing API requests
|
|
81
|
-
* (POST/PUT/PATCH/DELETE) are rejected unless the browser signals
|
|
82
|
-
* same-origin
|
|
83
|
-
* matches the request URL's origin.
|
|
84
|
-
*
|
|
81
|
+
* (POST/PUT/PATCH/DELETE) are rejected unless the browser signals an
|
|
82
|
+
* exact same-origin fetch (`Sec-Fetch-Site: same-origin`) or the request
|
|
83
|
+
* Origin/Referer matches the request URL's origin. `same-site` is not
|
|
84
|
+
* accepted by default because sibling subdomains can be attacker-controlled.
|
|
85
|
+
* Set to `false` to opt out if you build your own CSRF protection into middleware.
|
|
85
86
|
*/
|
|
86
87
|
requireSameOrigin?: boolean;
|
|
87
88
|
}
|
|
@@ -123,7 +124,7 @@ interface ParamRouteSegment {
|
|
|
123
124
|
}
|
|
124
125
|
interface CatchAllRouteSegment {
|
|
125
126
|
type: "catchall";
|
|
126
|
-
name:
|
|
127
|
+
name: string;
|
|
127
128
|
}
|
|
128
129
|
type RouteSegment = StaticRouteSegment | ParamRouteSegment | CatchAllRouteSegment;
|
|
129
130
|
interface ResolvedRoute extends Omit<RouteMeta, "middleware"> {
|
|
@@ -155,11 +156,16 @@ interface BaseRouteArgs<TContext = RegisteredContext> {
|
|
|
155
156
|
}
|
|
156
157
|
interface LoaderArgs<TContext = RegisteredContext> extends BaseRouteArgs<TContext> {}
|
|
157
158
|
interface MiddlewareArgs<TContext = RegisteredContext> extends BaseRouteArgs<TContext> {}
|
|
159
|
+
type HeadAttributes = Record<string, string | undefined>;
|
|
160
|
+
interface HeadScriptDescriptor extends HeadAttributes {
|
|
161
|
+
children?: string;
|
|
162
|
+
}
|
|
158
163
|
interface HeadMetadata {
|
|
159
164
|
title?: string;
|
|
160
165
|
lang?: string;
|
|
161
|
-
meta?:
|
|
162
|
-
link?:
|
|
166
|
+
meta?: HeadAttributes[];
|
|
167
|
+
link?: HeadAttributes[];
|
|
168
|
+
script?: HeadScriptDescriptor[];
|
|
163
169
|
}
|
|
164
170
|
type MaybePromise<T> = T | Promise<T>;
|
|
165
171
|
type LoaderLike = ((args: LoaderArgs<any>) => unknown) | undefined;
|
|
@@ -175,7 +181,10 @@ interface RouteComponentProps<TLoader extends LoaderLike = undefined> {
|
|
|
175
181
|
params: RouteParams;
|
|
176
182
|
}
|
|
177
183
|
interface ErrorBoundaryProps {
|
|
178
|
-
error: Error
|
|
184
|
+
error: Error & {
|
|
185
|
+
diagnostics?: unknown;
|
|
186
|
+
status?: number;
|
|
187
|
+
};
|
|
179
188
|
}
|
|
180
189
|
interface ShellProps {
|
|
181
190
|
children: ComponentChildren;
|
|
@@ -194,6 +203,7 @@ interface RouteModule<TContext = any, TLoader extends LoaderLike = undefined> {
|
|
|
194
203
|
interface ShellModule<TContext = any> {
|
|
195
204
|
Shell: FunctionComponent<ShellProps>;
|
|
196
205
|
Loading?: FunctionComponent;
|
|
206
|
+
ErrorBoundary?: FunctionComponent<ErrorBoundaryProps>;
|
|
197
207
|
head?: (args: BaseRouteArgs<TContext>) => MaybePromise<HeadMetadata>;
|
|
198
208
|
headers?: (args: BaseRouteArgs<TContext>) => MaybePromise<HeadersInit>;
|
|
199
209
|
}
|
|
@@ -339,6 +349,7 @@ declare function PrachtRuntimeProvider<TData>({
|
|
|
339
349
|
}>;
|
|
340
350
|
declare function startApp<TData = unknown>(options?: StartAppOptions<TData>): TData | undefined;
|
|
341
351
|
declare function readHydrationState<TData = unknown>(): PrachtHydrationState<TData> | undefined;
|
|
352
|
+
declare function useRouteData<TLoader extends LoaderLike>(): LoaderData<TLoader>;
|
|
342
353
|
declare function useRouteData<TData = unknown>(): TData;
|
|
343
354
|
declare function useLocation(): Location;
|
|
344
355
|
declare function useParams(): RouteParams;
|
|
@@ -395,6 +406,8 @@ interface PrerenderAppOptions {
|
|
|
395
406
|
cssManifest?: Record<string, string[]>;
|
|
396
407
|
/** Per-source-file JS map produced by the vite plugin for modulepreload hints. */
|
|
397
408
|
jsManifest?: Record<string, string[]>;
|
|
409
|
+
/** Maximum number of pages rendered concurrently. Defaults to 10. */
|
|
410
|
+
concurrency?: number;
|
|
398
411
|
}
|
|
399
412
|
declare function prerenderApp(options: PrerenderAppOptions): Promise<PrerenderResult[]>;
|
|
400
413
|
declare function prerenderApp(options: PrerenderAppOptions & {
|
|
@@ -423,4 +436,4 @@ interface InitClientRouterOptions {
|
|
|
423
436
|
}
|
|
424
437
|
declare function initClientRouter(options: InitClientRouterOptions): Promise<void>;
|
|
425
438
|
//#endregion
|
|
426
|
-
export { type ApiConfig, type ApiRouteArgs, type ApiRouteHandler, type ApiRouteMatch, type ApiRouteModule, type BaseRouteArgs, type DataModule, type ErrorBoundaryProps, Form, type FormProps, type GroupDefinition, type GroupMeta, type HandlePrachtRequestOptions, type HeadArgs, type HeadMetadata, type HeadersArgs, type HttpMethod, type ISGManifestEntry, type InitClientRouterOptions, type LoaderArgs, type LoaderData, type LoaderFn, type Location, type MiddlewareArgs, type MiddlewareFn, type MiddlewareModule, type MiddlewareResult, type ModuleImporter, type ModuleRef, type ModuleRegistry, type NavigateFn, type PrachtApp, type PrachtAppConfig, PrachtHttpError, type PrachtHydrationState, type PrachtRuntimeDiagnosticPhase, type PrachtRuntimeDiagnostics, PrachtRuntimeProvider, type PrefetchStrategy, type PrerenderAppOptions, type PrerenderAppResult, type PrerenderResult, type Register, type RenderMode, type ResolvedApiRoute, type ResolvedPrachtApp, type ResolvedRoute, type RouteComponentProps, type RouteConfig, type RouteDefinition, type RouteMatch, type RouteMeta, type RouteModule, type RouteParams, type RouteRevalidate, type RouteStateResult, type RouteTreeNode, type SerializedRouteError, type ShellModule, type ShellProps, type StartAppOptions, Suspense, type TimeRevalidatePolicy, applyDefaultSecurityHeaders, buildPathFromSegments, defineApp, forwardRef, group, handlePrachtRequest, initClientRouter, lazy, matchApiRoute, matchAppRoute, prerenderApp, readHydrationState, resolveApiRoutes, resolveApp, route, startApp, timeRevalidate, useIsHydrated, useLocation, useNavigate, useParams, useRevalidate, useRouteData };
|
|
439
|
+
export { type ApiConfig, type ApiRouteArgs, type ApiRouteHandler, type ApiRouteMatch, type ApiRouteModule, type BaseRouteArgs, type DataModule, type ErrorBoundaryProps, Form, type FormProps, type GroupDefinition, type GroupMeta, type HandlePrachtRequestOptions, type HeadArgs, type HeadAttributes, type HeadMetadata, type HeadScriptDescriptor, type HeadersArgs, type HttpMethod, type ISGManifestEntry, type InitClientRouterOptions, type LoaderArgs, type LoaderData, type LoaderFn, type Location, type MiddlewareArgs, type MiddlewareFn, type MiddlewareModule, type MiddlewareResult, type ModuleImporter, type ModuleRef, type ModuleRegistry, type NavigateFn, type PrachtApp, type PrachtAppConfig, PrachtHttpError, type PrachtHydrationState, type PrachtRuntimeDiagnosticPhase, type PrachtRuntimeDiagnostics, PrachtRuntimeProvider, type PrefetchStrategy, type PrerenderAppOptions, type PrerenderAppResult, type PrerenderResult, type Register, type RenderMode, type ResolvedApiRoute, type ResolvedPrachtApp, type ResolvedRoute, type RouteComponentProps, type RouteConfig, type RouteDefinition, type RouteMatch, type RouteMeta, type RouteModule, type RouteParams, type RouteRevalidate, type RouteStateResult, type RouteTreeNode, type SerializedRouteError, type ShellModule, type ShellProps, type StartAppOptions, Suspense, type TimeRevalidatePolicy, applyDefaultSecurityHeaders, buildPathFromSegments, defineApp, forwardRef, group, handlePrachtRequest, initClientRouter, lazy, matchApiRoute, matchAppRoute, prerenderApp, readHydrationState, resolveApiRoutes, resolveApp, route, startApp, timeRevalidate, useIsHydrated, useLocation, useNavigate, useParams, useRevalidate, useRouteData };
|
package/dist/index.mjs
CHANGED
|
@@ -119,7 +119,11 @@ function matchRouteSegments(routeSegments, targetSegments) {
|
|
|
119
119
|
while (routeIndex < routeSegments.length) {
|
|
120
120
|
const currentSegment = routeSegments[routeIndex];
|
|
121
121
|
if (currentSegment.type === "catchall") {
|
|
122
|
-
|
|
122
|
+
try {
|
|
123
|
+
params[currentSegment.name] = targetSegments.slice(targetIndex).map(decodeURIComponent).join("/");
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
123
127
|
return params;
|
|
124
128
|
}
|
|
125
129
|
const targetSegment = targetSegments[targetIndex];
|
|
@@ -142,10 +146,15 @@ function parseRouteSegments(path) {
|
|
|
142
146
|
type: "catchall",
|
|
143
147
|
name: "*"
|
|
144
148
|
};
|
|
149
|
+
if (segment.startsWith(":") && segment.endsWith("*")) return {
|
|
150
|
+
type: "catchall",
|
|
151
|
+
name: segment.slice(1, -1) || "*"
|
|
152
|
+
};
|
|
145
153
|
if (segment.startsWith(":")) return {
|
|
146
154
|
type: "param",
|
|
147
155
|
name: segment.slice(1)
|
|
148
156
|
};
|
|
157
|
+
assertSafeStaticRouteSegment(segment);
|
|
149
158
|
return {
|
|
150
159
|
type: "static",
|
|
151
160
|
value: segment
|
|
@@ -155,6 +164,10 @@ function parseRouteSegments(path) {
|
|
|
155
164
|
function splitPathSegments(path) {
|
|
156
165
|
return normalizeRoutePath(path).split("/").filter(Boolean);
|
|
157
166
|
}
|
|
167
|
+
function assertSafeStaticRouteSegment(segment) {
|
|
168
|
+
if (segment === "." || segment === "..") throw new Error(`Unsafe static route segment "${segment}" is not allowed.`);
|
|
169
|
+
if (segment.includes("\0") || /[\r\n\\]/.test(segment)) throw new Error(`Unsafe static route segment "${segment}" contains a forbidden character.`);
|
|
170
|
+
}
|
|
158
171
|
function mergeRoutePaths(prefix, path) {
|
|
159
172
|
if (!path) return normalizeRoutePath(prefix);
|
|
160
173
|
const normalizedPrefix = normalizeRoutePath(prefix);
|
|
@@ -171,19 +184,18 @@ function normalizeRoutePath(path) {
|
|
|
171
184
|
function buildPathFromSegments(segments, params) {
|
|
172
185
|
return normalizeRoutePath("/" + segments.map((segment) => {
|
|
173
186
|
if (segment.type === "static") return segment.value;
|
|
174
|
-
if (segment.type === "param") return
|
|
175
|
-
return (params["*"] ?? "").split("/").map((part) =>
|
|
187
|
+
if (segment.type === "param") return encodeDynamicPathSegment(params[segment.name] ?? "");
|
|
188
|
+
return (params[segment.name] ?? params["*"] ?? "").split("/").map((part) => encodeDynamicPathSegment(part)).join("/");
|
|
176
189
|
}).join("/"));
|
|
177
190
|
}
|
|
178
191
|
/**
|
|
179
|
-
* Encode
|
|
180
|
-
* leaves unreserved characters (including `.`) intact,
|
|
181
|
-
*
|
|
182
|
-
*
|
|
192
|
+
* Encode one dynamic URL path segment for SSG/ISG output. `encodeURIComponent`
|
|
193
|
+
* leaves unreserved characters (including `.`) intact, and even percent-encoded
|
|
194
|
+
* dot segments are normalized by URL parsers. Reject exact `.` / `..` segments
|
|
195
|
+
* instead of allowing them to reach filesystem output path construction.
|
|
183
196
|
*/
|
|
184
|
-
function
|
|
185
|
-
if (part === ".")
|
|
186
|
-
if (part === "..") return "%2E%2E";
|
|
197
|
+
function encodeDynamicPathSegment(part) {
|
|
198
|
+
if (part === "." || part === "..") throw new Error(`Unsafe dynamic route param segment "${part}" is not allowed.`);
|
|
187
199
|
return encodeURIComponent(part);
|
|
188
200
|
}
|
|
189
201
|
/**
|
|
@@ -530,7 +542,8 @@ async function fetchPrachtRouteState(url, options) {
|
|
|
530
542
|
[ROUTE_STATE_REQUEST_HEADER]: "1",
|
|
531
543
|
"Cache-Control": "no-cache"
|
|
532
544
|
},
|
|
533
|
-
redirect: "manual"
|
|
545
|
+
redirect: "manual",
|
|
546
|
+
signal: options?.signal
|
|
534
547
|
});
|
|
535
548
|
if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) return {
|
|
536
549
|
location: response.headers.get("location") ?? url,
|
|
@@ -572,6 +585,153 @@ async function navigateToClientLocation(location, options) {
|
|
|
572
585
|
window.location.href = targetUrl.toString();
|
|
573
586
|
}
|
|
574
587
|
//#endregion
|
|
588
|
+
//#region src/prefetch.ts
|
|
589
|
+
const CACHE_TTL_MS = 3e4;
|
|
590
|
+
const MAX_PREFETCH_CACHE_ENTRIES = 100;
|
|
591
|
+
const MAX_MATCH_CACHE_ENTRIES = 250;
|
|
592
|
+
const prefetchCache = /* @__PURE__ */ new Map();
|
|
593
|
+
function clearPrefetchCache() {
|
|
594
|
+
prefetchCache.clear();
|
|
595
|
+
}
|
|
596
|
+
function getCachedRouteState(url) {
|
|
597
|
+
const entry = prefetchCache.get(url);
|
|
598
|
+
if (!entry) return null;
|
|
599
|
+
if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
|
|
600
|
+
prefetchCache.delete(url);
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
prefetchCache.delete(url);
|
|
604
|
+
prefetchCache.set(url, entry);
|
|
605
|
+
return entry.promise;
|
|
606
|
+
}
|
|
607
|
+
function prefetchRouteState(url) {
|
|
608
|
+
const cached = getCachedRouteState(url);
|
|
609
|
+
if (cached) return cached;
|
|
610
|
+
sweepPrefetchCache();
|
|
611
|
+
const promise = fetchPrachtRouteState(url);
|
|
612
|
+
prefetchCache.set(url, {
|
|
613
|
+
promise,
|
|
614
|
+
timestamp: Date.now()
|
|
615
|
+
});
|
|
616
|
+
trimMapToSize(prefetchCache, MAX_PREFETCH_CACHE_ENTRIES);
|
|
617
|
+
return promise;
|
|
618
|
+
}
|
|
619
|
+
function setupPrefetching(app, warmModules) {
|
|
620
|
+
let hoverTimer = null;
|
|
621
|
+
const observedViewportAnchors = /* @__PURE__ */ new WeakSet();
|
|
622
|
+
const matchCache = /* @__PURE__ */ new Map();
|
|
623
|
+
function getRoutePathname(url) {
|
|
624
|
+
try {
|
|
625
|
+
return new URL(url, window.location.origin).pathname;
|
|
626
|
+
} catch {
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
function getInternalHref(anchor) {
|
|
631
|
+
const href = anchor.getAttribute("href");
|
|
632
|
+
if (!href || href.startsWith("#")) return null;
|
|
633
|
+
let url;
|
|
634
|
+
try {
|
|
635
|
+
url = new URL(href, window.location.origin);
|
|
636
|
+
} catch {
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
if (url.origin !== window.location.origin) return null;
|
|
640
|
+
return url.pathname + url.search;
|
|
641
|
+
}
|
|
642
|
+
function getMatchEntry(href) {
|
|
643
|
+
const cached = matchCache.get(href);
|
|
644
|
+
if (cached) {
|
|
645
|
+
matchCache.delete(href);
|
|
646
|
+
matchCache.set(href, cached);
|
|
647
|
+
return cached;
|
|
648
|
+
}
|
|
649
|
+
const routePathname = getRoutePathname(href);
|
|
650
|
+
const match = routePathname ? matchAppRoute(app, routePathname) ?? null : null;
|
|
651
|
+
const entry = {
|
|
652
|
+
match,
|
|
653
|
+
strategy: match ? match.route.prefetch ?? "intent" : "none"
|
|
654
|
+
};
|
|
655
|
+
matchCache.set(href, entry);
|
|
656
|
+
trimMapToSize(matchCache, MAX_MATCH_CACHE_ENTRIES);
|
|
657
|
+
return entry;
|
|
658
|
+
}
|
|
659
|
+
function prefetchHref(href) {
|
|
660
|
+
prefetchRouteState(href);
|
|
661
|
+
if (!warmModules) return;
|
|
662
|
+
const match = getMatchEntry(href).match;
|
|
663
|
+
if (match) warmModules(match);
|
|
664
|
+
}
|
|
665
|
+
document.addEventListener("mouseenter", (e) => {
|
|
666
|
+
const anchor = e.target.closest?.("a");
|
|
667
|
+
if (!anchor) return;
|
|
668
|
+
const href = getInternalHref(anchor);
|
|
669
|
+
if (!href) return;
|
|
670
|
+
const strategy = getMatchEntry(href).strategy;
|
|
671
|
+
if (strategy !== "hover" && strategy !== "intent") return;
|
|
672
|
+
if (hoverTimer) clearTimeout(hoverTimer);
|
|
673
|
+
hoverTimer = setTimeout(() => {
|
|
674
|
+
prefetchHref(href);
|
|
675
|
+
}, 50);
|
|
676
|
+
}, true);
|
|
677
|
+
document.addEventListener("mouseleave", (e) => {
|
|
678
|
+
if (!e.target.closest?.("a")) return;
|
|
679
|
+
if (hoverTimer) {
|
|
680
|
+
clearTimeout(hoverTimer);
|
|
681
|
+
hoverTimer = null;
|
|
682
|
+
}
|
|
683
|
+
}, true);
|
|
684
|
+
document.addEventListener("focusin", (e) => {
|
|
685
|
+
const anchor = e.target.closest?.("a");
|
|
686
|
+
if (!anchor) return;
|
|
687
|
+
const href = getInternalHref(anchor);
|
|
688
|
+
if (!href) return;
|
|
689
|
+
const strategy = getMatchEntry(href).strategy;
|
|
690
|
+
if (strategy !== "hover" && strategy !== "intent") return;
|
|
691
|
+
prefetchHref(href);
|
|
692
|
+
}, true);
|
|
693
|
+
if (typeof IntersectionObserver === "undefined") return;
|
|
694
|
+
const observer = new IntersectionObserver((entries) => {
|
|
695
|
+
for (const entry of entries) {
|
|
696
|
+
if (!entry.isIntersecting) continue;
|
|
697
|
+
const anchor = entry.target;
|
|
698
|
+
const href = getInternalHref(anchor);
|
|
699
|
+
if (!href) continue;
|
|
700
|
+
prefetchHref(href);
|
|
701
|
+
observer.unobserve(anchor);
|
|
702
|
+
}
|
|
703
|
+
}, { rootMargin: "200px" });
|
|
704
|
+
function observeAnchor(anchor) {
|
|
705
|
+
if (observedViewportAnchors.has(anchor)) return;
|
|
706
|
+
const href = getInternalHref(anchor);
|
|
707
|
+
if (!href) return;
|
|
708
|
+
if (getMatchEntry(href).strategy !== "viewport") return;
|
|
709
|
+
observedViewportAnchors.add(anchor);
|
|
710
|
+
observer.observe(anchor);
|
|
711
|
+
}
|
|
712
|
+
function observeViewportLinks(root) {
|
|
713
|
+
if (root instanceof HTMLAnchorElement) observeAnchor(root);
|
|
714
|
+
for (const anchor of root.querySelectorAll("a[href]")) observeAnchor(anchor);
|
|
715
|
+
}
|
|
716
|
+
observeViewportLinks(document.body);
|
|
717
|
+
new MutationObserver((records) => {
|
|
718
|
+
for (const record of records) for (const node of record.addedNodes) if (node instanceof HTMLElement || node instanceof DocumentFragment) observeViewportLinks(node);
|
|
719
|
+
}).observe(document.body, {
|
|
720
|
+
childList: true,
|
|
721
|
+
subtree: true
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
function sweepPrefetchCache(now = Date.now()) {
|
|
725
|
+
for (const [url, entry] of prefetchCache) if (now - entry.timestamp > CACHE_TTL_MS) prefetchCache.delete(url);
|
|
726
|
+
}
|
|
727
|
+
function trimMapToSize(map, maxEntries) {
|
|
728
|
+
while (map.size > maxEntries) {
|
|
729
|
+
const first = map.keys().next();
|
|
730
|
+
if (first.done) return;
|
|
731
|
+
map.delete(first.value);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
//#endregion
|
|
575
735
|
//#region src/runtime-hooks.ts
|
|
576
736
|
const RouteDataContext = createContext(void 0);
|
|
577
737
|
function PrachtRuntimeProvider({ children, data, params = EMPTY_ROUTE_PARAMS, routeId, stateVersion = 0, url }) {
|
|
@@ -664,6 +824,7 @@ function Form(props) {
|
|
|
664
824
|
const formMethod = (method ?? form.method ?? "post").toUpperCase();
|
|
665
825
|
if (SAFE_METHODS.has(formMethod)) return;
|
|
666
826
|
event.preventDefault();
|
|
827
|
+
clearPrefetchCache();
|
|
667
828
|
const response = await fetch(props.action ?? form.action, {
|
|
668
829
|
method: formMethod,
|
|
669
830
|
body: new FormData(form),
|
|
@@ -693,13 +854,75 @@ function escapeHtml(str) {
|
|
|
693
854
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
694
855
|
}
|
|
695
856
|
function serializeJsonForHtml(value) {
|
|
696
|
-
return JSON.stringify(value)
|
|
857
|
+
return escapeScriptText(JSON.stringify(value) ?? "null");
|
|
858
|
+
}
|
|
859
|
+
function escapeScriptText(value) {
|
|
860
|
+
return value.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
861
|
+
}
|
|
862
|
+
const SAFE_ATTRIBUTE_NAME_RE = /^[A-Za-z_:][A-Za-z0-9:._-]*$/;
|
|
863
|
+
const GLOBAL_HEAD_ATTRIBUTE_PREFIXES = ["data-", "aria-"];
|
|
864
|
+
const META_ATTRIBUTES = new Set([
|
|
865
|
+
"charset",
|
|
866
|
+
"content",
|
|
867
|
+
"http-equiv",
|
|
868
|
+
"itemprop",
|
|
869
|
+
"media",
|
|
870
|
+
"name",
|
|
871
|
+
"property"
|
|
872
|
+
]);
|
|
873
|
+
const LINK_ATTRIBUTES = new Set([
|
|
874
|
+
"as",
|
|
875
|
+
"blocking",
|
|
876
|
+
"color",
|
|
877
|
+
"crossorigin",
|
|
878
|
+
"disabled",
|
|
879
|
+
"fetchpriority",
|
|
880
|
+
"href",
|
|
881
|
+
"hreflang",
|
|
882
|
+
"imagesizes",
|
|
883
|
+
"imagesrcset",
|
|
884
|
+
"integrity",
|
|
885
|
+
"media",
|
|
886
|
+
"referrerpolicy",
|
|
887
|
+
"rel",
|
|
888
|
+
"sizes",
|
|
889
|
+
"title",
|
|
890
|
+
"type"
|
|
891
|
+
]);
|
|
892
|
+
const SCRIPT_ATTRIBUTES = new Set([
|
|
893
|
+
"async",
|
|
894
|
+
"blocking",
|
|
895
|
+
"class",
|
|
896
|
+
"crossorigin",
|
|
897
|
+
"defer",
|
|
898
|
+
"fetchpriority",
|
|
899
|
+
"id",
|
|
900
|
+
"integrity",
|
|
901
|
+
"nomodule",
|
|
902
|
+
"nonce",
|
|
903
|
+
"referrerpolicy",
|
|
904
|
+
"src",
|
|
905
|
+
"type"
|
|
906
|
+
]);
|
|
907
|
+
function renderAttributes(attributes, allowedAttributes) {
|
|
908
|
+
return Object.entries(attributes).filter(([key, value]) => isAllowedHeadAttribute(key, value, allowedAttributes)).map(([key, value]) => `${key}="${escapeHtml(value ?? "")}"`).join(" ");
|
|
909
|
+
}
|
|
910
|
+
function isAllowedHeadAttribute(key, value, allowedAttributes) {
|
|
911
|
+
if (key === "children" || typeof value === "undefined" || !SAFE_ATTRIBUTE_NAME_RE.test(key)) return false;
|
|
912
|
+
const normalized = key.toLowerCase();
|
|
913
|
+
if (normalized.startsWith("on")) return false;
|
|
914
|
+
return allowedAttributes.has(normalized) || GLOBAL_HEAD_ATTRIBUTE_PREFIXES.some((prefix) => normalized.startsWith(prefix));
|
|
697
915
|
}
|
|
698
916
|
function buildHtmlDocument(options) {
|
|
699
917
|
const { head, body, hydrationState, clientEntryUrl, cssUrls = [], modulePreloadUrls = [], routeStatePreloadUrl } = options;
|
|
700
918
|
const titleTag = head.title ? `<title>${escapeHtml(head.title)}</title>` : "";
|
|
701
|
-
const metaTags = (head.meta ?? []).map((m) =>
|
|
702
|
-
const linkTags = (head.link ?? []).map((l) =>
|
|
919
|
+
const metaTags = (head.meta ?? []).map((m) => renderAttributes(m, META_ATTRIBUTES)).filter(Boolean).map((attrs) => `<meta ${attrs}>`).join("\n ");
|
|
920
|
+
const linkTags = (head.link ?? []).map((l) => renderAttributes(l, LINK_ATTRIBUTES)).filter(Boolean).map((attrs) => `<link ${attrs}>`).join("\n ");
|
|
921
|
+
const scriptTags = (head.script ?? []).map((script) => {
|
|
922
|
+
const attrs = renderAttributes(script, SCRIPT_ATTRIBUTES);
|
|
923
|
+
const children = script.children ? escapeScriptText(script.children) : "";
|
|
924
|
+
return attrs ? `<script ${attrs}>${children}<\/script>` : `<script>${children}<\/script>`;
|
|
925
|
+
}).join("\n ");
|
|
703
926
|
const cssTags = cssUrls.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join("\n ");
|
|
704
927
|
const modulePreloadTags = modulePreloadUrls.map((url) => `<link rel="modulepreload" href="${escapeHtml(url)}">`).join("\n ");
|
|
705
928
|
const routeStatePreloadTag = routeStatePreloadUrl ? `<link rel="preload" as="fetch" href="${escapeHtml(routeStatePreloadUrl)}" crossorigin="anonymous">` : "";
|
|
@@ -712,6 +935,7 @@ function buildHtmlDocument(options) {
|
|
|
712
935
|
${titleTag}
|
|
713
936
|
${metaTags}
|
|
714
937
|
${linkTags}
|
|
938
|
+
${scriptTags}
|
|
715
939
|
${cssTags}
|
|
716
940
|
${modulePreloadTags}
|
|
717
941
|
${routeStatePreloadTag}
|
|
@@ -870,7 +1094,8 @@ async function mergeHeadMetadata(shellModule, routeModule, routeArgs, data) {
|
|
|
870
1094
|
title: routeHead.title ?? shellHead.title,
|
|
871
1095
|
lang: routeHead.lang ?? shellHead.lang,
|
|
872
1096
|
meta: [...shellHead.meta ?? [], ...routeHead.meta ?? []],
|
|
873
|
-
link: [...shellHead.link ?? [], ...routeHead.link ?? []]
|
|
1097
|
+
link: [...shellHead.link ?? [], ...routeHead.link ?? []],
|
|
1098
|
+
script: [...shellHead.script ?? [], ...routeHead.script ?? []]
|
|
874
1099
|
};
|
|
875
1100
|
}
|
|
876
1101
|
async function mergeDocumentHeaders(shellModule, routeModule, routeArgs, data) {
|
|
@@ -949,25 +1174,26 @@ async function renderRouteErrorResponse(options) {
|
|
|
949
1174
|
status: routeError.status
|
|
950
1175
|
})
|
|
951
1176
|
} : routeError;
|
|
952
|
-
if (
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
1177
|
+
if (options.isRouteStateRequest) return jsonErrorResponse(routeErrorWithDiagnostics, { isRouteStateRequest: true });
|
|
1178
|
+
const shellModule = options.shellModule ?? (options.shellFile ? await resolveRegistryModule(options.options.registry?.shellModules, options.shellFile) : void 0);
|
|
1179
|
+
const ErrorBoundary = options.routeModule?.ErrorBoundary ?? shellModule?.ErrorBoundary;
|
|
1180
|
+
if (!ErrorBoundary) {
|
|
1181
|
+
const message = routeErrorWithDiagnostics.status >= 500 && !exposeDetails ? "Internal Server Error" : routeErrorWithDiagnostics.message;
|
|
1182
|
+
const diagnostics = exposeDetails && routeErrorWithDiagnostics.diagnostics ? `\n\n${JSON.stringify(routeErrorWithDiagnostics.diagnostics, null, 2)}` : "";
|
|
1183
|
+
return withDefaultSecurityHeaders(new Response(`${message}${diagnostics}`, {
|
|
956
1184
|
status: routeErrorWithDiagnostics.status,
|
|
957
1185
|
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
958
1186
|
}));
|
|
959
1187
|
}
|
|
960
|
-
if (options.isRouteStateRequest) return jsonErrorResponse(routeErrorWithDiagnostics, { isRouteStateRequest: true });
|
|
961
|
-
const shellModule = options.shellModule ?? (options.shellFile ? await resolveRegistryModule(options.options.registry?.shellModules, options.shellFile) : void 0);
|
|
962
1188
|
const head = shellModule?.head ? await shellModule.head(options.routeArgs) : {};
|
|
963
1189
|
const documentHeaders = await mergeDocumentHeaders(shellModule, void 0, options.routeArgs, void 0);
|
|
964
1190
|
const cssUrls = resolvePageCssUrls(options.options.cssManifest, options.shellFile, options.routeArgs.route.file);
|
|
965
1191
|
const modulePreloadUrls = resolvePageJsUrls(options.options.jsManifest, options.shellFile, options.routeArgs.route.file);
|
|
966
1192
|
const renderToString = await getRenderToStringAsync();
|
|
967
|
-
const
|
|
1193
|
+
const Boundary = ErrorBoundary;
|
|
968
1194
|
const Shell = shellModule?.Shell;
|
|
969
1195
|
const errorValue = deserializeRouteError(routeErrorWithDiagnostics);
|
|
970
|
-
const componentTree = Shell ? h(Shell, null, h(
|
|
1196
|
+
const componentTree = Shell ? h(Shell, null, h(Boundary, { error: errorValue })) : h(Boundary, { error: errorValue });
|
|
971
1197
|
return htmlResponse(buildHtmlDocument({
|
|
972
1198
|
head,
|
|
973
1199
|
body: await renderToString(h(PrachtRuntimeProvider, {
|
|
@@ -1034,25 +1260,25 @@ function markdownResponse(source) {
|
|
|
1034
1260
|
}
|
|
1035
1261
|
//#endregion
|
|
1036
1262
|
//#region src/runtime.ts
|
|
1037
|
-
const
|
|
1263
|
+
const SAME_ORIGIN_FETCH_SITE = "same-origin";
|
|
1038
1264
|
/**
|
|
1039
1265
|
* Stricter variant of first-party detection used for CSRF protection on
|
|
1040
|
-
* state-changing API requests.
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
1045
|
-
* explicitly or pre-flight via middleware.
|
|
1266
|
+
* state-changing API requests. It rejects any browser signal that points
|
|
1267
|
+
* outside this exact origin — a cross-origin form POST will send `Origin`
|
|
1268
|
+
* from the attacker, and `Sec-Fetch-Site: same-site` is not enough because
|
|
1269
|
+
* sibling subdomains can be attacker-controlled. Requests with no browser
|
|
1270
|
+
* provenance headers are treated as non-browser callers.
|
|
1046
1271
|
*/
|
|
1047
1272
|
function isSameOriginMutation(request, url) {
|
|
1048
1273
|
const site = request.headers.get("sec-fetch-site");
|
|
1049
|
-
if (site) return
|
|
1274
|
+
if (site && site !== SAME_ORIGIN_FETCH_SITE) return false;
|
|
1050
1275
|
const origin = request.headers.get("origin");
|
|
1051
1276
|
if (origin) try {
|
|
1052
1277
|
return new URL(origin).origin === url.origin;
|
|
1053
1278
|
} catch {
|
|
1054
1279
|
return false;
|
|
1055
1280
|
}
|
|
1281
|
+
if (site === SAME_ORIGIN_FETCH_SITE) return true;
|
|
1056
1282
|
const referer = request.headers.get("referer");
|
|
1057
1283
|
if (referer) try {
|
|
1058
1284
|
return new URL(referer).origin === url.origin;
|
|
@@ -1067,27 +1293,31 @@ function isSameOriginMutation(request, url) {
|
|
|
1067
1293
|
* otherwise reachable via any cross-origin `<a href>` / redirect.
|
|
1068
1294
|
*
|
|
1069
1295
|
* Accepts a request as first-party when:
|
|
1070
|
-
* - Sec-Fetch-Site is `same-origin`
|
|
1296
|
+
* - Sec-Fetch-Site is `same-origin` (modern browsers),
|
|
1071
1297
|
* - OR Sec-Fetch-Site is absent AND the Origin header matches the
|
|
1072
1298
|
* request URL's origin (older clients that still send Origin),
|
|
1073
|
-
* - OR
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
1077
|
-
*
|
|
1078
|
-
* or `none` (for user-typed URLs Sec-Fetch-Site: none, Referer absent,
|
|
1079
|
-
* Origin absent — handled by the "no headers → allow" branch since that
|
|
1080
|
-
* matches a first-party typed URL too).
|
|
1299
|
+
* - OR Sec-Fetch-Site/Origin are absent AND Referer matches the request
|
|
1300
|
+
* URL's origin,
|
|
1301
|
+
* - OR no Origin/Sec-Fetch-Site/Referer is present (non-browser clients like
|
|
1302
|
+
* curl — CSRF is not the threat model there; blocking would break
|
|
1303
|
+
* tests and CLIs).
|
|
1081
1304
|
*/
|
|
1082
1305
|
function isFirstPartyFetch(request) {
|
|
1083
1306
|
const site = request.headers.get("sec-fetch-site");
|
|
1084
|
-
if (site) return
|
|
1307
|
+
if (site && site !== SAME_ORIGIN_FETCH_SITE) return false;
|
|
1085
1308
|
const origin = request.headers.get("origin");
|
|
1086
1309
|
if (origin) try {
|
|
1087
1310
|
return new URL(origin).origin === new URL(request.url).origin;
|
|
1088
1311
|
} catch {
|
|
1089
1312
|
return false;
|
|
1090
1313
|
}
|
|
1314
|
+
if (site === SAME_ORIGIN_FETCH_SITE) return true;
|
|
1315
|
+
const referer = request.headers.get("referer");
|
|
1316
|
+
if (referer) try {
|
|
1317
|
+
return new URL(referer).origin === new URL(request.url).origin;
|
|
1318
|
+
} catch {
|
|
1319
|
+
return false;
|
|
1320
|
+
}
|
|
1091
1321
|
return true;
|
|
1092
1322
|
}
|
|
1093
1323
|
async function handlePrachtRequest(options) {
|
|
@@ -1318,9 +1548,10 @@ async function prerenderApp(options) {
|
|
|
1318
1548
|
revalidate: route.revalidate
|
|
1319
1549
|
});
|
|
1320
1550
|
}
|
|
1321
|
-
const
|
|
1322
|
-
|
|
1323
|
-
|
|
1551
|
+
const concurrency = options.concurrency ?? 10;
|
|
1552
|
+
if (!Number.isInteger(concurrency) || concurrency <= 0) throw new Error("prerenderApp({ concurrency }) expects a positive integer.");
|
|
1553
|
+
for (let i = 0; i < work.length; i += concurrency) {
|
|
1554
|
+
const batch = work.slice(i, i + concurrency);
|
|
1324
1555
|
const batchResults = await Promise.all(batch.map(async (item) => {
|
|
1325
1556
|
const url = new URL(item.pathname, "http://localhost");
|
|
1326
1557
|
const request = new Request(url, { method: "GET" });
|
|
@@ -1369,127 +1600,70 @@ async function collectSSGPaths(route, registry) {
|
|
|
1369
1600
|
return (await routeModule.getStaticPaths()).map((params) => buildPathFromSegments(route.segments, params));
|
|
1370
1601
|
}
|
|
1371
1602
|
//#endregion
|
|
1372
|
-
//#region src/
|
|
1373
|
-
const
|
|
1374
|
-
|
|
1375
|
-
function
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
}
|
|
1384
|
-
function prefetchRouteState(url) {
|
|
1385
|
-
const cached = getCachedRouteState(url);
|
|
1386
|
-
if (cached) return cached;
|
|
1387
|
-
const promise = fetchPrachtRouteState(url);
|
|
1388
|
-
prefetchCache.set(url, {
|
|
1389
|
-
promise,
|
|
1390
|
-
timestamp: Date.now()
|
|
1391
|
-
});
|
|
1392
|
-
return promise;
|
|
1603
|
+
//#region src/hydration-mismatch.ts
|
|
1604
|
+
const HYDRATION_BANNER_ID = "__pracht_hydration_mismatch__";
|
|
1605
|
+
let installed = false;
|
|
1606
|
+
function installHydrationMismatchWarning() {
|
|
1607
|
+
if (installed) return;
|
|
1608
|
+
installed = true;
|
|
1609
|
+
const prev = options.__m;
|
|
1610
|
+
options.__m = function(vnode) {
|
|
1611
|
+
appendHydrationWarning(vnode);
|
|
1612
|
+
if (prev) prev(vnode);
|
|
1613
|
+
};
|
|
1393
1614
|
}
|
|
1394
|
-
function
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
if (!href || href.startsWith("#")) return null;
|
|
1406
|
-
let url;
|
|
1407
|
-
try {
|
|
1408
|
-
url = new URL(href, window.location.origin);
|
|
1409
|
-
} catch {
|
|
1410
|
-
return null;
|
|
1615
|
+
function appendHydrationWarning(vnode) {
|
|
1616
|
+
if (typeof document === "undefined") return;
|
|
1617
|
+
const componentName = getVNodeName(vnode);
|
|
1618
|
+
let banner = document.getElementById(HYDRATION_BANNER_ID);
|
|
1619
|
+
const message = `Hydration mismatch detected on <${componentName}>. The server-rendered HTML did not match the client.`;
|
|
1620
|
+
if (banner) {
|
|
1621
|
+
const list = banner.querySelector(`[data-pracht-mismatch-list]`);
|
|
1622
|
+
if (list) {
|
|
1623
|
+
const item = document.createElement("li");
|
|
1624
|
+
item.textContent = message;
|
|
1625
|
+
list.appendChild(item);
|
|
1411
1626
|
}
|
|
1412
|
-
|
|
1413
|
-
return url.pathname + url.search;
|
|
1414
|
-
}
|
|
1415
|
-
function getPrefetchStrategy(pathname) {
|
|
1416
|
-
const routePathname = getRoutePathname(pathname);
|
|
1417
|
-
if (!routePathname) return "none";
|
|
1418
|
-
const match = matchAppRoute(app, routePathname);
|
|
1419
|
-
if (!match) return "none";
|
|
1420
|
-
if (match.route.prefetch) return match.route.prefetch;
|
|
1421
|
-
return "intent";
|
|
1627
|
+
return;
|
|
1422
1628
|
}
|
|
1423
|
-
document.
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
}
|
|
1460
|
-
}, true);
|
|
1461
|
-
if (typeof IntersectionObserver === "undefined") return;
|
|
1462
|
-
const observer = new IntersectionObserver((entries) => {
|
|
1463
|
-
for (const entry of entries) {
|
|
1464
|
-
if (!entry.isIntersecting) continue;
|
|
1465
|
-
const anchor = entry.target;
|
|
1466
|
-
const href = getInternalHref(anchor);
|
|
1467
|
-
if (!href) continue;
|
|
1468
|
-
prefetchRouteState(href);
|
|
1469
|
-
if (warmModules) {
|
|
1470
|
-
const pathname = getRoutePathname(href);
|
|
1471
|
-
const m = pathname ? matchAppRoute(app, pathname) : void 0;
|
|
1472
|
-
if (m) warmModules(m);
|
|
1473
|
-
}
|
|
1474
|
-
observer.unobserve(anchor);
|
|
1475
|
-
}
|
|
1476
|
-
}, { rootMargin: "200px" });
|
|
1477
|
-
function observeViewportLinks() {
|
|
1478
|
-
const anchors = document.querySelectorAll("a[href]");
|
|
1479
|
-
for (const anchor of anchors) {
|
|
1480
|
-
const href = getInternalHref(anchor);
|
|
1481
|
-
if (!href) continue;
|
|
1482
|
-
if (getPrefetchStrategy(href) !== "viewport") continue;
|
|
1483
|
-
observer.observe(anchor);
|
|
1484
|
-
}
|
|
1629
|
+
banner = document.createElement("div");
|
|
1630
|
+
banner.id = HYDRATION_BANNER_ID;
|
|
1631
|
+
banner.setAttribute("role", "alert");
|
|
1632
|
+
banner.style.cssText = [
|
|
1633
|
+
"position:fixed",
|
|
1634
|
+
"top:0",
|
|
1635
|
+
"left:0",
|
|
1636
|
+
"right:0",
|
|
1637
|
+
"z-index:2147483647",
|
|
1638
|
+
"background:#1a1a2e",
|
|
1639
|
+
"color:#ff6b6b",
|
|
1640
|
+
"padding:12px 16px",
|
|
1641
|
+
"font:12px/1.5 ui-monospace,Menlo,Consolas,monospace",
|
|
1642
|
+
"border-bottom:2px solid #e74c3c",
|
|
1643
|
+
"box-shadow:0 2px 8px rgba(0,0,0,0.3)"
|
|
1644
|
+
].join(";");
|
|
1645
|
+
const title = document.createElement("strong");
|
|
1646
|
+
title.textContent = "pracht: hydration mismatch";
|
|
1647
|
+
title.style.cssText = "display:block;margin-bottom:4px;color:#fff";
|
|
1648
|
+
banner.appendChild(title);
|
|
1649
|
+
const list = document.createElement("ul");
|
|
1650
|
+
list.setAttribute("data-pracht-mismatch-list", "");
|
|
1651
|
+
list.style.cssText = "margin:0;padding-left:18px";
|
|
1652
|
+
const item = document.createElement("li");
|
|
1653
|
+
item.textContent = message;
|
|
1654
|
+
list.appendChild(item);
|
|
1655
|
+
banner.appendChild(list);
|
|
1656
|
+
document.body.appendChild(banner);
|
|
1657
|
+
}
|
|
1658
|
+
function getVNodeName(vnode) {
|
|
1659
|
+
if (!vnode) return "Unknown";
|
|
1660
|
+
const type = vnode.type;
|
|
1661
|
+
if (typeof type === "string") return type;
|
|
1662
|
+
if (typeof type === "function") {
|
|
1663
|
+
const fn = type;
|
|
1664
|
+
return fn.displayName || fn.name || "Component";
|
|
1485
1665
|
}
|
|
1486
|
-
|
|
1487
|
-
new MutationObserver(() => {
|
|
1488
|
-
observeViewportLinks();
|
|
1489
|
-
}).observe(document.body, {
|
|
1490
|
-
childList: true,
|
|
1491
|
-
subtree: true
|
|
1492
|
-
});
|
|
1666
|
+
return "Unknown";
|
|
1493
1667
|
}
|
|
1494
1668
|
//#endregion
|
|
1495
1669
|
//#region src/router.ts
|
|
@@ -1499,6 +1673,7 @@ function useNavigate() {
|
|
|
1499
1673
|
}
|
|
1500
1674
|
async function initClientRouter(options) {
|
|
1501
1675
|
const { app, routeModules, shellModules, root, findModuleKey } = options;
|
|
1676
|
+
if (import.meta.env?.DEV) installHydrationMismatchWarning();
|
|
1502
1677
|
const moduleCache = /* @__PURE__ */ new Map();
|
|
1503
1678
|
function loadModule(modules, key) {
|
|
1504
1679
|
let cached = moduleCache.get(key);
|
|
@@ -1521,6 +1696,8 @@ async function initClientRouter(options) {
|
|
|
1521
1696
|
}
|
|
1522
1697
|
let updateRouteState = null;
|
|
1523
1698
|
let routeStateVersion = 0;
|
|
1699
|
+
let latestNavigationId = 0;
|
|
1700
|
+
let activeNavigationAbort = null;
|
|
1524
1701
|
function RouterRoot({ initialState }) {
|
|
1525
1702
|
const [routeState, setRouteState] = useState(initialState);
|
|
1526
1703
|
updateRouteState = setRouteState;
|
|
@@ -1549,7 +1726,8 @@ async function initClientRouter(options) {
|
|
|
1549
1726
|
const resolvedShell = await (shellModPromise ?? startShellImport(match));
|
|
1550
1727
|
if (resolvedShell) Shell = resolvedShell.Shell;
|
|
1551
1728
|
const DefaultComponent = typeof routeMod.default === "function" ? routeMod.default : void 0;
|
|
1552
|
-
const
|
|
1729
|
+
const ErrorBoundary = routeMod.ErrorBoundary ?? resolvedShell?.ErrorBoundary;
|
|
1730
|
+
const Component = state.error ? ErrorBoundary : routeMod.Component ?? DefaultComponent;
|
|
1553
1731
|
if (!Component) return null;
|
|
1554
1732
|
const componentProps = state.error ? { error: deserializeRouteError(state.error) } : {
|
|
1555
1733
|
data: state.data,
|
|
@@ -1607,6 +1785,10 @@ async function initClientRouter(options) {
|
|
|
1607
1785
|
};
|
|
1608
1786
|
}
|
|
1609
1787
|
async function navigate(to, opts) {
|
|
1788
|
+
const navigationId = ++latestNavigationId;
|
|
1789
|
+
activeNavigationAbort?.abort();
|
|
1790
|
+
const abortController = new AbortController();
|
|
1791
|
+
activeNavigationAbort = abortController;
|
|
1610
1792
|
const target = resolveBrowserRouteTarget(to);
|
|
1611
1793
|
if (!target) {
|
|
1612
1794
|
window.location.href = to;
|
|
@@ -1617,7 +1799,7 @@ async function initClientRouter(options) {
|
|
|
1617
1799
|
window.location.href = target.browserUrl;
|
|
1618
1800
|
return;
|
|
1619
1801
|
}
|
|
1620
|
-
const statePromise = getCachedRouteState(target.requestUrl) ?? fetchPrachtRouteState(target.requestUrl);
|
|
1802
|
+
const statePromise = getCachedRouteState(target.requestUrl) ?? fetchPrachtRouteState(target.requestUrl, { signal: abortController.signal });
|
|
1621
1803
|
const routeModPromise = startRouteImport(match);
|
|
1622
1804
|
const shellModPromise = startShellImport(match);
|
|
1623
1805
|
let state = {
|
|
@@ -1626,6 +1808,7 @@ async function initClientRouter(options) {
|
|
|
1626
1808
|
};
|
|
1627
1809
|
try {
|
|
1628
1810
|
const result = await statePromise;
|
|
1811
|
+
if (navigationId !== latestNavigationId) return;
|
|
1629
1812
|
if (result.type === "redirect") {
|
|
1630
1813
|
if (result.location) {
|
|
1631
1814
|
const redirect = resolveRedirectTarget(result.location);
|
|
@@ -1661,12 +1844,15 @@ async function initClientRouter(options) {
|
|
|
1661
1844
|
error: null
|
|
1662
1845
|
};
|
|
1663
1846
|
} catch {
|
|
1847
|
+
if (abortController.signal.aborted || navigationId !== latestNavigationId) return;
|
|
1664
1848
|
window.location.href = target.browserUrl;
|
|
1665
1849
|
return;
|
|
1666
1850
|
}
|
|
1851
|
+
if (navigationId !== latestNavigationId) return;
|
|
1667
1852
|
if (!opts?._popstate) if (opts?.replace) history.replaceState(null, "", target.browserUrl);
|
|
1668
1853
|
else history.pushState(null, "", target.browserUrl);
|
|
1669
1854
|
const routeState = await resolveRouteState(match, state, target.requestUrl, routeModPromise, shellModPromise);
|
|
1855
|
+
if (navigationId !== latestNavigationId) return;
|
|
1670
1856
|
if (routeState) {
|
|
1671
1857
|
applyRouteState(routeState);
|
|
1672
1858
|
window.scrollTo(0, 0);
|