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`${input.todos.join(",")}
`); + + const Layout = defineLayout((children) => + ilha + .input<{ authSession: { id: string } }>() + .render(({ input }) => html`