evolit 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +73 -3
- package/package.json +13 -6
- package/src/build.js +37 -9
- package/src/cli.js +16 -9
- package/src/client-assets.js +326 -70
- package/src/compiler.js +176 -9
- package/src/deployment-runtime.js +246 -43
- package/src/development-events.js +96 -0
- package/src/navigation-client.js +519 -0
- package/src/navigation-server.js +3 -0
- package/src/navigation-url.js +28 -0
- package/src/render.js +328 -19
- package/src/route-config.js +3 -1
- package/src/route-segments.js +272 -0
- package/src/scaffold.js +4 -0
- package/src/server.js +156 -13
- package/src/ssr-adapter.js +127 -12
- package/src/terminal.js +32 -0
- package/templates/default/yarnrc.yml.template +1 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import { useHost, useOnConnect, useState } from "@litsx/core";
|
|
2
|
+
import { hydrateRoot, registerHydrationModules } from "@litsx/ssr/hydration";
|
|
3
|
+
import { createHref } from "./navigation-url.js";
|
|
4
|
+
|
|
5
|
+
export { createHref } from "./navigation-url.js";
|
|
6
|
+
|
|
7
|
+
let browserNavigation = null;
|
|
8
|
+
let nextHistoryEntryId = 0;
|
|
9
|
+
const historySessionId = globalThis.crypto?.randomUUID?.()
|
|
10
|
+
?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
11
|
+
|
|
12
|
+
function readRoute(documentRef) {
|
|
13
|
+
const source = documentRef.getElementById("__EVOLIT_ROUTE__")?.textContent;
|
|
14
|
+
try { return source ? JSON.parse(source) : null; } catch { return null; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function findMarkers(root, segmentId, matches = []) {
|
|
18
|
+
const documentRef = root.ownerDocument ?? root;
|
|
19
|
+
const walker = documentRef.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
|
|
20
|
+
const open = [];
|
|
21
|
+
while (walker.nextNode()) {
|
|
22
|
+
const node = walker.currentNode;
|
|
23
|
+
if (node.data === `evolit:segment:start:${segmentId}`) open.push(node);
|
|
24
|
+
if (node.data === `evolit:segment:end:${segmentId}` && open.length > 0) matches.push({ start: open.pop(), end: node });
|
|
25
|
+
}
|
|
26
|
+
for (const element of root.querySelectorAll?.("*") ?? []) {
|
|
27
|
+
if (element.shadowRoot) findMarkers(element.shadowRoot, segmentId, matches);
|
|
28
|
+
}
|
|
29
|
+
return matches;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function materializeDeclarativeShadowDom(root) {
|
|
33
|
+
for (const template of [...root.querySelectorAll?.("template[shadowrootmode],template[shadowroot]") ?? []]) {
|
|
34
|
+
const host = template.parentElement;
|
|
35
|
+
if (!host || host.shadowRoot) continue;
|
|
36
|
+
const mode = template.getAttribute("shadowrootmode") ?? template.getAttribute("shadowroot") ?? "open";
|
|
37
|
+
const shadowRoot = host.attachShadow({ mode });
|
|
38
|
+
shadowRoot.append(template.content);
|
|
39
|
+
template.remove();
|
|
40
|
+
materializeDeclarativeShadowDom(shadowRoot);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function findHydrationElement(root, rootId) {
|
|
45
|
+
const selector = `[data-litsx-root="${CSS.escape(rootId)}"]`;
|
|
46
|
+
const direct = root.querySelector?.(selector);
|
|
47
|
+
if (direct) return direct;
|
|
48
|
+
for (const element of root.querySelectorAll?.("*") ?? []) {
|
|
49
|
+
if (element.shadowRoot) {
|
|
50
|
+
const match = findHydrationElement(element.shadowRoot, rootId);
|
|
51
|
+
if (match) return match;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadStyleSheet(documentRef, href) {
|
|
58
|
+
const existing = documentRef.head.querySelector(`link[data-evolit-route-asset="style"][href="${CSS.escape(href)}"]`);
|
|
59
|
+
if (existing) return Promise.resolve(existing);
|
|
60
|
+
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
const link = documentRef.createElement("link");
|
|
63
|
+
link.rel = "stylesheet";
|
|
64
|
+
link.href = href;
|
|
65
|
+
link.dataset.evolitRouteAsset = "style";
|
|
66
|
+
link.addEventListener("load", () => resolve(link), { once: true });
|
|
67
|
+
link.addEventListener("error", () => resolve(link), { once: true });
|
|
68
|
+
documentRef.head.append(link);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function syncRouteHeadAssets(headAssets, documentRef) {
|
|
73
|
+
if (!headAssets) return;
|
|
74
|
+
const styles = [...new Set(headAssets.styles ?? [])];
|
|
75
|
+
const preloads = [...new Set(headAssets.preloads ?? [])];
|
|
76
|
+
await Promise.all(styles.map((href) => loadStyleSheet(documentRef, href)));
|
|
77
|
+
|
|
78
|
+
for (const href of preloads) {
|
|
79
|
+
const selector = `link[data-evolit-route-asset="preload"][href="${CSS.escape(href)}"]`;
|
|
80
|
+
if (documentRef.head.querySelector(selector)) continue;
|
|
81
|
+
const link = documentRef.createElement("link");
|
|
82
|
+
link.rel = "modulepreload";
|
|
83
|
+
link.href = href;
|
|
84
|
+
link.dataset.evolitRouteAsset = "preload";
|
|
85
|
+
documentRef.head.append(link);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const expected = new Set([
|
|
89
|
+
...styles.map((href) => `style:${href}`),
|
|
90
|
+
...preloads.map((href) => `preload:${href}`),
|
|
91
|
+
]);
|
|
92
|
+
for (const link of documentRef.head.querySelectorAll("link[data-evolit-route-asset]")) {
|
|
93
|
+
const key = `${link.dataset.evolitRouteAsset}:${link.getAttribute("href")}`;
|
|
94
|
+
if (!expected.has(key)) link.remove();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function throwIfAborted(signal) {
|
|
99
|
+
if (signal?.aborted) {
|
|
100
|
+
throw new DOMException("Navigation was superseded.", "AbortError");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function findRouteHeadMarkers(documentRef) {
|
|
105
|
+
const walker = documentRef.createTreeWalker(documentRef.head, NodeFilter.SHOW_COMMENT);
|
|
106
|
+
let start = null;
|
|
107
|
+
while (walker.nextNode()) {
|
|
108
|
+
const node = walker.currentNode;
|
|
109
|
+
if (node.data === "evolit:route-head:start") start = node;
|
|
110
|
+
if (node.data === "evolit:route-head:end" && start) return { start, end: node };
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function syncRouteHeadMarkup(markup, documentRef) {
|
|
116
|
+
if (typeof markup !== "string") return;
|
|
117
|
+
const markers = findRouteHeadMarkers(documentRef);
|
|
118
|
+
if (!markers) return;
|
|
119
|
+
const template = documentRef.createElement("template");
|
|
120
|
+
template.innerHTML = markup;
|
|
121
|
+
const range = documentRef.createRange();
|
|
122
|
+
range.setStartAfter(markers.start);
|
|
123
|
+
range.setEndBefore(markers.end);
|
|
124
|
+
range.deleteContents();
|
|
125
|
+
range.insertNode(template.content);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalizeManagedAttributes(attributes) {
|
|
129
|
+
return attributes && typeof attributes === "object" && !Array.isArray(attributes) ? attributes : {};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function readDocumentState(documentRef) {
|
|
133
|
+
const source = documentRef.getElementById("__EVOLIT_DOCUMENT__")?.textContent;
|
|
134
|
+
try { return source ? JSON.parse(source) : null; } catch { return null; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function syncElementAttributes(element, previousAttributes, nextAttributes) {
|
|
138
|
+
const previous = normalizeManagedAttributes(previousAttributes);
|
|
139
|
+
const next = normalizeManagedAttributes(nextAttributes);
|
|
140
|
+
for (const name of Object.keys(previous)) {
|
|
141
|
+
if (!(name in next)) element.removeAttribute(name);
|
|
142
|
+
}
|
|
143
|
+
for (const [name, value] of Object.entries(next)) {
|
|
144
|
+
if (value === true) element.setAttribute(name, "");
|
|
145
|
+
else element.setAttribute(name, String(value));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function syncDocumentState(nextState, documentRef) {
|
|
150
|
+
if (!nextState || typeof nextState !== "object") return;
|
|
151
|
+
const previous = readDocumentState(documentRef) ?? {};
|
|
152
|
+
syncElementAttributes(documentRef.documentElement, previous.htmlAttributes, nextState.htmlAttributes);
|
|
153
|
+
syncElementAttributes(documentRef.body, previous.bodyAttributes, nextState.bodyAttributes);
|
|
154
|
+
if (typeof nextState.lang === "string") documentRef.documentElement.lang = nextState.lang;
|
|
155
|
+
const script = documentRef.getElementById("__EVOLIT_DOCUMENT__");
|
|
156
|
+
if (script) script.textContent = JSON.stringify(nextState);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function applyRouteDelta(delta, documentRef = document, signal) {
|
|
160
|
+
throwIfAborted(signal);
|
|
161
|
+
const current = readRoute(documentRef);
|
|
162
|
+
const nextSegments = delta.route?.segments ?? [];
|
|
163
|
+
const currentSegments = current?.segments ?? [];
|
|
164
|
+
let index = nextSegments.findIndex((segment, offset) => segment.id !== currentSegments[offset]?.id);
|
|
165
|
+
if (index < 0) index = Math.max(0, nextSegments.length - 1);
|
|
166
|
+
const next = nextSegments[index];
|
|
167
|
+
const previous = currentSegments[index] ?? currentSegments.at(-1);
|
|
168
|
+
if (!next || !previous) return;
|
|
169
|
+
const targets = findMarkers(documentRef, previous.id);
|
|
170
|
+
await syncRouteHeadAssets(delta.headAssets, documentRef);
|
|
171
|
+
throwIfAborted(signal);
|
|
172
|
+
syncRouteHeadMarkup(delta.head, documentRef);
|
|
173
|
+
syncDocumentState(delta.document, documentRef);
|
|
174
|
+
const insertedRoots = [];
|
|
175
|
+
for (const [offset, target] of targets.entries()) {
|
|
176
|
+
throwIfAborted(signal);
|
|
177
|
+
const projection = next.projections[offset] ?? next.projections[0];
|
|
178
|
+
if (!projection) continue;
|
|
179
|
+
const template = documentRef.createElement("template");
|
|
180
|
+
template.innerHTML = projection.html;
|
|
181
|
+
const fragment = template.content;
|
|
182
|
+
materializeDeclarativeShadowDom(fragment);
|
|
183
|
+
const roots = (delta.hydrationData?.roots ?? []).map((root) => ({
|
|
184
|
+
root,
|
|
185
|
+
element: findHydrationElement(fragment, root.id),
|
|
186
|
+
})).filter((entry) => entry.element);
|
|
187
|
+
const range = documentRef.createRange();
|
|
188
|
+
range.setStartAfter(target.start);
|
|
189
|
+
range.setEndBefore(target.end);
|
|
190
|
+
range.deleteContents();
|
|
191
|
+
range.insertNode(fragment);
|
|
192
|
+
insertedRoots.push(...roots);
|
|
193
|
+
target.start.data = `evolit:segment:start:${next.id}`;
|
|
194
|
+
target.end.data = `evolit:segment:end:${next.id}`;
|
|
195
|
+
}
|
|
196
|
+
const script = documentRef.getElementById("__EVOLIT_ROUTE__");
|
|
197
|
+
if (script) script.textContent = JSON.stringify(delta.route);
|
|
198
|
+
if (delta.title) documentRef.title = delta.title;
|
|
199
|
+
throwIfAborted(signal);
|
|
200
|
+
await registerHydrationModules(
|
|
201
|
+
await Promise.all((delta.hydrationData?.clientImports ?? []).map((specifier) => import(specifier))),
|
|
202
|
+
);
|
|
203
|
+
for (const { root, element } of insertedRoots) {
|
|
204
|
+
throwIfAborted(signal);
|
|
205
|
+
await hydrateRoot(element, {
|
|
206
|
+
rootId: root.id,
|
|
207
|
+
hydrationData: delta.hydrationData,
|
|
208
|
+
clientImports: delta.hydrationData.clientImports,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function toHref(target, location) {
|
|
214
|
+
return new URL(target, location.href).href;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function toCacheKey(href) {
|
|
218
|
+
const url = new URL(href);
|
|
219
|
+
url.hash = "";
|
|
220
|
+
return url.href;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function getDeltaExpiry(delta, now = Date.now()) {
|
|
224
|
+
const policy = delta?.route?.cachePolicy;
|
|
225
|
+
if (!policy || policy.mode === "dynamic") return null;
|
|
226
|
+
if (policy.mode === "static") return Number.POSITIVE_INFINITY;
|
|
227
|
+
if (policy.mode === "revalidate" && Number.isFinite(policy.ttlSeconds)) {
|
|
228
|
+
return now + (policy.ttlSeconds * 1_000);
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function isNavigationResponse(response) {
|
|
234
|
+
return response.headers?.get("content-type")?.includes("application/vnd.evolit.navigation+json");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function shouldInterceptGetForm(event, windowRef) {
|
|
238
|
+
const form = event.target;
|
|
239
|
+
const submitter = event.submitter;
|
|
240
|
+
if (
|
|
241
|
+
event.defaultPrevented
|
|
242
|
+
|| form?.tagName?.toLowerCase?.() !== "form"
|
|
243
|
+
|| form.dataset?.evolitNavigation === "false"
|
|
244
|
+
|| submitter?.dataset?.evolitNavigation === "false"
|
|
245
|
+
) return false;
|
|
246
|
+
|
|
247
|
+
const method = String(submitter?.formMethod || form.method || "get").toLowerCase();
|
|
248
|
+
const target = submitter?.formTarget || form.target;
|
|
249
|
+
if (method !== "get" || (target && target !== "_self")) return false;
|
|
250
|
+
if (form.querySelector?.('input[type="file"]')) return false;
|
|
251
|
+
|
|
252
|
+
const action = submitter?.formAction || form.action || windowRef.location.href;
|
|
253
|
+
return new URL(action, windowRef.location.href).origin === windowRef.location.origin;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function createGetFormTarget(form, submitter, windowRef) {
|
|
257
|
+
const action = submitter?.formAction || form.action || windowRef.location.href;
|
|
258
|
+
const target = new URL(action, windowRef.location.href);
|
|
259
|
+
const data = new FormData(form, submitter);
|
|
260
|
+
const searchParams = new URLSearchParams();
|
|
261
|
+
for (const [name, value] of data) {
|
|
262
|
+
// File inputs are excluded before interception. Keep this defensive guard
|
|
263
|
+
// so an unusual FormData implementation cannot serialize a file as text.
|
|
264
|
+
if (typeof value !== "string") return null;
|
|
265
|
+
searchParams.append(name, value);
|
|
266
|
+
}
|
|
267
|
+
target.search = searchParams;
|
|
268
|
+
return target.href;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function currentScrollPosition(windowRef) {
|
|
272
|
+
return {
|
|
273
|
+
x: Number.isFinite(windowRef.scrollX) ? windowRef.scrollX : 0,
|
|
274
|
+
y: Number.isFinite(windowRef.scrollY) ? windowRef.scrollY : 0,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function findHashTarget(documentRef, href) {
|
|
279
|
+
const hash = new URL(href).hash.slice(1);
|
|
280
|
+
if (!hash) return null;
|
|
281
|
+
try {
|
|
282
|
+
return documentRef.getElementById(decodeURIComponent(hash))
|
|
283
|
+
?? documentRef.querySelector(`[name="${CSS.escape(decodeURIComponent(hash))}"]`);
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function restoreScrollAndFocus(windowRef, href, position) {
|
|
290
|
+
const target = findHashTarget(windowRef.document, href);
|
|
291
|
+
if (target?.scrollIntoView) {
|
|
292
|
+
target.scrollIntoView();
|
|
293
|
+
} else if (position) {
|
|
294
|
+
windowRef.scrollTo?.(position.x, position.y);
|
|
295
|
+
} else {
|
|
296
|
+
windowRef.scrollTo?.(0, 0);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const main = windowRef.document.querySelector?.("main");
|
|
300
|
+
if (main?.focus) {
|
|
301
|
+
if (!main.hasAttribute("tabindex")) main.setAttribute("tabindex", "-1");
|
|
302
|
+
main.focus({ preventScroll: true });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function createBrowserNavigation(options = {}) {
|
|
307
|
+
const windowRef = options.window ?? globalThis.window;
|
|
308
|
+
if (!windowRef?.history || !windowRef?.fetch) {
|
|
309
|
+
throw new Error("Browser navigation is only available in a browser context.");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const listeners = new Set();
|
|
313
|
+
const historyEntries = new Map();
|
|
314
|
+
let controller = null;
|
|
315
|
+
let navigationSequence = 0;
|
|
316
|
+
let state = { status: "idle", url: windowRef.location.href, pendingUrl: null, error: null };
|
|
317
|
+
const emit = () => listeners.forEach((listener) => listener(state));
|
|
318
|
+
const applyDelta = options.applyDelta
|
|
319
|
+
?? ((delta, { signal } = {}) => applyRouteDelta(delta, windowRef.document, signal));
|
|
320
|
+
|
|
321
|
+
function navigateDocument(href, mode) {
|
|
322
|
+
// This is deliberately a fallback, not the normal navigation path. It
|
|
323
|
+
// preserves correctness for responses that cannot be represented as an
|
|
324
|
+
// Evolit route delta (404s, external adapters, malformed payloads, ...).
|
|
325
|
+
if (mode === "replace") windowRef.location.replace(href);
|
|
326
|
+
else windowRef.location.assign(href);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function saveCurrentScrollPosition() {
|
|
330
|
+
windowRef.history.replaceState(
|
|
331
|
+
{ ...(windowRef.history.state ?? {}), __evolitScroll: currentScrollPosition(windowRef) },
|
|
332
|
+
"",
|
|
333
|
+
windowRef.location.href,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function createHistoryEntryId() {
|
|
338
|
+
nextHistoryEntryId += 1;
|
|
339
|
+
return `evolit-${historySessionId}-${nextHistoryEntryId}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function ensureHistoryEntry(entryId = windowRef.history.state?.__evolitNavigationEntry) {
|
|
343
|
+
const id = typeof entryId === "string" ? entryId : createHistoryEntryId();
|
|
344
|
+
if (!historyEntries.has(id)) historyEntries.set(id, { parentId: null, url: null, delta: null, expiresAt: null });
|
|
345
|
+
if (windowRef.history.state?.__evolitNavigationEntry !== id) {
|
|
346
|
+
windowRef.history.replaceState(
|
|
347
|
+
{ ...(windowRef.history.state ?? {}), __evolitNavigationEntry: id },
|
|
348
|
+
"",
|
|
349
|
+
windowRef.location.href,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
return id;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function removeForwardHistoryEntries(currentEntryId) {
|
|
356
|
+
const retained = new Set();
|
|
357
|
+
let entryId = currentEntryId;
|
|
358
|
+
while (entryId && !retained.has(entryId)) {
|
|
359
|
+
retained.add(entryId);
|
|
360
|
+
entryId = historyEntries.get(entryId)?.parentId ?? null;
|
|
361
|
+
}
|
|
362
|
+
for (const id of historyEntries.keys()) {
|
|
363
|
+
if (!retained.has(id)) historyEntries.delete(id);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function getCachedDelta(entryId, cacheKey, force) {
|
|
368
|
+
if (force) return null;
|
|
369
|
+
const entry = historyEntries.get(entryId);
|
|
370
|
+
if (!entry || entry.url !== cacheKey || entry.expiresAt == null || entry.expiresAt <= Date.now()) {
|
|
371
|
+
if (entry?.expiresAt != null && entry.expiresAt <= Date.now()) entry.delta = null;
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
return entry.delta;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function cacheDelta(entryId, cacheKey, delta) {
|
|
378
|
+
const expiresAt = getDeltaExpiry(delta);
|
|
379
|
+
if (expiresAt == null) return;
|
|
380
|
+
const entry = historyEntries.get(entryId);
|
|
381
|
+
if (!entry) return;
|
|
382
|
+
entry.url = cacheKey;
|
|
383
|
+
entry.delta = delta;
|
|
384
|
+
entry.expiresAt = expiresAt;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function navigate(target, mode = "push", fromPopState = false, options = {}) {
|
|
388
|
+
const href = toHref(target, windowRef.location);
|
|
389
|
+
const cacheKey = toCacheKey(href);
|
|
390
|
+
controller?.abort();
|
|
391
|
+
const navigationController = new AbortController();
|
|
392
|
+
controller = navigationController;
|
|
393
|
+
const navigationId = ++navigationSequence;
|
|
394
|
+
const isCurrent = () => navigationId === navigationSequence && !navigationController.signal.aborted;
|
|
395
|
+
const currentEntryId = ensureHistoryEntry();
|
|
396
|
+
let entryId = options.historyEntryId;
|
|
397
|
+
if (fromPopState) {
|
|
398
|
+
entryId = ensureHistoryEntry(entryId);
|
|
399
|
+
} else if (mode === "push") {
|
|
400
|
+
saveCurrentScrollPosition();
|
|
401
|
+
removeForwardHistoryEntries(currentEntryId);
|
|
402
|
+
entryId = createHistoryEntryId();
|
|
403
|
+
historyEntries.set(entryId, { parentId: currentEntryId, url: null, delta: null, expiresAt: null });
|
|
404
|
+
} else {
|
|
405
|
+
entryId = currentEntryId;
|
|
406
|
+
}
|
|
407
|
+
state = { ...state, status: "pending", pendingUrl: href, error: null };
|
|
408
|
+
emit();
|
|
409
|
+
try {
|
|
410
|
+
const cached = getCachedDelta(entryId, cacheKey, options.force);
|
|
411
|
+
const delta = cached
|
|
412
|
+
? cached
|
|
413
|
+
: await (async () => {
|
|
414
|
+
const response = await windowRef.fetch(href, {
|
|
415
|
+
headers: { accept: "application/vnd.evolit.navigation+json" },
|
|
416
|
+
// A document response may already be fresh in the browser HTTP cache.
|
|
417
|
+
// Navigation is a different representation of that URL, so it must
|
|
418
|
+
// reach the server rather than reusing the cached HTML document.
|
|
419
|
+
cache: "no-store",
|
|
420
|
+
signal: navigationController.signal,
|
|
421
|
+
credentials: "same-origin",
|
|
422
|
+
});
|
|
423
|
+
if (!isNavigationResponse(response)) {
|
|
424
|
+
navigateDocument(response.url || href, mode);
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
let nextDelta;
|
|
428
|
+
try {
|
|
429
|
+
nextDelta = await response.json();
|
|
430
|
+
} catch {
|
|
431
|
+
navigateDocument(response.url || href, mode);
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
if (nextDelta?.type !== "route" && nextDelta?.type !== "redirect") {
|
|
435
|
+
navigateDocument(response.url || href, mode);
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
if (!isCurrent()) return null;
|
|
439
|
+
return nextDelta;
|
|
440
|
+
})();
|
|
441
|
+
if (!delta || !isCurrent()) return null;
|
|
442
|
+
if (delta.type === "redirect") return navigate(delta.location, "replace", false);
|
|
443
|
+
await applyDelta(delta, { signal: navigationController.signal });
|
|
444
|
+
if (!isCurrent()) return null;
|
|
445
|
+
const canonicalHref = toHref(delta.url ?? href, windowRef.location);
|
|
446
|
+
if (!fromPopState) {
|
|
447
|
+
windowRef.history[mode === "replace" ? "replaceState" : "pushState"](
|
|
448
|
+
{
|
|
449
|
+
...(mode === "replace" ? windowRef.history.state : {}),
|
|
450
|
+
__evolitNavigationEntry: entryId,
|
|
451
|
+
__evolitScroll: { x: 0, y: 0 },
|
|
452
|
+
},
|
|
453
|
+
"",
|
|
454
|
+
canonicalHref,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
cacheDelta(entryId, toCacheKey(canonicalHref), delta);
|
|
458
|
+
restoreScrollAndFocus(windowRef, canonicalHref, options.scrollPosition);
|
|
459
|
+
state = { status: "idle", url: canonicalHref, pendingUrl: null, error: null };
|
|
460
|
+
emit();
|
|
461
|
+
return delta;
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (error?.name === "AbortError" || !isCurrent()) return null;
|
|
464
|
+
state = { ...state, status: "error", pendingUrl: null, error };
|
|
465
|
+
emit();
|
|
466
|
+
throw error;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
windowRef.addEventListener("popstate", (event) => {
|
|
471
|
+
void navigate(windowRef.location.href, "replace", true, {
|
|
472
|
+
historyEntryId: event.state?.__evolitNavigationEntry,
|
|
473
|
+
scrollPosition: event.state?.__evolitScroll ?? null,
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
windowRef.document.addEventListener("click", (event) => {
|
|
477
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
478
|
+
const anchor = event.target?.closest?.("a[href]");
|
|
479
|
+
if (!anchor || anchor.target || anchor.hasAttribute("download") || anchor.dataset.evolitNavigation === "false") return;
|
|
480
|
+
const target = new URL(anchor.href, windowRef.location.href);
|
|
481
|
+
if (target.origin !== windowRef.location.origin) return;
|
|
482
|
+
event.preventDefault();
|
|
483
|
+
void navigate(target.href, "push");
|
|
484
|
+
});
|
|
485
|
+
windowRef.document.addEventListener("submit", (event) => {
|
|
486
|
+
if (!shouldInterceptGetForm(event, windowRef)) return;
|
|
487
|
+
const target = createGetFormTarget(event.target, event.submitter, windowRef);
|
|
488
|
+
if (!target) return;
|
|
489
|
+
event.preventDefault();
|
|
490
|
+
void navigate(target, "push");
|
|
491
|
+
});
|
|
492
|
+
return {
|
|
493
|
+
getState: () => state,
|
|
494
|
+
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
|
495
|
+
push: (target) => navigate(target, "push"),
|
|
496
|
+
replace: (target) => navigate(target, "replace"),
|
|
497
|
+
refresh: () => navigate(windowRef.location.href, "replace", false, { force: true }),
|
|
498
|
+
createHref,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export function getNavigation() {
|
|
503
|
+
browserNavigation ??= createBrowserNavigation();
|
|
504
|
+
return browserNavigation;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function useNavigation() {
|
|
508
|
+
const navigation = getNavigation();
|
|
509
|
+
const host = useHost();
|
|
510
|
+
const [state, setState] = useState(host, () => navigation.getState());
|
|
511
|
+
useOnConnect(host, () => navigation.subscribe(setState), [navigation]);
|
|
512
|
+
return {
|
|
513
|
+
...state,
|
|
514
|
+
push: navigation.push,
|
|
515
|
+
replace: navigation.replace,
|
|
516
|
+
refresh: navigation.refresh,
|
|
517
|
+
createHref: navigation.createHref,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates an internal navigation target without forcing callers to serialize
|
|
3
|
+
* query parameters themselves. This module is deliberately browser-free so
|
|
4
|
+
* it can also be shared by route code that is evaluated during SSR.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} pathname An internal pathname, optionally with a hash.
|
|
7
|
+
* @param {URLSearchParams} [searchParams] Query parameters for the target.
|
|
8
|
+
* @returns {string} A relative URL suitable for navigation.
|
|
9
|
+
*/
|
|
10
|
+
export function createHref(pathname, searchParams) {
|
|
11
|
+
if (typeof pathname !== "string" || !pathname.startsWith("/")) {
|
|
12
|
+
throw new TypeError("createHref() expects an internal pathname starting with '/'.");
|
|
13
|
+
}
|
|
14
|
+
if (searchParams != null && !(searchParams instanceof URLSearchParams)) {
|
|
15
|
+
throw new TypeError("createHref() expects URLSearchParams when query parameters are provided.");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const hashIndex = pathname.indexOf("#");
|
|
19
|
+
const pathWithPossibleSearch = hashIndex < 0 ? pathname : pathname.slice(0, hashIndex);
|
|
20
|
+
const hash = hashIndex < 0 ? "" : pathname.slice(hashIndex);
|
|
21
|
+
const queryIndex = pathWithPossibleSearch.indexOf("?");
|
|
22
|
+
const path = queryIndex < 0 ? pathWithPossibleSearch : pathWithPossibleSearch.slice(0, queryIndex);
|
|
23
|
+
const query = searchParams == null
|
|
24
|
+
? pathWithPossibleSearch.slice(queryIndex + 1)
|
|
25
|
+
: searchParams.toString();
|
|
26
|
+
|
|
27
|
+
return `${path}${query ? `?${query}` : ""}${hash}`;
|
|
28
|
+
}
|