@pracht/core 0.8.1 → 0.9.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.
Files changed (37) hide show
  1. package/README.md +5 -1
  2. package/dist/app-BN3pjxtw.d.mts +24 -0
  3. package/dist/{app-w-P1wf5T.mjs → app-CGRKjZ6Z.mjs} +96 -7
  4. package/dist/app-graph-BlaCcGUZ.mjs +52 -0
  5. package/dist/app-graph-DBoDsHJ5.d.mts +38 -0
  6. package/dist/browser.d.mts +7 -6
  7. package/dist/browser.mjs +8 -7
  8. package/dist/client.d.mts +3 -3
  9. package/dist/client.mjs +3 -3
  10. package/dist/dev-404.d.mts +26 -0
  11. package/dist/dev-404.mjs +166 -0
  12. package/dist/devtools.d.mts +8 -0
  13. package/dist/devtools.mjs +156 -0
  14. package/dist/error-overlay.d.mts +38 -1
  15. package/dist/error-overlay.mjs +134 -5
  16. package/dist/index.d.mts +9 -7
  17. package/dist/index.mjs +9 -7
  18. package/dist/manifest.d.mts +2 -1
  19. package/dist/manifest.mjs +1 -1
  20. package/dist/{runtime-context-B5pREhcM.mjs → navigation-state-BjwXTeVz.mjs} +74 -2
  21. package/dist/{prefetch-D9amIQtw.mjs → prefetch-COYeuvQK.mjs} +44 -27
  22. package/dist/prefetch-api-Bf-P7XFo.d.mts +39 -0
  23. package/dist/prefetch-api-ChSCTbEB.mjs +69 -0
  24. package/dist/{prefetch-cache-DzP2Bj9H.mjs → prefetch-cache-BNLEp9H5.mjs} +12 -1
  25. package/dist/{prerender-B8_CqYwd.d.mts → prerender-BV42B9jK.d.mts} +31 -2
  26. package/dist/{prerender-CoMyuJKm.mjs → prerender-C8BpANz3.mjs} +53 -9
  27. package/dist/{router-BcUmg1kB.d.mts → router-B_Aj5TtY.d.mts} +2 -2
  28. package/dist/{router-BkkyNjqB.mjs → router-E5Wh-dOR.mjs} +251 -58
  29. package/dist/{runtime-context-ytVd7GmZ.d.mts → runtime-context-BYBwVA0M.d.mts} +1 -1
  30. package/dist/{runtime-middleware-aeBdgjYr.d.mts → runtime-middleware-pYubngnL.d.mts} +57 -4
  31. package/dist/server.d.mts +7 -5
  32. package/dist/server.mjs +6 -5
  33. package/dist/{app-BWriseLj.d.mts → types-B-sfVjaH.d.mts} +42 -22
  34. package/dist/{types-DQv2poC5.mjs → types-DEPXdbyp.mjs} +43 -15
  35. package/package.json +9 -1
  36. package/dist/hydration-M1QzbQ1O.d.mts +0 -23
  37. /package/dist/{forwardRef-grZ6t4GS.mjs → forwardRef-B0cSkHwH.mjs} +0 -0
@@ -0,0 +1,39 @@
1
+ import { J as RouteId, at as RouteTarget } from "./types-B-sfVjaH.mjs";
2
+ import { FunctionComponent } from "preact";
3
+
4
+ //#region src/forwardRef.d.ts
5
+ /**
6
+ * Pass ref down to a child. This is mainly used in libraries with HOCs that
7
+ * wrap components. Using `forwardRef` there is an easy way to get a reference
8
+ * of the wrapped component instead of one of the wrapper itself.
9
+ */
10
+ declare function forwardRef<P = {}>(fn: ((props: P, ref: any) => any) & {
11
+ displayName?: string;
12
+ }): FunctionComponent<P & {
13
+ ref?: any;
14
+ }>;
15
+ //#endregion
16
+ //#region src/hydration.d.ts
17
+ /**
18
+ * Returns `true` once the initial hydration (including all Suspense
19
+ * boundaries) has fully resolved. During SSR and hydration this returns
20
+ * `false`.
21
+ */
22
+ declare function useIsHydrated(): boolean;
23
+ //#endregion
24
+ //#region src/prefetch-api.d.ts
25
+ interface PrefetchFn {
26
+ (to: string): Promise<void>;
27
+ <TRoute extends RouteId>(to: RouteTarget<TRoute>): Promise<void>;
28
+ }
29
+ /**
30
+ * Imperatively prefetch a route: warms the route/shell module chunks and
31
+ * caches the route-state JSON in the shared prefetch cache. Accepts an href
32
+ * string or a typed route target (`{ route, params, search }`).
33
+ *
34
+ * Available once the client router has initialized; a no-op during SSR,
35
+ * before hydration, and for URLs that do not match a client route.
36
+ */
37
+ declare const prefetch: PrefetchFn;
38
+ //#endregion
39
+ export { forwardRef as i, prefetch as n, useIsHydrated as r, PrefetchFn as t };
@@ -0,0 +1,69 @@
1
+ import { o as matchAppRoute, t as buildHref } from "./app-CGRKjZ6Z.mjs";
2
+ import { a as removeCachedRouteState, c as fetchPrachtRouteState, d as routeNeedsServerFetch, i as getCachedRouteState, n as cacheRouteState, t as EMPTY_ROUTE_STATE_PROMISE } from "./prefetch-cache-BNLEp9H5.mjs";
3
+ //#region src/prefetch-api.ts
4
+ /**
5
+ * Imperative prefetch surface shared by the lazy prefetch listeners
6
+ * (`prefetch.ts`), the client router, and userland code via the public
7
+ * `prefetch()` export. Kept separate from `prefetch.ts` so importing the
8
+ * public API does not pull the document-level listener setup into the
9
+ * critical hydration path — every module imported here is already part of
10
+ * the core client bundle.
11
+ */
12
+ let activePrefetchTarget = null;
13
+ /**
14
+ * Called by the client router during initialization so prefetching can match
15
+ * URLs against the resolved app and warm route/shell module chunks.
16
+ */
17
+ function registerPrefetchTarget(app, warmModules) {
18
+ activePrefetchTarget = {
19
+ app,
20
+ warmModules
21
+ };
22
+ }
23
+ /**
24
+ * Fetch (or reuse) the route-state JSON for `url` and store it in the shared
25
+ * bounded prefetch cache so a subsequent client navigation can consume it
26
+ * without a second network request. Rejected fetches are evicted from the
27
+ * cache so a transient network error does not poison later navigations.
28
+ */
29
+ function prefetchRouteState(url, route) {
30
+ if (route && !routeNeedsServerFetch(route)) return EMPTY_ROUTE_STATE_PROMISE;
31
+ const cached = getCachedRouteState(url);
32
+ if (cached) return cached;
33
+ const promise = fetchPrachtRouteState(url);
34
+ cacheRouteState(url, promise);
35
+ promise.catch(() => removeCachedRouteState(url, promise));
36
+ return promise;
37
+ }
38
+ /**
39
+ * Imperatively prefetch a route: warms the route/shell module chunks and
40
+ * caches the route-state JSON in the shared prefetch cache. Accepts an href
41
+ * string or a typed route target (`{ route, params, search }`).
42
+ *
43
+ * Available once the client router has initialized; a no-op during SSR,
44
+ * before hydration, and for URLs that do not match a client route.
45
+ */
46
+ const prefetch = async (to) => {
47
+ if (typeof window === "undefined") return;
48
+ const target = activePrefetchTarget;
49
+ if (!target) return;
50
+ let href;
51
+ try {
52
+ href = typeof to === "string" ? to : buildHref(target.app.routes, to.route, to);
53
+ } catch {
54
+ return;
55
+ }
56
+ let url;
57
+ try {
58
+ url = new URL(href, window.location.href);
59
+ } catch {
60
+ return;
61
+ }
62
+ if (url.origin !== window.location.origin) return;
63
+ const match = matchAppRoute(target.app, url.pathname);
64
+ if (!match) return;
65
+ target.warmModules?.(match);
66
+ await prefetchRouteState(url.pathname + url.search, match.route).catch(() => {});
67
+ };
68
+ //#endregion
69
+ export { prefetchRouteState as n, registerPrefetchTarget as r, prefetch as t };
@@ -4,6 +4,9 @@ const HYDRATION_STATE_ELEMENT_ID = "pracht-state";
4
4
  const ROUTE_STATE_REQUEST_HEADER = "x-pracht-route-state-request";
5
5
  const ROUTE_STATE_CACHE_CONTROL = "no-store";
6
6
  const EMPTY_ROUTE_PARAMS = {};
7
+ const PREFETCH_ATTRIBUTE = "data-pracht-prefetch";
8
+ const PRESERVE_SCROLL_ATTRIBUTE = "data-pracht-preserve-scroll";
9
+ const VIEW_TRANSITION_ATTRIBUTE = "data-pracht-view-transition";
7
10
  //#endregion
8
11
  //#region src/runtime-client-fetch.ts
9
12
  const SAFE_NAVIGATION_PROTOCOLS = new Set(["http:", "https:"]);
@@ -115,6 +118,14 @@ function cacheRouteState(url, promise) {
115
118
  });
116
119
  trimMapToSize(prefetchCache, MAX_PREFETCH_CACHE_ENTRIES);
117
120
  }
121
+ /**
122
+ * Remove a cached entry, but only when it still holds `promise` — a newer
123
+ * entry cached under the same URL must not be evicted by an older rejection.
124
+ */
125
+ function removeCachedRouteState(url, promise) {
126
+ const entry = prefetchCache.get(url);
127
+ if (entry && entry.promise === promise) prefetchCache.delete(url);
128
+ }
118
129
  function sweepPrefetchCache(now = Date.now()) {
119
130
  for (const [url, entry] of prefetchCache) if (now - entry.timestamp > CACHE_TTL_MS) prefetchCache.delete(url);
120
131
  }
@@ -126,4 +137,4 @@ function trimMapToSize(map, maxEntries) {
126
137
  }
127
138
  }
128
139
  //#endregion
129
- export { trimMapToSize as a, navigateToClientLocation as c, EMPTY_ROUTE_PARAMS as d, HYDRATION_STATE_ELEMENT_ID as f, SAFE_METHODS as h, getCachedRouteState as i, parseSafeNavigationUrl as l, ROUTE_STATE_REQUEST_HEADER as m, cacheRouteState as n, buildRouteStateUrl as o, ROUTE_STATE_CACHE_CONTROL as p, clearPrefetchCache as r, fetchPrachtRouteState as s, EMPTY_ROUTE_STATE_PROMISE as t, routeNeedsServerFetch as u };
140
+ export { ROUTE_STATE_REQUEST_HEADER as _, removeCachedRouteState as a, fetchPrachtRouteState as c, routeNeedsServerFetch as d, EMPTY_ROUTE_PARAMS as f, ROUTE_STATE_CACHE_CONTROL as g, PRESERVE_SCROLL_ATTRIBUTE as h, getCachedRouteState as i, navigateToClientLocation as l, PREFETCH_ATTRIBUTE as m, cacheRouteState as n, trimMapToSize as o, HYDRATION_STATE_ELEMENT_ID as p, clearPrefetchCache as r, buildRouteStateUrl as s, EMPTY_ROUTE_STATE_PROMISE as t, parseSafeNavigationUrl as u, SAFE_METHODS as v, VIEW_TRANSITION_ATTRIBUTE as y };
@@ -1,4 +1,27 @@
1
- import { H as ModuleRegistry, W as PrachtApp, X as ResolvedApiRoute, lt as RouteRevalidate } from "./app-BWriseLj.mjs";
1
+ import { F as PrachtApp, N as ModuleRegistry, V as ResolvedApiRoute, nt as RouteRevalidate } from "./types-B-sfVjaH.mjs";
2
+ //#region src/runtime-timing.d.ts
3
+ /**
4
+ * Per-request phase timing for dev tooling.
5
+ *
6
+ * The runtime only records durations when a collector object is passed via
7
+ * `HandlePrachtRequestOptions.timings` — the dev server passes one, production
8
+ * adapters never do, so production requests skip all timing work.
9
+ */
10
+ interface PrachtPhaseTimings {
11
+ /** Milliseconds spent in the middleware chain, excluding loader and render. */
12
+ mw?: number;
13
+ /** Milliseconds spent awaiting the route loader. */
14
+ loader?: number;
15
+ /** Milliseconds spent resolving modules and rendering the response, excluding the loader. */
16
+ render?: number;
17
+ }
18
+ /**
19
+ * Format collected phase timings as a standards-compliant `Server-Timing`
20
+ * header value, e.g. `mw;dur=1.2, loader;dur=14.8, render;dur=3.1`.
21
+ * Returns an empty string when nothing was recorded.
22
+ */
23
+ declare function formatServerTimingHeader(timings: PrachtPhaseTimings): string;
24
+ //#endregion
2
25
  //#region src/runtime-headers.d.ts
3
26
  declare function applyDefaultSecurityHeaders(headers: Headers): Headers;
4
27
  //#endregion
@@ -16,6 +39,12 @@ interface HandlePrachtRequestOptions<TContext = unknown> {
16
39
  /** Per-source-file JS chunk map produced by the vite plugin for modulepreload hints. */
17
40
  jsManifest?: Record<string, string[]>;
18
41
  apiRoutes?: ResolvedApiRoute[];
42
+ /**
43
+ * Dev-only phase-timing collector. When provided, the runtime records
44
+ * middleware/loader/render durations (ms) onto it so callers can emit a
45
+ * `Server-Timing` header. Leave unset in production — no timing work runs.
46
+ */
47
+ timings?: PrachtPhaseTimings;
19
48
  }
20
49
  declare function handlePrachtRequest<TContext>(options: HandlePrachtRequestOptions<TContext>): Promise<Response>;
21
50
  //#endregion
@@ -48,4 +77,4 @@ declare function prerenderApp(options: PrerenderAppOptions & {
48
77
  withISGManifest: true;
49
78
  }): Promise<PrerenderAppResult>;
50
79
  //#endregion
51
- export { prerenderApp as a, applyDefaultSecurityHeaders as c, PrerenderResult as i, PrerenderAppOptions as n, HandlePrachtRequestOptions as o, PrerenderAppResult as r, handlePrachtRequest as s, ISGManifestEntry as t };
80
+ export { prerenderApp as a, applyDefaultSecurityHeaders as c, PrerenderResult as i, PrachtPhaseTimings as l, PrerenderAppOptions as n, HandlePrachtRequestOptions as o, PrerenderAppResult as r, handlePrachtRequest as s, ISGManifestEntry as t, formatServerTimingHeader as u };
@@ -1,7 +1,7 @@
1
- import { a as matchApiRoute, c as resolveApp, n as buildPathFromSegments, o as matchAppRoute } from "./app-w-P1wf5T.mjs";
2
- import { _ as applyDefaultSecurityHeaders, b as withDefaultSecurityHeaders, c as mergeDocumentHeaders, d as runMiddlewareChain, f as resolveDataFunctions, g as appendVaryHeader, h as resolveRegistryModule, l as mergeHeadMetadata, m as resolvePageJsUrls, p as resolvePageCssUrls, v as applyHeaders, x as withRouteResponseHeaders, y as applySecurityAndRouteHeaders } from "./types-DQv2poC5.mjs";
3
- import { f as HYDRATION_STATE_ELEMENT_ID, h as SAFE_METHODS, m as ROUTE_STATE_REQUEST_HEADER, o as buildRouteStateUrl } from "./prefetch-cache-DzP2Bj9H.mjs";
4
- import { a as buildRuntimeDiagnostics, c as normalizeRouteError, l as shouldExposeServerErrors, o as createSerializedRouteError, s as deserializeRouteError, t as PrachtRuntimeProvider } from "./runtime-context-B5pREhcM.mjs";
1
+ import { a as matchApiRoute, c as resolveApp, n as buildPathFromSegments, o as matchAppRoute } from "./app-CGRKjZ6Z.mjs";
2
+ import { S as withRouteResponseHeaders, _ as appendVaryHeader, b as applySecurityAndRouteHeaders, f as runMiddlewareChain, g as resolveRegistryModule, h as resolvePageJsUrls, l as mergeDocumentHeaders, m as resolvePageCssUrls, p as resolveDataFunctions, u as mergeHeadMetadata, v as applyDefaultSecurityHeaders, x as withDefaultSecurityHeaders, y as applyHeaders } from "./types-DEPXdbyp.mjs";
3
+ import { _ as ROUTE_STATE_REQUEST_HEADER, p as HYDRATION_STATE_ELEMENT_ID, s as buildRouteStateUrl, v as SAFE_METHODS } from "./prefetch-cache-BNLEp9H5.mjs";
4
+ import { d as buildRuntimeDiagnostics, f as createSerializedRouteError, h as shouldExposeServerErrors, m as normalizeRouteError, p as deserializeRouteError, s as PrachtRuntimeProvider } from "./navigation-state-BjwXTeVz.mjs";
5
5
  import { h } from "preact";
6
6
  //#region src/runtime-html.ts
7
7
  function escapeHtml(str) {
@@ -260,6 +260,29 @@ function markdownResponse(source, initHeaders) {
260
260
  });
261
261
  }
262
262
  //#endregion
263
+ //#region src/runtime-timing.ts
264
+ const PHASE_ORDER = [
265
+ "mw",
266
+ "loader",
267
+ "render"
268
+ ];
269
+ /**
270
+ * Format collected phase timings as a standards-compliant `Server-Timing`
271
+ * header value, e.g. `mw;dur=1.2, loader;dur=14.8, render;dur=3.1`.
272
+ * Returns an empty string when nothing was recorded.
273
+ */
274
+ function formatServerTimingHeader(timings) {
275
+ const entries = [];
276
+ for (const phase of PHASE_ORDER) {
277
+ const duration = timings[phase];
278
+ if (typeof duration === "number" && Number.isFinite(duration)) entries.push(`${phase};dur=${formatDuration(duration)}`);
279
+ }
280
+ return entries.join(", ");
281
+ }
282
+ function formatDuration(duration) {
283
+ return String(Math.max(0, Math.round(duration * 10) / 10));
284
+ }
285
+ //#endregion
263
286
  //#region src/runtime.ts
264
287
  const SAME_ORIGIN_FETCH_SITE = "same-origin";
265
288
  /**
@@ -431,6 +454,7 @@ async function handlePrachtRequest(options) {
431
454
  let shellModule;
432
455
  let loaderFile;
433
456
  let currentPhase = "middleware";
457
+ const timings = options.timings;
434
458
  try {
435
459
  const routeModulePromise = resolveRegistryModule(registry.routeModules, match.route.file);
436
460
  const shellModulePromise = match.route.shellFile ? resolveRegistryModule(registry.shellModules, match.route.shellFile) : Promise.resolve(void 0);
@@ -445,7 +469,12 @@ async function handlePrachtRequest(options) {
445
469
  currentPhase = "loader";
446
470
  const { loader, loaderFile: resolvedLoaderFile } = await dataFunctionsPromise;
447
471
  loaderFile = resolvedLoaderFile;
448
- const loaderResult = loader ? await loader(routeArgs) : void 0;
472
+ let loaderResult;
473
+ if (loader) {
474
+ const loaderStart = timings ? performance.now() : 0;
475
+ loaderResult = await loader(routeArgs);
476
+ if (timings) timings.loader = performance.now() - loaderStart;
477
+ }
449
478
  if (loaderResult instanceof Response) return loaderResult;
450
479
  const data = loaderResult;
451
480
  if (isRouteStateRequest) return Response.json({ data });
@@ -517,7 +546,20 @@ async function handlePrachtRequest(options) {
517
546
  modulePreloadUrls
518
547
  }), 200, documentHeaders);
519
548
  };
520
- return normalizePageResponse(await runMiddlewareChain({
549
+ let terminal = pageTerminal;
550
+ let chainStart = 0;
551
+ if (timings) {
552
+ terminal = async () => {
553
+ const terminalStart = performance.now();
554
+ try {
555
+ return await pageTerminal();
556
+ } finally {
557
+ timings.render = performance.now() - terminalStart - (timings.loader ?? 0);
558
+ }
559
+ };
560
+ chainStart = performance.now();
561
+ }
562
+ const response = await runMiddlewareChain({
521
563
  context: pageContext,
522
564
  middlewareFiles: match.route.middlewareFiles,
523
565
  params: match.params,
@@ -526,8 +568,10 @@ async function handlePrachtRequest(options) {
526
568
  route: match.route,
527
569
  signal: requestSignal,
528
570
  url,
529
- terminal: pageTerminal
530
- }), { isRouteStateRequest });
571
+ terminal
572
+ });
573
+ if (timings) timings.mw = performance.now() - chainStart - (timings.render ?? 0) - (timings.loader ?? 0);
574
+ return normalizePageResponse(response, { isRouteStateRequest });
531
575
  } catch (error) {
532
576
  return renderRouteErrorResponse({
533
577
  error,
@@ -643,4 +687,4 @@ async function collectSSGPaths(route, registry) {
643
687
  return (await routeModule.getStaticPaths()).map((params) => buildPathFromSegments(route.segments, params));
644
688
  }
645
689
  //#endregion
646
- export { handlePrachtRequest as n, prerenderApp as t };
690
+ export { handlePrachtRequest as n, formatServerTimingHeader as r, prerenderApp as t };
@@ -1,5 +1,5 @@
1
- import { U as NavigateOptions, Z as ResolvedPrachtApp, dt as RouteTarget, nt as RouteId } from "./app-BWriseLj.mjs";
2
- import { t as PrachtHydrationState } from "./runtime-context-ytVd7GmZ.mjs";
1
+ import { H as ResolvedPrachtApp, J as RouteId, P as NavigateOptions, at as RouteTarget } from "./types-B-sfVjaH.mjs";
2
+ import { t as PrachtHydrationState } from "./runtime-context-BYBwVA0M.mjs";
3
3
 
4
4
  //#region src/router.d.ts
5
5
  declare global {
@@ -1,8 +1,9 @@
1
- import { o as matchAppRoute, t as buildHref } from "./app-w-P1wf5T.mjs";
2
- import { i as getCachedRouteState, l as parseSafeNavigationUrl, s as fetchPrachtRouteState, u as routeNeedsServerFetch } from "./prefetch-cache-DzP2Bj9H.mjs";
3
- import { s as deserializeRouteError, t as PrachtRuntimeProvider } from "./runtime-context-B5pREhcM.mjs";
1
+ import { o as matchAppRoute, t as buildHref } from "./app-CGRKjZ6Z.mjs";
2
+ import { c as fetchPrachtRouteState, d as routeNeedsServerFetch, i as getCachedRouteState, u as parseSafeNavigationUrl } from "./prefetch-cache-BNLEp9H5.mjs";
3
+ import { a as settleNavigation, p as deserializeRouteError, r as createNavigationLocation, s as PrachtRuntimeProvider, t as beginLoadingNavigation } from "./navigation-state-BjwXTeVz.mjs";
4
+ import { r as registerPrefetchTarget } from "./prefetch-api-ChSCTbEB.mjs";
4
5
  import { createContext, h, hydrate, options, render } from "preact";
5
- import { useContext, useEffect, useMemo, useState } from "preact/hooks";
6
+ import { useContext, useEffect, useLayoutEffect, useMemo, useState } from "preact/hooks";
6
7
  import { Suspense } from "preact-suspense";
7
8
  //#region src/hydration.ts
8
9
  const MODE_HYDRATE$1 = 32;
@@ -200,6 +201,92 @@ function getVNodeName(vnode) {
200
201
  return "Unknown";
201
202
  }
202
203
  //#endregion
204
+ //#region src/scroll-restoration.ts
205
+ const STORAGE_KEY = "pracht:scroll-positions";
206
+ const MAX_SCROLL_ENTRIES = 50;
207
+ const HISTORY_STATE_KEY = "__prachtScrollKey";
208
+ function readEntries(storage) {
209
+ if (!storage) return [];
210
+ let raw = null;
211
+ try {
212
+ raw = storage.getItem(STORAGE_KEY);
213
+ } catch {
214
+ return [];
215
+ }
216
+ if (!raw) return [];
217
+ try {
218
+ const parsed = JSON.parse(raw);
219
+ if (!Array.isArray(parsed)) return [];
220
+ return parsed.filter((entry) => Array.isArray(entry) && typeof entry[0] === "string" && typeof entry[1] === "number" && typeof entry[2] === "number");
221
+ } catch {
222
+ return [];
223
+ }
224
+ }
225
+ /**
226
+ * Create a scroll position store backed by the given storage (normally
227
+ * `sessionStorage`). Storage failures (private mode, quota) degrade to an
228
+ * in-memory map for the current page lifetime.
229
+ */
230
+ function createScrollPositionStore(storage, maxEntries = MAX_SCROLL_ENTRIES) {
231
+ const positions = /* @__PURE__ */ new Map();
232
+ for (const [key, x, y] of readEntries(storage)) positions.set(key, {
233
+ x,
234
+ y
235
+ });
236
+ function persist() {
237
+ if (!storage) return;
238
+ const entries = [];
239
+ for (const [key, position] of positions) entries.push([
240
+ key,
241
+ position.x,
242
+ position.y
243
+ ]);
244
+ try {
245
+ storage.setItem(STORAGE_KEY, JSON.stringify(entries));
246
+ } catch {}
247
+ }
248
+ return {
249
+ get(key) {
250
+ return positions.get(key) ?? null;
251
+ },
252
+ set(key, position) {
253
+ positions.delete(key);
254
+ positions.set(key, position);
255
+ while (positions.size > maxEntries) {
256
+ const oldest = positions.keys().next();
257
+ if (oldest.done) break;
258
+ positions.delete(oldest.value);
259
+ }
260
+ persist();
261
+ }
262
+ };
263
+ }
264
+ function getSessionScrollStorage() {
265
+ if (typeof window === "undefined") return null;
266
+ try {
267
+ return window.sessionStorage ?? null;
268
+ } catch {
269
+ return null;
270
+ }
271
+ }
272
+ function generateScrollKey() {
273
+ return Math.random().toString(36).slice(2, 10);
274
+ }
275
+ /** Read the pracht scroll key from a `history.state` value, if present. */
276
+ function readScrollKeyFromHistoryState(state) {
277
+ if (!state || typeof state !== "object") return null;
278
+ const key = state[HISTORY_STATE_KEY];
279
+ return typeof key === "string" ? key : null;
280
+ }
281
+ /** Merge the pracht scroll key into an existing `history.state` value. */
282
+ function withScrollKeyInHistoryState(state, key) {
283
+ if (state && typeof state === "object" && !Array.isArray(state)) return {
284
+ ...state,
285
+ [HISTORY_STATE_KEY]: key
286
+ };
287
+ return { [HISTORY_STATE_KEY]: key };
288
+ }
289
+ //#endregion
203
290
  //#region src/router.ts
204
291
  const NavigateContext = createContext(async () => {});
205
292
  function useNavigate() {
@@ -232,11 +319,56 @@ async function initClientRouter(options) {
232
319
  let routeStateVersion = 0;
233
320
  let latestNavigationId = 0;
234
321
  let activeNavigationAbort = null;
322
+ const scrollStore = createScrollPositionStore(getSessionScrollStorage());
323
+ if ("scrollRestoration" in history) history.scrollRestoration = "manual";
324
+ let currentScrollKey = readScrollKeyFromHistoryState(history.state) ?? "";
325
+ const hadExistingScrollKey = currentScrollKey !== "";
326
+ if (!hadExistingScrollKey) {
327
+ currentScrollKey = generateScrollKey();
328
+ try {
329
+ history.replaceState(withScrollKeyInHistoryState(history.state, currentScrollKey), "", window.location.href);
330
+ } catch {}
331
+ }
332
+ function saveScrollPosition() {
333
+ scrollStore.set(currentScrollKey, {
334
+ x: window.scrollX,
335
+ y: window.scrollY
336
+ });
337
+ }
338
+ window.addEventListener("pagehide", saveScrollPosition);
339
+ function restoreOrResetScroll(opts, browserUrl) {
340
+ if (opts?.preserveScroll) return;
341
+ if (opts?._popstate) {
342
+ const saved = scrollStore.get(currentScrollKey);
343
+ window.scrollTo(saved?.x ?? 0, saved?.y ?? 0);
344
+ return;
345
+ }
346
+ const hashIndex = browserUrl.indexOf("#");
347
+ if (hashIndex !== -1) {
348
+ let id = browserUrl.slice(hashIndex + 1);
349
+ try {
350
+ id = decodeURIComponent(id);
351
+ } catch {}
352
+ const hashTarget = id ? document.getElementById(id) : null;
353
+ if (hashTarget && typeof hashTarget.scrollIntoView === "function") {
354
+ hashTarget.scrollIntoView();
355
+ return;
356
+ }
357
+ }
358
+ window.scrollTo(0, 0);
359
+ }
360
+ let afterCommitCallback = null;
235
361
  function RouterRoot({ initialState }) {
236
362
  const [routeState, setRouteState] = useState(initialState);
237
363
  updateRouteState = setRouteState;
238
364
  const navigateValue = useMemo(() => navigate, []);
239
365
  const { Shell, Component, componentProps, data, params, routeId, url, version } = routeState;
366
+ useLayoutEffect(() => {
367
+ if (!afterCommitCallback) return;
368
+ const callback = afterCommitCallback;
369
+ afterCommitCallback = null;
370
+ callback();
371
+ }, [version]);
240
372
  const componentTree = Shell ? h(Shell, null, h(Component, componentProps)) : h(Component, componentProps);
241
373
  return h(NavigateContext.Provider, { value: navigateValue }, h(PrachtRuntimeProvider, {
242
374
  data,
@@ -337,69 +469,86 @@ async function initClientRouter(options) {
337
469
  window.location.href = target.browserUrl;
338
470
  return;
339
471
  }
340
- let statePromise;
341
- if (routeNeedsServerFetch(match.route)) statePromise = getCachedRouteState(target.requestUrl) ?? fetchPrachtRouteState(target.requestUrl, { signal: abortController.signal });
342
- else statePromise = Promise.resolve({
343
- type: "data",
344
- data: void 0
345
- });
346
- const routeModPromise = startRouteImport(match);
347
- const shellModPromise = startShellImport(match);
348
- let state = {
349
- data: void 0,
350
- error: null
351
- };
472
+ const navigationToken = beginLoadingNavigation(createNavigationLocation(target.browserUrl));
352
473
  try {
353
- const result = await statePromise;
354
- if (navigationId !== latestNavigationId) return;
355
- if (result.type === "redirect") {
356
- if (result.location) {
357
- const redirect = resolveRedirectTarget(result.location);
358
- if (redirect.unsafe) {
359
- console.error(`[pracht] refused to navigate to unsafe URL: ${result.location}`);
360
- return;
361
- }
362
- if (redirect.externalUrl) {
363
- window.location.href = redirect.externalUrl;
364
- return;
365
- }
366
- if (redirect.isCurrentLocation) return;
367
- if (redirect.documentUrl) {
368
- window.location.href = redirect.documentUrl;
369
- return;
370
- }
371
- if (redirect.internalPath) {
372
- await navigate(redirect.internalPath, opts);
474
+ let statePromise;
475
+ if (routeNeedsServerFetch(match.route)) statePromise = getCachedRouteState(target.requestUrl) ?? fetchPrachtRouteState(target.requestUrl, { signal: abortController.signal });
476
+ else statePromise = Promise.resolve({
477
+ type: "data",
478
+ data: void 0
479
+ });
480
+ const routeModPromise = startRouteImport(match);
481
+ const shellModPromise = startShellImport(match);
482
+ let state = {
483
+ data: void 0,
484
+ error: null
485
+ };
486
+ try {
487
+ const result = await statePromise;
488
+ if (navigationId !== latestNavigationId) return;
489
+ if (result.type === "redirect") {
490
+ if (result.location) {
491
+ const redirect = resolveRedirectTarget(result.location);
492
+ if (redirect.unsafe) {
493
+ console.error(`[pracht] refused to navigate to unsafe URL: ${result.location}`);
494
+ return;
495
+ }
496
+ if (redirect.externalUrl) {
497
+ window.location.href = redirect.externalUrl;
498
+ return;
499
+ }
500
+ if (redirect.isCurrentLocation) return;
501
+ if (redirect.documentUrl) {
502
+ window.location.href = redirect.documentUrl;
503
+ return;
504
+ }
505
+ if (redirect.internalPath) {
506
+ await navigate(redirect.internalPath, opts);
507
+ return;
508
+ }
509
+ window.location.href = target.browserUrl;
373
510
  return;
374
511
  }
375
512
  window.location.href = target.browserUrl;
376
513
  return;
377
514
  }
515
+ if (result.type === "error") state = {
516
+ data: void 0,
517
+ error: result.error
518
+ };
519
+ else state = {
520
+ data: result.data,
521
+ error: null
522
+ };
523
+ } catch {
524
+ if (abortController.signal.aborted || navigationId !== latestNavigationId) return;
378
525
  window.location.href = target.browserUrl;
379
526
  return;
380
527
  }
381
- if (result.type === "error") state = {
382
- data: void 0,
383
- error: result.error
384
- };
385
- else state = {
386
- data: result.data,
387
- error: null
528
+ if (navigationId !== latestNavigationId) return;
529
+ if (!opts?._popstate) {
530
+ saveScrollPosition();
531
+ if (opts?.replace) history.replaceState(withScrollKeyInHistoryState(history.state, currentScrollKey), "", target.browserUrl);
532
+ else {
533
+ const nextScrollKey = generateScrollKey();
534
+ history.pushState(withScrollKeyInHistoryState(null, nextScrollKey), "", target.browserUrl);
535
+ currentScrollKey = nextScrollKey;
536
+ }
537
+ }
538
+ const routeState = await resolveRouteState(match, state, target.requestUrl, routeModPromise, shellModPromise);
539
+ if (navigationId !== latestNavigationId) return;
540
+ if (!routeState) {
541
+ window.location.href = target.browserUrl;
542
+ return;
543
+ }
544
+ const commit = () => {
545
+ afterCommitCallback = () => restoreOrResetScroll(opts, target.browserUrl);
546
+ applyRouteState(routeState);
388
547
  };
389
- } catch {
390
- if (abortController.signal.aborted || navigationId !== latestNavigationId) return;
391
- window.location.href = target.browserUrl;
392
- return;
548
+ await commitWithOptionalViewTransition(commit, opts?.viewTransition ?? app.viewTransitions === true);
549
+ } finally {
550
+ settleNavigation(navigationToken);
393
551
  }
394
- if (navigationId !== latestNavigationId) return;
395
- if (!opts?._popstate) if (opts?.replace) history.replaceState(null, "", target.browserUrl);
396
- else history.pushState(null, "", target.browserUrl);
397
- const routeState = await resolveRouteState(match, state, target.requestUrl, routeModPromise, shellModPromise);
398
- if (navigationId !== latestNavigationId) return;
399
- if (routeState) {
400
- applyRouteState(routeState);
401
- window.scrollTo(0, 0);
402
- } else window.location.href = target.browserUrl;
403
552
  }
404
553
  const initialTarget = resolveBrowserRouteTarget(options.initialState.url);
405
554
  const initialRequestUrl = initialTarget?.requestUrl ?? options.initialState.url;
@@ -468,21 +617,65 @@ async function initClientRouter(options) {
468
617
  }
469
618
  if (url.origin !== window.location.origin) return;
470
619
  e.preventDefault();
471
- navigate(url.pathname + url.search + url.hash);
620
+ const navOptions = {};
621
+ if (anchor.hasAttribute("data-pracht-preserve-scroll")) navOptions.preserveScroll = true;
622
+ if (anchor.hasAttribute("data-pracht-view-transition")) navOptions.viewTransition = true;
623
+ navigate(url.pathname + url.search + url.hash, navOptions);
472
624
  });
473
625
  window.addEventListener("popstate", () => {
626
+ saveScrollPosition();
627
+ let nextScrollKey = readScrollKeyFromHistoryState(history.state);
628
+ if (!nextScrollKey) {
629
+ nextScrollKey = generateScrollKey();
630
+ try {
631
+ history.replaceState(withScrollKeyInHistoryState(history.state, nextScrollKey), "", window.location.href);
632
+ } catch {}
633
+ }
634
+ currentScrollKey = nextScrollKey;
474
635
  navigate(window.location.pathname + window.location.search + window.location.hash, { _popstate: true });
475
636
  });
476
637
  window.__PRACHT_NAVIGATE__ = navigate;
477
638
  window.__PRACHT_ROUTER_READY__ = true;
639
+ document.documentElement.setAttribute("data-pracht-hydrated", "true");
640
+ if (hadExistingScrollKey) {
641
+ const savedPosition = scrollStore.get(currentScrollKey);
642
+ if (savedPosition) window.scrollTo(savedPosition.x, savedPosition.y);
643
+ }
478
644
  const warmModules = (match) => {
479
645
  startRouteImport(match);
480
646
  startShellImport(match);
481
647
  };
482
- import("./prefetch-D9amIQtw.mjs").then(({ setupPrefetching }) => {
648
+ registerPrefetchTarget(app, warmModules);
649
+ import("./prefetch-COYeuvQK.mjs").then(({ setupPrefetching }) => {
483
650
  setupPrefetching(app, warmModules);
484
651
  });
485
652
  }
653
+ /**
654
+ * Commit a navigation's DOM update, optionally wrapped in
655
+ * `document.startViewTransition()`. Falls back to a plain commit when view
656
+ * transitions are disabled or unsupported. Resolves once the DOM update has
657
+ * been applied (not when the transition animation finishes).
658
+ */
659
+ async function commitWithOptionalViewTransition(commit, useViewTransition) {
660
+ const doc = document;
661
+ if (!useViewTransition || typeof doc.startViewTransition !== "function") {
662
+ commit();
663
+ return;
664
+ }
665
+ let committed = false;
666
+ let transition;
667
+ try {
668
+ transition = doc.startViewTransition(async () => {
669
+ committed = true;
670
+ commit();
671
+ await new Promise((resolve) => setTimeout(resolve, 0));
672
+ });
673
+ } catch {}
674
+ try {
675
+ await transition?.updateCallbackDone;
676
+ } catch {}
677
+ if (!committed) commit();
678
+ }
486
679
  function resolveBrowserRouteTarget(to) {
487
680
  if (typeof window === "undefined") return null;
488
681
  try {