@ilha/router 0.11.9 → 0.11.11

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/dist/index.d.ts CHANGED
@@ -90,7 +90,9 @@ export interface RenderPageOptions {
90
90
  markers?: boolean;
91
91
  }
92
92
  export interface RouterBuilder {
93
- route: (pattern: string, page: Page) => RouterBuilder;
93
+ route: (pattern: string, page: Page, opts?: {
94
+ layouts?: LayoutHandler[];
95
+ }) => RouterBuilder;
94
96
  errorBoundary: (pattern: string, handler: ErrorHandler) => RouterBuilder;
95
97
  routes: () => RouteRecord[];
96
98
  prime: () => void;
@@ -134,6 +136,33 @@ export declare const prime: () => void;
134
136
  export declare const beforeNavigate: (fn: BeforeNavigateHook) => (() => void);
135
137
  export declare const afterNavigate: (fn: AfterNavigateHook) => (() => void);
136
138
  export declare const navigate: (to: string, opts?: NavigateOptions) => void;
139
+ export interface SearchParamOptions<T> {
140
+ /** Value when the param is absent. Writing it removes the param. */
141
+ default: T;
142
+ /** Decode the raw param. Default: the raw string. */
143
+ parse?: (raw: string) => T;
144
+ /** Encode a value for the URL. Default: `String(value)`. */
145
+ serialize?: (value: T) => string;
146
+ }
147
+ export interface SearchParam<T> {
148
+ (): T;
149
+ set: (next: T) => void;
150
+ update: (f: (current: T) => T) => void;
151
+ }
152
+ export interface SearchParamFn {
153
+ /** String param: `parse` is optional. */
154
+ (name: string, opts: SearchParamOptions<string>): SearchParam<string>;
155
+ /** Non-string param: `parse` is required to decode the raw string. */
156
+ <T>(name: string, opts: SearchParamOptions<T> & {
157
+ parse: (raw: string) => T;
158
+ }): SearchParam<T>;
159
+ }
160
+ /**
161
+ * Read and write one query param through the router. Writes `replace` the
162
+ * history entry without scrolling, so the mounted router re-renders the
163
+ * current route in place instead of remounting it.
164
+ */
165
+ export declare const searchParam: SearchParamFn;
137
166
  export type LinkInterceptionOptions = Record<string, never>;
138
167
  export declare const enableLinkInterception: (root?: Element | Document, _options?: LinkInterceptionOptions) => (() => void);
139
168
  export interface IsActiveOptions {
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { t as REQUEST_ALS_KEY } from "./als-key-CVxbuM3z.js";
2
2
  import { a as withHeadStore, i as serializeHead, n as head, r as openBrowserHead, t as applyHeadEntriesToDocument } from "./head-WG_AtbdX.js";
3
- import { h, mount, renderToString } from "ilha";
3
+ import { ErrorBoundary, atom, h, mount, renderToString } from "ilha";
4
4
 
5
5
  //#region src/hash.ts
6
6
  const isBrowser$1 = globalThis.window !== void 0 && globalThis.document !== void 0;
@@ -448,6 +448,36 @@ const navigate = (to, opts = {}) => {
448
448
  type
449
449
  });
450
450
  };
451
+ /**
452
+ * Read and write one query param through the router. Writes `replace` the
453
+ * history entry without scrolling, so the mounted router re-renders the
454
+ * current route in place instead of remounting it.
455
+ */
456
+ const searchParam = (name, opts) => {
457
+ const serialize = opts.serialize ?? String;
458
+ const read = () => {
459
+ const raw = new URLSearchParams(_search).get(name);
460
+ if (raw === null) return opts.default;
461
+ return opts.parse ? opts.parse(raw) : raw;
462
+ };
463
+ const set = (next) => {
464
+ const params = new URLSearchParams(_search);
465
+ const encoded = serialize(next);
466
+ if (encoded === serialize(opts.default)) params.delete(name);
467
+ else params.set(name, encoded);
468
+ const qs = params.toString();
469
+ navigate(`${_path}${qs ? `?${qs}` : ""}${_hash}`, {
470
+ replace: true,
471
+ scroll: false
472
+ });
473
+ };
474
+ return Object.assign(read, {
475
+ set,
476
+ update: (f) => {
477
+ set(f(read()));
478
+ }
479
+ });
480
+ };
451
481
  const NON_NAV_SCHEME = /^(?:mailto|tel|javascript):/iu;
452
482
  const resolveClickTarget = (_href, url) => {
453
483
  if (getHistoryMode() === "hash") {
@@ -456,31 +486,32 @@ const resolveClickTarget = (_href, url) => {
456
486
  }
457
487
  return url.pathname + url.search + url.hash;
458
488
  };
489
+ const onLinkClick = (event) => {
490
+ const mouseEvent = event;
491
+ if (mouseEvent.defaultPrevented || mouseEvent.button !== 0 || mouseEvent.metaKey || mouseEvent.ctrlKey || mouseEvent.shiftKey || mouseEvent.altKey) return;
492
+ const { target } = mouseEvent;
493
+ if (!(target instanceof Element)) return;
494
+ const anchor = target.closest("a");
495
+ if (!anchor || anchor.hasAttribute("download") || anchor.getAttribute("target") === "_blank" || anchor.matches("[data-no-intercept]")) return;
496
+ const href = anchor.getAttribute("href");
497
+ if (!href || NON_NAV_SCHEME.test(href)) return;
498
+ if (href.startsWith("#") && getHistoryMode() !== "hash") return;
499
+ if (href.startsWith("#") && !href.startsWith("#/")) return;
500
+ let url;
501
+ try {
502
+ url = new URL(href, location.href);
503
+ } catch {
504
+ return;
505
+ }
506
+ if (url.origin !== location.origin) return;
507
+ mouseEvent.preventDefault();
508
+ navigate(resolveClickTarget(href, url));
509
+ };
459
510
  const enableLinkInterception = (root = document, _options = {}) => {
460
511
  if (!isBrowser) return noop;
461
- const clickHandler = (event) => {
462
- const mouseEvent = event;
463
- if (mouseEvent.defaultPrevented || mouseEvent.button !== 0 || mouseEvent.metaKey || mouseEvent.ctrlKey || mouseEvent.shiftKey || mouseEvent.altKey) return;
464
- const { target } = mouseEvent;
465
- if (!(target instanceof Element)) return;
466
- const anchor = target.closest("a");
467
- if (!anchor || anchor.hasAttribute("download") || anchor.getAttribute("target") === "_blank" || anchor.matches("[data-no-intercept]")) return;
468
- const href = anchor.getAttribute("href");
469
- if (!href || NON_NAV_SCHEME.test(href)) return;
470
- if (href.startsWith("#") && getHistoryMode() !== "hash") return;
471
- if (href.startsWith("#") && !href.startsWith("#/")) return;
472
- let url;
473
- try {
474
- url = new URL(href, location.href);
475
- } catch {
476
- return;
477
- }
478
- if (url.origin !== location.origin) return;
479
- mouseEvent.preventDefault();
480
- navigate(resolveClickTarget(href, url));
481
- };
482
- root.addEventListener("click", clickHandler);
483
- return () => root.removeEventListener("click", clickHandler);
512
+ const listener = { handleEvent: onLinkClick };
513
+ root.addEventListener("click", listener);
514
+ return () => root.removeEventListener("click", listener);
484
515
  };
485
516
  const isActive = (pattern, options = {}) => {
486
517
  if (options.end) return _path === pattern;
@@ -516,10 +547,16 @@ const resolveRequestUrl = (urlOrRequest) => {
516
547
  return new URL("http://localhost/");
517
548
  }
518
549
  };
519
- const pageForPath = (pathname, routes, notFound) => {
550
+ const composedPageForPath = (pathname, routes, notFound) => {
520
551
  const match = matchRoute(routes, pathname);
521
- if (match) return match.data.page;
522
- return notFound;
552
+ if (!match) return notFound;
553
+ const { layouts } = match.data;
554
+ let { page } = match.data;
555
+ for (let j = layouts.length - 1; j >= 0; j -= 1) {
556
+ const layout = layouts[j];
557
+ if (layout) page = wrapLayout(layout, page);
558
+ }
559
+ return page;
523
560
  };
524
561
  const renderPage = (page, opts) => renderToString(page, opts);
525
562
  const buildRespondBody = (result, respondOpts, head) => {
@@ -577,25 +614,51 @@ const router = (options = {}) => {
577
614
  let mounted = true;
578
615
  const prevScroll = "scrollRestoration" in history ? history.scrollRestoration : null;
579
616
  if (prevScroll !== null) history.scrollRestoration = "manual";
580
- let unmountView = null;
617
+ let unmountShell = null;
581
618
  let headSession = null;
582
- const remount = () => {
583
- unmountView?.();
584
- headSession?.close();
585
- headSession = null;
586
- const page = pageForPath(_path, routes, notFound);
587
- if (!page) {
588
- unmountView = null;
589
- applyHeadEntriesToDocument([]);
590
- return;
619
+ let bumpShell = null;
620
+ const Shell = () => {
621
+ const tick = atom(0);
622
+ bumpShell = () => {
623
+ tick.update((t) => t + 1);
624
+ };
625
+ tick();
626
+ const match = matchRoute(routes, _path);
627
+ if (!match) return notFound ? h(notFound, null) : "";
628
+ const { errorHandler, layouts, page } = match.data;
629
+ let view = h(page, null);
630
+ for (let j = layouts.length - 1; j >= 0; j -= 1) {
631
+ const layout = layouts[j];
632
+ if (layout) view = h(layout, null, view);
633
+ }
634
+ if (errorHandler) {
635
+ const onBoundary = errorHandler;
636
+ view = h(ErrorBoundary, { fallback: ({ error }) => onBoundary({ message: error.message }, snapshot()) }, view);
591
637
  }
638
+ return view;
639
+ };
640
+ const mountShell = () => {
641
+ headSession?.close();
592
642
  const session = openBrowserHead();
593
643
  headSession = session;
594
- unmountView = mount(host, page, { hydrate: unmountView === null && hydrate });
644
+ unmountShell = mount(host, Shell, { hydrate });
595
645
  queueMicrotask(() => {
596
646
  session.flush();
597
647
  });
598
648
  };
649
+ const refresh = () => {
650
+ if (bumpShell) {
651
+ headSession?.close();
652
+ const session = openBrowserHead();
653
+ headSession = session;
654
+ bumpShell();
655
+ queueMicrotask(() => {
656
+ session.flush();
657
+ });
658
+ return;
659
+ }
660
+ mountShell();
661
+ };
599
662
  const popHandler = () => {
600
663
  if (!mounted) return;
601
664
  const prevPath = _path + _search + _hash;
@@ -611,18 +674,20 @@ const router = (options = {}) => {
611
674
  to: _path + _search + _hash,
612
675
  type: "pop"
613
676
  });
614
- remount();
677
+ refresh();
615
678
  };
616
679
  navChangeCleanup = getAdapter().onChange(popHandler);
617
680
  linkCleanup = mountInterceptLinks ?? defaultInterceptLinks ? enableLinkInterception(document) : null;
618
- remount();
681
+ mountShell();
619
682
  const offNav = afterNavigate(() => {
620
- if (mounted) remount();
683
+ if (mounted) refresh();
621
684
  });
622
685
  return () => {
623
686
  mounted = false;
624
687
  offNav();
625
- unmountView?.();
688
+ bumpShell = null;
689
+ unmountShell?.();
690
+ unmountShell = null;
626
691
  headSession?.close();
627
692
  headSession = null;
628
693
  navChangeCleanup?.();
@@ -642,7 +707,7 @@ const router = (options = {}) => {
642
707
  syncFromParts(parsed.pathname, parsed.search, parsed.hash, routes);
643
708
  const store = { entries: [] };
644
709
  try {
645
- const page = pageForPath(parsed.pathname, routes, notFound);
710
+ const page = composedPageForPath(parsed.pathname, routes, notFound);
646
711
  if (!page) throw new RouteError(404, "Not found");
647
712
  let captured;
648
713
  const wrapped = async () => {
@@ -711,8 +776,9 @@ const router = (options = {}) => {
711
776
  status: result.kind === "error" ? result.status : result.status ?? 200
712
777
  });
713
778
  },
714
- route(pattern, page) {
779
+ route(pattern, page, opts = {}) {
715
780
  const data = {
781
+ layouts: opts.layouts ?? [],
716
782
  page,
717
783
  pattern
718
784
  };
@@ -732,4 +798,4 @@ const router = (options = {}) => {
732
798
  };
733
799
 
734
800
  //#endregion
735
- export { EMPTY_HEAD, Redirect, RouteError, afterNavigate, beforeNavigate, defineLayout, enableLinkInterception, throwAppError as error, getHistoryMode, head, httpResponse, isActive, navigate, navigating, prime, redirect, resolveRedirectTarget, routeHash, routeParams, routePath, routeSearch, router, serializeHead, setHistoryMode, useContext, useRoute, wrapError, wrapLayout };
801
+ export { EMPTY_HEAD, Redirect, RouteError, afterNavigate, beforeNavigate, defineLayout, enableLinkInterception, throwAppError as error, getHistoryMode, head, httpResponse, isActive, navigate, navigating, prime, redirect, resolveRedirectTarget, routeHash, routeParams, routePath, routeSearch, router, searchParam, serializeHead, setHistoryMode, useContext, useRoute, wrapError, wrapLayout };
@@ -465,10 +465,13 @@ const pushLayoutErrorImports = (imports, entry, index, importPath) => {
465
465
  for (const [j, l] of entry.layouts.entries()) imports.push(`import { default as _layout${index}_${j} } from ${JSON.stringify(importPath(l))};`);
466
466
  for (const [j, e] of entry.errors.entries()) imports.push(`import { default as _error${index}_${j} } from ${JSON.stringify(importPath(e))};`);
467
467
  };
468
- const pushRegistryAndRoutes = (entry, index, entriesLength, wrappedId, registryLines, routeLines, includeRoutes) => {
468
+ const pushRegistryAndRoutes = (entry, index, entriesLength, wrappedId, registryLines, routeLines, includeRoutes, decomposeLayouts = false) => {
469
469
  registryLines.push(` ${JSON.stringify(entry.name)}: ${wrappedId}${index < entriesLength - 1 ? "," : ""}`);
470
470
  if (!includeRoutes) return;
471
- routeLines.push(` .route(${JSON.stringify(entry.pattern)}, ${wrappedId})`);
471
+ if (decomposeLayouts && entry.layouts.length > 0) {
472
+ const layouts = entry.layouts.map((_, j) => `_layout${index}_${j}`).join(", ");
473
+ routeLines.push(` .route(${JSON.stringify(entry.pattern)}, _page${index}, { layouts: [${layouts}] })`);
474
+ } else routeLines.push(` .route(${JSON.stringify(entry.pattern)}, ${wrappedId})`);
472
475
  if (entry.errors.length > 0) routeLines.push(` .errorBoundary(${JSON.stringify(entry.pattern)}, _error${index}_${entry.errors.length - 1})`);
473
476
  };
474
477
  const buildServerFile = (entries, serverFile) => {
@@ -510,7 +513,7 @@ const emitClientEntry = (entry, index, entriesLength, isStatic, clientImport, im
510
513
  const expr = wrapIslandExpr(`_page${index}`, entry.layouts.length, entry.errors.length, index);
511
514
  const wrappedId = `_wrapped${index}`;
512
515
  wrappedIslandLines.push(`const ${wrappedId} = ${expr};`);
513
- pushRegistryAndRoutes(entry, index, entriesLength, wrappedId, registryLines, routeLines, !isStatic);
516
+ pushRegistryAndRoutes(entry, index, entriesLength, wrappedId, registryLines, routeLines, !isStatic, true);
514
517
  };
515
518
  const buildClientFile = (entries, clientFile, opts) => {
516
519
  const { isStatic, interceptLinks } = opts;
package/dist/rsbuild.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as ilhaPages } from "./plugin-Cn_H92L0.js";
1
+ import { t as ilhaPages } from "./plugin-FVaWBiNQ.js";
2
2
 
3
3
  //#region src/rsbuild.ts
4
4
  /** Rsbuild plugin — use via `@ilha/router/rsbuild`. */
@@ -178,15 +178,15 @@ const hydrateServerIsland = (host, id, wiring, props, moduleKey) => {
178
178
  const actionFn = actionKey === null || actionKey === void 0 ? void 0 : wiring.actions?.[actionKey];
179
179
  if (!actionFn) continue;
180
180
  const boundKey = actionKey;
181
+ const run = async () => {
182
+ try {
183
+ await Promise.resolve(actionFn(...callArgs));
184
+ hooks.repaintModule();
185
+ } catch (error) {
186
+ console.error(`[ilha-router] action "${String(boundKey)}" failed:`, error);
187
+ }
188
+ };
181
189
  const listener = () => {
182
- const run = async () => {
183
- try {
184
- await Promise.resolve(actionFn(...callArgs));
185
- hooks.repaintModule();
186
- } catch (error) {
187
- console.error(`[ilha-router] action "${String(boundKey)}" failed:`, error);
188
- }
189
- };
190
190
  run();
191
191
  };
192
192
  el.addEventListener(eventType, listener);
@@ -209,14 +209,12 @@ const hydrateServerIsland = (host, id, wiring, props, moduleKey) => {
209
209
  if (!actionFn) continue;
210
210
  let args = [];
211
211
  if (Array.isArray(marker.a)) args = marker.a;
212
- const revived = (..._runtimeArgs) => {
213
- const run = async () => {
214
- const result = await Promise.resolve(actionFn(...args));
215
- hooks.repaintModule();
216
- return result;
217
- };
218
- return run();
212
+ const run = async () => {
213
+ const result = await Promise.resolve(actionFn(...args));
214
+ hooks.repaintModule();
215
+ return result;
219
216
  };
217
+ const revived = (..._runtimeArgs) => run();
220
218
  Object.assign(next, { [key]: revived });
221
219
  }
222
220
  return next;
package/dist/vite.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as ilhaPages } from "./plugin-Cn_H92L0.js";
1
+ import { t as ilhaPages } from "./plugin-FVaWBiNQ.js";
2
2
 
3
3
  //#region src/vite.ts
4
4
  /** Vite plugin — use via `@ilha/router/vite`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ilha/router",
3
- "version": "0.11.9",
3
+ "version": "0.11.11",
4
4
  "description": "A tiny SPA router for Ilha",
5
5
  "keywords": [
6
6
  "frontend",
@@ -69,17 +69,17 @@
69
69
  "test": "bun test"
70
70
  },
71
71
  "dependencies": {
72
- "effect": "4.0.0-rc.112",
72
+ "effect": "4.0.0-rc.118",
73
73
  "unplugin": "3.3.0"
74
74
  },
75
75
  "devDependencies": {
76
- "ilha": "^0.14.6",
77
- "oxidejs": "^0.3.2",
78
- "vite": "^8.2.2"
76
+ "ilha": "^0.14.8",
77
+ "oxidejs": "^0.5.7",
78
+ "vite": "^8.3.1"
79
79
  },
80
80
  "peerDependencies": {
81
- "ilha": ">=0.14.6",
82
- "oxidejs": ">=0.3.2"
81
+ "ilha": ">=0.14.8",
82
+ "oxidejs": ">=0.5.7"
83
83
  },
84
84
  "peerDependenciesMeta": {
85
85
  "oxidejs": {