/** * Server-Side Rendering (SSR) and hydration support for Volt.js * * Provides utilities for serializing scope state, embedding it in HTML, * and hydrating client-side without re-rendering. */ import type { Nullable } from "$types/helpers"; import type { HydrateOptions, HydrateResult, Scope, SerializedScope } from "$types/volt"; import { mount } from "./binder"; import { evaluate } from "./evaluator"; import { getComputedAttributes, isSignal } from "./shared"; import { computed, signal } from "./signal"; /** * Serialize a {@link Scope} object to JSON for embedding in server-rendered HTML * * Converts all signals in the scope to their plain values. * Computed signals are serialized as their current values. * * @param scope - The reactive scope to serialize * @returns JSON string representing the scope state * * @example * ```ts * const scope = { * count: signal(0), * double: computed(() => scope.count.get() * 2) * }; * const json = serializeScope(scope); * // Returns: '{"count":0,"double":0}' * ``` */ export function serializeScope(scope: Scope): string { const serialized: SerializedScope = {}; for (const [key, value] of Object.entries(scope)) { if (isSignal(value)) { serialized[key] = value.get(); } else { serialized[key] = value; } } return JSON.stringify(serialized); } /** * Deserialize JSON state data back into a reactive scope * * Recreates signals from plain values. Does not recreate computed signals * (those should be redefined with data-volt-computed attributes). * * @param data - Plain object with state values * @returns Reactive scope with signals * * @example * ```ts * const scope = deserializeScope({ count: 42, name: "Alice" }); * // Returns: { count: signal(42), name: signal("Alice") } * ``` */ export function deserializeScope(data: SerializedScope): Scope { const scope: Scope = {}; for (const [key, value] of Object.entries(data)) { scope[key] = signal(value); } return scope; } /** * Check if an element has already been hydrated * * @param el - Element to check * @returns True if element is marked as hydrated */ export function isHydrated(el: Element): boolean { return Object.hasOwn((el as HTMLElement).dataset, "voltHydrated"); } /** * Mark an element as hydrated to prevent double-hydration * * @param el - Element to mark */ function markHydrated(el: Element): void { (el as HTMLElement).dataset.voltHydrated = "true"; } /** * Check if an element has server-rendered state embedded * * Looks for a script tag with id="volt-state-{element-id}" containing JSON state. * * @param el - Element to check * @returns True if serialized state is found */ export function isServerRendered(el: Element): boolean { return getSerializedState(el) !== null; } /** * Extract serialized state from a server-rendered element * * Searches for a ` * * ``` */ export function getSerializedState(el: Element): Nullable { const elementId = el.id; if (!elementId) { return null; } const scriptId = `volt-state-${elementId}`; const scriptTag = el.querySelector(`script[type="application/json"]#${scriptId}`); if (!scriptTag || !scriptTag.textContent) { return null; } try { return JSON.parse(scriptTag.textContent) as SerializedScope; } catch (error) { console.error(`Failed to parse serialized state from #${scriptId}:`, error); return null; } } /** * Create a reactive scope from element, preferring server-rendered state * * This is similar to createScopeFromElement in charge.ts, but checks for * server-rendered state first before falling back to data-volt-state. * * @param el - The root element * @returns Reactive scope object with signals */ function createHydrationScope(el: Element): Scope { let scope: Scope = {}; const serializedState = getSerializedState(el); if (serializedState) { scope = deserializeScope(serializedState); } else { const stateAttr = (el as HTMLElement).dataset.voltState; if (stateAttr) { try { const stateData = JSON.parse(stateAttr); if (typeof stateData !== "object" || isSignal(stateData) || Array.isArray(stateData)) { console.error(`data-volt-state must be a JSON object, got ${typeof stateData}:`, el); } else { for (const [key, value] of Object.entries(stateData)) { scope[key] = signal(value); } } } catch (error) { console.error("Failed to parse data-volt-state JSON:", stateAttr, error); console.error("Element:", el); } } } const computedAttrs = getComputedAttributes(el); for (const [name, expression] of computedAttrs) { try { scope[name] = computed(() => evaluate(expression, scope)); } catch (error) { console.error(`Failed to create computed "${name}" with expression "${expression}":`, error); } } return scope; } /** * Hydrate server-rendered Volt roots without re-rendering * * Similar to charge(), but designed for server-rendered content. * Preserves existing DOM structure and only attaches reactive bindings. * * @param options - Hydration options * @returns HydrateResult containing mounted roots and cleanup function * * @example * Server renders: * ```html *
* *

0

*
* ``` * * Client hydrates: * ```ts * const { cleanup } = hydrate(); * // DOM is preserved, bindings are attached * ``` */ export function hydrate(options: HydrateOptions = {}): HydrateResult { const { rootSelector = "[data-volt]", skipHydrated = true } = options; const elements = document.querySelectorAll(rootSelector); const chargedRoots: Array<{ element: Element; scope: Scope; cleanup: () => void }> = []; for (const element of elements) { if (skipHydrated && isHydrated(element)) { continue; } try { const scope = createHydrationScope(element); const cleanup = mount(element, scope); markHydrated(element); chargedRoots.push({ element, scope, cleanup }); } catch (error) { console.error("Error hydrating Volt root:", element, error); } } return { roots: chargedRoots, cleanup: () => { for (const root of chargedRoots) { try { root.cleanup(); } catch (error) { console.error("Error cleaning up hydrated Volt root:", root.element, error); } } }, }; }