// Client islands — the marker contract (transform-free). // // Authoring is via the JSX runtime: a `"use client"` component used with a // `client:*` directive (``) is emitted as a // `` marker by `@junejs/core/jsx-runtime` (no wrapper, no transform). // This module is just the SHARED contract — the marker tag/attributes + prop // (de)serialization + the Strategy type — imported by both the server (jsx-runtime) // and the client (islands-client) so a rename can never desync the two halves. // // PURE: no `node:*` / `Bun.*`, so it lives in the contract layer and the dev server // + built worker render identical markers. import { createElement, type ReactElement, type ReactNode } from "react"; export const ISLAND_TAG = "june-island"; export const ISLAND_NAME_ATTR = "data-june-island"; export const ISLAND_PROPS_ATTR = "data-june-props"; // When to bring the island to life (the `client:*` directive's value). export const ISLAND_STRATEGY_ATTR = "data-june-strategy"; // Present when an island wraps server-rendered children as a slot (it took // children). The element below carries that content across the SSR→hydrate boundary. export const ISLAND_SLOT_ATTR = "data-june-slot"; export const ISLAND_SLOT_TAG = "june-slot"; // Carry the island's live node across a client-router soft navigation: its // already-hydrated node (React state, open sockets and all) is moved into the next // page instead of re-created. No-op without `clientRouter`. The match key across // pages is the island name, so a persisted island keeps the same name on every route. export const ISLAND_PERSIST_ATTR = "data-june-persist"; // load — hydrate immediately (the default) // idle — hydrate in requestIdleCallback (defer non-critical interactivity) // visible — hydrate when scrolled into view (IntersectionObserver) // only — never SSR; mount fresh on the client (browser-only components) export type Strategy = "load" | "idle" | "visible" | "only"; // Props cross the server→client boundary as JSON, so they must be JSON-serializable // (no functions, no class instances). The empty object is the canonical "no props" // value so the client can `JSON.parse` unconditionally. export function serializeIslandProps(props: unknown): string { return JSON.stringify(props ?? {}); } export function deserializeIslandProps(raw: string | null | undefined): Record { if (!raw) return {}; try { const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" ? (parsed as Record) : {}; } catch { return {}; } } // The slot boundary an island's children pass through — the SAME component on both // sides of the SSR→hydrate boundary, so it renders byte-identical markup: // - SERVER: render the children normally → zero-JS HTML inside ``. // - CLIENT: render the captured server HTML opaquely (dangerouslySetInnerHTML + // suppressHydrationWarning). Same string → hydration aligns; React never // reconciles inside, so the content stays the server's zero-JS HTML and any // nested island markers within it survive to self-hydrate. // Internal: the JSX runtime (server) and islands-client (client) substitute an // island's `children` with this — the author just renders `{children}`. // // AUTHOR CONSTRAINTS (the two costs of a slot island): // 1. The slot content is INERT — frozen server HTML. Children that need to share // the shell's React state must be a nested island or use a cross-island store. // 2. The shell must HIDE the slot (CSS / [hidden]), never conditionally UNMOUNT it // (`{open && children}`) — unmounting destroys the nested islands inside it. // (islands-client warns in dev if a slot's content is removed from the DOM.) export function JuneSlot({ children, html }: { children?: ReactNode; html?: string }): ReactElement { if (html != null) { return createElement(ISLAND_SLOT_TAG, { dangerouslySetInnerHTML: { __html: html }, suppressHydrationWarning: true }); } return createElement(ISLAND_SLOT_TAG, null, children); }