Skip to content

Commit a9f8ff8

Browse files
committed
feat(router): make router hydratable
1 parent 7692e6a commit a9f8ff8

File tree

17 files changed

+613
-167
lines changed

17 files changed

+613
-167
lines changed

packages/router/src/index.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ const userPage = ilha.render(() => {
5353
});
5454
const notFound = ilha.render(() => `<p>404</p>`);
5555

56+
// shared registry used across hydratable tests
57+
const registry: Record<string, typeof homePage> = {
58+
home: homePage,
59+
about: aboutPage,
60+
user: userPage,
61+
notFound: notFound,
62+
};
63+
5664
// ─────────────────────────────────────────────
5765
// route matching
5866
// ─────────────────────────────────────────────
@@ -614,3 +622,120 @@ describe("SSR render()", () => {
614622
expect(search()).toBe("?sort=asc");
615623
});
616624
});
625+
626+
// ─────────────────────────────────────────────
627+
// SSR — router().renderHydratable()
628+
// ─────────────────────────────────────────────
629+
630+
describe("SSR renderHydratable()", () => {
631+
it("returns a string (is async)", async () => {
632+
const html = await router()
633+
.route("/", homePage)
634+
.route("/**", notFound)
635+
.renderHydratable("/", registry);
636+
expect(typeof html).toBe("string");
637+
});
638+
639+
it("wraps output in data-router-view", async () => {
640+
const html = await router().route("/", homePage).renderHydratable("/", registry);
641+
expect(html).toContain("data-router-view");
642+
});
643+
644+
it("includes data-ilha attribute with the island name", async () => {
645+
const html = await router().route("/", homePage).renderHydratable("/", registry);
646+
expect(html).toContain(`data-ilha="home"`);
647+
});
648+
649+
it("includes island content in the output", async () => {
650+
const html = await router().route("/", homePage).renderHydratable("/", registry);
651+
expect(html).toContain("home");
652+
});
653+
654+
it("resolves correct island for /about", async () => {
655+
const html = await router()
656+
.route("/", homePage)
657+
.route("/about", aboutPage)
658+
.route("/**", notFound)
659+
.renderHydratable("/about", registry);
660+
expect(html).toContain(`data-ilha="about"`);
661+
expect(html).toContain("about");
662+
expect(html).not.toContain(`data-ilha="home"`);
663+
});
664+
665+
it("renders data-router-empty when no route matches", async () => {
666+
const html = await router().route("/", homePage).renderHydratable("/unknown", registry);
667+
expect(html).toContain("data-router-empty");
668+
expect(html).not.toContain("data-ilha");
669+
});
670+
671+
it("populates route signals identically to render()", async () => {
672+
await router().route("/user/:id", userPage).renderHydratable("/user/42", registry);
673+
expect(routePath()).toBe("/user/42");
674+
expect(routeParams()).toEqual({ id: "42" });
675+
});
676+
677+
it("populates routeSearch signal", async () => {
678+
await router().route("/about", aboutPage).renderHydratable("/about?tab=docs", registry);
679+
expect(routeSearch()).toBe("?tab=docs");
680+
});
681+
682+
it("accepts a full URL string", async () => {
683+
const html = await router()
684+
.route("/about", aboutPage)
685+
.renderHydratable("http://example.com/about", registry);
686+
expect(html).toContain(`data-ilha="about"`);
687+
});
688+
689+
it("accepts a URL object", async () => {
690+
const html = await router()
691+
.route("/about", aboutPage)
692+
.renderHydratable(new URL("http://example.com/about"), registry);
693+
expect(html).toContain(`data-ilha="about"`);
694+
});
695+
696+
it("falls back to plain SSR and warns when island is not in registry", async () => {
697+
const warn = spyOn(console, "warn").mockImplementation(() => {});
698+
const unregistered = ilha.render(() => `<p>unregistered</p>`);
699+
const html = await router().route("/", unregistered).renderHydratable("/", registry);
700+
701+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("[ilha-router]"));
702+
expect(html).toContain("data-router-view");
703+
expect(html).toContain("unregistered");
704+
expect(html).not.toContain("data-ilha");
705+
warn.mockRestore();
706+
});
707+
708+
it("does not include data-ilha when falling back to plain SSR", async () => {
709+
const warn = spyOn(console, "warn").mockImplementation(() => {});
710+
const unregistered = ilha.render(() => `<p>x</p>`);
711+
const html = await router().route("/", unregistered).renderHydratable("/", {});
712+
expect(html).not.toContain("data-ilha");
713+
warn.mockRestore();
714+
});
715+
716+
it("snapshot option is forwarded — data-ilha-state present", async () => {
717+
const stateful = ilha.state("count", 0).render(() => `<p>count</p>`);
718+
const reg = { stateful };
719+
const html = await router().route("/", stateful).renderHydratable("/", reg, { snapshot: true });
720+
expect(html).toContain("data-ilha-state");
721+
});
722+
723+
it("snapshot: false omits data-ilha-state", async () => {
724+
const stateful = ilha.state("count", 0).render(() => `<p>count</p>`);
725+
const reg = { stateful };
726+
const html = await router()
727+
.route("/", stateful)
728+
.renderHydratable("/", reg, { snapshot: false });
729+
expect(html).not.toContain("data-ilha-state");
730+
});
731+
732+
it("each call is independent — registry lookup uses active island", async () => {
733+
const r = router().route("/", homePage).route("/about", aboutPage);
734+
735+
const h1 = await r.renderHydratable("/", registry);
736+
const h2 = await r.renderHydratable("/about", registry);
737+
738+
expect(h1).toContain(`data-ilha="home"`);
739+
expect(h2).toContain(`data-ilha="about"`);
740+
});
741+
});

packages/router/src/index.ts

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { context } from "ilha";
2-
import type { Island } from "ilha";
2+
import type { Island, HydratableOptions } from "ilha";
33
import ilha, { html } from "ilha";
44
import { createRouter, addRoute, findRoute } from "rou3";
55

@@ -22,12 +22,24 @@ export interface NavigateOptions {
2222
replace?: boolean;
2323
}
2424

25+
export interface HydratableRenderOptions extends Partial<Omit<HydratableOptions, "name">> {}
26+
2527
export interface RouterBuilder {
2628
route(pattern: string, island: Island<any, any>): RouterBuilder;
2729
/** Client-side: mount into a DOM element or selector, returns unmount fn */
2830
mount(target: string | Element): () => void;
2931
/** Server-side: resolve a URL to an HTML string */
3032
render(url: string | URL): string;
33+
/**
34+
* Server-side: resolve a URL and render with hydration markers.
35+
* Requires an island registry (name → island) shared with the client.
36+
* Use with ilha.mount(registry) on the client for zero-flash hydration.
37+
*/
38+
renderHydratable(
39+
url: string | URL,
40+
registry: Record<string, Island<any, any>>,
41+
options?: HydratableRenderOptions,
42+
): Promise<string>;
3143
}
3244

3345
// ─────────────────────────────────────────────
@@ -69,7 +81,6 @@ function syncRouteFromURL(url: string | URL): void {
6981
const match = findRoute(_rou3, "GET", path);
7082
const island = match?.data ?? null;
7183

72-
// rou3 returns decoded param values already; re-decode just in case
7384
const params: Record<string, string> = {};
7485
for (const [k, v] of Object.entries(match?.params ?? {})) {
7586
params[k] = decodeURIComponent(v as string);
@@ -185,7 +196,7 @@ export function router(): RouterBuilder {
185196
mount(target: string | Element): () => void {
186197
if (!isBrowser) {
187198
console.warn(
188-
"[ilha-router] mount() called in a non-browser environment — use render() for SSR",
199+
"[ilha-router] mount() called in a non-browser environment — use render() or renderHydratable() for SSR",
189200
);
190201
return () => {};
191202
}
@@ -202,7 +213,6 @@ export function router(): RouterBuilder {
202213
const popHandler = () => syncRoute();
203214
window.addEventListener("popstate", popHandler);
204215
_popstateCleanup = () => window.removeEventListener("popstate", popHandler);
205-
206216
_linkCleanup = enableLinkInterception(document);
207217

208218
const unmountView = RouterView.mount(host);
@@ -216,11 +226,47 @@ export function router(): RouterBuilder {
216226
};
217227
},
218228

219-
// ── Server-side ───────────────────────────
229+
// ── Server-side — plain SSR ───────────────
220230
render(url: string | URL): string {
221231
syncRouteFromURL(url);
222232
return RouterView.toString();
223233
},
234+
235+
// ── Server-side — hydratable SSR ─────────
236+
async renderHydratable(
237+
url: string | URL,
238+
registry: Record<string, Island<any, any>>,
239+
options: HydratableRenderOptions = {},
240+
): Promise<string> {
241+
syncRouteFromURL(url);
242+
243+
const island = activeIsland();
244+
245+
if (!island) return `<div data-router-empty></div>`;
246+
247+
const name = Object.entries(registry).find(([, v]) => v === island)?.[0];
248+
249+
if (!name) {
250+
// island not registered — fall back to plain SSR, no hydration
251+
console.warn(
252+
`[ilha-router] renderHydratable: active island for "${routePath()}" is not in the registry. ` +
253+
`Falling back to plain SSR — the island will not be interactive on the client.`,
254+
);
255+
return `<div data-router-view>${island.toString()}</div>`;
256+
}
257+
258+
const html = await island.hydratable(
259+
{},
260+
{
261+
name,
262+
as: "div",
263+
snapshot: true,
264+
...options,
265+
},
266+
);
267+
268+
return `<div data-router-view>${html}</div>`;
269+
},
224270
};
225271

226272
return builder;

0 commit comments

Comments
 (0)