Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/router/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ilha/router",
"version": "0.8.2",
"version": "0.8.3",
"description": "A tiny SPA router for Ilha",
"keywords": [
"frontend",
Expand Down
74 changes: 74 additions & 0 deletions packages/router/src/codegen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
206 changes: 206 additions & 0 deletions packages/router/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1289,6 +1289,19 @@
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 () => {
Expand Down Expand Up @@ -1375,6 +1388,21 @@
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) => `<p>ok</p>`);

Check warning on line 1397 in packages/router/src/index.test.ts

View workflow job for this annotation

GitHub Actions / Build, test & publish

eslint(no-unused-vars)

Parameter 'input' is declared but never used. Unused parameters should start with a '_'.
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" }] });
Expand Down Expand Up @@ -1520,6 +1548,25 @@
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 }) => `<p>${String(input.page)}</p>`);
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("&quot;root&quot;:true");
expect(html).toContain("&quot;nested&quot;:true");
expect(html).toContain("&quot;page&quot;:true");
});

it("loader receives params from the matched route", async () => {
const UserIsland = ilha
.input(createSchema<{ id?: string }>())
Expand Down Expand Up @@ -1918,6 +1965,108 @@
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) =>
`<p>L:${input?.fromLayout ?? "-"}|P:${input?.fromPage ?? "-"}|S:${input?.shared ?? "-"}</p>`,
);
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) =>
`<p>L:${input?.fromLayout ?? "-"}|P:${input?.fromPage ?? "-"}|S:${input?.shared ?? "-"}</p>`,
);
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) => `<p>L:${input?.fromLayout ?? "-"}|P:${input?.fromPage ?? "-"}</p>`,
);
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) => `<p>L:${input?.fromLayout ?? "-"}|P:${input?.fromPage ?? "-"}</p>`,
);
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(() => `<p>from</p>`);
unmount = router()
Expand Down Expand Up @@ -2602,6 +2751,63 @@
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`<p data-abc>${input.a}-${input.b}-${input.c}</p>`);

const Inner = defineLayout((children) =>
ilha
.input<{ b: number }>()
.render(({ input }) => html`<div>${children({ b: input.b })}</div>`),
);
const Outer = defineLayout((children) =>
ilha
.input<{ a: number }>()
.render(({ input }) => html`<div>${children({ a: input.a })}</div>`),
);

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(`<div data-router-view>${ssr}</div>`);
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`<p data-todos>${input.todos.join(",")}</p>`);

const Layout = defineLayout((children) =>
ilha
.input<{ authSession: { id: string } }>()
.render(({ input }) => html`<main>${children({ authSession: input.authSession })}</main>`),
);

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(`<div data-router-view>${ssr}</div>`);
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
Expand Down
Loading
Loading