From 011b3bf13af463f1685664cecb231a3a9ac72f39 Mon Sep 17 00:00:00 2001 From: Ryuz Date: Mon, 20 Jul 2026 15:46:07 +0200 Subject: [PATCH 1/2] chore(ilha): reinforce island nesting --- apps/website/package.json | 4 +- bun.lock | 18 +- packages/ilha/README.md | 2 + packages/ilha/package.json | 2 +- packages/ilha/src/index.test.ts | 29 + packages/ilha/src/index.ts | 140 +++- packages/ilha/src/jsx-runtime.test.tsx | 2 +- packages/ilha/src/nesting-stress.test.ts | 648 ++++++++++++++++++ packages/router/README.md | 2 + packages/router/package.json | 6 +- .../router/src/layout-nested-children.test.ts | 505 ++++++++++++++ packages/store/package.json | 10 +- templates/elysia/package.json | 6 +- templates/hono/package.json | 6 +- templates/nitro/package.json | 6 +- templates/vite/package.json | 6 +- 16 files changed, 1349 insertions(+), 43 deletions(-) create mode 100644 packages/ilha/src/nesting-stress.test.ts create mode 100644 packages/router/src/layout-nested-children.test.ts diff --git a/apps/website/package.json b/apps/website/package.json index 337108f..25fdf8d 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -11,11 +11,11 @@ "dependencies": { "@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/geist-mono": "^5.2.8", - "@ilha/router": "^0.8.8", + "@ilha/router": "^0.8.9", "@ilha/store": "^0.7.6", "areia": "^0.1.37", "dedent": "^1.7.2", - "ilha": "^0.9.4", + "ilha": "^0.9.5", "imprensa": "^0.1.21", "lucide": "^1.23.0", "shiki": "^4.3.1", diff --git a/bun.lock b/bun.lock index e7962d7..0f00023 100644 --- a/bun.lock +++ b/bun.lock @@ -38,7 +38,7 @@ }, "packages/ilha": { "name": "ilha", - "version": "0.9.4", + "version": "0.9.5", "dependencies": { "alien-signals": "3.2.1", }, @@ -48,32 +48,32 @@ }, "packages/router": { "name": "@ilha/router", - "version": "0.8.8", + "version": "0.8.9", "dependencies": { "rou3": "0.9.0", "unplugin": "3.3.0", }, "devDependencies": { - "ilha": "0.9.4", + "ilha": "0.9.5", "vite": "^8.1.3", }, "peerDependencies": { - "ilha": ">=0.9.4", + "ilha": ">=0.9.5", }, }, "packages/store": { "name": "@ilha/store", - "version": "0.7.6", + "version": "0.7.7", "devDependencies": { - "@ilha/router": "0.8.8", + "@ilha/router": "0.8.9", "alien-signals": "3.2.1", - "ilha": "0.9.4", + "ilha": "0.9.5", "zod": "4.4.3", }, "peerDependencies": { - "@ilha/router": ">=0.8.8", + "@ilha/router": ">=0.8.9", "alien-signals": "^3.2.1", - "ilha": ">=0.9.4", + "ilha": ">=0.9.5", }, "optionalPeers": [ "@ilha/router", diff --git a/packages/ilha/README.md b/packages/ilha/README.md index 5a4fcf1..62354c1 100644 --- a/packages/ilha/README.md +++ b/packages/ilha/README.md @@ -420,6 +420,8 @@ const Card = ilha.render( ); ``` +Props (including JSX `children` and function callbacks) stay on the **live slot map** during parent render/mount. `data-ilha-props` only carries JSON-safe scalars for hydration hints — never rely on it for children or handlers. Nested islands under layout shells (`defineLayout` / `wrapLayout`) use the same path as page-level composition. + **Keyed children** — use `.key()` when a child may reorder or appear conditionally. Keys must be unique within a parent render: ```ts diff --git a/packages/ilha/package.json b/packages/ilha/package.json index 8cf10a4..65c4151 100644 --- a/packages/ilha/package.json +++ b/packages/ilha/package.json @@ -1,6 +1,6 @@ { "name": "ilha", - "version": "0.9.4", + "version": "0.9.5", "description": "A tiny, framework-free island architecture library", "keywords": [ "framework-free", diff --git a/packages/ilha/src/index.test.ts b/packages/ilha/src/index.test.ts index 7afc382..161902c 100644 --- a/packages/ilha/src/index.test.ts +++ b/packages/ilha/src/index.test.ts @@ -9025,3 +9025,32 @@ describe("morph selection restore with retained focus", () => { cleanup(el); }); }); + +describe("slot props attr omits children and functions", () => { + it("SSR slot embeds only JSON-safe scalar props", () => { + const Child = ilha + .input<{ page: number; setPage?: (n: number) => void; children?: unknown }>() + .render(({ input }) => html`

${input.children as any}

`); + + const Parent = ilha.render( + () => + html`
+ ${Child({ + page: 3, + setPage: () => {}, + children: [{ [Symbol.for("ilha.raw")]: true, value: "hi" }], + })} +
`, + ); + + const out = String(Parent()); + expect(out).toContain('data-ilha-slot="p:0"'); + expect(out).toContain("hi"); + const m = out.match(/data-ilha-props='([^']*)'/); + expect(m).not.toBeNull(); + const props = JSON.parse(m![1]!.replace(/"/g, '"')); + expect(props).toEqual({ page: 3 }); + expect(props.children).toBeUndefined(); + expect(props.setPage).toBeUndefined(); + }); +}); diff --git a/packages/ilha/src/index.ts b/packages/ilha/src/index.ts index 30f84fe..89ce971 100644 --- a/packages/ilha/src/index.ts +++ b/packages/ilha/src/index.ts @@ -291,9 +291,108 @@ function isStableInlineSlotMount( return true; } +/** + * Props safe to embed in `data-ilha-props`. + * Functions cannot round-trip through JSON; children are owned by the live slot + * map (client) and/or the inlined SSR subtree — never depend on the attr for them. + * RawHtml is tagged so a rare attr-only mount can revive `Symbol.for("ilha.raw")`. + */ +function slotPropsForAttr( + props: Record | undefined, +): Record | undefined { + if (props === undefined) return undefined; + let out: Record | undefined; + for (const key of Object.keys(props)) { + if (key === "children") continue; + const value = props[key]; + if (typeof value === "function" || typeof value === "symbol") continue; + const encoded = encodeSlotPropValue(value); + if (encoded === undefined && value !== undefined && value !== null) continue; + (out ??= {})[key] = encoded as unknown; + } + return out; +} + +// Use Symbol.for directly — these helpers sit above the RAW const binding. +const SLOT_RAW = Symbol.for("ilha.raw"); + +function isRawHtmlValue(v: unknown): v is RawHtml { + return !!(v && typeof v === "object" && SLOT_RAW in v); +} + +function encodeSlotPropValue(value: unknown): unknown { + if (value == null) return value; + if (typeof value === "function" || typeof value === "symbol") return undefined; + if (typeof value !== "object") return value; + if (isRawHtmlValue(value)) return { __ilha: "raw", value: value.value }; + if (Array.isArray(value)) { + return value.map((item) => encodeSlotPropValue(item)).filter((item) => item !== undefined); + } + if (Object.getPrototypeOf(value) !== Object.prototype) return undefined; + const obj = value as Record; + const encoded: Record = {}; + for (const key of Object.keys(obj)) { + const next = encodeSlotPropValue(obj[key]); + if (next !== undefined || obj[key] === null) encoded[key] = next as unknown; + } + return encoded; +} + +function makeRawHtml(value: string): RawHtml { + return { [SLOT_RAW]: true, value } as unknown as RawHtml; +} + +function reviveSlotPropValue(value: unknown): unknown { + if (value == null || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map(reviveSlotPropValue); + const obj = value as Record; + if (obj.__ilha === "raw" && typeof obj.value === "string") return makeRawHtml(obj.value); + const out: Record = {}; + for (const key of Object.keys(obj)) out[key] = reviveSlotPropValue(obj[key]); + return out; +} + +/** Revive attr-parsed props (RawHtml tags). Children stay as plain data if present. */ +function reviveSlotProps(props: Record): Record { + const out = reviveSlotPropValue(props) as Record; + // Legacy / debug attrs may still carry children as `{ value: string }` blobs + // after JSON.stringify stripped the RawHtml brand — restore them so a child + // island that only sees attr props can still paint compound children. + if ("children" in out) out.children = reviveLegacyChildren(out.children); + return out; +} + +function reviveLegacyChildren(children: unknown): unknown { + if (!Array.isArray(children)) { + if ( + children && + typeof children === "object" && + "value" in (children as object) && + typeof (children as { value: unknown }).value === "string" && + !isRawHtmlValue(children) + ) { + return makeRawHtml((children as { value: string }).value); + } + return reviveSlotPropValue(children); + } + return children.map((child) => { + if ( + child && + typeof child === "object" && + "value" in (child as object) && + typeof (child as { value: unknown }).value === "string" && + !isRawHtmlValue(child) + ) { + return makeRawHtml((child as { value: string }).value); + } + return reviveSlotPropValue(child); + }); +} + /** Serialized form of slot props, matching the decoded `data-ilha-props` attr. */ function serializeSlotProps(props: Record | undefined): string { - return props === undefined ? "" : JSON.stringify(props); + const safe = slotPropsForAttr(props); + return safe === undefined ? "" : JSON.stringify(safe); } // --------------------------------------------- @@ -837,7 +936,16 @@ interface BindRecord { accessor: ExternalSignal; } -const renderCtxStack: IslandRenderCtx[] = []; +// Shared across duplicate ilha copies in one realm (app + component library). +// Module-local stacks break nested islands: a child island closed over copy B +// cannot see the parent render context from copy A, so it SSR-stringifies +// instead of returning an IslandCall and the parent only records a slot shell. +const RENDER_CTX_STACK = Symbol.for("ilha.renderCtxStack"); + +function renderCtxStack(): IslandRenderCtx[] { + const g = globalThis as typeof globalThis & { [RENDER_CTX_STACK]?: IslandRenderCtx[] }; + return (g[RENDER_CTX_STACK] ??= []); +} function pushRenderCtx(liveHost?: Element, asyncChildren?: boolean): IslandRenderCtx { const ctx: IslandRenderCtx = { @@ -847,16 +955,17 @@ function pushRenderCtx(liveHost?: Element, asyncChildren?: boolean): IslandRende pending: asyncChildren ? new Map() : undefined, binds: [], }; - renderCtxStack.push(ctx); + renderCtxStack().push(ctx); return ctx; } function popRenderCtx(): void { - renderCtxStack.pop(); + renderCtxStack().pop(); } function currentRenderCtx(): IslandRenderCtx | undefined { - return renderCtxStack[renderCtxStack.length - 1]; + const stack = renderCtxStack(); + return stack[stack.length - 1]; } // Brand checks use `Symbol.for`, which resolves to the SAME symbol across @@ -903,7 +1012,8 @@ function emitIslandSlot( const slotTag = getIslandSlotTag(island); - const propsAttr = props ? ` ${PROPS_ATTR}='${escapeHtml(JSON.stringify(props))}'` : ""; + const attrProps = slotPropsForAttr(props); + const propsAttr = attrProps ? ` ${PROPS_ATTR}='${escapeHtml(JSON.stringify(attrProps))}'` : ""; // Client re-render path: emit an EMPTY stub. Post-morph, mountSlots rehomes // the preserved live slot element (with all its mounted children, listeners, @@ -939,7 +1049,7 @@ function emitIslandSlot( // Child rendered synchronously — inline its HTML as usual. return wrapIslandSlotHtml(slotTag, id, propsAttr, String(result)); } finally { - renderCtxStack.push(ctx); + renderCtxStack().push(ctx); } } @@ -2958,7 +3068,9 @@ class IlhaBuilder< const rawProps = host.getAttribute(PROPS_ATTR); if (rawProps) { const parsed = safeParseSnapshot(rawProps, PROPS_ATTR); - if (parsed !== undefined) props = parsed as Partial; + if (parsed !== undefined) { + props = reviveSlotProps(parsed as Record) as Partial; + } } } @@ -3202,7 +3314,9 @@ class IlhaBuilder< const rawProps = slotEl.getAttribute(PROPS_ATTR) ?? slotEl.getAttribute("data-props"); if (rawProps) { const parsed = safeParseSnapshot(rawProps, `props on [${SLOT_ATTR}="${id}"]`); - if (parsed !== undefined) slotProps = parsed as Record; + if (parsed !== undefined) { + slotProps = reviveSlotProps(parsed as Record); + } } } @@ -3538,6 +3652,12 @@ class IlhaBuilder< [...newSlotMap.keys()].every((id) => mountedSlots.has(id)) && isStableInlineSlotMount(initialRenderedHtml, rendered, newSlotMap.keys()) ) { + // Slot set is stable, but live props (functions, children RawHtml) + // are not reflected in the stub markup — still push them. + for (const [id, entry] of mountedSlots) { + const next = newSlotMap.get(id); + if (next) entry.updateProps(next.props); + } return; } // Divergence: a state write between the eager initial render and @@ -4038,7 +4158,7 @@ function mountAll(registry: IslandRegistry, options: MountOptions = {}): MountRe const rawProps = host.getAttribute(PROPS_ATTR); if (rawProps) { const parsed = safeParseSnapshot(rawProps, `${PROPS_ATTR} on [data-ilha="${name}"]`); - if (parsed !== undefined) props = parsed as Record; + if (parsed !== undefined) props = reviveSlotProps(parsed as Record); } unmounts.push(island.mount(host, props)); diff --git a/packages/ilha/src/jsx-runtime.test.tsx b/packages/ilha/src/jsx-runtime.test.tsx index d9a9daf..cb01cb3 100644 --- a/packages/ilha/src/jsx-runtime.test.tsx +++ b/packages/ilha/src/jsx-runtime.test.tsx @@ -643,7 +643,7 @@ describe("ilha JSX runtime", () => { )); expect(normalizeHtml(Parent.toString())).toBe( - "

Parent

", + '

Parent

', ); }); diff --git a/packages/ilha/src/nesting-stress.test.ts b/packages/ilha/src/nesting-stress.test.ts new file mode 100644 index 0000000..0cfd2ff --- /dev/null +++ b/packages/ilha/src/nesting-stress.test.ts @@ -0,0 +1,648 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; + +import { z } from "zod"; + +import ilha, { html, mount, raw, signal, type Island } from "./index"; +import { jsx, jsxs } from "./jsx-runtime"; + +const RAW = Symbol.for("ilha.raw"); + +function makeEl(inner = ""): Element { + const el = document.createElement("div"); + el.innerHTML = inner; + document.body.appendChild(el); + return el; +} + +function cleanup(el: Element): void { + el.remove(); +} + +function flush(): Promise { + return new Promise((r) => queueMicrotask(() => queueMicrotask(r))); +} + +function parseSlotPropsAttr(html: string, slotId: string): Record | null { + const re = new RegExp( + `data-ilha-slot="${slotId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^>]*data-ilha-props='([^']*)'`, + ); + const m = html.match(re); + if (!m) return null; + const json = m[1]! + .replace(/"/g, '"') + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/'/g, "'") + .replace(/&/g, "&"); + return JSON.parse(json) as Record; +} + +function paintChildren(kids: unknown): unknown { + if (!Array.isArray(kids)) { + if (kids && typeof kids === "object" && "value" in (kids as object)) { + return raw(String((kids as { value: string }).value)); + } + return kids ?? ""; + } + return kids.map((k) => { + if (k && typeof k === "object" && RAW in k) return raw((k as { value: string }).value); + if (k && typeof k === "object" && "value" in (k as object)) { + return raw(String((k as { value: string }).value)); + } + return k; + }); +} + +// --------------------------------------------------------------------------- +// Serialization contract +// --------------------------------------------------------------------------- + +describe("stress: slot props serialization", () => { + it("omits functions, symbols, and children from data-ilha-props", () => { + const sym = Symbol("x"); + const Child = ilha + .input<{ + page: number; + label: string; + setPage?: (n: number) => void; + meta?: { nested: boolean }; + children?: unknown; + [key: symbol]: unknown; + }>() + .render( + ({ input }) => + html`
+ ${paintChildren(input.children)} +
`, + ); + + const Parent = ilha.render( + () => + html`
+ ${Child({ + page: 7, + label: "hi", + setPage: () => {}, + meta: { nested: true }, + children: [ + { [RAW]: true, value: "x" }, + { [RAW]: true, value: "y" }, + ], + [sym]: "nope", + } as never)} +
`, + ); + + const out = String(Parent()); + expect(out).toContain('data-ilha-slot="p:0"'); + expect(out).toContain("data-bold"); + expect(out).toContain("data-italic"); + + const props = parseSlotPropsAttr(out, "p:0"); + expect(props).not.toBeNull(); + expect(props).toEqual({ page: 7, label: "hi", meta: { nested: true } }); + expect("children" in props!).toBe(false); + expect("setPage" in props!).toBe(false); + }); + + it("omits children even when they are huge HTML strings", () => { + const huge = `
${"Z".repeat(20_000)}
`; + const Child = ilha + .input<{ n: number; children?: unknown }>() + .render( + ({ input }) => + html`
${paintChildren(input.children)}
`, + ); + + const Parent = ilha.render( + () => + html`
+ ${Child.key("big")({ + n: 1, + children: [{ [RAW]: true, value: huge }], + })} +
`, + ); + + const out = String(Parent()); + expect(out).toContain("data-huge"); + expect(out.length).toBeGreaterThan(20_000); + + const props = parseSlotPropsAttr(out, "k:big"); + expect(props).toEqual({ n: 1 }); + // Attr itself must stay small relative to children HTML + const attr = out.match(/data-ilha-props='([^']*)'/)?.[1] ?? ""; + expect(attr.length).toBeLessThan(200); + }); + + it("revives legacy {value} children when mounting from attr only", () => { + const Child = ilha + .input<{ page: number; children?: unknown }>() + .render( + ({ input }) => + html`
+ ${paintChildren(input.children)} +
`, + ); + + const el = makeEl(); + el.setAttribute( + "data-ilha-props", + JSON.stringify({ + page: 4, + children: [{ value: "revived" }], + }), + ); + const unmount = Child.mount(el); + expect(el.querySelector("[data-legacy]")?.textContent).toBe("revived"); + expect(el.querySelector("[data-from-attr]")?.getAttribute("data-page")).toBe("4"); + unmount(); + cleanup(el); + }); + + it("revives tagged __ilha raw markers from attr props", () => { + const Child = ilha + .input<{ children?: unknown }>() + .render(({ input }) => html`
${paintChildren(input.children)}
`); + + const el = makeEl(); + el.setAttribute( + "data-ilha-props", + JSON.stringify({ + children: [{ __ilha: "raw", value: "ok" }], + }), + ); + const unmount = Child.mount(el); + expect(el.querySelector("[data-tagged]")?.textContent).toBe("ok"); + unmount(); + cleanup(el); + }); + + it("special characters in scalar props survive attr round-trip", () => { + const Child = ilha + .input(z.object({ title: z.string(), note: z.string() })) + .render( + ({ input }) => + html`

+ ${input.title}${input.note} +

`, + ); + + const tricky = `O'Brien & "quotes" & 中文`; + const Parent = ilha.render(() => html`
${Child({ title: tricky, note: tricky })}
`); + const ssr = String(Parent()); + const props = parseSlotPropsAttr(ssr, "p:0"); + expect(props?.title).toBe(tricky); + expect(props?.note).toBe(tricky); + + const el = makeEl(); + const unmount = Parent.mount(el); + // Text content is escaped on write and decoded by the DOM. + expect(el.querySelector("[data-title]")?.textContent).toBe(tricky); + expect(el.querySelector("[data-note]")?.textContent).toBe(tricky); + unmount(); + cleanup(el); + }); +}); + +// --------------------------------------------------------------------------- +// Deep / wide nesting +// --------------------------------------------------------------------------- + +describe("stress: deep and wide island nesting", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + it("5-level deep chain mounts, paints, and keeps leaf callbacks", async () => { + const clicks: string[] = []; + + function makeLevel(name: string, next?: Island): Island { + return ilha + .input<{ label: string; onPing?: () => void; children?: unknown }>() + .on(`[data-ping=${name}]@click`, ({ input }) => input.onPing?.()) + .render(({ input }) => { + const body = next + ? next({ + label: `${input.label}>${name}`, + onPing: input.onPing, + children: input.children, + }) + : paintChildren(input.children); + return html`
+ + ${body} +
`; + }); + } + + const L5 = makeLevel("L5"); + const L4 = makeLevel("L4", L5); + const L3 = makeLevel("L3", L4); + const L2 = makeLevel("L2", L3); + const L1 = makeLevel("L1", L2); + + const Root = ilha.render( + () => + html`
+ ${L1({ + label: "root", + onPing: () => clicks.push("ping"), + children: [{ [RAW]: true, value: "leaf" }], + })} +
`, + ); + + const el = makeEl(); + const unmount = Root.mount(el); + await flush(); + + expect(el.querySelectorAll("[data-level]").length).toBe(5); + expect(el.querySelector("[data-leaf]")?.textContent).toBe("leaf"); + // Outer-most ping button + el.querySelector("[data-ping=L1]")!.click(); + expect(clicks).toEqual(["ping"]); + + unmount(); + cleanup(el); + }); + + it("wide fan-out: 40 keyed siblings with unique children and callbacks", async () => { + const hits: number[] = []; + + const Item = ilha + .input<{ id: number; onHit?: (id: number) => void; children?: unknown }>() + .on("[data-hit]@click", ({ input }) => input.onHit?.(input.id)) + .render( + ({ input }) => + html`
  • + + ${paintChildren(input.children)} +
  • `, + ); + + const List = ilha.render(() => { + const items = Array.from({ length: 40 }, (_, id) => + Item.key(`i${id}`)({ + id, + onHit: (n) => hits.push(n), + children: [{ [RAW]: true, value: `item-${id}` }], + }), + ); + return html`
      + ${items} +
    `; + }); + + const el = makeEl(); + const unmount = List.mount(el); + await flush(); + + expect(el.querySelectorAll("[data-item]").length).toBe(40); + expect(el.querySelector('[data-label="39"]')?.textContent).toBe("item-39"); + + // Spot-check several callbacks + for (const id of [0, 7, 19, 39]) { + el.querySelector(`[data-hit][data-id="${id}"]`)!.click(); + } + expect(hits).toEqual([0, 7, 19, 39]); + + // Attrs must not bloat with children + const ssr = String(List()); + expect(ssr.match(/"children"/g)).toBeNull(); + + unmount(); + cleanup(el); + }); + + it("mixed keyed + positional slots under one parent stay independent", async () => { + const A = ilha + .input<{ name: string; children?: unknown }>() + .render( + ({ input }) => html`
    ${input.name}:${paintChildren(input.children)}
    `, + ); + const B = ilha + .input<{ name: string }>() + .render(({ input }) => html`
    ${input.name}
    `); + + const Parent = ilha.render( + () => + html`
    + ${A.key("featured")({ + name: "A", + children: [{ [RAW]: true, value: "ca" }], + })} + ${B({ name: "B0" })} ${B({ name: "B1" })} +
    `, + ); + + const el = makeEl(); + const unmount = Parent.mount(el); + await flush(); + + expect(el.querySelector("[data-ilha-slot='k:featured']")).not.toBeNull(); + expect(el.querySelector("[data-ilha-slot='p:0']")).not.toBeNull(); + expect(el.querySelector("[data-ilha-slot='p:1']")).not.toBeNull(); + expect(el.querySelector("[data-child-a]")?.textContent).toBe("ca"); + expect(el.querySelector("[data-b]")?.textContent).toBe("B0"); + + unmount(); + cleanup(el); + }); +}); + +// --------------------------------------------------------------------------- +// Parent re-render / prop churn +// --------------------------------------------------------------------------- + +describe("stress: parent re-render prop churn", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + it("rapid parent updates keep children painted and latest callback", async () => { + const calls: number[] = []; + + const Child = ilha + .input<{ page: number; setPage?: (n: number) => void; children?: unknown }>() + .on("[data-go]@click", ({ input }) => input.setPage?.(input.page + 10)) + .render( + ({ input }) => + html`
    + ${paintChildren(input.children)} + +
    `, + ); + + let setPage!: (n: number) => void; + const Parent = ilha.state("page", 1).render(({ state }) => { + setPage = state.page as unknown as typeof setPage; + const page = state.page(); + return html`
    + ${Child.key("pag")({ + page, + setPage: (n) => { + calls.push(n); + state.page(n); + }, + children: [ + { + [RAW]: true, + value: `page-${page}`, + }, + ], + })} +
    `; + }); + + const el = makeEl(); + const unmount = Parent.mount(el); + await flush(); + + for (let i = 2; i <= 25; i++) { + setPage(i); + await flush(); + expect(el.querySelector("[data-info]")?.textContent).toBe(`page-${i}`); + expect(el.querySelector("[data-child]")?.getAttribute("data-page")).toBe(String(i)); + expect(el.querySelector("[data-ilha-slot='k:pag']")!.innerHTML.length).toBeGreaterThan(0); + } + + el.querySelector("[data-go]")!.click(); + await flush(); + expect(calls.at(-1)).toBe(35); + expect(el.querySelector("[data-info]")?.textContent).toBe("page-35"); + + unmount(); + cleanup(el); + }); + + it("onMount state write + first-effect path keeps nested compound children", async () => { + const Child = ilha + .input<{ n: number; children?: unknown }>() + .render( + ({ input }) => + html`
    ${paintChildren(input.children)}
    `, + ); + + const Parent = ilha + .state("n", 1) + .onMount(({ state }) => { + state.n(2); + }) + .render(({ state }) => { + const n = state.n(); + return html`
    + ${Child.key("x")({ + n, + children: [ + { [RAW]: true, value: `n=${n}` }, + { [RAW]: true, value: `` }, + ], + })} +
    `; + }); + + const el = makeEl(); + const unmount = Parent.mount(el); + await flush(); + + expect(el.querySelector("[data-info]")?.textContent).toBe("n=2"); + expect(el.querySelector("[data-btn]")).not.toBeNull(); + expect(el.querySelector("[data-root]")?.getAttribute("data-n")).toBe("2"); + + unmount(); + cleanup(el); + }); + + it("conditional swap of sibling islands does not leave empty slots", async () => { + const Left = ilha + .input<{ children?: unknown }>() + .render(({ input }) => html`
    ${paintChildren(input.children)}
    `); + const Right = ilha + .input<{ children?: unknown }>() + .render(({ input }) => html`
    ${paintChildren(input.children)}
    `); + + let setSide!: (s: string) => void; + const Parent = ilha.state("side", "left").render(({ state }) => { + setSide = state.side as unknown as typeof setSide; + const side = state.side(); + const island = side === "left" ? Left : Right; + return html`
    + ${island.key("slot")({ + children: [{ [RAW]: true, value: `${side}` }], + })} +
    `; + }); + + const el = makeEl(); + const unmount = Parent.mount(el); + await flush(); + expect(el.querySelector("[data-left]")).not.toBeNull(); + expect(el.querySelector("[data-body]")?.textContent).toBe("left"); + + setSide("right"); + await flush(); + expect(el.querySelector("[data-right]")).not.toBeNull(); + expect(el.querySelector("[data-left]")).toBeNull(); + expect(el.querySelector("[data-body]")?.textContent).toBe("right"); + expect(el.querySelector("[data-ilha-slot='k:slot']")!.innerHTML).not.toBe(""); + + setSide("left"); + await flush(); + expect(el.querySelector("[data-left]")).not.toBeNull(); + expect(el.querySelector("[data-body]")?.textContent).toBe("left"); + + unmount(); + cleanup(el); + }); + + it("external signal driven list shrink/grow preserves remaining child content", async () => { + const count = signal(5); + + const Cell = ilha + .input<{ id: number; children?: unknown }>() + .render( + ({ input }) => + html`
  • ${paintChildren(input.children)}
  • `, + ); + + const List = ilha.render(() => { + const n = count(); + return html`
      + ${Array.from({ length: n }, (_, id) => + Cell.key(`c${id}`)({ + id, + children: [{ [RAW]: true, value: `c${id}` }], + }), + )} +
    `; + }); + + const el = makeEl(); + const unmount = List.mount(el); + await flush(); + expect(el.querySelectorAll("[data-cell]").length).toBe(5); + + count(2); + await flush(); + expect(el.querySelectorAll("[data-cell]").length).toBe(2); + expect(el.querySelector('[data-c="0"]')?.textContent).toBe("c0"); + expect(el.querySelector('[data-c="1"]')?.textContent).toBe("c1"); + + count(8); + await flush(); + expect(el.querySelectorAll("[data-cell]").length).toBe(8); + expect(el.querySelector('[data-c="7"]')?.textContent).toBe("c7"); + + unmount(); + cleanup(el); + }); +}); + +// --------------------------------------------------------------------------- +// Hydration / SSR ↔ client +// --------------------------------------------------------------------------- + +describe("stress: hydration with nested compound children", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("hydratable snapshot + mount wires nested island with children and callbacks", async () => { + const calls: number[] = []; + + const Child = ilha + .input<{ page: number; setPage?: (n: number) => void; children?: unknown }>() + .on("[data-next]@click", ({ input }) => input.setPage?.(input.page + 1)) + .render( + ({ input }) => + html`
    + ${paintChildren(input.children)} + +
    `, + ); + + const Page = ilha.render( + () => + html`
    + ${Child.key("grid")({ + page: 2, + setPage: (n) => calls.push(n), + children: [ + { [RAW]: true, value: "info" }, + { [RAW]: true, value: "extra" }, + ], + })} +
    `, + ); + + const ssr = await Page.hydratable({}, { name: "page", snapshot: true }); + expect(ssr).toContain('data-ilha-slot="k:grid"'); + expect(ssr).toContain("data-info"); + expect(parseSlotPropsAttr(ssr, "k:grid")).toEqual({ page: 2 }); + + const root = makeEl(`
    ${ssr}
    `); + const { unmount } = mount({ page: Page }, { root }); + await flush(); + + expect(root.querySelector("[data-info]")?.textContent).toBe("info"); + expect(root.querySelector("[data-extra]")?.textContent).toBe("extra"); + root.querySelector("[data-next]")!.click(); + expect(calls).toEqual([3]); + + unmount(); + cleanup(root); + }); + + it("JSX compound children under nested islands hydrate and stay interactive", async () => { + const calls: string[] = []; + + const Inner = ilha + .input<{ id: string; onAct?: () => void; children?: unknown }>() + .on("[data-act]@click", ({ input }) => input.onAct?.()) + .render(({ input }) => + jsxs("div", { + "data-inner": input.id, + children: [ + paintChildren(input.children), + jsx("button", { "data-act": true, type: "button", children: "act" }), + ], + }), + ); + + const Outer = ilha.render(() => + jsxs("section", { + children: [ + jsx(Inner as never, { + key: "one", + id: "one", + onAct: () => calls.push("one"), + children: [jsx("span", { "data-label": true, children: "label-one" })], + }), + jsx(Inner as never, { + key: "two", + id: "two", + onAct: () => calls.push("two"), + children: [jsx("span", { "data-label": true, children: "label-two" })], + }), + ], + }), + ); + + const ssr = await Outer.hydratable({}, { name: "outer", snapshot: true }); + const root = makeEl(ssr); + const host = root.querySelector("[data-ilha]") ?? root; + const unmount = Outer.mount(host as Element); + await flush(); + + const labels = [...root.querySelectorAll("[data-label]")].map((n) => n.textContent); + expect(labels).toEqual(["label-one", "label-two"]); + + root.querySelector("[data-inner=one] [data-act]")!.click(); + root.querySelector("[data-inner=two] [data-act]")!.click(); + expect(calls).toEqual(["one", "two"]); + + unmount(); + cleanup(root); + }); +}); diff --git a/packages/router/README.md b/packages/router/README.md index d9c1063..faf9741 100644 --- a/packages/router/README.md +++ b/packages/router/README.md @@ -645,6 +645,8 @@ Wraps a page island with a layout handler. Used internally by the Vite plugin co On client hydration, `wrapLayout` mounts the full layout island (layout child slots `p:*` and the keyed page slot `k:page`) from existing SSR DOM — it does not re-render layout markup from serialized props. Interactive components in `+layout.tsx` (state, event handlers, nested islands) hydrate the same way as the page. +**Nested islands under layouts are fully supported.** JSX children and callback props (`setPage`, etc.) are passed through the live slot map on every parent render — not recovered from `data-ilha-props` JSON. Compound children (e.g. Areia `` with `Pagination.Info` / `Controls`) mount into the slot host as real DOM; you do not need `.Static` or to lift controls into the layout. + With **nested** layouts (codegen: `wrapLayout(outer, wrapLayout(inner, page))`), `renderHydratable()` composes layout markup by awaiting the **leaf** page’s `hydratable()` envelope and injecting that HTML into each layout’s `k:page` slot (outer slot receives the inner layout tree; the innermost slot receives the page). Page state snapshots always come from the leaf page island. ```ts diff --git a/packages/router/package.json b/packages/router/package.json index 3fdbcd4..badb41d 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@ilha/router", - "version": "0.8.8", + "version": "0.8.9", "description": "A tiny SPA router for Ilha", "keywords": [ "frontend", @@ -68,10 +68,10 @@ "unplugin": "3.3.0" }, "devDependencies": { - "ilha": "0.9.4", + "ilha": "0.9.5", "vite": "^8.1.3" }, "peerDependencies": { - "ilha": ">=0.9.4" + "ilha": ">=0.9.5" } } diff --git a/packages/router/src/layout-nested-children.test.ts b/packages/router/src/layout-nested-children.test.ts new file mode 100644 index 0000000..282ca1f --- /dev/null +++ b/packages/router/src/layout-nested-children.test.ts @@ -0,0 +1,505 @@ +import { afterEach, describe, expect, it } from "bun:test"; + +import ilha, { html, ISLAND_MOUNT_HANDLES, mount as ilhaMount, raw } from "ilha"; +import { jsx, jsxs } from "ilha/jsx-runtime"; + +import { defineLayout, wrapLayout } from "./index"; + +const RAW = Symbol.for("ilha.raw"); + +function makeEl(inner = ""): Element { + const el = document.createElement("div"); + el.innerHTML = inner; + document.body.appendChild(el); + return el; +} + +function flushEffects() { + return new Promise((r) => queueMicrotask(() => queueMicrotask(r))); +} + +function paintChildren(kids: unknown): unknown { + if (!Array.isArray(kids)) { + if (kids && typeof kids === "object" && "value" in (kids as object)) { + return raw(String((kids as { value: string }).value)); + } + return kids ?? ""; + } + return kids.map((k) => { + if (k && typeof k === "object" && RAW in k) return raw((k as { value: string }).value); + if (k && typeof k === "object" && "value" in (k as object)) { + return raw(String((k as { value: string }).value)); + } + return k; + }); +} + +function slotPropsFromSsr(ssr: string, slotId: string): Record | null { + const re = new RegExp( + `data-ilha-slot="${slotId.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}"[^>]*data-ilha-props='([^']*)'`, + ); + const m = ssr.match(re); + if (!m) return null; + const json = m[1]! + .replace(/"/g, '"') + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/'/g, "'") + .replace(/&/g, "&"); + return JSON.parse(json) as Record; +} + +describe("wrapLayout nested island children", () => { + let el: Element | undefined; + + afterEach(() => { + if (el) { + el.remove(); + el = undefined; + } + document.body.innerHTML = ""; + }); + + it("hydratable + mount paints compound children and keeps callbacks", async () => { + const setPageCalls: number[] = []; + + const Pagination = ilha + .input<{ + page: number; + setPage?: (n: number) => void; + children?: unknown; + }>() + .on("[data-next]@click", ({ input }) => { + input.setPage?.(input.page + 1); + }) + .render(({ input }) => { + return html`
    + ${paintChildren(input.children)} +
    `; + }); + + const Page = ilha.render(() => html`

    page

    `); + + const Layout = defineLayout((Children) => + ilha.input<{ page: number }>().render(({ input }) => + jsxs("div", { + class: "flex", + children: [ + jsx("footer", { + children: jsx(Pagination as never, { + key: "grid-pagination", + page: input.page, + setPage: (p: number) => { + setPageCalls.push(p); + }, + children: [ + jsx("span", { "data-info": true, children: `info ${input.page}` }), + jsx("div", { class: "grow" }), + ], + }), + }), + jsx(Children as never, {}), + ], + }), + ), + ); + + const Wrapped = wrapLayout(Layout, Page); + const ssr = await Wrapped.hydratable({ page: 1 }, { name: "page", snapshot: true }); + + expect(ssr).toContain('data-ilha-slot="k:grid-pagination"'); + expect(ssr).toContain('data-slot="pagination"'); + expect(ssr).toContain("data-info"); + expect(slotPropsFromSsr(ssr, "k:grid-pagination")).toEqual({ page: 1 }); + + el = makeEl(`
    ${ssr}
    `); + const { unmount } = ilhaMount({ page: Wrapped }, { root: el }); + await flushEffects(); + + const slot = el.querySelector('[data-ilha-slot="k:grid-pagination"]'); + expect(slot).not.toBeNull(); + expect(slot!.innerHTML).not.toBe(""); + expect(el.querySelector("[data-info]")?.textContent).toContain("info"); + expect(el.querySelector("[data-slot='pagination']")).not.toBeNull(); + + el.querySelector("[data-next]")!.click(); + await flushEffects(); + expect(setPageCalls).toEqual([2]); + + unmount(); + }); + + it("layout input update keeps nested children painted and callbacks live", async () => { + const setPageCalls: number[] = []; + + const Pagination = ilha + .input<{ + page: number; + totalCount: number; + setPage?: (n: number) => void; + children?: unknown; + }>() + .on("[data-next]@click", ({ input }) => { + input.setPage?.(input.page + 1); + }) + .render(({ input }) => { + return html`
    + ${paintChildren(input.children)} +
    `; + }); + + const Page = ilha.render(() => html`

    page

    `); + + const Layout = defineLayout((Children) => + ilha.input<{ page: number; totalCount: number }>().render(({ input }) => + jsxs("div", { + class: "flex", + children: [ + jsx("footer", { + children: jsx(Pagination as never, { + key: "grid-pagination", + page: input.page, + totalCount: input.totalCount, + setPage: (p: number) => { + setPageCalls.push(p); + }, + children: [ + jsx("span", { + "data-info": true, + children: `info ${input.page}/${input.totalCount}`, + }), + ], + }), + }), + jsx(Children as never, {}), + ], + }), + ), + ); + + const Wrapped = wrapLayout(Layout, Page); + const ssr = await Wrapped.hydratable( + { page: 1, totalCount: 100 }, + { name: "page", snapshot: true }, + ); + + el = makeEl(`
    ${ssr}
    `); + const { unmount } = ilhaMount({ page: Wrapped }, { root: el }); + await flushEffects(); + + expect(el.querySelector("[data-info]")?.textContent).toContain("info 1/100"); + + const host = el.querySelector("[data-ilha]")!; + const handle = ISLAND_MOUNT_HANDLES.get(host); + expect(handle).toBeTruthy(); + handle!.updateProps({ page: 2, totalCount: 200 }); + await flushEffects(); + + expect(el.querySelector("[data-ilha-slot='k:grid-pagination']")!.innerHTML).not.toBe(""); + expect(el.querySelector("[data-info]")?.textContent).toContain("info 2/200"); + expect(el.querySelector("[data-slot='pagination']")?.getAttribute("data-page")).toBe("2"); + + el.querySelector("[data-next]")!.click(); + await flushEffects(); + expect(setPageCalls).toEqual([3]); + + unmount(); + }); +}); + +describe("stress: wrapLayout nesting + serialization", () => { + let el: Element | undefined; + + afterEach(() => { + if (el) { + el.remove(); + el = undefined; + } + document.body.innerHTML = ""; + }); + + it("nested wrapLayout (outer→inner→page) keeps layout-level compound islands", async () => { + const clicks: string[] = []; + + const Tool = ilha + .input<{ name: string; onGo?: () => void; children?: unknown }>() + .on("[data-go]@click", ({ input }) => input.onGo?.()) + .render( + ({ input }) => + html`
    + ${paintChildren(input.children)} + +
    `, + ); + + const Page = ilha.render(() => html`

    page

    `); + + const Inner = defineLayout((Children) => + ilha.render(() => + jsxs("div", { + "data-inner-layout": true, + children: [ + jsx(Tool as never, { + key: "inner-tool", + name: "inner", + onGo: () => clicks.push("inner"), + children: [jsx("span", { "data-inner-child": true, children: "inner-child" })], + }), + jsx(Children as never, {}), + ], + }), + ), + ); + + const Outer = defineLayout((Children) => + ilha.render(() => + jsxs("div", { + "data-outer-layout": true, + children: [ + jsx(Tool as never, { + key: "outer-tool", + name: "outer", + onGo: () => clicks.push("outer"), + children: [jsx("span", { "data-outer-child": true, children: "outer-child" })], + }), + jsx(Children as never, {}), + ], + }), + ), + ); + + const Wrapped = wrapLayout(Outer, wrapLayout(Inner, Page)); + const ssr = await Wrapped.hydratable({}, { name: "page", snapshot: true }); + + expect(ssr).toContain('data-ilha-slot="k:outer-tool"'); + expect(ssr).toContain('data-ilha-slot="k:inner-tool"'); + expect(ssr).toContain("data-outer-child"); + expect(ssr).toContain("data-inner-child"); + expect(slotPropsFromSsr(ssr, "k:outer-tool")).toEqual({ name: "outer" }); + expect(slotPropsFromSsr(ssr, "k:inner-tool")).toEqual({ name: "inner" }); + + el = makeEl(`
    ${ssr}
    `); + const { unmount } = ilhaMount({ page: Wrapped }, { root: el }); + await flushEffects(); + + expect(el.querySelector("[data-outer-child]")?.textContent).toBe("outer-child"); + expect(el.querySelector("[data-inner-child]")?.textContent).toBe("inner-child"); + expect(el.querySelector("[data-page]")?.textContent).toBe("page"); + + el.querySelector("[data-tool=outer] [data-go]")!.click(); + el.querySelector("[data-tool=inner] [data-go]")!.click(); + expect(clicks).toEqual(["outer", "inner"]); + + unmount(); + }); + + it("many layout child islands + page slot: all paint and callbacks survive updateProps", async () => { + const hits: number[] = []; + + const Chip = ilha + .input<{ id: number; onHit?: (id: number) => void; children?: unknown }>() + .on("[data-hit]@click", ({ input }) => input.onHit?.(input.id)) + .render( + ({ input }) => + html``, + ); + + const Page = ilha + .input<{ marker: string }>() + .render(({ input }) => html`

    ${input.marker}

    `); + + const Layout = defineLayout((Children) => + ilha.input<{ marker: string; tick: number }>().render(({ input }) => { + const chips = Array.from({ length: 12 }, (_, id) => + jsx(Chip as never, { + key: `chip-${id}`, + id, + onHit: (n: number) => hits.push(n), + children: [ + jsx("span", { + "data-chip-label": true, + children: `c${id}@${input.tick}`, + }), + ], + }), + ); + return jsxs("div", { + "data-layout": true, + children: [jsx("nav", { "data-nav": true, children: chips }), jsx(Children as never, {})], + }); + }), + ); + + const Wrapped = wrapLayout(Layout, Page); + const ssr = await Wrapped.hydratable( + { marker: "m1", tick: 1 }, + { name: "page", snapshot: true }, + ); + + // No children blobs in any chip slot props + expect(ssr.match(/"children"/g)).toBeNull(); + expect(ssr.match(/data-ilha-slot="k:chip-\d+"/g)?.length).toBe(12); + + el = makeEl(`
    ${ssr}
    `); + const { unmount } = ilhaMount({ page: Wrapped }, { root: el }); + await flushEffects(); + + expect(el.querySelectorAll("[data-chip]").length).toBe(12); + expect(el.querySelector("[data-page-marker]")?.textContent).toBe("m1"); + expect( + [...el.querySelectorAll("[data-chip-label]")].every((n) => + (n.textContent ?? "").endsWith("@1"), + ), + ).toBe(true); + + el.querySelector("[data-chip='3']")!.click(); + el.querySelector("[data-chip='11']")!.click(); + expect(hits).toEqual([3, 11]); + + const host = el.querySelector("[data-ilha]")!; + ISLAND_MOUNT_HANDLES.get(host)!.updateProps({ marker: "m2", tick: 2 }); + await flushEffects(); + + expect(el.querySelector("[data-page-marker]")?.textContent).toBe("m2"); + expect(el.querySelectorAll("[data-chip]").length).toBe(12); + expect( + [...el.querySelectorAll("[data-chip-label]")].every((n) => + (n.textContent ?? "").endsWith("@2"), + ), + ).toBe(true); + // Still interactive after update + el.querySelector("[data-chip='0']")!.click(); + expect(hits).toEqual([3, 11, 0]); + + unmount(); + }); + + it("rapid layout updateProps churn never empties nested compound slots", async () => { + const Pagination = ilha + .input<{ + page: number; + total: number; + setPage?: (n: number) => void; + children?: unknown; + }>() + .on("[data-next]@click", ({ input }) => input.setPage?.(input.page + 1)) + .render( + ({ input }) => + html`
    + ${paintChildren(input.children)} + +
    `, + ); + + const calls: number[] = []; + const Page = ilha.render(() => html`

    p

    `); + const Layout = defineLayout((Children) => + ilha.input<{ page: number; total: number }>().render(({ input }) => + jsxs("div", { + children: [ + jsx(Pagination as never, { + key: "pag", + page: input.page, + total: input.total, + setPage: (n: number) => calls.push(n), + children: [ + jsx("span", { + "data-info": true, + children: `${input.page}/${input.total}`, + }), + jsx("span", { + "data-fat": true, + children: "x".repeat(500), + }), + ], + }), + jsx(Children as never, {}), + ], + }), + ), + ); + + const Wrapped = wrapLayout(Layout, Page); + const ssr = await Wrapped.hydratable({ page: 1, total: 10 }, { name: "page", snapshot: true }); + el = makeEl(`
    ${ssr}
    `); + const { unmount } = ilhaMount({ page: Wrapped }, { root: el }); + await flushEffects(); + + const host = el.querySelector("[data-ilha]")!; + const handle = ISLAND_MOUNT_HANDLES.get(host)!; + + for (let page = 2; page <= 30; page++) { + handle.updateProps({ page, total: page * 10 }); + await flushEffects(); + const slot = el!.querySelector('[data-ilha-slot="k:pag"]'); + expect(slot?.innerHTML.length ?? 0).toBeGreaterThan(0); + expect(el!.querySelector("[data-info]")?.textContent).toBe(`${page}/${page * 10}`); + expect(el!.querySelector("[data-pag]")?.getAttribute("data-page")).toBe(String(page)); + expect(el!.querySelector("[data-fat]")?.textContent?.length).toBe(500); + } + + el.querySelector("[data-next]")!.click(); + expect(calls).toEqual([31]); + + unmount(); + }); + + it("layout with both positional and keyed nested islands + page", async () => { + const Pos = ilha + .input<{ label: string; children?: unknown }>() + .render( + ({ input }) => + html``, + ); + const Keyed = ilha + .input<{ label: string; children?: unknown }>() + .render( + ({ input }) => + html`
    ${input.label}:${paintChildren(input.children)}
    `, + ); + + const Page = ilha.render(() => html`
    main
    `); + const Layout = defineLayout((Children) => + ilha.render(() => + jsxs("div", { + children: [ + jsx(Pos as never, { + label: "p", + children: [jsx("span", { "data-pos-child": true, children: "pc" })], + }), + jsx(Keyed as never, { + key: "hdr", + label: "k", + children: [jsx("span", { "data-key-child": true, children: "kc" })], + }), + jsx(Children as never, {}), + ], + }), + ), + ); + + const Wrapped = wrapLayout(Layout, Page); + const ssr = await Wrapped.hydratable({}, { name: "page", snapshot: true }); + expect(ssr).toContain('data-ilha-slot="p:0"'); + expect(ssr).toContain('data-ilha-slot="k:hdr"'); + expect(ssr).toContain('data-ilha-slot="k:page"'); + expect(slotPropsFromSsr(ssr, "p:0")).toEqual({ label: "p" }); + expect(slotPropsFromSsr(ssr, "k:hdr")).toEqual({ label: "k" }); + + el = makeEl(`
    ${ssr}
    `); + const { unmount } = ilhaMount({ page: Wrapped }, { root: el }); + await flushEffects(); + + expect(el.querySelector("[data-pos-child]")?.textContent).toBe("pc"); + expect(el.querySelector("[data-key-child]")?.textContent).toBe("kc"); + expect(el.querySelector("[data-main]")?.textContent).toBe("main"); + + unmount(); + }); +}); diff --git a/packages/store/package.json b/packages/store/package.json index 2c8a7c0..8bd5f73 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -1,6 +1,6 @@ { "name": "@ilha/store", - "version": "0.7.6", + "version": "0.7.7", "description": "Shared reactive store for Ilha islands.", "keywords": [ "forms", @@ -56,15 +56,15 @@ "cleanup": "rimraf dist node_modules" }, "devDependencies": { - "@ilha/router": "0.8.8", + "@ilha/router": "0.8.9", "alien-signals": "3.2.1", - "ilha": "0.9.4", + "ilha": "0.9.5", "zod": "4.4.3" }, "peerDependencies": { - "@ilha/router": ">=0.8.8", + "@ilha/router": ">=0.8.9", "alien-signals": "^3.2.1", - "ilha": ">=0.9.4" + "ilha": ">=0.9.5" }, "peerDependenciesMeta": { "@ilha/router": { diff --git a/templates/elysia/package.json b/templates/elysia/package.json index 8f0a3c3..b1eab64 100644 --- a/templates/elysia/package.json +++ b/templates/elysia/package.json @@ -10,11 +10,11 @@ }, "dependencies": { "@elysiajs/static": "^1.4.10", - "@ilha/router": "^0.8.8", - "@ilha/store": "^0.7.6", + "@ilha/router": "^0.8.9", + "@ilha/store": "^0.7.7", "areia": "^0.1.37", "elysia": "^1.4.29", - "ilha": "^0.9.4", + "ilha": "^0.9.5", "quando": "^0.1.6" }, "devDependencies": { diff --git a/templates/hono/package.json b/templates/hono/package.json index a96c3da..9e3e0d2 100644 --- a/templates/hono/package.json +++ b/templates/hono/package.json @@ -8,11 +8,11 @@ }, "dependencies": { "@hono/node-server": "^2.0.8", - "@ilha/router": "^0.8.8", - "@ilha/store": "^0.7.6", + "@ilha/router": "^0.8.9", + "@ilha/store": "^0.7.7", "areia": "^0.1.37", "hono": "^4.12.28", - "ilha": "^0.9.4", + "ilha": "^0.9.5", "quando": "^0.1.6" }, "devDependencies": { diff --git a/templates/nitro/package.json b/templates/nitro/package.json index 974499f..0182ec5 100644 --- a/templates/nitro/package.json +++ b/templates/nitro/package.json @@ -9,10 +9,10 @@ "preview": "vite preview" }, "dependencies": { - "@ilha/router": "^0.8.8", - "@ilha/store": "^0.7.6", + "@ilha/router": "^0.8.9", + "@ilha/store": "^0.7.7", "areia": "^0.1.37", - "ilha": "^0.9.4", + "ilha": "^0.9.5", "nitro": "^3.0.260610-beta", "quando": "^0.1.6" }, diff --git a/templates/vite/package.json b/templates/vite/package.json index 9cfe99e..8eaaee9 100644 --- a/templates/vite/package.json +++ b/templates/vite/package.json @@ -9,10 +9,10 @@ "preview": "vite preview" }, "dependencies": { - "@ilha/router": "^0.8.8", - "@ilha/store": "^0.7.6", + "@ilha/router": "^0.8.9", + "@ilha/store": "^0.7.7", "areia": "^0.1.37", - "ilha": "^0.9.4", + "ilha": "^0.9.5", "quando": "^0.1.6" }, "devDependencies": { From 40d79d1d1b3b0c7234b078bf15615663ab017924 Mon Sep 17 00:00:00 2001 From: Ryuz Date: Mon, 20 Jul 2026 16:04:07 +0200 Subject: [PATCH 2/2] fix(pr): improvements --- apps/website/package.json | 2 +- packages/ilha/src/index.ts | 78 +++++++++++-------- packages/ilha/src/nesting-stress.test.ts | 31 ++++++++ .../router/src/layout-nested-children.test.ts | 2 +- 4 files changed, 78 insertions(+), 35 deletions(-) diff --git a/apps/website/package.json b/apps/website/package.json index 25fdf8d..9866826 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -12,7 +12,7 @@ "@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/geist-mono": "^5.2.8", "@ilha/router": "^0.8.9", - "@ilha/store": "^0.7.6", + "@ilha/store": "^0.7.7", "areia": "^0.1.37", "dedent": "^1.7.2", "ilha": "^0.9.5", diff --git a/packages/ilha/src/index.ts b/packages/ilha/src/index.ts index 89ce971..361c51f 100644 --- a/packages/ilha/src/index.ts +++ b/packages/ilha/src/index.ts @@ -320,19 +320,28 @@ function isRawHtmlValue(v: unknown): v is RawHtml { return !!(v && typeof v === "object" && SLOT_RAW in v); } -function encodeSlotPropValue(value: unknown): unknown { +function encodeSlotPropValue(value: unknown, seen?: WeakSet): unknown { if (value == null) return value; if (typeof value === "function" || typeof value === "symbol") return undefined; if (typeof value !== "object") return value; if (isRawHtmlValue(value)) return { __ilha: "raw", value: value.value }; + + const visited = seen ?? new WeakSet(); + if (visited.has(value as object)) { + throw new TypeError("encodeSlotPropValue: circular reference in slot props"); + } + visited.add(value as object); + if (Array.isArray(value)) { - return value.map((item) => encodeSlotPropValue(item)).filter((item) => item !== undefined); + return value + .map((item) => encodeSlotPropValue(item, visited)) + .filter((item) => item !== undefined); } if (Object.getPrototypeOf(value) !== Object.prototype) return undefined; const obj = value as Record; const encoded: Record = {}; for (const key of Object.keys(obj)) { - const next = encodeSlotPropValue(obj[key]); + const next = encodeSlotPropValue(obj[key], visited); if (next !== undefined || obj[key] === null) encoded[key] = next as unknown; } return encoded; @@ -352,8 +361,28 @@ function reviveSlotPropValue(value: unknown): unknown { return out; } +/** + * Exact legacy shape produced when RawHtml lost its brand under JSON.stringify: + * a plain object with a single own key `value` that is a string. + */ +function isLegacyRawHtmlBlob(value: unknown): value is { value: string } { + if (!value || typeof value !== "object" || isRawHtmlValue(value)) return false; + if (Object.getPrototypeOf(value) !== Object.prototype) return false; + const keys = Object.keys(value as object); + return ( + keys.length === 1 && + keys[0] === "value" && + typeof (value as { value: unknown }).value === "string" + ); +} + /** Revive attr-parsed props (RawHtml tags). Children stay as plain data if present. */ function reviveSlotProps(props: Record): Record { + // Callers normally pass safeParseSnapshot output (already a plain object), + // but guard so a mistaken non-object never throws inside revival. + if (typeof props !== "object" || props === null || Array.isArray(props)) { + return {}; + } const out = reviveSlotPropValue(props) as Record; // Legacy / debug attrs may still carry children as `{ value: string }` blobs // after JSON.stringify stripped the RawHtml brand — restore them so a child @@ -364,27 +393,11 @@ function reviveSlotProps(props: Record): Record { - if ( - child && - typeof child === "object" && - "value" in (child as object) && - typeof (child as { value: unknown }).value === "string" && - !isRawHtmlValue(child) - ) { - return makeRawHtml((child as { value: string }).value); - } + if (isLegacyRawHtmlBlob(child)) return makeRawHtml(child.value); return reviveSlotPropValue(child); }); } @@ -3272,6 +3285,14 @@ class IlhaBuilder< return index; } + /** Push live props from a fresh slot map into already-mounted children. */ + function pushUpdatedProps(nextSlots: IslandRenderCtx["slots"]): void { + for (const [id, entry] of mountedSlots) { + const next = nextSlots.get(id); + if (next) entry.updateProps(next.props); + } + } + function mountSlots(slotMap: IslandRenderCtx["slots"]) { // Unmount slots that are no longer present (conditionally removed). for (const [id, entry] of mountedSlots) { @@ -3623,10 +3644,7 @@ class IlhaBuilder< // Hydration: listeners/bindings were wired to SSR DOM — never morph // on the first effect pass (layout + page slots both use this path). if (preserveSSRDom) { - for (const [id, entry] of mountedSlots) { - const next = newSlotMap.get(id); - if (next) entry.updateProps(next.props); - } + pushUpdatedProps(newSlotMap); if (rendered === initialRenderedHtml) { lastRendered = rendered; return; @@ -3654,10 +3672,7 @@ class IlhaBuilder< ) { // Slot set is stable, but live props (functions, children RawHtml) // are not reflected in the stub markup — still push them. - for (const [id, entry] of mountedSlots) { - const next = newSlotMap.get(id); - if (next) entry.updateProps(next.props); - } + pushUpdatedProps(newSlotMap); return; } // Divergence: a state write between the eager initial render and @@ -3680,10 +3695,7 @@ class IlhaBuilder< mountedSlots.size === newSlotMap.size && [...newSlotMap].every(([id, next]) => mountedSlots.get(id)?.island === next.island) ) { - for (const [id, entry] of mountedSlots) { - const next = newSlotMap.get(id); - if (next) entry.updateProps(next.props); - } + pushUpdatedProps(newSlotMap); return; } diff --git a/packages/ilha/src/nesting-stress.test.ts b/packages/ilha/src/nesting-stress.test.ts index 0cfd2ff..d1b60f2 100644 --- a/packages/ilha/src/nesting-stress.test.ts +++ b/packages/ilha/src/nesting-stress.test.ts @@ -160,6 +160,37 @@ describe("stress: slot props serialization", () => { cleanup(el); }); + it("does not treat {value, extra} objects as legacy RawHtml children", () => { + const seen: unknown[] = []; + const Child = ilha.input<{ children?: unknown }>().render(({ input }) => { + seen.push(input.children); + return html`
    `; + }); + + const el = makeEl(); + el.setAttribute( + "data-ilha-props", + JSON.stringify({ + children: [{ value: "x", kind: "note" }], + }), + ); + const unmount = Child.mount(el); + const kids = seen[0] as unknown[]; + expect(Array.isArray(kids)).toBe(true); + expect(kids[0]).toEqual({ value: "x", kind: "note" }); + expect(kids[0] && typeof kids[0] === "object" && RAW in (kids[0] as object)).toBe(false); + unmount(); + cleanup(el); + }); + + it("encodeSlotPropValue rejects circular slot props predictably", () => { + const cyclic: Record = { page: 1 }; + cyclic.self = cyclic; + const Child = ilha.input<{ page: number }>().render(() => html`
    `); + const Parent = ilha.render(() => html`
    ${Child(cyclic as never)}
    `); + expect(() => String(Parent())).toThrow(/circular reference/); + }); + it("revives tagged __ilha raw markers from attr props", () => { const Child = ilha .input<{ children?: unknown }>() diff --git a/packages/router/src/layout-nested-children.test.ts b/packages/router/src/layout-nested-children.test.ts index 282ca1f..bbeb472 100644 --- a/packages/router/src/layout-nested-children.test.ts +++ b/packages/router/src/layout-nested-children.test.ts @@ -36,7 +36,7 @@ function paintChildren(kids: unknown): unknown { function slotPropsFromSsr(ssr: string, slotId: string): Record | null { const re = new RegExp( - `data-ilha-slot="${slotId.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}"[^>]*data-ilha-props='([^']*)'`, + `data-ilha-slot="${slotId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^>]*data-ilha-props='([^']*)'`, ); const m = ssr.match(re); if (!m) return null;