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
6 changes: 3 additions & 3 deletions apps/website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
"dependencies": {
"@fontsource-variable/geist": "^5.2.9",
"@fontsource-variable/geist-mono": "^5.2.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",
"dedent": "^1.7.2",
"ilha": "^0.9.4",
"ilha": "^0.9.5",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"imprensa": "^0.1.21",
"lucide": "^1.23.0",
"shiki": "^4.3.1",
Expand Down
18 changes: 9 additions & 9 deletions bun.lock

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

2 changes: 2 additions & 0 deletions packages/ilha/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/ilha/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
29 changes: 29 additions & 0 deletions packages/ilha/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`<p data-page=${String(input.page)}>${input.children as any}</p>`);

const Parent = ilha.render(
() =>
html`<div>
${Child({
page: 3,
setPage: () => {},
children: [{ [Symbol.for("ilha.raw")]: true, value: "<em>hi</em>" }],
})}
</div>`,
);

const out = String(Parent());
expect(out).toContain('data-ilha-slot="p:0"');
expect(out).toContain("<em>hi</em>");
const m = out.match(/data-ilha-props='([^']*)'/);
expect(m).not.toBeNull();
const props = JSON.parse(m![1]!.replace(/&quot;/g, '"'));
expect(props).toEqual({ page: 3 });
expect(props.children).toBeUndefined();
expect(props.setPage).toBeUndefined();
});
});
168 changes: 150 additions & 18 deletions packages/ilha/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,9 +291,121 @@
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<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (props === undefined) return undefined;
let out: Record<string, unknown> | 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, seen?: WeakSet<object>): 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<object>();
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, visited))
.filter((item) => item !== undefined);
}
if (Object.getPrototypeOf(value) !== Object.prototype) return undefined;
const obj = value as Record<string, unknown>;
const encoded: Record<string, unknown> = {};
for (const key of Object.keys(obj)) {
const next = encodeSlotPropValue(obj[key], visited);
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<string, unknown>;
if (obj.__ilha === "raw" && typeof obj.value === "string") return makeRawHtml(obj.value);
const out: Record<string, unknown> = {};
for (const key of Object.keys(obj)) out[key] = reviveSlotPropValue(obj[key]);
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<string, unknown>): Record<string, unknown> {
// 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<string, unknown>;
// 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 (isLegacyRawHtmlBlob(children)) return makeRawHtml(children.value);
return reviveSlotPropValue(children);
}
return children.map((child) => {
if (isLegacyRawHtmlBlob(child)) return makeRawHtml(child.value);
return reviveSlotPropValue(child);
});
}

/** Serialized form of slot props, matching the decoded `data-ilha-props` attr. */
function serializeSlotProps(props: Record<string, unknown> | undefined): string {
return props === undefined ? "" : JSON.stringify(props);
const safe = slotPropsForAttr(props);
return safe === undefined ? "" : JSON.stringify(safe);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// ---------------------------------------------
Expand Down Expand Up @@ -837,7 +949,16 @@
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 = {
Expand All @@ -847,16 +968,17 @@
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
Expand Down Expand Up @@ -903,7 +1025,8 @@

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,
Expand Down Expand Up @@ -939,7 +1062,7 @@
// Child rendered synchronously — inline its HTML as usual.
return wrapIslandSlotHtml(slotTag, id, propsAttr, String(result));
} finally {
renderCtxStack.push(ctx);
renderCtxStack().push(ctx);
}
}

Expand Down Expand Up @@ -2684,8 +2807,8 @@

function resolveInput(props?: Partial<TInput>): TInput {
const merged = {
...(defaultInput ?? {}),

Check warning on line 2810 in packages/ilha/src/index.ts

View workflow job for this annotation

GitHub Actions / Build, test & publish

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
...(props ?? {}),

Check warning on line 2811 in packages/ilha/src/index.ts

View workflow job for this annotation

GitHub Actions / Build, test & publish

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
} as Record<string, unknown>;
if (!schema) return merged as TInput;
return validateSchema(schema, merged) as TInput;
Expand Down Expand Up @@ -2958,7 +3081,9 @@
const rawProps = host.getAttribute(PROPS_ATTR);
if (rawProps) {
const parsed = safeParseSnapshot(rawProps, PROPS_ATTR);
if (parsed !== undefined) props = parsed as Partial<TInput>;
if (parsed !== undefined) {
props = reviveSlotProps(parsed as Record<string, unknown>) as Partial<TInput>;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -3160,6 +3285,14 @@
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) {
Expand Down Expand Up @@ -3202,7 +3335,9 @@
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<string, unknown>;
if (parsed !== undefined) {
slotProps = reviveSlotProps(parsed as Record<string, unknown>);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -3509,10 +3644,7 @@
// 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;
Expand All @@ -3538,6 +3670,9 @@
[...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.
pushUpdatedProps(newSlotMap);
return;
}
// Divergence: a state write between the eager initial render and
Expand All @@ -3560,10 +3695,7 @@
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;
}

Expand Down Expand Up @@ -4038,7 +4170,7 @@
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<string, unknown>;
if (parsed !== undefined) props = reviveSlotProps(parsed as Record<string, unknown>);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

unmounts.push(island.mount(host, props));
Expand Down
2 changes: 1 addition & 1 deletion packages/ilha/src/jsx-runtime.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,7 @@ describe("ilha JSX runtime", () => {
));

expect(normalizeHtml(Parent.toString())).toBe(
"<section><h1>Parent</h1><div data-ilha-slot=\"p:0\" data-ilha-props='{}'><button>0</button></div></section>",
'<section><h1>Parent</h1><div data-ilha-slot="p:0"><button>0</button></div></section>',
);
});

Expand Down
Loading
Loading