From edbb7f4856c2e521a2f9cbcbf70f66c559aaa520 Mon Sep 17 00:00:00 2001 From: Ryuz Date: Wed, 8 Jul 2026 12:43:45 +0200 Subject: [PATCH] fix(router): ensure layout and page loaders are merged correctly --- apps/website/package.json | 2 +- bun.lock | 2 +- packages/router/package.json | 2 +- packages/router/src/codegen.test.ts | 74 ++++++++++ packages/router/src/index.test.ts | 206 ++++++++++++++++++++++++++++ packages/router/src/index.ts | 28 +++- templates/elysia/package.json | 2 +- templates/hono/package.json | 2 +- templates/nitro/package.json | 2 +- templates/vite/package.json | 2 +- 10 files changed, 309 insertions(+), 13 deletions(-) diff --git a/apps/website/package.json b/apps/website/package.json index b23a2a3..baf4879 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.2", + "@ilha/router": "^0.8.3", "@ilha/store": "^0.7.1", "areia": "^0.1.36", "dedent": "^1.7.2", diff --git a/bun.lock b/bun.lock index 9ce7b09..53298e8 100644 --- a/bun.lock +++ b/bun.lock @@ -48,7 +48,7 @@ }, "packages/router": { "name": "@ilha/router", - "version": "0.8.2", + "version": "0.8.3", "dependencies": { "rou3": "0.9.0", "unplugin": "3.3.0", diff --git a/packages/router/package.json b/packages/router/package.json index 7b1265a..004849f 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@ilha/router", - "version": "0.8.2", + "version": "0.8.3", "description": "A tiny SPA router for Ilha", "keywords": [ "frontend", diff --git a/packages/router/src/codegen.test.ts b/packages/router/src/codegen.test.ts index 90ac177..b1f9566 100644 --- a/packages/router/src/codegen.test.ts +++ b/packages/router/src/codegen.test.ts @@ -905,6 +905,36 @@ describe("codegen — loader detection", () => { expect(loaders).toContain("+layout.ts"); }); + it("composeLoaders chains root layout, nested layout, and page server loads", async () => { + await writePage( + pagesDir, + "user/index.ts", + `export const load = async () => ({ page: true }); export default null;`, + ); + await writePage( + pagesDir, + "+layout.ts", + `export const load = async () => ({ root: true }); export default null;`, + ); + await writePage( + pagesDir, + "user/+layout.ts", + `export const load = async () => ({ nested: true }); export default null;`, + ); + const { loaders } = await runCodegen(); + const userLine = loaders.split("\n").find((l) => l.includes('attachLoader("/user"')); + expect(userLine).toContain("composeLoaders"); + const ids = userLine! + .match(/composeLoaders\(\[([^\]]+)\]\)/)?.[1]! + .split(",") + .map((s) => s.trim()); + expect(ids).toHaveLength(3); + expect(ids![0]).toMatch(/_l0/); + expect(ids![1]).toMatch(/_l1/); + expect(ids![2]).toMatch(/_p/); + expect(ids![2]).not.toMatch(/_l/); + }); + it("composeLoaders is used when Page and layout both have loaders", async () => { await writePage( pagesDir, @@ -1115,6 +1145,50 @@ describe("codegen — clientLoad detection", () => { expect(client).toContain(`import { composeLoaders, router,`); }); + it("composes root, nested, and page clientLoads for a page under two layouts", async () => { + await writePage( + pagesDir, + "+layout.ts", + `export const clientLoad = async () => ({ root: true }); export default null;`, + ); + await writePage( + pagesDir, + "user/+layout.ts", + `export const clientLoad = async () => ({ nested: true }); export default null;`, + ); + await writePage( + pagesDir, + "user/index.ts", + `export const clientLoad = async () => ({ page: true }); export default null;`, + ); + const { client } = await runCodegen(); + expect(client).toContain(`import { composeLoaders, router,`); + const userClientLoader = client.split("\n").find((l) => l.includes('.clientLoader("/user"')); + expect(userClientLoader).toBeDefined(); + expect(userClientLoader).toMatch(/composeLoaders\(\[_cl\d+_l0, _cl\d+_l1, _cl\d+\]\)/); + }); + + it("layout clientLoad + page load only wires layout into clientLoader and page into attachLoader", async () => { + await writePage( + pagesDir, + "+layout.ts", + `export const clientLoad = async () => ({ fromLayout: true }); export default null;`, + ); + await writePage( + pagesDir, + "index.ts", + `export const load = async () => ({ fromPage: true }); export default null;`, + ); + const { client, loaders } = await runCodegen(); + // Client navigations run only the layout's clientLoad — not the page's server load. + expect(client).toContain(`.clientLoader("/", _cl0_l0)`); + expect(client).not.toContain("composeLoaders"); + expect(client).toContain(`.markLoader("/")`); + // Server attachLoader composes server `load` exports only (page here; layout has no load). + expect(loaders).toContain(`pageRouter.attachLoader("/", _p0)`); + expect(loaders).not.toContain("_p0_l0"); + }); + it("a page with both load and clientLoad gets markLoader and clientLoader on the client", async () => { await writePage( pagesDir, diff --git a/packages/router/src/index.test.ts b/packages/router/src/index.test.ts index eb5d98f..3b22eb1 100644 --- a/packages/router/src/index.test.ts +++ b/packages/router/src/index.test.ts @@ -1289,6 +1289,19 @@ describe("composeLoaders()", () => { expect(result).toEqual({ user: "Page-user", extra: 1 }); }); + it("merges three loaders (root layout, nested layout, page) with page winning collisions", async () => { + const root = async () => ({ root: true, shared: "root" }); + const nested = async () => ({ nested: true, shared: "nested" }); + const page = async () => ({ page: true, shared: "page" }); + const composed = composeLoaders([root, nested, page]); + expect(await composed(ctx())).toEqual({ + root: true, + nested: true, + page: true, + shared: "page", + }); + }); + it("runs loaders in parallel (concurrent, not sequential)", async () => { const order: string[] = []; const slow = async () => { @@ -1375,6 +1388,21 @@ describe("router.runLoader()", () => { expect(result).toEqual({ kind: "data", data: { user: "alice" } }); }); + it("runs a composed server loader (root + nested + page) like ilha:loaders attachLoader", async () => { + const composed = composeLoaders([ + loader(async () => ({ root: true, shared: "root" })), + loader(async () => ({ nested: true, shared: "nested" })), + loader(async () => ({ page: true, shared: "page" })), + ]); + const Page = ilha.render(({ input }: any) => `

ok

`); + const r = router().route("/user", Page).attachLoader("/user", composed); + const result = await r.runLoader("/user"); + expect(result).toEqual({ + kind: "data", + data: { root: true, nested: true, page: true, shared: "page" }, + }); + }); + it("returns serialized head when loader calls ctx.head", async () => { const load = loader(async ({ head: h }) => { h({ title: "From loader", meta: [{ name: "x", content: "y" }] }); @@ -1520,6 +1548,25 @@ describe("renderHydratable() with loader", () => { expect(html).toContain("hello world"); }); + it("serializes composed attachLoader data on SSR hydratable (mirrors ilha:loaders)", async () => { + const Page = ilha + .input<{ root: boolean; nested: boolean; page: boolean }>() + .render(({ input }) => `

${String(input.page)}

`); + const composed = composeLoaders([ + loader(async () => ({ root: true })), + loader(async () => ({ nested: true })), + loader(async () => ({ page: true })), + ]); + const html = await router() + .route("/app", Page) + .attachLoader("/app", composed) + .renderHydratable("/app", { app: Page }, { snapshot: true }); + expect(html).toContain("data-ilha-props"); + expect(html).toContain(""root":true"); + expect(html).toContain(""nested":true"); + expect(html).toContain(""page":true"); + }); + it("loader receives params from the matched route", async () => { const UserIsland = ilha .input(createSchema<{ id?: string }>()) @@ -1918,6 +1965,108 @@ describe("SPA client loaders", () => { expect(clientLoad).toHaveBeenCalledTimes(1); }); + it("composed clientLoaders (layout + page) merge into island input", async () => { + const layoutLoad = mock(async () => ({ fromLayout: "L", shared: "layout" })); + const pageLoad = mock(async () => ({ fromPage: "P", shared: "page" })); + const MergedPage = ilha.render( + ({ input }: any) => + `

L:${input?.fromLayout ?? "-"}|P:${input?.fromPage ?? "-"}|S:${input?.shared ?? "-"}

`, + ); + const composed = composeLoaders([loader(layoutLoad), loader(pageLoad)]); + unmount = router() + .route("/", HomePage) + .route("/merged", MergedPage) + .clientLoader("/merged", composed) + .mount(el); + + navigate("/merged"); + await flush(); + + expect(el.innerHTML).toContain("L:L"); + expect(el.innerHTML).toContain("P:P"); + expect(el.innerHTML).toContain("S:page"); + expect(layoutLoad).toHaveBeenCalledTimes(1); + expect(pageLoad).toHaveBeenCalledTimes(1); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("markLoader + endpoint fetch passes server-composed loader data to island", async () => { + fetchSpy.mockImplementation(async (url: string) => { + if (typeof url === "string" && url.includes("/__ilha/loader")) { + return new Response( + JSON.stringify({ + kind: "data", + data: { fromLayout: "L", fromPage: "P", shared: "page" }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return new Response(JSON.stringify({ kind: "data", data: {} }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + const ServerPage = ilha.render( + ({ input }: any) => + `

L:${input?.fromLayout ?? "-"}|P:${input?.fromPage ?? "-"}|S:${input?.shared ?? "-"}

`, + ); + unmount = router() + .route("/", HomePage) + .route("/server", ServerPage) + .markLoader("/server") + .mount(el); + + navigate("/server"); + await flush(); + + expect(el.innerHTML).toContain("L:L"); + expect(el.innerHTML).toContain("P:P"); + expect(el.innerHTML).toContain("S:page"); + expect(fetchSpy).toHaveBeenCalled(); + }); + + it("layout clientLoad only on route (no page clientLoad) still feeds layout keys", async () => { + const layoutOnly = mock(async () => ({ fromLayout: "only-layout" })); + const LayoutOnlyPage = ilha.render( + ({ input }: any) => `

L:${input?.fromLayout ?? "-"}|P:${input?.fromPage ?? "-"}

`, + ); + unmount = router() + .route("/", HomePage) + .route("/layout-only", LayoutOnlyPage) + .clientLoader("/layout-only", loader(layoutOnly)) + .mount(el); + + navigate("/layout-only"); + await flush(); + + expect(el.innerHTML).toContain("L:only-layout"); + expect(layoutOnly).toHaveBeenCalledTimes(1); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("mixed: layout clientLoad + page server loader on same route — client uses clientLoader only (page server load not merged)", async () => { + const layoutClient = mock(async () => ({ fromLayout: "L" })); + const pageServer = mock(async () => ({ fromPage: "P" })); + const MixedPage = ilha.render( + ({ input }: any) => `

L:${input?.fromLayout ?? "-"}|P:${input?.fromPage ?? "-"}

`, + ); + unmount = router() + .route("/", HomePage) + .route("/mixed", MixedPage, loader(pageServer)) + .clientLoader("/mixed", loader(layoutClient)) + .mount(el); + + navigate("/mixed"); + await flush(); + + // clientLoader wins over .route() server loader — layout client runs, page server does not + expect(el.innerHTML).toContain("L:L"); + expect(el.innerHTML).toContain("P:-"); + expect(layoutClient).toHaveBeenCalledTimes(1); + expect(pageServer).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + it("follows a local loader redirect() on the client", async () => { const FromPage = ilha.render(() => `

from

`); unmount = router() @@ -2602,6 +2751,63 @@ describe("wrapError / wrapLayout hydration", () => { unmount(); }); + it("nested wrapLayout passes full merged loader input to leaf when each layout passes a subset", async () => { + const Page = ilha + .input<{ a: number; b: number; c: number }>() + .render(({ input }) => html`

${input.a}-${input.b}-${input.c}

`); + + const Inner = defineLayout((children) => + ilha + .input<{ b: number }>() + .render(({ input }) => html`
${children({ b: input.b })}
`), + ); + const Outer = defineLayout((children) => + ilha + .input<{ a: number }>() + .render(({ input }) => html`
${children({ a: input.a })}
`), + ); + + const Wrapped = wrapLayout(Outer, wrapLayout(Inner, Page)); + const merged = { a: 1, b: 2, c: 3 }; + const ssr = await Wrapped.hydratable(merged, { name: "nested", snapshot: true }); + expect(ssr).toContain("1-2-3"); + + el = makeEl(`
${ssr}
`); + const { unmount } = ilhaMount({ nested: Wrapped }, { root: el }); + await flushEffects(); + expect(el.querySelector("[data-abc]")?.textContent).toBe("1-2-3"); + unmount(); + }); + + it("wrapLayout page slot keeps full merged loader input when layout passes a subset to Children", async () => { + const Page = ilha + .input<{ authSession: { id: string }; todos: string[] }>() + .onMount(({ input }) => { + if (!input.todos?.length) throw new Error("missing page loader keys on slot"); + }) + .render(({ input }) => html`

${input.todos.join(",")}

`); + + const Layout = defineLayout((children) => + ilha + .input<{ authSession: { id: string } }>() + .render(({ input }) => html`
${children({ authSession: input.authSession })}
`), + ); + + const Wrapped = wrapLayout(Layout, Page); + const merged = { + authSession: { id: "u1" }, + todos: ["a", "b"], + }; + const ssr = await Wrapped.hydratable(merged, { name: "index", snapshot: true }); + expect(ssr).toContain("a,b"); + + el = makeEl(`
${ssr}
`); + const { unmount } = ilhaMount({ index: Wrapped }, { root: el }); + await flushEffects(); + expect(el.querySelector("[data-todos]")?.textContent).toBe("a,b"); + unmount(); + }); + it("wrapLayout hydrates module store seeded from loader props on k:page (no innerHTML wipe)", async () => { const external: { items: string[] } = { items: [] }; const Page = ilha diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 44f791a..937ff67 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -328,10 +328,13 @@ function layoutHtmlWithEmptyKPage( ((wrappedLayout as unknown as Record>)[WRAP_LAYOUT_LEAF] as | Island | undefined) ?? wrappedLayout; - const emptyPage = Object.assign(leaf.key("page"), { - toString: () => "", - }) as unknown as Island; - return handler(emptyPage).toString(props as never); + const rawKeyed = leaf.key("page"); + // Layouts often pass a subset to ``; merge full route loader props + // so slot markers and SSR do not drop page-only keys (e.g. todos + authSession). + const shellChild = ((partial?: Record) => + rawKeyed({ ...props, ...(partial ?? {}) })) as unknown as Island; + Object.assign(shellChild, { toString: () => "" }); + return handler(shellChild).toString(props as never); } async function wrapLayoutSlotMarkup( @@ -359,14 +362,24 @@ export function wrapLayout(layout: LayoutHandler, page: Island): Islan // Key the page slot so its id (k:page) never collides with positional // child slots (p:0, p:1, …) inside the page render. - const KeyedPage = Object.assign(page.key("page"), { - toString: page.toString.bind(page), + const rawKeyedPage = page.key("page"); + const layoutInputRef: { merged?: Record } = {}; + const KeyedPage = ((props?: Record) => { + const merged = layoutInputRef.merged; + const slotProps = + merged && typeof merged === "object" ? { ...merged, ...(props ?? {}) } : props; + return rawKeyedPage(slotProps as never); }) as unknown as Island; + Object.assign(KeyedPage, { toString: page.toString.bind(page) }); const Wrapped = layout(KeyedPage); (Wrapped as unknown as Record>)[WRAP_LAYOUT_LEAF] = leafPage; (Wrapped as unknown as Record)[WRAP_LAYOUT_HANDLER] = layout; + function setLayoutMergedInput(props: Record | undefined): void { + layoutInputRef.merged = props; + } + function pageMountHost(host: Element): Element { const slots = [...host.querySelectorAll('[data-ilha-slot="k:page"]')].filter((slot) => { const boundary = slot.closest("[data-ilha]"); @@ -473,6 +486,7 @@ export function wrapLayout(layout: LayoutHandler, page: Island): Islan // and the keyed page slot (k:page). preparePageMountHost copies outer SSR // state onto k:page and clears _skipOnMount so the page onMount still runs. Wrapped.mount = (host: Element, props?: Record) => { + setLayoutMergedInput(props as Record | undefined); prepareLayoutMountHost(host); return layoutMount(host, props as never); }; @@ -481,6 +495,7 @@ export function wrapLayout(layout: LayoutHandler, page: Island): Islan host: Element, props?: Record, ) => { + setLayoutMergedInput(props as Record | undefined); prepareLayoutMountHost(host); if (typeof layoutInternal === "function") { return layoutInternal(host, props); @@ -497,6 +512,7 @@ export function wrapLayout(layout: LayoutHandler, page: Island): Islan ): Promise => { if (!opts?.name) throw new Error("wrapLayout: hydratable requires options.name"); const resolvedProps = props ?? {}; + setLayoutMergedInput(resolvedProps); // Snapshot attrs from the leaf page island, not the layout shell. const pageBlock = await leafPage.hydratable(resolvedProps, opts); const open = parseHydratableOpenTag(pageBlock); diff --git a/templates/elysia/package.json b/templates/elysia/package.json index 03fb130..1377e2f 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.2", + "@ilha/router": "^0.8.3", "@ilha/store": "^0.7.1", "areia": "^0.1.36", "elysia": "^1.4.29", diff --git a/templates/hono/package.json b/templates/hono/package.json index 014123b..c98682e 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.2", + "@ilha/router": "^0.8.3", "@ilha/store": "^0.7.1", "areia": "^0.1.36", "hono": "^4.12.28", diff --git a/templates/nitro/package.json b/templates/nitro/package.json index 04a697d..392680c 100644 --- a/templates/nitro/package.json +++ b/templates/nitro/package.json @@ -9,7 +9,7 @@ "preview": "vite preview" }, "dependencies": { - "@ilha/router": "^0.8.2", + "@ilha/router": "^0.8.3", "@ilha/store": "^0.7.1", "areia": "^0.1.36", "ilha": "^0.9.1", diff --git a/templates/vite/package.json b/templates/vite/package.json index 620995f..f090d6a 100644 --- a/templates/vite/package.json +++ b/templates/vite/package.json @@ -9,7 +9,7 @@ "preview": "vite preview" }, "dependencies": { - "@ilha/router": "^0.8.2", + "@ilha/router": "^0.8.3", "@ilha/store": "^0.7.1", "areia": "^0.1.36", "ilha": "^0.9.1",