@ilha/router 0.11.9 → 0.11.10

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") {
@@ -516,10 +546,16 @@ const resolveRequestUrl = (urlOrRequest) => {
516
546
  return new URL("http://localhost/");
517
547
  }
518
548
  };
519
- const pageForPath = (pathname, routes, notFound) => {
549
+ const composedPageForPath = (pathname, routes, notFound) => {
520
550
  const match = matchRoute(routes, pathname);
521
- if (match) return match.data.page;
522
- return notFound;
551
+ if (!match) return notFound;
552
+ const { layouts } = match.data;
553
+ let { page } = match.data;
554
+ for (let j = layouts.length - 1; j >= 0; j -= 1) {
555
+ const layout = layouts[j];
556
+ if (layout) page = wrapLayout(layout, page);
557
+ }
558
+ return page;
523
559
  };
524
560
  const renderPage = (page, opts) => renderToString(page, opts);
525
561
  const buildRespondBody = (result, respondOpts, head) => {
@@ -577,25 +613,51 @@ const router = (options = {}) => {
577
613
  let mounted = true;
578
614
  const prevScroll = "scrollRestoration" in history ? history.scrollRestoration : null;
579
615
  if (prevScroll !== null) history.scrollRestoration = "manual";
580
- let unmountView = null;
616
+ let unmountShell = null;
581
617
  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;
618
+ let bumpShell = null;
619
+ const Shell = () => {
620
+ const tick = atom(0);
621
+ bumpShell = () => {
622
+ tick.update((t) => t + 1);
623
+ };
624
+ tick();
625
+ const match = matchRoute(routes, _path);
626
+ if (!match) return notFound ? h(notFound, null) : "";
627
+ const { errorHandler, layouts, page } = match.data;
628
+ let view = h(page, null);
629
+ for (let j = layouts.length - 1; j >= 0; j -= 1) {
630
+ const layout = layouts[j];
631
+ if (layout) view = h(layout, null, view);
591
632
  }
633
+ if (errorHandler) {
634
+ const onBoundary = errorHandler;
635
+ view = h(ErrorBoundary, { fallback: ({ error }) => onBoundary({ message: error.message }, snapshot()) }, view);
636
+ }
637
+ return view;
638
+ };
639
+ const mountShell = () => {
640
+ headSession?.close();
592
641
  const session = openBrowserHead();
593
642
  headSession = session;
594
- unmountView = mount(host, page, { hydrate: unmountView === null && hydrate });
643
+ unmountShell = mount(host, Shell, { hydrate });
595
644
  queueMicrotask(() => {
596
645
  session.flush();
597
646
  });
598
647
  };
648
+ const refresh = () => {
649
+ if (bumpShell) {
650
+ headSession?.close();
651
+ const session = openBrowserHead();
652
+ headSession = session;
653
+ bumpShell();
654
+ queueMicrotask(() => {
655
+ session.flush();
656
+ });
657
+ return;
658
+ }
659
+ mountShell();
660
+ };
599
661
  const popHandler = () => {
600
662
  if (!mounted) return;
601
663
  const prevPath = _path + _search + _hash;
@@ -611,18 +673,20 @@ const router = (options = {}) => {
611
673
  to: _path + _search + _hash,
612
674
  type: "pop"
613
675
  });
614
- remount();
676
+ refresh();
615
677
  };
616
678
  navChangeCleanup = getAdapter().onChange(popHandler);
617
679
  linkCleanup = mountInterceptLinks ?? defaultInterceptLinks ? enableLinkInterception(document) : null;
618
- remount();
680
+ mountShell();
619
681
  const offNav = afterNavigate(() => {
620
- if (mounted) remount();
682
+ if (mounted) refresh();
621
683
  });
622
684
  return () => {
623
685
  mounted = false;
624
686
  offNav();
625
- unmountView?.();
687
+ bumpShell = null;
688
+ unmountShell?.();
689
+ unmountShell = null;
626
690
  headSession?.close();
627
691
  headSession = null;
628
692
  navChangeCleanup?.();
@@ -642,7 +706,7 @@ const router = (options = {}) => {
642
706
  syncFromParts(parsed.pathname, parsed.search, parsed.hash, routes);
643
707
  const store = { entries: [] };
644
708
  try {
645
- const page = pageForPath(parsed.pathname, routes, notFound);
709
+ const page = composedPageForPath(parsed.pathname, routes, notFound);
646
710
  if (!page) throw new RouteError(404, "Not found");
647
711
  let captured;
648
712
  const wrapped = async () => {
@@ -711,8 +775,9 @@ const router = (options = {}) => {
711
775
  status: result.kind === "error" ? result.status : result.status ?? 200
712
776
  });
713
777
  },
714
- route(pattern, page) {
778
+ route(pattern, page, opts = {}) {
715
779
  const data = {
780
+ layouts: opts.layouts ?? [],
716
781
  page,
717
782
  pattern
718
783
  };
@@ -732,4 +797,4 @@ const router = (options = {}) => {
732
797
  };
733
798
 
734
799
  //#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 };
800
+ 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`. */
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.10",
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",
76
+ "ilha": "^0.14.7",
77
+ "oxidejs": "^0.5.7",
78
78
  "vite": "^8.2.2"
79
79
  },
80
80
  "peerDependencies": {
81
- "ilha": ">=0.14.6",
82
- "oxidejs": ">=0.3.2"
81
+ "ilha": ">=0.14.7",
82
+ "oxidejs": ">=0.5.7"
83
83
  },
84
84
  "peerDependenciesMeta": {
85
85
  "oxidejs": {