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(`

table:a|page:-

`); + + // 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",