evolit 0.1.6 → 0.1.7
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 +13 -1
- package/package.json +1 -1
- package/src/navigation-client.js +129 -14
- package/src/navigation-context.js +87 -0
- package/src/render.js +45 -6
- package/src/request-context.js +3 -0
- package/src/response-cache.js +3 -1
package/README.md
CHANGED
|
@@ -140,12 +140,18 @@ export default function CollectionControls() {
|
|
|
140
140
|
}
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
-
The hook returns
|
|
143
|
+
The hook returns
|
|
144
|
+
`{ status, url, pendingUrl, error, context, push, replace, replaceContext, refresh, createHref }`:
|
|
144
145
|
|
|
145
146
|
- `push(target, { scroll: false })` adds a browser-history entry. Pass
|
|
146
147
|
`scroll: false` to keep the current viewport position.
|
|
147
148
|
- `replace(target, { scroll: false })` updates the current entry, useful for
|
|
148
149
|
visual-only query state. It accepts the same scroll option.
|
|
150
|
+
- `push(target, { context })` and `replace(target, { context })` attach JSON-safe transient state
|
|
151
|
+
to that history entry and make it available to the destination page and layouts as their
|
|
152
|
+
`navigationContext` prop during SSR.
|
|
153
|
+
- `replaceContext(contextOrUpdater)` updates only the current history entry and hook state. It does
|
|
154
|
+
not change the URL or fetch a route delta.
|
|
149
155
|
- `refresh()` bypasses the client delta cache for the current URL.
|
|
150
156
|
- `createHref(pathname, searchParams)` creates a relative internal URL. It accepts standard
|
|
151
157
|
`URLSearchParams`, preserving repeated keys such as `facet=brand&facet=material`.
|
|
@@ -154,6 +160,12 @@ The hook returns `{ status, url, pendingUrl, error, push, replace, refresh, crea
|
|
|
154
160
|
share with server-evaluated route code. `useNavigation()` itself is browser-only and must only run
|
|
155
161
|
from a connected client component.
|
|
156
162
|
|
|
163
|
+
Navigation context is intended for compact UI state that must survive back/forward navigation but
|
|
164
|
+
does not belong in the public URL. It must be a JSON-safe object and is limited to 8 KiB after UTF-8
|
|
165
|
+
serialization. Evolit includes it in browser, response, and segment cache identity so the same URL
|
|
166
|
+
cannot reuse markup rendered for a different context. Invalid context is rejected at the client
|
|
167
|
+
boundary; malformed context received by the server is ignored.
|
|
168
|
+
|
|
157
169
|
Client components can also read the active route state with browser-only hooks:
|
|
158
170
|
|
|
159
171
|
```jsx
|
package/package.json
CHANGED
package/src/navigation-client.js
CHANGED
|
@@ -7,6 +7,11 @@ import {
|
|
|
7
7
|
registerHydrationModules,
|
|
8
8
|
} from "@litsx/ssr/hydration";
|
|
9
9
|
import { createHref as createBaseHref } from "./navigation-url.js";
|
|
10
|
+
import {
|
|
11
|
+
EVOLIT_NAVIGATION_CONTEXT_HEADER,
|
|
12
|
+
encodeNavigationContext,
|
|
13
|
+
normalizeNavigationContext,
|
|
14
|
+
} from "./navigation-context.js";
|
|
10
15
|
import {
|
|
11
16
|
getNavigationExtensions,
|
|
12
17
|
registerNavigationExtensions,
|
|
@@ -305,10 +310,18 @@ function toHref(target, location) {
|
|
|
305
310
|
return new URL(target, location.href).href;
|
|
306
311
|
}
|
|
307
312
|
|
|
308
|
-
function toCacheKey(href) {
|
|
313
|
+
function toCacheKey(href, encodedContext = null) {
|
|
309
314
|
const url = new URL(href);
|
|
310
315
|
url.hash = "";
|
|
311
|
-
return url.href
|
|
316
|
+
return `${url.href}\ncontext:${encodedContext ?? ""}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function readStoredNavigationContext(value) {
|
|
320
|
+
try {
|
|
321
|
+
return normalizeNavigationContext(value ?? null);
|
|
322
|
+
} catch {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
312
325
|
}
|
|
313
326
|
|
|
314
327
|
function getDeltaExpiry(delta, now = Date.now()) {
|
|
@@ -406,7 +419,15 @@ export function createBrowserNavigation(options = {}) {
|
|
|
406
419
|
const historyEntries = new Map();
|
|
407
420
|
let controller = null;
|
|
408
421
|
let navigationSequence = 0;
|
|
409
|
-
let state = {
|
|
422
|
+
let state = {
|
|
423
|
+
status: "idle",
|
|
424
|
+
url: windowRef.location.href,
|
|
425
|
+
pendingUrl: null,
|
|
426
|
+
error: null,
|
|
427
|
+
context: readStoredNavigationContext(
|
|
428
|
+
windowRef.history.state?.__evolitNavigationContext ?? null,
|
|
429
|
+
),
|
|
430
|
+
};
|
|
410
431
|
const emit = () => listeners.forEach((listener) => listener(state));
|
|
411
432
|
const notifyLocationChange = () => {
|
|
412
433
|
const EventConstructor = windowRef.CustomEvent ?? globalThis.CustomEvent;
|
|
@@ -445,7 +466,17 @@ export function createBrowserNavigation(options = {}) {
|
|
|
445
466
|
|
|
446
467
|
function ensureHistoryEntry(entryId = windowRef.history.state?.__evolitNavigationEntry) {
|
|
447
468
|
const id = typeof entryId === "string" ? entryId : createHistoryEntryId();
|
|
448
|
-
if (!historyEntries.has(id))
|
|
469
|
+
if (!historyEntries.has(id)) {
|
|
470
|
+
historyEntries.set(id, {
|
|
471
|
+
parentId: null,
|
|
472
|
+
url: null,
|
|
473
|
+
delta: null,
|
|
474
|
+
expiresAt: null,
|
|
475
|
+
context: readStoredNavigationContext(
|
|
476
|
+
windowRef.history.state?.__evolitNavigationContext ?? null,
|
|
477
|
+
),
|
|
478
|
+
});
|
|
479
|
+
}
|
|
449
480
|
if (windowRef.history.state?.__evolitNavigationEntry !== id) {
|
|
450
481
|
windowRef.history.replaceState(
|
|
451
482
|
{ ...(windowRef.history.state ?? {}), __evolitNavigationEntry: id },
|
|
@@ -490,6 +521,7 @@ export function createBrowserNavigation(options = {}) {
|
|
|
490
521
|
|
|
491
522
|
async function navigate(target, mode = "push", fromPopState = false, options = {}) {
|
|
492
523
|
const previousHref = windowRef.location.href;
|
|
524
|
+
const previousContext = state.context;
|
|
493
525
|
const requestedHref = toHref(target, windowRef.location);
|
|
494
526
|
const initialHref = toHref(transformNavigationUrl(requestedHref, { type: "navigation" }), windowRef.location);
|
|
495
527
|
const navigationExtensions = getNavigationExtensions();
|
|
@@ -502,7 +534,11 @@ export function createBrowserNavigation(options = {}) {
|
|
|
502
534
|
: { url: initialHref, cancelled: false };
|
|
503
535
|
if (beforeNavigation.cancelled) return null;
|
|
504
536
|
const href = toHref(beforeNavigation.url, windowRef.location);
|
|
505
|
-
const
|
|
537
|
+
const navigationContext = normalizeNavigationContext(
|
|
538
|
+
options.context === undefined ? null : options.context,
|
|
539
|
+
);
|
|
540
|
+
const encodedContext = encodeNavigationContext(navigationContext);
|
|
541
|
+
const cacheKey = toCacheKey(href, encodedContext);
|
|
506
542
|
const scrollPosition = options.scroll === false
|
|
507
543
|
? currentScrollPosition(windowRef)
|
|
508
544
|
: options.scrollPosition;
|
|
@@ -513,17 +549,33 @@ export function createBrowserNavigation(options = {}) {
|
|
|
513
549
|
const isCurrent = () => navigationId === navigationSequence && !navigationController.signal.aborted;
|
|
514
550
|
const currentEntryId = ensureHistoryEntry();
|
|
515
551
|
let entryId = options.historyEntryId;
|
|
552
|
+
let createdEntry = false;
|
|
516
553
|
if (fromPopState) {
|
|
517
554
|
entryId = ensureHistoryEntry(entryId);
|
|
518
555
|
} else if (mode === "push") {
|
|
519
556
|
saveCurrentScrollPosition();
|
|
520
557
|
removeForwardHistoryEntries(currentEntryId);
|
|
521
558
|
entryId = createHistoryEntryId();
|
|
522
|
-
|
|
559
|
+
createdEntry = true;
|
|
560
|
+
historyEntries.set(entryId, {
|
|
561
|
+
parentId: currentEntryId,
|
|
562
|
+
url: null,
|
|
563
|
+
delta: null,
|
|
564
|
+
expiresAt: null,
|
|
565
|
+
context: navigationContext,
|
|
566
|
+
});
|
|
523
567
|
} else {
|
|
524
568
|
entryId = currentEntryId;
|
|
569
|
+
const entry = historyEntries.get(entryId);
|
|
570
|
+
if (entry) entry.context = navigationContext;
|
|
525
571
|
}
|
|
526
|
-
state = {
|
|
572
|
+
state = {
|
|
573
|
+
...state,
|
|
574
|
+
status: "pending",
|
|
575
|
+
pendingUrl: href,
|
|
576
|
+
error: null,
|
|
577
|
+
context: navigationContext,
|
|
578
|
+
};
|
|
527
579
|
emit();
|
|
528
580
|
try {
|
|
529
581
|
const cached = getCachedDelta(entryId, cacheKey, options.force);
|
|
@@ -531,7 +583,12 @@ export function createBrowserNavigation(options = {}) {
|
|
|
531
583
|
? cached
|
|
532
584
|
: await (async () => {
|
|
533
585
|
const response = await windowRef.fetch(href, {
|
|
534
|
-
headers: {
|
|
586
|
+
headers: {
|
|
587
|
+
accept: "application/vnd.evolit.navigation+json",
|
|
588
|
+
...(encodedContext
|
|
589
|
+
? { [EVOLIT_NAVIGATION_CONTEXT_HEADER]: encodedContext }
|
|
590
|
+
: {}),
|
|
591
|
+
},
|
|
535
592
|
// A document response may already be fresh in the browser HTTP cache.
|
|
536
593
|
// Navigation is a different representation of that URL, so it must
|
|
537
594
|
// reach the server rather than reusing the cached HTML document.
|
|
@@ -558,7 +615,9 @@ export function createBrowserNavigation(options = {}) {
|
|
|
558
615
|
return nextDelta;
|
|
559
616
|
})();
|
|
560
617
|
if (!delta || !isCurrent()) return null;
|
|
561
|
-
if (delta.type === "redirect")
|
|
618
|
+
if (delta.type === "redirect") {
|
|
619
|
+
return navigate(delta.location, "replace", false, { context: navigationContext });
|
|
620
|
+
}
|
|
562
621
|
const applied = await applyDelta(delta, {
|
|
563
622
|
signal: navigationController.signal,
|
|
564
623
|
moduleVersion: options.moduleVersion,
|
|
@@ -576,19 +635,26 @@ export function createBrowserNavigation(options = {}) {
|
|
|
576
635
|
...(mode === "replace" ? windowRef.history.state : {}),
|
|
577
636
|
__evolitNavigationEntry: entryId,
|
|
578
637
|
__evolitScroll: scrollPosition ?? { x: 0, y: 0 },
|
|
638
|
+
__evolitNavigationContext: navigationContext,
|
|
579
639
|
},
|
|
580
640
|
"",
|
|
581
641
|
canonicalHref,
|
|
582
642
|
);
|
|
583
643
|
}
|
|
584
|
-
cacheDelta(entryId, toCacheKey(canonicalHref), delta);
|
|
644
|
+
cacheDelta(entryId, toCacheKey(canonicalHref, encodedContext), delta);
|
|
585
645
|
restoreScrollAndFocus(
|
|
586
646
|
windowRef,
|
|
587
647
|
canonicalHref,
|
|
588
648
|
scrollPosition,
|
|
589
649
|
options.scroll === false,
|
|
590
650
|
);
|
|
591
|
-
state = {
|
|
651
|
+
state = {
|
|
652
|
+
status: "idle",
|
|
653
|
+
url: canonicalHref,
|
|
654
|
+
pendingUrl: null,
|
|
655
|
+
error: null,
|
|
656
|
+
context: navigationContext,
|
|
657
|
+
};
|
|
592
658
|
emit();
|
|
593
659
|
notifyLocationChange();
|
|
594
660
|
if (navigationExtensions.length > 0) {
|
|
@@ -603,16 +669,53 @@ export function createBrowserNavigation(options = {}) {
|
|
|
603
669
|
return delta;
|
|
604
670
|
} catch (error) {
|
|
605
671
|
if (error?.name === "AbortError" || !isCurrent()) return null;
|
|
606
|
-
|
|
672
|
+
if (!fromPopState) {
|
|
673
|
+
if (createdEntry) historyEntries.delete(entryId);
|
|
674
|
+
else {
|
|
675
|
+
const entry = historyEntries.get(entryId);
|
|
676
|
+
if (entry) entry.context = previousContext;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
state = {
|
|
680
|
+
...state,
|
|
681
|
+
status: "error",
|
|
682
|
+
pendingUrl: null,
|
|
683
|
+
error,
|
|
684
|
+
context: fromPopState ? navigationContext : previousContext,
|
|
685
|
+
};
|
|
607
686
|
emit();
|
|
608
687
|
throw error;
|
|
609
688
|
}
|
|
610
689
|
}
|
|
611
690
|
|
|
691
|
+
async function replaceContext(nextContext) {
|
|
692
|
+
const currentContext = normalizeNavigationContext(state.context);
|
|
693
|
+
const resolvedContext = normalizeNavigationContext(
|
|
694
|
+
typeof nextContext === "function" ? nextContext(currentContext) : nextContext,
|
|
695
|
+
);
|
|
696
|
+
const entryId = ensureHistoryEntry();
|
|
697
|
+
const entry = historyEntries.get(entryId);
|
|
698
|
+
|
|
699
|
+
if (entry) entry.context = resolvedContext;
|
|
700
|
+
windowRef.history.replaceState(
|
|
701
|
+
{
|
|
702
|
+
...(windowRef.history.state ?? {}),
|
|
703
|
+
__evolitNavigationEntry: entryId,
|
|
704
|
+
__evolitNavigationContext: resolvedContext,
|
|
705
|
+
},
|
|
706
|
+
"",
|
|
707
|
+
windowRef.location.href,
|
|
708
|
+
);
|
|
709
|
+
state = { ...state, context: resolvedContext };
|
|
710
|
+
emit();
|
|
711
|
+
return resolvedContext;
|
|
712
|
+
}
|
|
713
|
+
|
|
612
714
|
windowRef.addEventListener("popstate", (event) => {
|
|
613
715
|
void navigate(windowRef.location.href, "replace", true, {
|
|
614
716
|
historyEntryId: event.state?.__evolitNavigationEntry,
|
|
615
717
|
scrollPosition: event.state?.__evolitScroll ?? null,
|
|
718
|
+
context: readStoredNavigationContext(event.state?.__evolitNavigationContext),
|
|
616
719
|
});
|
|
617
720
|
});
|
|
618
721
|
windowRef.addEventListener(DEVELOPMENT_REFRESH_EVENT, (event) => {
|
|
@@ -635,7 +738,13 @@ export function createBrowserNavigation(options = {}) {
|
|
|
635
738
|
requireSegmentChange: true,
|
|
636
739
|
}).then((applied) => {
|
|
637
740
|
if (applied === false || refreshController.signal.aborted) return false;
|
|
638
|
-
state = {
|
|
741
|
+
state = {
|
|
742
|
+
status: "idle",
|
|
743
|
+
url: windowRef.location.href,
|
|
744
|
+
pendingUrl: null,
|
|
745
|
+
error: null,
|
|
746
|
+
context: state.context,
|
|
747
|
+
};
|
|
639
748
|
emit();
|
|
640
749
|
return true;
|
|
641
750
|
}).catch((error) => {
|
|
@@ -667,7 +776,12 @@ export function createBrowserNavigation(options = {}) {
|
|
|
667
776
|
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
|
668
777
|
push: (target, options) => navigate(target, "push", false, options),
|
|
669
778
|
replace: (target, options) => navigate(target, "replace", false, options),
|
|
670
|
-
|
|
779
|
+
replaceContext,
|
|
780
|
+
refresh: (options = {}) => navigate(windowRef.location.href, "replace", false, {
|
|
781
|
+
...options,
|
|
782
|
+
context: options.context ?? state.context,
|
|
783
|
+
force: true,
|
|
784
|
+
}),
|
|
671
785
|
createHref,
|
|
672
786
|
};
|
|
673
787
|
}
|
|
@@ -686,6 +800,7 @@ export function useNavigation() {
|
|
|
686
800
|
...state,
|
|
687
801
|
push: navigation.push,
|
|
688
802
|
replace: navigation.replace,
|
|
803
|
+
replaceContext: navigation.replaceContext,
|
|
689
804
|
refresh: navigation.refresh,
|
|
690
805
|
createHref: navigation.createHref,
|
|
691
806
|
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export const EVOLIT_NAVIGATION_CONTEXT_HEADER = "x-evolit-navigation-context";
|
|
2
|
+
export const MAX_NAVIGATION_CONTEXT_BYTES = 8 * 1024;
|
|
3
|
+
|
|
4
|
+
function assertJsonSafe(value, path, seen, depth) {
|
|
5
|
+
if (depth > 32) {
|
|
6
|
+
throw new TypeError(`Navigation context at ${path} is not JSON-safe.`);
|
|
7
|
+
}
|
|
8
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
if (typeof value === "number") {
|
|
12
|
+
if (Number.isFinite(value)) return;
|
|
13
|
+
throw new TypeError(`Navigation context at ${path} is not JSON-safe.`);
|
|
14
|
+
}
|
|
15
|
+
if (typeof value !== "object") {
|
|
16
|
+
throw new TypeError(`Navigation context at ${path} is not JSON-safe.`);
|
|
17
|
+
}
|
|
18
|
+
if (seen.has(value)) {
|
|
19
|
+
throw new TypeError(`Navigation context at ${path} is not JSON-safe.`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const prototype = Object.getPrototypeOf(value);
|
|
23
|
+
if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) {
|
|
24
|
+
throw new TypeError(`Navigation context at ${path} is not JSON-safe.`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
seen.add(value);
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
value.forEach((item, index) => assertJsonSafe(item, `${path}[${index}]`, seen, depth + 1));
|
|
30
|
+
} else {
|
|
31
|
+
for (const [key, item] of Object.entries(value)) {
|
|
32
|
+
assertJsonSafe(item, `${path}.${key}`, seen, depth + 1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
seen.delete(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function serializeNavigationContext(context) {
|
|
39
|
+
if (context === null || context === undefined) return null;
|
|
40
|
+
if (typeof context !== "object" || Array.isArray(context)) {
|
|
41
|
+
throw new TypeError("Navigation context must be a JSON-safe object.");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
assertJsonSafe(context, "context", new Set(), 0);
|
|
45
|
+
const serialized = JSON.stringify(context);
|
|
46
|
+
const bytes = new TextEncoder().encode(serialized);
|
|
47
|
+
|
|
48
|
+
if (bytes.byteLength > MAX_NAVIGATION_CONTEXT_BYTES) {
|
|
49
|
+
throw new RangeError("Navigation context cannot exceed 8 KiB when serialized.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { serialized, bytes };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function normalizeNavigationContext(context) {
|
|
56
|
+
const result = serializeNavigationContext(context);
|
|
57
|
+
return result ? JSON.parse(result.serialized) : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function encodeNavigationContext(context) {
|
|
61
|
+
const result = serializeNavigationContext(context);
|
|
62
|
+
if (!result) return null;
|
|
63
|
+
const base64 = btoa(String.fromCharCode(...result.bytes));
|
|
64
|
+
return base64.replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function decodeNavigationContext(encoded) {
|
|
68
|
+
if (typeof encoded !== "string" || encoded.length === 0) return null;
|
|
69
|
+
if (encoded.length > Math.ceil(MAX_NAVIGATION_CONTEXT_BYTES * 4 / 3) + 4) return null;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const base64 = encoded.replaceAll("-", "+").replaceAll("_", "/");
|
|
73
|
+
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
|
|
74
|
+
const binary = atob(padded);
|
|
75
|
+
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
76
|
+
const context = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
77
|
+
return normalizeNavigationContext(context);
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function readNavigationContext(request) {
|
|
84
|
+
return decodeNavigationContext(
|
|
85
|
+
request?.headers?.get?.(EVOLIT_NAVIGATION_CONTEXT_HEADER) ?? null,
|
|
86
|
+
);
|
|
87
|
+
}
|
package/src/render.js
CHANGED
|
@@ -206,6 +206,7 @@ async function renderComponentTree(
|
|
|
206
206
|
await component({
|
|
207
207
|
params: requestContext.params,
|
|
208
208
|
searchParams: requestContext.searchParams,
|
|
209
|
+
navigationContext: requestContext.navigationContext,
|
|
209
210
|
request,
|
|
210
211
|
...extraProps,
|
|
211
212
|
}, incomingRef),
|
|
@@ -221,6 +222,7 @@ async function renderComponentTree(
|
|
|
221
222
|
return wrapRouteSegment(segment, await layoutComponents[index]({
|
|
222
223
|
params: requestContext.params,
|
|
223
224
|
searchParams: requestContext.searchParams,
|
|
225
|
+
navigationContext: requestContext.navigationContext,
|
|
224
226
|
request,
|
|
225
227
|
children,
|
|
226
228
|
}, incomingRef));
|
|
@@ -292,7 +294,14 @@ function createTrackedRouteValues(values) {
|
|
|
292
294
|
};
|
|
293
295
|
}
|
|
294
296
|
|
|
295
|
-
function createSegmentCacheKey(
|
|
297
|
+
function createSegmentCacheKey(
|
|
298
|
+
segment,
|
|
299
|
+
idPrefix,
|
|
300
|
+
profile,
|
|
301
|
+
params,
|
|
302
|
+
searchParams,
|
|
303
|
+
navigationContext,
|
|
304
|
+
) {
|
|
296
305
|
const select = (values) => Object.fromEntries(
|
|
297
306
|
(profile.all ? Object.keys(values) : profile.keys)
|
|
298
307
|
.sort()
|
|
@@ -303,10 +312,11 @@ function createSegmentCacheKey(segment, idPrefix, profile, params, searchParams)
|
|
|
303
312
|
idPrefix,
|
|
304
313
|
params: select(params),
|
|
305
314
|
searchParams: select(searchParams),
|
|
315
|
+
navigationContext: navigationContext ?? null,
|
|
306
316
|
});
|
|
307
317
|
}
|
|
308
318
|
|
|
309
|
-
function createSegmentInputKey(profile, params, searchParams) {
|
|
319
|
+
function createSegmentInputKey(profile, params, searchParams, navigationContext) {
|
|
310
320
|
const select = (values) => Object.fromEntries(
|
|
311
321
|
(profile.all ? Object.keys(values) : profile.keys)
|
|
312
322
|
.sort()
|
|
@@ -315,6 +325,7 @@ function createSegmentInputKey(profile, params, searchParams) {
|
|
|
315
325
|
return JSON.stringify({
|
|
316
326
|
params: select(params),
|
|
317
327
|
searchParams: select(searchParams),
|
|
328
|
+
navigationContext: navigationContext ?? null,
|
|
318
329
|
});
|
|
319
330
|
}
|
|
320
331
|
|
|
@@ -331,13 +342,20 @@ function createSegmentRenderCache(projectRoot, onDevelopmentEvent) {
|
|
|
331
342
|
const profiles = new Map();
|
|
332
343
|
|
|
333
344
|
return {
|
|
334
|
-
get(segment, idPrefix, params, searchParams) {
|
|
345
|
+
get(segment, idPrefix, params, searchParams, navigationContext) {
|
|
335
346
|
const profile = profiles.get(segment.modulePath);
|
|
336
347
|
if (!profile) {
|
|
337
348
|
onDevelopmentEvent?.({ type: "segment-cache-miss", modulePath: segment.modulePath, reason: "cold" });
|
|
338
349
|
return null;
|
|
339
350
|
}
|
|
340
|
-
const key = createSegmentCacheKey(
|
|
351
|
+
const key = createSegmentCacheKey(
|
|
352
|
+
segment,
|
|
353
|
+
idPrefix,
|
|
354
|
+
profile,
|
|
355
|
+
params,
|
|
356
|
+
searchParams,
|
|
357
|
+
navigationContext,
|
|
358
|
+
);
|
|
341
359
|
const entry = entries.get(key);
|
|
342
360
|
if (!entry || entry.expiresAt <= Date.now()) {
|
|
343
361
|
if (entry) entries.delete(key);
|
|
@@ -354,11 +372,27 @@ function createSegmentRenderCache(projectRoot, onDevelopmentEvent) {
|
|
|
354
372
|
getProfile(segment) {
|
|
355
373
|
return profiles.get(segment.modulePath) ?? null;
|
|
356
374
|
},
|
|
357
|
-
set(
|
|
375
|
+
set(
|
|
376
|
+
segment,
|
|
377
|
+
idPrefix,
|
|
378
|
+
params,
|
|
379
|
+
searchParams,
|
|
380
|
+
navigationContext,
|
|
381
|
+
profile,
|
|
382
|
+
result,
|
|
383
|
+
cachePolicy,
|
|
384
|
+
) {
|
|
358
385
|
const expiresAt = getSegmentCacheExpiry(cachePolicy);
|
|
359
386
|
if (expiresAt == null) return;
|
|
360
387
|
profiles.set(segment.modulePath, profile);
|
|
361
|
-
const key = createSegmentCacheKey(
|
|
388
|
+
const key = createSegmentCacheKey(
|
|
389
|
+
segment,
|
|
390
|
+
idPrefix,
|
|
391
|
+
profile,
|
|
392
|
+
params,
|
|
393
|
+
searchParams,
|
|
394
|
+
navigationContext,
|
|
395
|
+
);
|
|
362
396
|
entries.set(key, { result, expiresAt, modulePath: segment.modulePath });
|
|
363
397
|
if (entries.size > 256) entries.delete(entries.keys().next().value);
|
|
364
398
|
},
|
|
@@ -425,6 +459,7 @@ async function renderSegmentedComponentTree(
|
|
|
425
459
|
const pageValue = await component({
|
|
426
460
|
params: requestContext.params,
|
|
427
461
|
searchParams: requestContext.searchParams,
|
|
462
|
+
navigationContext: requestContext.navigationContext,
|
|
428
463
|
request,
|
|
429
464
|
...extraProps,
|
|
430
465
|
}, incomingRef);
|
|
@@ -445,6 +480,7 @@ async function renderSegmentedComponentTree(
|
|
|
445
480
|
idPrefix,
|
|
446
481
|
requestContext.params,
|
|
447
482
|
requestContext.searchParams,
|
|
483
|
+
requestContext.navigationContext,
|
|
448
484
|
) ?? null;
|
|
449
485
|
if (!layoutResult) {
|
|
450
486
|
const trackedParams = createTrackedRouteValues(requestContext.params);
|
|
@@ -453,6 +489,7 @@ async function renderSegmentedComponentTree(
|
|
|
453
489
|
const layoutValue = await layoutComponents[index]({
|
|
454
490
|
params: trackedParams.value,
|
|
455
491
|
searchParams: trackedSearchParams.value,
|
|
492
|
+
navigationContext: requestContext.navigationContext,
|
|
456
493
|
request,
|
|
457
494
|
children: withForwardedChildRef(html`${unsafeHTML(childrenMarker)}`, childRef),
|
|
458
495
|
}, incomingRef);
|
|
@@ -474,6 +511,7 @@ async function renderSegmentedComponentTree(
|
|
|
474
511
|
idPrefix,
|
|
475
512
|
requestContext.params,
|
|
476
513
|
requestContext.searchParams,
|
|
514
|
+
requestContext.navigationContext,
|
|
477
515
|
profile,
|
|
478
516
|
layoutResult,
|
|
479
517
|
options.cachePolicy,
|
|
@@ -485,6 +523,7 @@ async function renderSegmentedComponentTree(
|
|
|
485
523
|
profile,
|
|
486
524
|
requestContext.params,
|
|
487
525
|
requestContext.searchParams,
|
|
526
|
+
requestContext.navigationContext,
|
|
488
527
|
);
|
|
489
528
|
}
|
|
490
529
|
results.push(layoutResult);
|
package/src/request-context.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { readNavigationContext } from "./navigation-context.js";
|
|
2
3
|
|
|
3
4
|
const REQUEST_CONTEXT_STORAGE = Symbol.for("evolit.request-context.storage");
|
|
4
5
|
const HTTP_SIGNAL_CLASS = Symbol.for("evolit.request-context.http-signal");
|
|
@@ -106,6 +107,7 @@ export function createRequestContext({
|
|
|
106
107
|
request,
|
|
107
108
|
params = {},
|
|
108
109
|
searchParams = {},
|
|
110
|
+
navigationContext = readNavigationContext(request),
|
|
109
111
|
extensionValues = {},
|
|
110
112
|
didUseDynamicRequestData = false,
|
|
111
113
|
}) {
|
|
@@ -113,6 +115,7 @@ export function createRequestContext({
|
|
|
113
115
|
request,
|
|
114
116
|
params: Object.freeze({ ...params }),
|
|
115
117
|
searchParams: Object.freeze({ ...searchParams }),
|
|
118
|
+
navigationContext,
|
|
116
119
|
responseHeaders: new Headers(),
|
|
117
120
|
responseCookies: [],
|
|
118
121
|
didUseDynamicRequestData: didUseDynamicRequestData === true,
|
package/src/response-cache.js
CHANGED
|
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { BUILD_DIRECTORY, INTERNAL_DIRECTORY, ROUTE_CACHE_DIRECTORY } from "./constants.js";
|
|
5
5
|
import { ensureDirectory, pathExists, readJson, writeJson } from "./fs-utils.js";
|
|
6
|
+
import { encodeNavigationContext, readNavigationContext } from "./navigation-context.js";
|
|
6
7
|
|
|
7
8
|
function createCacheFileName(cacheKey) {
|
|
8
9
|
return `${crypto.createHash("sha1").update(cacheKey).digest("hex")}.json`;
|
|
@@ -160,7 +161,8 @@ export class ObjectStorageResponseCacheStore {
|
|
|
160
161
|
|
|
161
162
|
export function createDefaultRouteCacheKey(request) {
|
|
162
163
|
const url = new URL(request.url);
|
|
163
|
-
|
|
164
|
+
const context = encodeNavigationContext(readNavigationContext(request));
|
|
165
|
+
return `${url.pathname}${url.search}${context ? `\ncontext:${context}` : ""}`;
|
|
164
166
|
}
|
|
165
167
|
|
|
166
168
|
export function getRouteCacheArtifactFileName(cacheKey) {
|