UNPKG

@raven-js/reflex

Version:

Universal reactive signals for modern JavaScript - zero dependencies, SSR/hydration, DOM updates

409 lines (368 loc) 13 kB
/** * @author Anonyfox <max@anonyfox.com> * @license MIT * @see {@link https://github.com/Anonyfox/ravenjs} * @see {@link https://ravenjs.dev} * @see {@link https://anonyfox.com} */ /** * @file Browser DOM mounting with reactive templates, SSR hydration awareness, and scroll preservation during updates. */ import { __getWriteVersion, effect, withTemplateContext } from "../index.js"; import { ssr } from "./ssr.js"; // Re-export ssr for convenience export { ssr } from "./ssr.js"; /** * Replace element HTML efficiently with scroll position preservation. * @param {Element} el * @param {string} html */ function setHTML(el, html) { const scrollable = el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth || el === document.scrollingElement; const top = scrollable ? el.scrollTop : 0; const left = scrollable ? el.scrollLeft : 0; if (document.createRange && el.replaceChildren) { try { const range = document.createRange(); const frag = range.createContextualFragment(html); el.replaceChildren(frag); if (scrollable) { el.scrollTop = top; el.scrollLeft = left; } return; } catch {} } el.innerHTML = html; if (scrollable) { el.scrollTop = top; el.scrollLeft = left; } } /** * Resolve a selector string or return element directly with error handling. * @param {string|Element} target * @returns {Element} */ function resolveTarget(target) { if (typeof target === "string") { const el = document.querySelector(target); if (!el) throw new Error(`mount(): No element for selector ${target}`); return el; } return /** @type {Element} */ (target); } /** * Schedule callback with requestAnimationFrame alignment and microtask fallback. * @template T * @param {() => T} cb * @returns {Promise<T>} */ function schedule(cb) { if (typeof requestAnimationFrame === "function") { return new Promise((res) => { requestAnimationFrame(() => { Promise.resolve().then(() => res(cb())); }); }); } return Promise.resolve().then(cb); } /** * Mount reactive template into DOM element with automatic signal tracking and SSR hydration awareness. * * Signal reads inside template function automatically trigger DOM updates. Prevents downgrade * from server-rendered content during initial hydration when no reactive writes have occurred. * Schedules DOM updates via requestAnimationFrame for optimal rendering performance. * * @example * // Basic usage * import { mount } from '@raven-js/reflex/dom'; * import { signal } from '@raven-js/reflex'; * * const count = signal(0); * const app = mount(() => `<h1>Count: ${count()}</h1>`, '#app'); * * @example * // Async templates * const AsyncWidget = mount(async () => { * const data = await fetch('/api/data'); * return `<div>${await data.text()}</div>`; * }, document.querySelector('#widget')); * * @example * // Manual cleanup * const app = mount(() => template(), '#app'); * // Later... * app.unmount(); // Optional - cleanup happens automatically * * @param {() => (string|Promise<string>)} templateFn * @param {string|Element} target * @param {{}} [_options] * @returns {{ unmount(): void }} */ export function mount(templateFn, target, /** @type {{}} */ _options = {}) { if (typeof window === "undefined") throw new Error("mount() is browser-only"); const el = resolveTarget(target); const scope = { slots: /** @type {any[]} */ ([]), cursor: 0 }; // --- HYDRATION AWARENESS --- const hydrationBaseline = __getWriteVersion(); // version before any client writes const hasSSRContent = el.childNodes && el.childNodes.length > 0; let first = !hasSSRContent; // if SSR present, we won't do a "first setHTML" let last = hasSSRContent ? el.innerHTML : ""; // track SSR HTML to compare against // --------------------------- let token = 0; // coalescing guard const dispose = effect(() => { const out = withTemplateContext(templateFn, scope); const runId = ++token; /** @param {string} html */ const apply = (html) => { if (runId !== token) return; // --- SKIP INITIAL DOWNGRADE --- // If SSR already painted and no reactive writes occurred yet, // don't replace DOM with a different (usually "empty") HTML. if (hasSSRContent && __getWriteVersion() === hydrationBaseline) { // just record what the client *would* render; do not touch DOM yet last = String(html ?? ""); return; } // ---------------------------------- if (first) { setHTML(el, html); last = html; first = false; } else if (html !== last) { schedule(() => { if (runId !== token) return; if (html !== last) { setHTML(el, html); last = html; } }); } }; if (out && typeof (/** @type {any} */ (out).then) === "function") { /** @type {Promise<string>} */ (out).then((s) => apply(String(s ?? ""))).catch((e) => console.error(e)); } else { apply(String(out ?? "")); } }); return { unmount() { dispose(); // optional: el.innerHTML = ""; }, }; } /** * Import a module respecting a loading strategy. * @param {"load"|"idle"|"visible"} strategy * @param {Element} el * @param {string} modulePath * @returns {Promise<any>} */ function importWithStrategy(strategy, el, modulePath) { switch (strategy) { case "idle": { return new Promise((resolve, reject) => { const schedule = window.requestIdleCallback || ((cb) => setTimeout(cb, 0)); schedule(() => import(modulePath).then(resolve, reject)); }); } case "visible": { return new Promise((resolve, reject) => { if (typeof window.IntersectionObserver === "function") { const observer = new IntersectionObserver((entries) => { if (entries[0]?.isIntersecting) { observer.disconnect(); import(modulePath).then(resolve, reject); } }); observer.observe(el); } else { import(modulePath).then(resolve, reject); } }); } default: { return import(modulePath); } } } /** * Hydrate a single island element. * @param {Element} el */ async function hydrateIsland(el) { const modulePath = el.getAttribute("data-module"); if (!modulePath) { console.error("[islands] Missing data-module on", el); return; } if (el.getAttribute("data-hydrated") === "1") return; const exportName = el.getAttribute("data-export") || "default"; const strategy = /** @type {"load"|"idle"|"visible"} */ (el.getAttribute("data-client") || "load"); const propsAttr = el.getAttribute("data-props"); let props = {}; if (propsAttr) { try { props = JSON.parse(decodeURIComponent(propsAttr)); } catch (e) { console.error("[islands] Failed to parse data-props", e); } } try { const mod = await importWithStrategy(strategy, el, modulePath); const Component = mod?.[exportName] || mod?.default; if (typeof Component !== "function") { console.error(`[islands] Export "${exportName}" not found or not a function in ${modulePath}`); return; } // Check if this island was SSR'd and needs wrapping const needsSSR = el.getAttribute("data-ssr") === "true"; // Clear SSR children and mount reactive component el.textContent = ""; if (needsSSR) { // Wrap with ssr() to consume cached data const wrappedComponent = ssr(Component); mount(() => wrappedComponent(props), el, { replace: true }); } else { // Direct mount for client-only islands mount(() => Component(props), el, { replace: true }); } el.setAttribute("data-hydrated", "1"); } catch (err) { console.error(`[islands] Failed to load ${modulePath}`, err); } } /** * Generate an island placeholder for client-side hydration. * * Creates a div with data attributes for client-side hydration. Use this for islands * that don't need server-side rendering, only client-side interactivity. * * @example * // Basic island for client-side only * import { island } from '@raven-js/reflex/dom'; * const html = island({ src: '/apps/counter.js#Counter', props: { initial: 0 } }); * * @example * // With loading strategy * const html = island({ * src: '/apps/counter.js#Counter', * props: { initial: 10 }, * on: 'visible' * }); * * @param {{ src: string, props?: Object, on?: 'load'|'idle'|'visible', id?: string }} cfg * @returns {string} HTML placeholder for hydration */ export function island(cfg) { const on = cfg?.on ?? "load"; const src = cfg?.src; const props = cfg?.props ?? {}; if (!src) { throw new Error("island(): src is required (e.g. '/apps/counter.js' or '/apps/counter.js#Counter')"); } // Parse module path and export from src (e.g. "/apps/counter.js#Counter") const [modulePath, exportName = "default"] = src.split("#"); const id = cfg?.id || `island-${Math.random().toString(36).substr(2, 9)}`; // Serialize props into attribute using URI encoding to avoid HTML escaping issues const propsAttr = encodeURIComponent(JSON.stringify(props)); return `<div id="${id}" data-island data-module="${modulePath}" data-export="${exportName}" data-client="${on}" data-props="${propsAttr}"></div>`; } /** * Generate an island with server-side rendering and hydration metadata. * * Creates a div with pre-rendered content and data attributes for client-side hydration. * The component is automatically wrapped in the ssr() function for multi-pass rendering, * promise settlement, and fetch caching. * * @example * // Basic SSR island * import { islandSSR } from '@raven-js/reflex/dom'; * import { Counter } from './counter.js'; * const html = await islandSSR({ * src: '/apps/counter.js#Counter', * ssr: Counter, * props: { initial: 0 } * }); * * @example * // With loading strategy * const html = await islandSSR({ * src: '/apps/counter.js#Counter', * ssr: Counter, * props: { initial: 10 }, * on: 'visible' * }); * * @example * // In async page templates * export const body = async () => md` * # My Page * ${await islandSSR({ src: '/apps/counter.js', ssr: Counter })} * `; * * @param {{ src: string, ssr: Function, props?: Object, on?: 'load'|'idle'|'visible', id?: string }} cfg * @returns {Promise<string>} HTML with SSR content and hydration metadata */ export async function islandSSR(cfg) { const on = cfg?.on ?? "load"; const src = cfg?.src; const props = cfg?.props ?? {}; const ssrFn = cfg?.ssr; if (!src) { throw new Error("islandSSR(): src is required (e.g. '/apps/counter.js' or '/apps/counter.js#Counter')"); } if (!ssrFn || typeof ssrFn !== "function") { throw new Error("islandSSR(): ssr function is required"); } // Parse module path and export from src (e.g. "/apps/counter.js#Counter") const [modulePath, exportName = "default"] = src.split("#"); const id = cfg?.id || `island-${Math.random().toString(36).substr(2, 9)}`; // Serialize props into attribute using URI encoding to avoid HTML escaping issues const propsAttr = encodeURIComponent(JSON.stringify(props)); // Wrap the component in ssr() if not already wrapped const wrappedFn = /** @type {any} */ (ssrFn)._ssrWrapped ? ssrFn : ssr(/** @type {(...a:any) => any} */ (ssrFn)); // Server-side render the component with full async support let ssrContent = ""; try { const result = await wrappedFn(props); // Convert null/undefined to empty string ssrContent = result == null ? "" : String(result); } catch (err) { // Log error but don't throw - return empty content console.error("islandSSR component error:", err); ssrContent = ""; } return `<div id="${id}" data-island data-ssr="true" data-module="${modulePath}" data-export="${exportName}" data-client="${on}" data-props="${propsAttr}">${ssrContent}</div>`; } /** * Hydrate all islands on the page with selective loading strategies. * * Scans for elements with `data-island` and `data-module` attributes and hydrates them * using the specified loading strategy (load, idle, visible). Each island is hydrated * with its component from the specified module path and export name. * * @example * // Hydrate all islands immediately * import { hydrateIslands } from '@raven-js/reflex/dom'; * hydrateIslands(); * * @example * // Auto-hydrate on DOM ready * if (document.readyState === 'loading') { * document.addEventListener('DOMContentLoaded', hydrateIslands); * } else { * hydrateIslands(); * } */ export function hydrateIslands() { if (typeof window === "undefined") throw new Error("hydrateIslands() is browser-only"); const islands = document.querySelectorAll("[data-island][data-module]"); islands.forEach((el) => { void hydrateIsland(el); }); }