@ilha/router 0.1.1 → 0.2.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 +386 -37
- package/dist/index.d.ts +128 -17
- package/dist/index.js +350 -55
- package/dist/vite.js +125 -13
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -2,6 +2,54 @@ import ilha, { context, html, mount } from "ilha";
|
|
|
2
2
|
import { addRoute, createRouter, findRoute } from "rou3";
|
|
3
3
|
//#region src/index.ts
|
|
4
4
|
const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
|
|
5
|
+
/**
|
|
6
|
+
* Identity function for declaring a loader. Exists purely as a type anchor and
|
|
7
|
+
* a marker for the Vite plugin to detect by export name.
|
|
8
|
+
*/
|
|
9
|
+
function loader(fn) {
|
|
10
|
+
return fn;
|
|
11
|
+
}
|
|
12
|
+
var Redirect = class {
|
|
13
|
+
__ilhaRedirect = true;
|
|
14
|
+
to;
|
|
15
|
+
status;
|
|
16
|
+
constructor(to, status = 302) {
|
|
17
|
+
this.to = to;
|
|
18
|
+
this.status = status;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
var LoaderError = class {
|
|
22
|
+
__ilhaLoaderError = true;
|
|
23
|
+
status;
|
|
24
|
+
message;
|
|
25
|
+
constructor(status, message) {
|
|
26
|
+
this.status = status;
|
|
27
|
+
this.message = message;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
function redirect(to, status = 302) {
|
|
31
|
+
throw new Redirect(to, status);
|
|
32
|
+
}
|
|
33
|
+
function error(status, message) {
|
|
34
|
+
throw new LoaderError(status, message);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Compose a list of loaders into a single loader. Later loaders win on key
|
|
38
|
+
* collision (page loader overrides layout loader for the same key). All loaders
|
|
39
|
+
* run concurrently within a chain since they share the same abort signal and
|
|
40
|
+
* request — re-fetching is cheap with a request-scoped cache (future work).
|
|
41
|
+
*
|
|
42
|
+
* For v1 we run them in parallel via `Promise.all`. If a loader throws a
|
|
43
|
+
* `Redirect` or `LoaderError`, the composed loader re-throws it unchanged.
|
|
44
|
+
*/
|
|
45
|
+
function composeLoaders(loaders) {
|
|
46
|
+
if (loaders.length === 0) return async () => ({});
|
|
47
|
+
if (loaders.length === 1) return loaders[0];
|
|
48
|
+
return async (ctx) => {
|
|
49
|
+
const results = await Promise.all(loaders.map((l) => l(ctx)));
|
|
50
|
+
return Object.assign({}, ...results);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
5
53
|
function wrapLayout(layout, page) {
|
|
6
54
|
return layout(page);
|
|
7
55
|
}
|
|
@@ -44,33 +92,114 @@ function wrapError(handler, page) {
|
|
|
44
92
|
};
|
|
45
93
|
return wrapper;
|
|
46
94
|
}
|
|
95
|
+
function defineLayout(layout) {
|
|
96
|
+
return layout;
|
|
97
|
+
}
|
|
47
98
|
function buildReverseRegistry(registry) {
|
|
48
99
|
const map = /* @__PURE__ */ new Map();
|
|
49
100
|
for (const [name, island] of Object.entries(registry)) if (!map.has(island)) map.set(island, name);
|
|
50
101
|
return map;
|
|
51
102
|
}
|
|
103
|
+
/** Path of the loader endpoint served by the Vite plugin / production adapter. */
|
|
104
|
+
const LOADER_ENDPOINT = "/__ilha/loader";
|
|
105
|
+
/** In-memory cache for prefetched loader data, keyed by path+search. */
|
|
106
|
+
const prefetchCache = /* @__PURE__ */ new Map();
|
|
107
|
+
async function fetchLoaderData(pathWithSearch, signal) {
|
|
108
|
+
const cached = prefetchCache.get(pathWithSearch);
|
|
109
|
+
if (cached) {
|
|
110
|
+
prefetchCache.delete(pathWithSearch);
|
|
111
|
+
try {
|
|
112
|
+
return await cached;
|
|
113
|
+
} catch {}
|
|
114
|
+
}
|
|
115
|
+
const url = `${LOADER_ENDPOINT}?path=${encodeURIComponent(pathWithSearch)}`;
|
|
116
|
+
try {
|
|
117
|
+
const res = await fetch(url, {
|
|
118
|
+
signal,
|
|
119
|
+
headers: { accept: "application/json" }
|
|
120
|
+
});
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
try {
|
|
123
|
+
const body = await res.json();
|
|
124
|
+
if (body && typeof body === "object" && "kind" in body) return body;
|
|
125
|
+
} catch {}
|
|
126
|
+
return {
|
|
127
|
+
kind: "error",
|
|
128
|
+
status: res.status,
|
|
129
|
+
message: res.statusText
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return await res.json();
|
|
133
|
+
} catch (e) {
|
|
134
|
+
if (e?.name === "AbortError") throw e;
|
|
135
|
+
return {
|
|
136
|
+
kind: "error",
|
|
137
|
+
status: 0,
|
|
138
|
+
message: e?.message ?? "network error"
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Prefetch loader data for a given path. Safe to call repeatedly — a single
|
|
144
|
+
* inflight request is reused until it either resolves (and is consumed by
|
|
145
|
+
* navigation) or is superseded by another prefetch.
|
|
146
|
+
*/
|
|
147
|
+
function prefetch(pathWithSearch) {
|
|
148
|
+
if (!isBrowser) return;
|
|
149
|
+
if (prefetchCache.has(pathWithSearch)) return;
|
|
150
|
+
const pathOnly = pathWithSearch.split("?")[0] ?? "";
|
|
151
|
+
if (!findRoute(_rou3, "GET", pathOnly)?.data?.loader) return;
|
|
152
|
+
const promise = fetchLoaderData(pathWithSearch).catch((e) => {
|
|
153
|
+
return {
|
|
154
|
+
kind: "error",
|
|
155
|
+
status: 0,
|
|
156
|
+
message: e?.message ?? "prefetch failed"
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
prefetchCache.set(pathWithSearch, promise);
|
|
160
|
+
}
|
|
52
161
|
/**
|
|
53
162
|
* Mounts a route island with proper hydration for client-side navigation.
|
|
54
|
-
* Looks up the island in the reverse registry,
|
|
55
|
-
* markers, and mounts it for interactivity.
|
|
163
|
+
* Looks up the island in the reverse registry, runs the loader (via fetch),
|
|
164
|
+
* renders it with hydration markers, and mounts it for interactivity.
|
|
56
165
|
*/
|
|
57
|
-
async function mountRouteWithHydration(island, host, registry, reverseRegistry) {
|
|
166
|
+
async function mountRouteWithHydration(island, host, pathWithSearch, signal, registry, reverseRegistry) {
|
|
58
167
|
if (!island) {
|
|
59
168
|
host.innerHTML = `<div data-router-empty></div>`;
|
|
60
169
|
return () => {};
|
|
61
170
|
}
|
|
171
|
+
const hasLoader = !!findRoute(_rou3, "GET", pathWithSearch.split("?")[0] ?? "")?.data?.loader;
|
|
172
|
+
let props = {};
|
|
173
|
+
const loaderResult = hasLoader ? await fetchLoaderData(pathWithSearch, signal) : {
|
|
174
|
+
kind: "data",
|
|
175
|
+
data: {}
|
|
176
|
+
};
|
|
177
|
+
if (loaderResult.kind === "redirect") {
|
|
178
|
+
navigate(loaderResult.to, { replace: true });
|
|
179
|
+
return () => {};
|
|
180
|
+
}
|
|
181
|
+
if (loaderResult.kind === "error") {
|
|
182
|
+
const escaped = String(loaderResult.message).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
183
|
+
host.innerHTML = `<div data-router-view data-router-error="${loaderResult.status}">${escaped}</div>`;
|
|
184
|
+
return () => {};
|
|
185
|
+
}
|
|
186
|
+
if (loaderResult.kind === "not-found") {
|
|
187
|
+
host.innerHTML = `<div data-router-empty></div>`;
|
|
188
|
+
return () => {};
|
|
189
|
+
}
|
|
190
|
+
props = loaderResult.data;
|
|
62
191
|
if (!registry) {
|
|
63
192
|
console.warn("[ilha-router] No registry provided for client-side navigation. Island will not be interactive.");
|
|
64
|
-
host.innerHTML = `<div data-router-view>${island.toString()}</div>`;
|
|
193
|
+
host.innerHTML = `<div data-router-view>${island.toString(props)}</div>`;
|
|
65
194
|
return () => {};
|
|
66
195
|
}
|
|
67
196
|
const name = reverseRegistry?.get(island) ?? Object.entries(registry).find(([, v]) => v === island)?.[0];
|
|
68
197
|
if (!name) {
|
|
69
198
|
console.warn("[ilha-router] Island not found in registry for client-side navigation.");
|
|
70
|
-
host.innerHTML = `<div data-router-view>${island.toString()}</div>`;
|
|
199
|
+
host.innerHTML = `<div data-router-view>${island.toString(props)}</div>`;
|
|
71
200
|
return () => {};
|
|
72
201
|
}
|
|
73
|
-
host.innerHTML = `<div data-router-view>${await island.hydratable(
|
|
202
|
+
host.innerHTML = `<div data-router-view>${await island.hydratable(props, {
|
|
74
203
|
name,
|
|
75
204
|
as: "div",
|
|
76
205
|
snapshot: true
|
|
@@ -95,6 +224,7 @@ const activeIsland = context("router.active", null);
|
|
|
95
224
|
let _records = [];
|
|
96
225
|
let _rou3 = createRouter();
|
|
97
226
|
let _islandToPattern = /* @__PURE__ */ new Map();
|
|
227
|
+
let _patternToData = /* @__PURE__ */ new Map();
|
|
98
228
|
function extractParams(matchParams) {
|
|
99
229
|
const params = {};
|
|
100
230
|
if (matchParams) for (const [k, v] of Object.entries(matchParams)) params[k] = decodeURIComponent(v);
|
|
@@ -107,7 +237,7 @@ function syncRouteFromURL(url) {
|
|
|
107
237
|
routeParams(extractParams(match?.params));
|
|
108
238
|
routeSearch(parsed.search);
|
|
109
239
|
routeHash(parsed.hash);
|
|
110
|
-
activeIsland(match?.data ?? null);
|
|
240
|
+
activeIsland(match?.data?.island ?? null);
|
|
111
241
|
}
|
|
112
242
|
/** Client-only fast path — reads directly from `location` instead of parsing a URL. */
|
|
113
243
|
function syncRouteFromLocation() {
|
|
@@ -116,25 +246,12 @@ function syncRouteFromLocation() {
|
|
|
116
246
|
routeParams(extractParams(match?.params));
|
|
117
247
|
routeSearch(location.search);
|
|
118
248
|
routeHash(location.hash);
|
|
119
|
-
activeIsland(match?.data ?? null);
|
|
249
|
+
activeIsland(match?.data?.island ?? null);
|
|
120
250
|
}
|
|
121
251
|
/**
|
|
122
252
|
* Prime route context signals from the current `location` so that islands
|
|
123
253
|
* hydrated by `ilha.mount()` see the correct route values on their first
|
|
124
254
|
* render — preventing a mismatch morph that would destroy hydrated bindings.
|
|
125
|
-
*
|
|
126
|
-
* Call this **before** `ilha.mount()` and **after** all routes have been
|
|
127
|
-
* registered (i.e. after the `router().route(…).route(…)` chain).
|
|
128
|
-
*
|
|
129
|
-
* ```ts
|
|
130
|
-
* import { mount } from "ilha";
|
|
131
|
-
* import { pageRouter } from "ilha:pages";
|
|
132
|
-
* import { registry } from "ilha:registry";
|
|
133
|
-
*
|
|
134
|
-
* pageRouter.prime(); // ← sync signals first
|
|
135
|
-
* mount(registry, { root: … }); // ← then hydrate islands
|
|
136
|
-
* pageRouter.mount("#app", { hydrate: true });
|
|
137
|
-
* ```
|
|
138
255
|
*/
|
|
139
256
|
function prime() {
|
|
140
257
|
if (isBrowser) syncRouteFromLocation();
|
|
@@ -146,25 +263,42 @@ function navigate(to, opts = {}) {
|
|
|
146
263
|
else history.pushState(null, "", to);
|
|
147
264
|
syncRouteFromLocation();
|
|
148
265
|
}
|
|
149
|
-
function enableLinkInterception(root = document) {
|
|
266
|
+
function enableLinkInterception(root = document, options = {}) {
|
|
150
267
|
if (!isBrowser) return () => {};
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (!target) return;
|
|
268
|
+
const prefetchEnabled = options.prefetch !== false;
|
|
269
|
+
/** Determine whether this anchor is a same-origin in-app link we should handle. */
|
|
270
|
+
function eligibleLink(target, e) {
|
|
155
271
|
const href = target.getAttribute("href");
|
|
156
|
-
if (!href) return;
|
|
272
|
+
if (!href) return false;
|
|
157
273
|
const isAnchorOnly = href.startsWith("#");
|
|
158
274
|
const isBlank = target.getAttribute("target") === "_blank";
|
|
159
|
-
const hasModifier = e.ctrlKey || e.metaKey || e.shiftKey;
|
|
275
|
+
const hasModifier = !!e && (e.ctrlKey || e.metaKey || e.shiftKey);
|
|
160
276
|
const isExternal = !!target.hostname && (target.hostname !== location.hostname || target.protocol !== location.protocol);
|
|
161
277
|
const hasNoIntercept = target.hasAttribute("data-no-intercept");
|
|
162
|
-
|
|
278
|
+
return !(isExternal || isAnchorOnly || isBlank || hasModifier || hasNoIntercept);
|
|
279
|
+
}
|
|
280
|
+
const clickHandler = (e) => {
|
|
281
|
+
if (e.defaultPrevented) return;
|
|
282
|
+
const target = e.target.closest("a");
|
|
283
|
+
if (!target) return;
|
|
284
|
+
if (!eligibleLink(target, e)) return;
|
|
163
285
|
e.preventDefault();
|
|
164
286
|
navigate(target.pathname + target.search + target.hash);
|
|
165
287
|
};
|
|
166
|
-
|
|
167
|
-
|
|
288
|
+
const hoverHandler = (e) => {
|
|
289
|
+
const target = e.target.closest("a");
|
|
290
|
+
if (!target) return;
|
|
291
|
+
const flag = target.getAttribute("data-prefetch");
|
|
292
|
+
if (flag === null || flag === "false") return;
|
|
293
|
+
if (!eligibleLink(target)) return;
|
|
294
|
+
prefetch(target.pathname + target.search);
|
|
295
|
+
};
|
|
296
|
+
root.addEventListener("click", clickHandler);
|
|
297
|
+
if (prefetchEnabled) root.addEventListener("mouseover", hoverHandler, { passive: true });
|
|
298
|
+
return () => {
|
|
299
|
+
root.removeEventListener("click", clickHandler);
|
|
300
|
+
if (prefetchEnabled) root.removeEventListener("mouseover", hoverHandler);
|
|
301
|
+
};
|
|
168
302
|
}
|
|
169
303
|
const RouterView = ilha.render(() => {
|
|
170
304
|
const island = activeIsland();
|
|
@@ -174,28 +308,100 @@ const RouterView = ilha.render(() => {
|
|
|
174
308
|
const RouterLink = ilha.state("href", "").state("label", "").on("[data-link]@click", ({ state, event }) => {
|
|
175
309
|
event.preventDefault();
|
|
176
310
|
navigate(state.href());
|
|
177
|
-
}).
|
|
311
|
+
}).on("[data-link]@mouseenter", ({ state }) => {
|
|
312
|
+
const href = state.href();
|
|
313
|
+
if (!href) return;
|
|
314
|
+
if (/^https?:\/\//i.test(href)) try {
|
|
315
|
+
const u = new URL(href);
|
|
316
|
+
if (u.origin !== location.origin) return;
|
|
317
|
+
prefetch(u.pathname + u.search);
|
|
318
|
+
return;
|
|
319
|
+
} catch {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
prefetch(href);
|
|
323
|
+
}).render(({ state }) => html`<a data-link data-prefetch href="${state.href}">${state.label}</a>`);
|
|
178
324
|
function isActive(pattern) {
|
|
179
325
|
const match = findRoute(_rou3, "GET", routePath());
|
|
180
326
|
if (!match) return false;
|
|
181
|
-
return _islandToPattern.get(match.data) === pattern;
|
|
327
|
+
return _islandToPattern.get(match.data.island) === pattern;
|
|
328
|
+
}
|
|
329
|
+
function parsedURL(url) {
|
|
330
|
+
return typeof url === "string" ? new URL(url, "http://localhost") : url;
|
|
331
|
+
}
|
|
332
|
+
function defaultRequest(url) {
|
|
333
|
+
try {
|
|
334
|
+
return new Request(url.toString());
|
|
335
|
+
} catch {
|
|
336
|
+
return {
|
|
337
|
+
url: url.toString(),
|
|
338
|
+
headers: new Headers()
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
async function executeLoader(loader, url, params, request, signal) {
|
|
343
|
+
try {
|
|
344
|
+
return {
|
|
345
|
+
kind: "data",
|
|
346
|
+
data: await loader({
|
|
347
|
+
params,
|
|
348
|
+
request,
|
|
349
|
+
url,
|
|
350
|
+
signal
|
|
351
|
+
}) ?? {}
|
|
352
|
+
};
|
|
353
|
+
} catch (e) {
|
|
354
|
+
if (e instanceof Redirect) return {
|
|
355
|
+
kind: "redirect",
|
|
356
|
+
to: e.to,
|
|
357
|
+
status: e.status
|
|
358
|
+
};
|
|
359
|
+
if (e instanceof LoaderError) return {
|
|
360
|
+
kind: "error",
|
|
361
|
+
status: e.status,
|
|
362
|
+
message: e.message
|
|
363
|
+
};
|
|
364
|
+
return {
|
|
365
|
+
kind: "error",
|
|
366
|
+
status: e?.status ?? 500,
|
|
367
|
+
message: e?.message ?? "Loader failed"
|
|
368
|
+
};
|
|
369
|
+
}
|
|
182
370
|
}
|
|
183
371
|
function router() {
|
|
184
372
|
_records = [];
|
|
185
373
|
_rou3 = createRouter();
|
|
186
374
|
_islandToPattern = /* @__PURE__ */ new Map();
|
|
375
|
+
_patternToData = /* @__PURE__ */ new Map();
|
|
187
376
|
let _popstateCleanup = null;
|
|
188
377
|
let _linkCleanup = null;
|
|
189
378
|
const builder = {
|
|
190
|
-
route(pattern, island) {
|
|
379
|
+
route(pattern, island, loader) {
|
|
380
|
+
const data = {
|
|
381
|
+
island,
|
|
382
|
+
loader
|
|
383
|
+
};
|
|
191
384
|
_records.push({
|
|
192
385
|
pattern,
|
|
193
|
-
island
|
|
386
|
+
island,
|
|
387
|
+
loader
|
|
194
388
|
});
|
|
195
|
-
addRoute(_rou3, "GET", pattern,
|
|
389
|
+
addRoute(_rou3, "GET", pattern, data);
|
|
390
|
+
_patternToData.set(pattern, data);
|
|
196
391
|
if (!_islandToPattern.has(island)) _islandToPattern.set(island, pattern);
|
|
197
392
|
return builder;
|
|
198
393
|
},
|
|
394
|
+
attachLoader(pattern, loader) {
|
|
395
|
+
const data = _patternToData.get(pattern);
|
|
396
|
+
if (!data) {
|
|
397
|
+
console.warn(`[ilha-router] attachLoader("${pattern}", …): pattern was never registered via .route(). The loader will be ignored.`);
|
|
398
|
+
return builder;
|
|
399
|
+
}
|
|
400
|
+
data.loader = loader;
|
|
401
|
+
const rec = _records.find((r) => r.pattern === pattern);
|
|
402
|
+
if (rec) rec.loader = loader;
|
|
403
|
+
return builder;
|
|
404
|
+
},
|
|
199
405
|
prime,
|
|
200
406
|
mount(target, { hydrate = false, registry } = {}) {
|
|
201
407
|
if (!isBrowser) {
|
|
@@ -213,6 +419,7 @@ function router() {
|
|
|
213
419
|
_popstateCleanup = () => window.removeEventListener("popstate", popHandler);
|
|
214
420
|
_linkCleanup = enableLinkInterception(document);
|
|
215
421
|
let unmountView = null;
|
|
422
|
+
let navAbort = null;
|
|
216
423
|
if (hydrate) {
|
|
217
424
|
const viewHost = host.querySelector("[data-router-view]") ?? host;
|
|
218
425
|
let currentMountedIsland = activeIsland();
|
|
@@ -222,10 +429,18 @@ function router() {
|
|
|
222
429
|
const current = activeIsland();
|
|
223
430
|
if (current !== currentMountedIsland) {
|
|
224
431
|
const thisNav = ++navVersion;
|
|
432
|
+
navAbort?.abort();
|
|
433
|
+
navAbort = new AbortController();
|
|
434
|
+
const signal = navAbort.signal;
|
|
225
435
|
queueMicrotask(async () => {
|
|
226
436
|
if (thisNav !== navVersion) return;
|
|
227
437
|
unmountView?.();
|
|
228
|
-
|
|
438
|
+
try {
|
|
439
|
+
unmountView = await mountRouteWithHydration(current, viewHost, location.pathname + location.search, signal, registry, reverseRegistry);
|
|
440
|
+
} catch (e) {
|
|
441
|
+
if (e?.name === "AbortError") return;
|
|
442
|
+
throw e;
|
|
443
|
+
}
|
|
229
444
|
currentMountedIsland = current;
|
|
230
445
|
});
|
|
231
446
|
}
|
|
@@ -237,6 +452,7 @@ function router() {
|
|
|
237
452
|
const unmountNavHandler = navHandler.mount(navHost);
|
|
238
453
|
return () => {
|
|
239
454
|
++navVersion;
|
|
455
|
+
navAbort?.abort();
|
|
240
456
|
unmountNavHandler();
|
|
241
457
|
navHost.remove();
|
|
242
458
|
unmountView?.();
|
|
@@ -250,23 +466,43 @@ function router() {
|
|
|
250
466
|
let currentMountedIsland = null;
|
|
251
467
|
let navVersion = 0;
|
|
252
468
|
unmountView = RouterView.mount(host);
|
|
253
|
-
/**
|
|
254
|
-
|
|
469
|
+
/**
|
|
470
|
+
* Fetch loader data and mount the active island. SPA mode also fetches
|
|
471
|
+
* from the loader endpoint — otherwise navigation after the initial SSR
|
|
472
|
+
* render would have no access to loader data.
|
|
473
|
+
*/
|
|
474
|
+
async function mountActiveIsland(island, signal) {
|
|
255
475
|
unmountIsland?.();
|
|
256
476
|
unmountIsland = null;
|
|
257
477
|
currentMountedIsland = island;
|
|
258
478
|
if (!island) return;
|
|
259
479
|
const viewHost = host?.querySelector("[data-router-view]");
|
|
260
|
-
if (viewHost)
|
|
480
|
+
if (!viewHost) return;
|
|
481
|
+
const result = !!findRoute(_rou3, "GET", location.pathname)?.data?.loader ? await fetchLoaderData(location.pathname + location.search, signal) : {
|
|
482
|
+
kind: "data",
|
|
483
|
+
data: {}
|
|
484
|
+
};
|
|
485
|
+
if (signal.aborted) return;
|
|
486
|
+
if (result.kind === "redirect") {
|
|
487
|
+
navigate(result.to, { replace: true });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const props = result.kind === "data" ? result.data : {};
|
|
491
|
+
viewHost.innerHTML = island.toString(props);
|
|
492
|
+
unmountIsland = island.mount(viewHost, props);
|
|
261
493
|
}
|
|
262
|
-
|
|
494
|
+
navAbort = new AbortController();
|
|
495
|
+
mountActiveIsland(activeIsland(), navAbort.signal);
|
|
263
496
|
const navHandler = ilha.render(() => {
|
|
264
497
|
const current = activeIsland();
|
|
265
498
|
if (current !== currentMountedIsland) {
|
|
266
499
|
const thisNav = ++navVersion;
|
|
500
|
+
navAbort?.abort();
|
|
501
|
+
navAbort = new AbortController();
|
|
502
|
+
const signal = navAbort.signal;
|
|
267
503
|
queueMicrotask(() => {
|
|
268
504
|
if (thisNav !== navVersion) return;
|
|
269
|
-
mountActiveIsland(current);
|
|
505
|
+
mountActiveIsland(current, signal);
|
|
270
506
|
});
|
|
271
507
|
}
|
|
272
508
|
return "";
|
|
@@ -277,6 +513,7 @@ function router() {
|
|
|
277
513
|
const unmountNavHandler = navHandler.mount(navHost);
|
|
278
514
|
return () => {
|
|
279
515
|
++navVersion;
|
|
516
|
+
navAbort?.abort();
|
|
280
517
|
unmountIsland?.();
|
|
281
518
|
unmountNavHandler();
|
|
282
519
|
navHost.remove();
|
|
@@ -291,21 +528,74 @@ function router() {
|
|
|
291
528
|
syncRouteFromURL(url);
|
|
292
529
|
return RouterView.toString();
|
|
293
530
|
},
|
|
294
|
-
async renderHydratable(url, registry, options = {}) {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
if (
|
|
531
|
+
async renderHydratable(url, registry, options = {}, request) {
|
|
532
|
+
const response = await this.renderResponse(url, registry, options, request);
|
|
533
|
+
if (response.kind === "html") return response.html;
|
|
534
|
+
if (response.kind === "error") return response.html;
|
|
535
|
+
return `<meta http-equiv="refresh" content="0; url=${response.to}">`;
|
|
536
|
+
},
|
|
537
|
+
async renderResponse(url, registry, options = {}, request) {
|
|
538
|
+
const parsed = parsedURL(url);
|
|
539
|
+
syncRouteFromURL(parsed);
|
|
540
|
+
const match = findRoute(_rou3, "GET", parsed.pathname);
|
|
541
|
+
const island = match?.data?.island ?? null;
|
|
542
|
+
if (!island) return {
|
|
543
|
+
kind: "html",
|
|
544
|
+
html: `<div data-router-empty></div>`,
|
|
545
|
+
status: 404
|
|
546
|
+
};
|
|
547
|
+
let props = {};
|
|
548
|
+
if (match?.data?.loader) {
|
|
549
|
+
const req = request ?? defaultRequest(parsed);
|
|
550
|
+
const ctrl = new AbortController();
|
|
551
|
+
const result = await executeLoader(match.data.loader, parsed, routeParams(), req, ctrl.signal);
|
|
552
|
+
if (result.kind === "redirect") return {
|
|
553
|
+
kind: "redirect",
|
|
554
|
+
to: result.to,
|
|
555
|
+
status: result.status
|
|
556
|
+
};
|
|
557
|
+
if (result.kind === "error") {
|
|
558
|
+
const escapedMessage = String(result.message).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
559
|
+
const html = `<div data-router-view data-router-error="${result.status}">${escapedMessage}</div>`;
|
|
560
|
+
return {
|
|
561
|
+
kind: "error",
|
|
562
|
+
status: result.status,
|
|
563
|
+
message: result.message,
|
|
564
|
+
html
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
props = result.data;
|
|
568
|
+
}
|
|
298
569
|
const name = buildReverseRegistry(registry).get(island);
|
|
299
570
|
if (!name) {
|
|
300
571
|
console.warn(`[ilha-router] renderHydratable: active island for "${routePath()}" is not in the registry. Falling back to plain SSR — the island will not be interactive on the client.`);
|
|
301
|
-
return
|
|
572
|
+
return {
|
|
573
|
+
kind: "html",
|
|
574
|
+
html: `<div data-router-view>${island.toString(props)}</div>`
|
|
575
|
+
};
|
|
302
576
|
}
|
|
303
|
-
return
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
577
|
+
return {
|
|
578
|
+
kind: "html",
|
|
579
|
+
html: `<div data-router-view>${await island.hydratable(props, {
|
|
580
|
+
name,
|
|
581
|
+
as: "div",
|
|
582
|
+
snapshot: true,
|
|
583
|
+
...options
|
|
584
|
+
})}</div>`
|
|
585
|
+
};
|
|
586
|
+
},
|
|
587
|
+
async runLoader(url, request) {
|
|
588
|
+
const parsed = parsedURL(url);
|
|
589
|
+
const match = findRoute(_rou3, "GET", parsed.pathname);
|
|
590
|
+
if (!match?.data?.island) return { kind: "not-found" };
|
|
591
|
+
if (!match.data.loader) return {
|
|
592
|
+
kind: "data",
|
|
593
|
+
data: {}
|
|
594
|
+
};
|
|
595
|
+
const params = extractParams(match.params);
|
|
596
|
+
const req = request ?? defaultRequest(parsed);
|
|
597
|
+
const ctrl = new AbortController();
|
|
598
|
+
return executeLoader(match.data.loader, parsed, params, req, ctrl.signal);
|
|
309
599
|
},
|
|
310
600
|
hydrate(registry, options = {}) {
|
|
311
601
|
if (!isBrowser) {
|
|
@@ -335,8 +625,13 @@ var src_default = {
|
|
|
335
625
|
isActive,
|
|
336
626
|
enableLinkInterception,
|
|
337
627
|
prime,
|
|
628
|
+
prefetch,
|
|
338
629
|
RouterView,
|
|
339
|
-
RouterLink
|
|
630
|
+
RouterLink,
|
|
631
|
+
loader,
|
|
632
|
+
redirect,
|
|
633
|
+
error,
|
|
634
|
+
composeLoaders
|
|
340
635
|
};
|
|
341
636
|
//#endregion
|
|
342
|
-
export { RouterLink, RouterView, src_default as default, enableLinkInterception, isActive, navigate, prime, routeHash, routeParams, routePath, routeSearch, router, useRoute, wrapError, wrapLayout };
|
|
637
|
+
export { LOADER_ENDPOINT, LoaderError, Redirect, RouterLink, RouterView, composeLoaders, src_default as default, defineLayout, enableLinkInterception, error, isActive, loader, navigate, prefetch, prime, redirect, routeHash, routeParams, routePath, routeSearch, router, useRoute, wrapError, wrapLayout };
|