@ilha/router 0.8.6 → 0.8.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/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- import { A as wrapLayout, C as routePath, D as src_default, E as serializeHead, M as setHistoryMode, O as useRoute, S as routeParams, T as router, _ as navigating, a as RouterView, b as redirect, c as composeLoaders, d as error, f as head, g as navigate, h as loader, i as RouterLink, j as getHistoryMode, k as wrapError, l as defineLayout, m as isActive, n as LoaderError, o as afterNavigate, p as invalidate, r as Redirect, s as beforeNavigate, t as LOADER_ENDPOINT, u as enableLinkInterception, v as prefetch, w as routeSearch, x as routeHash, y as prime } from "./src-_T1q_MN5.js";
1
+ import { A as wrapLayout, C as routePath, D as src_default, E as serializeHead, M as setHistoryMode, O as useRoute, S as routeParams, T as router, _ as navigating, a as RouterView, b as redirect, c as composeLoaders, d as error, f as head, g as navigate, h as loader, i as RouterLink, j as getHistoryMode, k as wrapError, l as defineLayout, m as isActive, n as LoaderError, o as afterNavigate, p as invalidate, r as Redirect, s as beforeNavigate, t as LOADER_ENDPOINT, u as enableLinkInterception, v as prefetch, w as routeSearch, x as routeHash, y as prime } from "./src-Dfq3JPRz.js";
2
2
 
3
3
  export { LOADER_ENDPOINT, LoaderError, Redirect, RouterLink, RouterView, afterNavigate, beforeNavigate, composeLoaders, src_default as default, defineLayout, enableLinkInterception, error, getHistoryMode, head, invalidate, isActive, loader, navigate, navigating, prefetch, prime, redirect, routeHash, routeParams, routePath, routeSearch, router, serializeHead, setHistoryMode, useRoute, wrapError, wrapLayout };
@@ -1,4 +1,4 @@
1
- import ilha, { ISLAND_MOUNT_INTERNAL, context, html, mount } from "ilha";
1
+ import ilha, { ISLAND_MOUNT_HANDLES, ISLAND_MOUNT_INTERNAL, context, html, mount } from "ilha";
2
2
  import { addRoute, createRouter, findRoute } from "rou3";
3
3
 
4
4
  //#region src/hash.ts
@@ -310,7 +310,6 @@ function injectKPageSlot(layoutHtml, slotInnerHtml, which) {
310
310
  const target = which === "innermost" ? spans[spans.length - 1] : spans[0];
311
311
  return layoutHtml.slice(0, target.openEnd) + slotInnerHtml + layoutHtml.slice(target.closeStart);
312
312
  }
313
- /** Layout shell HTML with an empty `k:page` — avoids scanning MDX/twoslash inside `Wrapped.toString()`. */
314
313
  function layoutHtmlWithEmptyKPage(wrappedLayout, props) {
315
314
  const handler = wrappedLayout[WRAP_LAYOUT_HANDLER];
316
315
  if (!handler) return wrappedLayout.toString(props);
@@ -397,6 +396,43 @@ function wrapLayout(layout, page) {
397
396
  };
398
397
  }
399
398
  wrapLeafPageMountHooks(leafPage);
399
+ const pageHandles = /* @__PURE__ */ new Map();
400
+ const pageInternalBase = page[ISLAND_MOUNT_INTERNAL];
401
+ if (typeof pageInternalBase === "function") page[ISLAND_MOUNT_INTERNAL] = (host, props) => {
402
+ const handle = pageInternalBase(host, props);
403
+ const entry = {
404
+ handle,
405
+ mountProps: props
406
+ };
407
+ pageHandles.set(host, entry);
408
+ return {
409
+ unmount: () => {
410
+ if (pageHandles.get(host) === entry) pageHandles.delete(host);
411
+ return handle.unmount();
412
+ },
413
+ updateProps: (p) => {
414
+ entry.mountProps = p;
415
+ handle.updateProps(p);
416
+ }
417
+ };
418
+ };
419
+ /**
420
+ * In-place prop update for one mounted layout instance: refresh the
421
+ * merged-input ref (so any layout re-render passes fresh props to `k:page`),
422
+ * push the merged loader props directly into the page mounted under *this*
423
+ * layout host (spread over its latest slot props so layout-provided extras
424
+ * survive), then update the layout island's own input. Page-first ordering
425
+ * lets a layout that *does* read its input re-render afterwards and win with
426
+ * its own fresher slot props.
427
+ */
428
+ const layoutUpdateProps = (layoutHost, coreUpdate) => (p) => {
429
+ setLayoutMergedInput(p);
430
+ for (const [pageHost, entry] of pageHandles) if (layoutHost.contains(pageHost)) entry.handle.updateProps({
431
+ ...entry.mountProps ?? {},
432
+ ...p ?? {}
433
+ });
434
+ coreUpdate?.(p);
435
+ };
400
436
  const layoutMount = Wrapped.mount.bind(Wrapped);
401
437
  const layoutInternal = Wrapped[ISLAND_MOUNT_INTERNAL];
402
438
  function prepareLayoutMountHost(host) {
@@ -405,16 +441,27 @@ function wrapLayout(layout, page) {
405
441
  Wrapped.mount = (host, props) => {
406
442
  setLayoutMergedInput(props);
407
443
  prepareLayoutMountHost(host);
408
- return layoutMount(host, props);
444
+ const unmount = layoutMount(host, props);
445
+ const core = ISLAND_MOUNT_HANDLES.get(host);
446
+ ISLAND_MOUNT_HANDLES.set(host, {
447
+ unmount,
448
+ updateProps: layoutUpdateProps(host, core?.updateProps)
449
+ });
450
+ return unmount;
409
451
  };
410
452
  Wrapped[ISLAND_MOUNT_INTERNAL] = (host, props) => {
411
453
  setLayoutMergedInput(props);
412
454
  prepareLayoutMountHost(host);
413
- if (typeof layoutInternal === "function") return layoutInternal(host, props);
414
- return {
455
+ const base = typeof layoutInternal === "function" ? layoutInternal(host, props) : {
415
456
  unmount: layoutMount(host, props),
416
457
  updateProps: () => {}
417
458
  };
459
+ const enhanced = {
460
+ unmount: base.unmount,
461
+ updateProps: layoutUpdateProps(host, base.updateProps)
462
+ };
463
+ ISLAND_MOUNT_HANDLES.set(host, enhanced);
464
+ return enhanced;
418
465
  };
419
466
  Wrapped.hydratable = async (props, opts) => {
420
467
  if (!opts?.name) throw new Error("wrapLayout: hydratable requires options.name");
@@ -644,6 +691,51 @@ function prefetch(pathWithSearch) {
644
691
  expires: Date.now() + PREFETCH_TTL_MS
645
692
  });
646
693
  }
694
+ const noopRouteHandle = () => ({
695
+ unmount: () => {},
696
+ updateProps: null
697
+ });
698
+ /** Mount an island keeping the full internal handle so later same-island
699
+ * navigations can push new loader props instead of remounting. */
700
+ function mountIslandWithHandle(island, host, props) {
701
+ const internal = island[ISLAND_MOUNT_INTERNAL];
702
+ if (typeof internal === "function") {
703
+ const h = internal(host, props);
704
+ return {
705
+ unmount: () => void h.unmount(),
706
+ updateProps: (p) => h.updateProps(p)
707
+ };
708
+ }
709
+ return {
710
+ unmount: island.mount(host, props),
711
+ updateProps: null
712
+ };
713
+ }
714
+ /**
715
+ * Same-island fast path: fetch fresh loader data and push it into the mounted
716
+ * island via `updateProps` — ilha's fine-grained morph reconciles the DOM, so
717
+ * focus, caret, selection, and scroll survive (the reason persistQuery-driven
718
+ * filter inputs don't blur while typing). Returns "updated" when applied (or
719
+ * redirected), "remount" when the caller must run the full teardown + mount
720
+ * path (loader error / not-found need boundary DOM; the full path re-fetches,
721
+ * accepted for these rare cases). Throws AbortError when superseded.
722
+ */
723
+ async function updateRouteInPlace(handle, pathWithSearch, signal) {
724
+ if (!handle.updateProps) return "remount";
725
+ const result = !!findRoute(_rou3, "GET", pathWithSearch.split("?")[0] ?? "")?.data?.hasLoader ? await fetchLoaderData(pathWithSearch, signal) : {
726
+ kind: "data",
727
+ data: {}
728
+ };
729
+ signal.throwIfAborted();
730
+ if (result.kind === "redirect") {
731
+ clientRedirect(result.to);
732
+ return "updated";
733
+ }
734
+ if (result.kind !== "data") return "remount";
735
+ if (result.headEntries?.length) applyHeadEntriesToDocument([...result.headEntries]);
736
+ handle.updateProps(result.data);
737
+ return "updated";
738
+ }
647
739
  /**
648
740
  * Mounts a route island with proper hydration for client-side navigation.
649
741
  * Looks up the island in the reverse registry, runs the loader (via fetch),
@@ -656,13 +748,16 @@ async function mountRouteWithHydration(island, host, pathWithSearch, signal, reg
656
748
  return withViewSwap(() => {
657
749
  host.innerHTML = `<div data-router-view data-router-not-found>${nf.toString()}</div>`;
658
750
  const nfHost = host.firstElementChild;
659
- return nfHost ? nf.mount(nfHost) : () => {};
751
+ return {
752
+ unmount: nfHost ? nf.mount(nfHost) : () => {},
753
+ updateProps: null
754
+ };
660
755
  });
661
756
  }
662
757
  await withViewSwap(() => {
663
758
  host.innerHTML = `<div data-router-empty></div>`;
664
759
  });
665
- return () => {};
760
+ return noopRouteHandle();
666
761
  }
667
762
  const clientMatch = findRoute(_rou3, "GET", pathWithSearch.split("?")[0] ?? "");
668
763
  const hasLoader = !!clientMatch?.data?.hasLoader;
@@ -673,22 +768,25 @@ async function mountRouteWithHydration(island, host, pathWithSearch, signal, reg
673
768
  };
674
769
  if (loaderResult.kind === "redirect") {
675
770
  clientRedirect(loaderResult.to);
676
- return () => {};
771
+ return noopRouteHandle();
677
772
  }
678
773
  if (loaderResult.kind === "error") {
679
774
  const boundary = clientMatch?.data?.errorHandler;
680
- if (boundary) return withViewSwap(() => mountLoaderErrorBoundary(boundary, host, loaderResult.status, loaderResult.message));
775
+ if (boundary) return withViewSwap(() => ({
776
+ unmount: mountLoaderErrorBoundary(boundary, host, loaderResult.status, loaderResult.message),
777
+ updateProps: null
778
+ }));
681
779
  const escaped = escapeHtml(loaderResult.message);
682
780
  await withViewSwap(() => {
683
781
  host.innerHTML = `<div data-router-view data-router-error="${loaderResult.status}">${escaped}</div>`;
684
782
  });
685
- return () => {};
783
+ return noopRouteHandle();
686
784
  }
687
785
  if (loaderResult.kind === "not-found") {
688
786
  await withViewSwap(() => {
689
787
  host.innerHTML = `<div data-router-empty></div>`;
690
788
  });
691
- return () => {};
789
+ return noopRouteHandle();
692
790
  }
693
791
  props = loaderResult.data;
694
792
  const headStore = { entries: [...loaderResult.headEntries ?? []] };
@@ -699,7 +797,7 @@ async function mountRouteWithHydration(island, host, pathWithSearch, signal, reg
699
797
  applyHeadEntriesToDocument(headStore.entries);
700
798
  host.innerHTML = `<div data-router-view>${html}</div>`;
701
799
  });
702
- return () => {};
800
+ return noopRouteHandle();
703
801
  }
704
802
  const name = reverseRegistry?.get(island) ?? Object.entries(registry).find(([, v]) => v === island)?.[0];
705
803
  if (!name) {
@@ -709,7 +807,7 @@ async function mountRouteWithHydration(island, host, pathWithSearch, signal, reg
709
807
  applyHeadEntriesToDocument(headStore.entries);
710
808
  host.innerHTML = `<div data-router-view>${html}</div>`;
711
809
  });
712
- return () => {};
810
+ return noopRouteHandle();
713
811
  }
714
812
  const html = await withHeadStore(headStore, () => island.hydratable(props, {
715
813
  name,
@@ -720,7 +818,7 @@ async function mountRouteWithHydration(island, host, pathWithSearch, signal, reg
720
818
  applyHeadEntriesToDocument(headStore.entries);
721
819
  host.innerHTML = `<div data-router-view>${html}</div>`;
722
820
  const islandHost = host.querySelector(`[data-ilha="${name}"]`);
723
- return islandHost ? island.mount(islandHost) : () => {};
821
+ return islandHost ? mountIslandWithHandle(island, islandHost) : noopRouteHandle();
724
822
  });
725
823
  }
726
824
  /** Render + mount a `+error` boundary island for a failed loader. */
@@ -964,7 +1062,12 @@ function navigate(to, opts = {}) {
964
1062
  }
965
1063
  _lastNavKey = currentNavKey();
966
1064
  syncRouteFromLocation();
967
- if (opts.scroll !== false) scrollAfterNavigate(adapter.readLocation().hash);
1065
+ if (opts.scroll !== false) {
1066
+ const dest = adapter.readLocation();
1067
+ const fromMatch = findRoute(_rou3, "GET", cur.pathname);
1068
+ const destMatch = findRoute(_rou3, "GET", dest.pathname);
1069
+ if (!(fromMatch?.data?.island != null && destMatch?.data?.island === fromMatch.data.island) || dest.hash && dest.hash !== "#") scrollAfterNavigate(dest.hash);
1070
+ }
968
1071
  runAfterNavigateHooks({
969
1072
  from: current,
970
1073
  to,
@@ -1539,6 +1642,16 @@ function router(options = {}) {
1539
1642
  const viewHost = host.querySelector("[data-router-view]") ?? host;
1540
1643
  let currentMountedIsland = activeIsland();
1541
1644
  let currentMountedPath = routePath() + routeSearch();
1645
+ let viewHandle = null;
1646
+ const adoptHydratedHandle = () => {
1647
+ const el = viewHost.querySelector("[data-ilha]");
1648
+ if (!el) return null;
1649
+ const h = ISLAND_MOUNT_HANDLES.get(el);
1650
+ return h ? {
1651
+ unmount: () => void h.unmount(),
1652
+ updateProps: (p) => h.updateProps(p)
1653
+ } : null;
1654
+ };
1542
1655
  const reverseRegistry = registry ? buildReverseRegistry(registry) : void 0;
1543
1656
  let navVersion = 0;
1544
1657
  const NavHandler = ilha.render(() => {
@@ -1552,10 +1665,20 @@ function router(options = {}) {
1552
1665
  queueMicrotask(async () => {
1553
1666
  if (thisNav !== navVersion) return;
1554
1667
  const settle = beginNavigation();
1555
- unmountView?.();
1556
- unmountView = null;
1557
1668
  try {
1558
- unmountView = await mountRouteWithHydration(current, viewHost, pathWithSearch, signal, registry, reverseRegistry);
1669
+ if (current !== null && current === currentMountedIsland) {
1670
+ const handle = viewHandle ?? adoptHydratedHandle();
1671
+ if (handle?.updateProps) {
1672
+ if (await updateRouteInPlace(handle, pathWithSearch, signal) === "updated") {
1673
+ viewHandle = handle;
1674
+ currentMountedPath = pathWithSearch;
1675
+ return;
1676
+ }
1677
+ }
1678
+ }
1679
+ viewHandle?.unmount();
1680
+ viewHandle = null;
1681
+ viewHandle = await mountRouteWithHydration(current, viewHost, pathWithSearch, signal, registry, reverseRegistry);
1559
1682
  } catch (e) {
1560
1683
  if (e?.name === "AbortError") return;
1561
1684
  console.error("[ilha-router] navigation failed:", e);
@@ -1586,8 +1709,8 @@ function router(options = {}) {
1586
1709
  navAbort = ac;
1587
1710
  try {
1588
1711
  const um = await mountRouteWithHydration(island, viewHost, pathWithSearch, ac.signal, registry, reverseRegistry);
1589
- if (thisNav === navVersion) unmountView = um;
1590
- else um();
1712
+ if (thisNav === navVersion) viewHandle = um;
1713
+ else um.unmount();
1591
1714
  } catch (e) {
1592
1715
  if (e?.name !== "AbortError") console.error("[ilha-router] initial client loader render failed:", e);
1593
1716
  }
@@ -1614,13 +1737,25 @@ function router(options = {}) {
1614
1737
  try {
1615
1738
  const loc = getAdapter().readLocation();
1616
1739
  const pathWithSearch = loc.pathname + loc.search;
1740
+ if (island !== null && island === currentMountedIsland) {
1741
+ const handle = viewHandle ?? adoptHydratedHandle();
1742
+ if (handle?.updateProps) {
1743
+ if (await updateRouteInPlace(handle, pathWithSearch, ac.signal) === "updated") {
1744
+ if (thisNav === navVersion) {
1745
+ viewHandle = handle;
1746
+ currentMountedPath = pathWithSearch;
1747
+ }
1748
+ return;
1749
+ }
1750
+ }
1751
+ }
1617
1752
  const um = await mountRouteWithHydration(island, viewHost, pathWithSearch, ac.signal, registry, reverseRegistry);
1618
1753
  if (thisNav === navVersion) {
1619
- unmountView?.();
1620
- unmountView = um;
1754
+ viewHandle?.unmount();
1755
+ viewHandle = um;
1621
1756
  currentMountedIsland = island;
1622
1757
  currentMountedPath = pathWithSearch;
1623
- } else um();
1758
+ } else um.unmount();
1624
1759
  } catch (e) {
1625
1760
  if (e?.name !== "AbortError") console.error("[ilha-router] invalidate failed:", e);
1626
1761
  } finally {
@@ -1634,7 +1769,7 @@ function router(options = {}) {
1634
1769
  navAbort?.abort();
1635
1770
  unmountNavHandler();
1636
1771
  navHost.remove();
1637
- unmountView?.();
1772
+ viewHandle?.unmount();
1638
1773
  _linkCleanup?.();
1639
1774
  _navChangeCleanup?.();
1640
1775
  _linkCleanup = null;
@@ -1642,7 +1777,8 @@ function router(options = {}) {
1642
1777
  if (prevScrollRestoration !== null) history.scrollRestoration = prevScrollRestoration;
1643
1778
  };
1644
1779
  }
1645
- let unmountIsland = null;
1780
+ let mountedHandle = null;
1781
+ let mountedHandleIsland = null;
1646
1782
  let currentMountedIsland = null;
1647
1783
  let currentMountedPath = null;
1648
1784
  let navVersion = 0;
@@ -1653,13 +1789,21 @@ function router(options = {}) {
1653
1789
  * render would have no access to loader data.
1654
1790
  */
1655
1791
  async function mountActiveIsland(island, signal) {
1656
- unmountIsland?.();
1657
- unmountIsland = null;
1792
+ const sameIsland = island !== null && island === mountedHandleIsland && mountedHandle?.updateProps != null;
1793
+ const teardown = () => {
1794
+ mountedHandle?.unmount();
1795
+ mountedHandle = null;
1796
+ mountedHandleIsland = null;
1797
+ };
1798
+ if (!sameIsland) teardown();
1658
1799
  currentMountedIsland = island;
1659
1800
  currentMountedPath = routePath() + routeSearch();
1660
1801
  if (!island) {
1661
1802
  const nfHost = host?.querySelector("[data-router-not-found]");
1662
- if (_notFound && nfHost) unmountIsland = _notFound.mount(nfHost);
1803
+ if (_notFound && nfHost) mountedHandle = {
1804
+ unmount: _notFound.mount(nfHost),
1805
+ updateProps: null
1806
+ };
1663
1807
  return;
1664
1808
  }
1665
1809
  const viewHost = host?.querySelector("[data-router-view]");
@@ -1676,9 +1820,13 @@ function router(options = {}) {
1676
1820
  return;
1677
1821
  }
1678
1822
  if (result.kind === "error") {
1823
+ if (sameIsland) teardown();
1679
1824
  const boundary = clientMatch?.data?.errorHandler;
1680
1825
  if (boundary) {
1681
- unmountIsland = await withViewSwap(() => mountLoaderErrorBoundary(boundary, viewHost, result.status, result.message));
1826
+ mountedHandle = await withViewSwap(() => ({
1827
+ unmount: mountLoaderErrorBoundary(boundary, viewHost, result.status, result.message),
1828
+ updateProps: null
1829
+ }));
1682
1830
  return;
1683
1831
  }
1684
1832
  const escaped = escapeHtml(result.message);
@@ -1688,13 +1836,19 @@ function router(options = {}) {
1688
1836
  return;
1689
1837
  }
1690
1838
  const props = result.kind === "data" ? result.data : {};
1839
+ if (sameIsland && mountedHandle?.updateProps) {
1840
+ if (result.kind === "data" && result.headEntries?.length) applyHeadEntriesToDocument([...result.headEntries]);
1841
+ mountedHandle.updateProps(props);
1842
+ return;
1843
+ }
1691
1844
  const headStore = { entries: [...result.kind === "data" ? result.headEntries ?? [] : []] };
1692
1845
  const html = await withHeadStore(headStore, () => island.toString(props));
1693
- unmountIsland = await withViewSwap(() => {
1846
+ mountedHandle = await withViewSwap(() => {
1694
1847
  applyHeadEntriesToDocument(headStore.entries);
1695
1848
  viewHost.innerHTML = html;
1696
- return island.mount(viewHost, props);
1849
+ return mountIslandWithHandle(island, viewHost, props);
1697
1850
  });
1851
+ mountedHandleIsland = island;
1698
1852
  }
1699
1853
  navAbort = new AbortController();
1700
1854
  mountActiveIsland(activeIsland(), navAbort.signal).catch((e) => {
@@ -1742,7 +1896,7 @@ function router(options = {}) {
1742
1896
  ++navVersion;
1743
1897
  _revalidate = null;
1744
1898
  navAbort?.abort();
1745
- unmountIsland?.();
1899
+ mountedHandle?.unmount();
1746
1900
  unmountNavHandler();
1747
1901
  navHost.remove();
1748
1902
  unmountView?.();
package/dist/ssr.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as LOADER_ENDPOINT } from "./src-_T1q_MN5.js";
1
+ import { t as LOADER_ENDPOINT } from "./src-Dfq3JPRz.js";
2
2
  import { pageRouter, registry } from "ilha:pages/server";
3
3
  import "ilha:loaders";
4
4
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ilha/router",
3
- "version": "0.8.6",
3
+ "version": "0.8.7",
4
4
  "description": "A tiny SPA router for Ilha",
5
5
  "keywords": [
6
6
  "frontend",
@@ -68,10 +68,10 @@
68
68
  "unplugin": "3.3.0"
69
69
  },
70
70
  "devDependencies": {
71
- "ilha": "0.9.2",
71
+ "ilha": "0.9.3",
72
72
  "vite": "^8.1.3"
73
73
  },
74
74
  "peerDependencies": {
75
- "ilha": ">=0.9.2"
75
+ "ilha": ">=0.9.3"
76
76
  }
77
77
  }