diff --git a/apps/website/package.json b/apps/website/package.json
index db079df..df1c24f 100644
--- a/apps/website/package.json
+++ b/apps/website/package.json
@@ -11,7 +11,7 @@
"dependencies": {
"@fontsource-variable/geist": "^5.2.9",
"@fontsource-variable/geist-mono": "^5.2.8",
- "@ilha/router": "^0.8.5",
+ "@ilha/router": "^0.8.6",
"@ilha/store": "^0.7.2",
"areia": "^0.1.36",
"dedent": "^1.7.2",
diff --git a/bun.lock b/bun.lock
index c7bf0b5..3351b4b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -48,7 +48,7 @@
},
"packages/router": {
"name": "@ilha/router",
- "version": "0.8.5",
+ "version": "0.8.6",
"dependencies": {
"rou3": "0.9.0",
"unplugin": "3.3.0",
diff --git a/packages/router/package.json b/packages/router/package.json
index 87c036f..f6b5a42 100644
--- a/packages/router/package.json
+++ b/packages/router/package.json
@@ -1,6 +1,6 @@
{
"name": "@ilha/router",
- "version": "0.8.5",
+ "version": "0.8.6",
"description": "A tiny SPA router for Ilha",
"keywords": [
"frontend",
diff --git a/packages/router/src/index.test.ts b/packages/router/src/index.test.ts
index 0a60eeb..00e51e2 100644
--- a/packages/router/src/index.test.ts
+++ b/packages/router/src/index.test.ts
@@ -3041,3 +3041,194 @@ describe("SSR compound render-part regression (Areia Resizable pattern)", () =>
expect(result).not.toMatch(/<\/main>\s*
{
+ let el: Element;
+ let unmount: (() => void) | null = null;
+ let fetchSpy: ReturnType
>;
+
+ const flush = async () => {
+ await new Promise((r) => setTimeout(r, 0));
+ await new Promise((r) => setTimeout(r, 0));
+ };
+
+ beforeEach(() => {
+ setLocation("/");
+ el = makeEl();
+ fetchSpy = (spyOn(globalThis, "fetch") as any).mockImplementation(async () => {
+ return new Response(JSON.stringify({ kind: "data", data: {} }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+ });
+ });
+
+ afterEach(() => {
+ unmount?.();
+ unmount = null;
+ fetchSpy.mockRestore();
+ cleanup(el);
+ setLocation("/");
+ });
+
+ const TablePage = ilha.render(
+ ({ input }: any) => `table:${input?.table ?? "?"}|page:${input?.page ?? "-"}
`,
+ );
+
+ function mountTableRouter(load: ReturnType) {
+ unmount = router()
+ .route("/", HomePage)
+ .route("/about", AboutPage)
+ .route("/t/:name", TablePage, loader(load as any))
+ .mount(el);
+ }
+
+ it("re-runs the loader when only a param changes (/t/a → /t/b)", async () => {
+ const load = mock(async ({ params }: any) => ({ table: params.name }));
+ mountTableRouter(load);
+
+ navigate("/t/a");
+ await flush();
+ expect(el.innerHTML).toContain("table:a");
+
+ navigate("/t/b");
+ await flush();
+
+ expect(load).toHaveBeenCalledTimes(2);
+ expect(load.mock.calls[1]?.[0]?.params?.name).toBe("b");
+ expect(el.innerHTML).toContain("table:b");
+ });
+
+ it("re-runs the loader when only search changes (?page=1 → ?page=2)", async () => {
+ const load = mock(async ({ params, url }: any) => ({
+ table: params.name,
+ page: url.searchParams.get("page"),
+ }));
+ mountTableRouter(load);
+
+ navigate("/t/a?page=1");
+ await flush();
+ expect(el.innerHTML).toContain("page:1");
+
+ navigate("/t/a?page=2");
+ await flush();
+
+ expect(load).toHaveBeenCalledTimes(2);
+ expect(load.mock.calls[1]?.[0]?.url?.searchParams.get("page")).toBe("2");
+ expect(el.innerHTML).toContain("page:2");
+ });
+
+ it("does not re-run the loader for an identical URL", async () => {
+ const load = mock(async ({ params }: any) => ({ table: params.name }));
+ mountTableRouter(load);
+
+ navigate("/t/a");
+ await flush();
+ navigate("/t/a");
+ await flush();
+
+ expect(load).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not re-run the loader for a hash-only change", async () => {
+ const load = mock(async ({ params }: any) => ({ table: params.name }));
+ mountTableRouter(load);
+
+ navigate("/t/a");
+ await flush();
+ navigate("/t/a#section");
+ await flush();
+
+ expect(load).toHaveBeenCalledTimes(1);
+ expect(el.innerHTML).toContain("table:a");
+ });
+
+ it("rapid same-pattern navigations mount only the final result", async () => {
+ const load = mock(async ({ params }: any) => ({ table: params.name }));
+ mountTableRouter(load);
+
+ navigate("/t/a");
+ navigate("/t/b");
+ navigate("/t/c");
+ await flush();
+ await flush();
+
+ expect(el.innerHTML).toContain("table:c");
+ expect(el.innerHTML).not.toContain("table:a");
+ expect(el.innerHTML).not.toContain("table:b");
+ });
+
+ it("cross-pattern navigation still remounts exactly once", async () => {
+ const load = mock(async ({ params }: any) => ({ table: params.name }));
+ mountTableRouter(load);
+
+ navigate("/t/a");
+ await flush();
+ navigate("/about");
+ await flush();
+
+ expect(el.innerHTML).toContain("about");
+ expect(load).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("same-pattern navigation re-runs loaders (hydrate / server loader)", () => {
+ const flush = async () => {
+ await new Promise((r) => setTimeout(r, 0));
+ await new Promise((r) => setTimeout(r, 0));
+ };
+
+ it("re-fetches loader data from the endpoint on param-only and search-only navigations", async () => {
+ const TablePage = ilha.render(
+ ({ input }: any) => `table:${input?.table ?? "?"}|page:${input?.page ?? "-"}
`,
+ );
+ const reg = { home: HomePage, table: TablePage };
+
+ const requested: string[] = [];
+ const fetchSpy = (spyOn(globalThis, "fetch") as any).mockImplementation(async (url: any) => {
+ const u = new URL(String(url), "http://localhost");
+ const path = u.searchParams.get("path") ?? "";
+ requested.push(path);
+ const target = new URL(path, "http://localhost");
+ const table = target.pathname.split("/").pop();
+ return new Response(
+ JSON.stringify({
+ kind: "data",
+ data: { table, page: target.searchParams.get("page") },
+ }),
+ { status: 200, headers: { "content-type": "application/json" } },
+ );
+ });
+
+ setLocation("/t/a");
+ const el = makeEl(``);
+
+ // markLoader simulates the SSR-split client graph: hasLoader without the
+ // loader function itself, so navigation must fetch the loader endpoint.
+ const unmount = router()
+ .route("/", HomePage)
+ .route("/t/:name", TablePage)
+ .markLoader("/t/:name")
+ .mount(el, { hydrate: true, registry: reg });
+ await flush();
+
+ navigate("/t/b");
+ await flush();
+ expect(requested).toContain("/t/b");
+ expect(el.innerHTML).toContain("table:b");
+
+ navigate("/t/b?page=2");
+ await flush();
+ expect(requested).toContain("/t/b?page=2");
+ expect(el.innerHTML).toContain("page:2");
+
+ unmount();
+ cleanup(el);
+ fetchSpy.mockRestore();
+ setLocation("/");
+ });
+});
diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts
index 8986660..40a0534 100644
--- a/packages/router/src/index.ts
+++ b/packages/router/src/index.ts
@@ -96,6 +96,10 @@ export type Loader = (ctx: LoaderContext) => Promise | T;
/**
* Identity function for declaring a loader. Exists purely as a type anchor and
* a marker for the Vite plugin to detect by export name.
+ *
+ * Loaders must read `ctx.params`/`ctx.url` rather than `useRoute()` — the
+ * route store still holds the previous route while a navigation's loader is
+ * in flight, so `useRoute().params()` inside a loader reads stale params.
*/
export function loader(fn: Loader): Loader {
return fn;
@@ -2349,6 +2353,10 @@ export function router(options: RouterOptions = {}): RouterBuilder {
// destroying the event listeners and signal bindings ilha.mount() wired up.
const viewHost = host.querySelector("[data-router-view]") ?? host;
let currentMountedIsland: Island | null = activeIsland();
+ // Logical URL (pathname + search, no hash) the view was mounted for —
+ // same-pattern navigations keep the island identity but must still
+ // re-run loaders when params or search change.
+ let currentMountedPath: string = routePath() + routeSearch();
const reverseRegistry = registry ? buildReverseRegistry(registry) : undefined;
@@ -2356,7 +2364,11 @@ export function router(options: RouterOptions = {}): RouterBuilder {
const NavHandler = ilha.render((): string => {
const current = activeIsland();
- if (current !== currentMountedIsland) {
+ // Read the route-store signals (not location directly) so this
+ // handler re-runs on param/search-only navigations. Hash excluded —
+ // fragment changes must not re-run loaders.
+ const pathWithSearch = routePath() + routeSearch();
+ if (current !== currentMountedIsland || pathWithSearch !== currentMountedPath) {
const thisNav = ++navVersion;
// Cancel any in-flight loader fetch for the previous nav
navAbort?.abort();
@@ -2369,11 +2381,10 @@ export function router(options: RouterOptions = {}): RouterBuilder {
unmountView?.();
unmountView = null;
try {
- const loc = getAdapter().readLocation();
unmountView = await mountRouteWithHydration(
current,
viewHost,
- loc.pathname + loc.search,
+ pathWithSearch,
signal,
registry,
reverseRegistry,
@@ -2389,6 +2400,7 @@ export function router(options: RouterOptions = {}): RouterBuilder {
settle();
}
currentMountedIsland = current;
+ currentMountedPath = pathWithSearch;
});
}
return "";
@@ -2454,10 +2466,11 @@ export function router(options: RouterOptions = {}): RouterBuilder {
const settle = beginNavigation();
try {
const loc = getAdapter().readLocation();
+ const pathWithSearch = loc.pathname + loc.search;
const um = await mountRouteWithHydration(
island,
viewHost,
- loc.pathname + loc.search,
+ pathWithSearch,
ac.signal,
registry,
reverseRegistry,
@@ -2466,6 +2479,7 @@ export function router(options: RouterOptions = {}): RouterBuilder {
unmountView?.();
unmountView = um;
currentMountedIsland = island;
+ currentMountedPath = pathWithSearch;
} else {
um();
}
@@ -2495,6 +2509,10 @@ export function router(options: RouterOptions = {}): RouterBuilder {
// SPA mode — RouterView renders HTML but islands need .mount() for interactivity.
let unmountIsland: (() => void) | null = null;
let currentMountedIsland: Island | null = null;
+ // Logical URL (pathname + search, no hash) the view was mounted for —
+ // same-pattern navigations keep the island identity but must still
+ // re-run loaders when params or search change.
+ let currentMountedPath: string | null = null;
let navVersion = 0;
unmountView = RouterView.mount(host);
@@ -2511,6 +2529,7 @@ export function router(options: RouterOptions = {}): RouterBuilder {
unmountIsland?.();
unmountIsland = null;
currentMountedIsland = island;
+ currentMountedPath = routePath() + routeSearch();
if (!island) {
// RouterView rendered the 404 island's HTML statically; mount it so
// it gets a real island lifecycle (events, effects) like any route.
@@ -2579,7 +2598,11 @@ export function router(options: RouterOptions = {}): RouterBuilder {
const NavHandler = ilha.render((): string => {
const current = activeIsland();
- if (current !== currentMountedIsland) {
+ // Read the route-store signals (not location directly) so this
+ // handler re-runs on param/search-only navigations. Hash excluded —
+ // fragment changes must not re-run loaders.
+ const pathWithSearch = routePath() + routeSearch();
+ if (current !== currentMountedIsland || pathWithSearch !== currentMountedPath) {
const thisNav = ++navVersion;
navAbort?.abort();
navAbort = new AbortController();
diff --git a/packages/store/src/index.test.ts b/packages/store/src/index.test.ts
index 89938f3..54860dc 100644
--- a/packages/store/src/index.test.ts
+++ b/packages/store/src/index.test.ts
@@ -1335,6 +1335,15 @@ describe("persist()", () => {
stop();
});
+ it("hydration is synchronous — getState() immediately after persist() sees stored data", () => {
+ window.localStorage.setItem("p1b", JSON.stringify({ count: 42, label: "restored" }));
+ const s = store({ count: 0, label: "" }).build();
+ const stop = persist(s, "p1b");
+ // Eager, non-reactive read right after persist() — no effect/subscribe involved.
+ expect(s.getState()).toEqual({ count: 42, label: "restored" });
+ stop();
+ });
+
it("writes state on every commit", () => {
const s = store({ count: 0 }).build();
const stop = persist(s, "p2");
diff --git a/templates/elysia/package.json b/templates/elysia/package.json
index 994ffde..d0e7904 100644
--- a/templates/elysia/package.json
+++ b/templates/elysia/package.json
@@ -10,7 +10,7 @@
},
"dependencies": {
"@elysiajs/static": "^1.4.10",
- "@ilha/router": "^0.8.5",
+ "@ilha/router": "^0.8.6",
"@ilha/store": "^0.7.2",
"areia": "^0.1.36",
"elysia": "^1.4.29",
diff --git a/templates/hono/package.json b/templates/hono/package.json
index f3f6706..748c56d 100644
--- a/templates/hono/package.json
+++ b/templates/hono/package.json
@@ -8,7 +8,7 @@
},
"dependencies": {
"@hono/node-server": "^2.0.8",
- "@ilha/router": "^0.8.5",
+ "@ilha/router": "^0.8.6",
"@ilha/store": "^0.7.2",
"areia": "^0.1.36",
"hono": "^4.12.28",
diff --git a/templates/nitro/package.json b/templates/nitro/package.json
index 771893b..35318b1 100644
--- a/templates/nitro/package.json
+++ b/templates/nitro/package.json
@@ -9,7 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
- "@ilha/router": "^0.8.5",
+ "@ilha/router": "^0.8.6",
"@ilha/store": "^0.7.2",
"areia": "^0.1.36",
"ilha": "^0.9.2",
diff --git a/templates/vite/package.json b/templates/vite/package.json
index e74a6d7..e4e96a4 100644
--- a/templates/vite/package.json
+++ b/templates/vite/package.json
@@ -9,7 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
- "@ilha/router": "^0.8.5",
+ "@ilha/router": "^0.8.6",
"@ilha/store": "^0.7.2",
"areia": "^0.1.36",
"ilha": "^0.9.2",