a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 250 lines 7.0 kB view raw
1/** 2 * Server-Side Rendering (SSR) and hydration support for Volt.js 3 * 4 * Provides utilities for serializing scope state, embedding it in HTML, 5 * and hydrating client-side without re-rendering. 6 */ 7 8import type { Nullable } from "$types/helpers"; 9import type { HydrateOptions, HydrateResult, Scope, SerializedScope } from "$types/volt"; 10import { mount } from "./binder"; 11import { evaluate } from "./evaluator"; 12import { getComputedAttributes, isSignal } from "./shared"; 13import { computed, signal } from "./signal"; 14 15/** 16 * Serialize a {@link Scope} object to JSON for embedding in server-rendered HTML 17 * 18 * Converts all signals in the scope to their plain values. 19 * Computed signals are serialized as their current values. 20 * 21 * @param scope - The reactive scope to serialize 22 * @returns JSON string representing the scope state 23 * 24 * @example 25 * ```ts 26 * const scope = { 27 * count: signal(0), 28 * double: computed(() => scope.count.get() * 2) 29 * }; 30 * const json = serializeScope(scope); 31 * // Returns: '{"count":0,"double":0}' 32 * ``` 33 */ 34export function serializeScope(scope: Scope): string { 35 const serialized: SerializedScope = {}; 36 37 for (const [key, value] of Object.entries(scope)) { 38 if (isSignal(value)) { 39 serialized[key] = value.get(); 40 } else { 41 serialized[key] = value; 42 } 43 } 44 45 return JSON.stringify(serialized); 46} 47 48/** 49 * Deserialize JSON state data back into a reactive scope 50 * 51 * Recreates signals from plain values. Does not recreate computed signals 52 * (those should be redefined with data-volt-computed attributes). 53 * 54 * @param data - Plain object with state values 55 * @returns Reactive scope with signals 56 * 57 * @example 58 * ```ts 59 * const scope = deserializeScope({ count: 42, name: "Alice" }); 60 * // Returns: { count: signal(42), name: signal("Alice") } 61 * ``` 62 */ 63export function deserializeScope(data: SerializedScope): Scope { 64 const scope: Scope = {}; 65 66 for (const [key, value] of Object.entries(data)) { 67 scope[key] = signal(value); 68 } 69 70 return scope; 71} 72 73/** 74 * Check if an element has already been hydrated 75 * 76 * @param el - Element to check 77 * @returns True if element is marked as hydrated 78 */ 79export function isHydrated(el: Element): boolean { 80 return Object.hasOwn((el as HTMLElement).dataset, "voltHydrated"); 81} 82 83/** 84 * Mark an element as hydrated to prevent double-hydration 85 * 86 * @param el - Element to mark 87 */ 88function markHydrated(el: Element): void { 89 (el as HTMLElement).dataset.voltHydrated = "true"; 90} 91 92/** 93 * Check if an element has server-rendered state embedded 94 * 95 * Looks for a script tag with id="volt-state-{element-id}" containing JSON state. 96 * 97 * @param el - Element to check 98 * @returns True if serialized state is found 99 */ 100export function isServerRendered(el: Element): boolean { 101 return getSerializedState(el) !== null; 102} 103 104/** 105 * Extract serialized state from a server-rendered element 106 * 107 * Searches for a `<script type="application/json" id="volt-state-{id}">` tag 108 * containing the serialized scope data. 109 * 110 * @param el - Root element to extract state from 111 * @returns Parsed state object, or null if not found 112 * 113 * @example 114 * ```html 115 * <div id="app" data-volt> 116 * <script type="application/json" id="volt-state-app"> 117 * {"count": 42} 118 * </script> 119 * </div> 120 * ``` 121 */ 122export function getSerializedState(el: Element): Nullable<SerializedScope> { 123 const elementId = el.id; 124 if (!elementId) { 125 return null; 126 } 127 128 const scriptId = `volt-state-${elementId}`; 129 const scriptTag = el.querySelector(`script[type="application/json"]#${scriptId}`); 130 131 if (!scriptTag || !scriptTag.textContent) { 132 return null; 133 } 134 135 try { 136 return JSON.parse(scriptTag.textContent) as SerializedScope; 137 } catch (error) { 138 console.error(`Failed to parse serialized state from #${scriptId}:`, error); 139 return null; 140 } 141} 142 143/** 144 * Create a reactive scope from element, preferring server-rendered state 145 * 146 * This is similar to createScopeFromElement in charge.ts, but checks for 147 * server-rendered state first before falling back to data-volt-state. 148 * 149 * @param el - The root element 150 * @returns Reactive scope object with signals 151 */ 152function createHydrationScope(el: Element): Scope { 153 let scope: Scope = {}; 154 155 const serializedState = getSerializedState(el); 156 if (serializedState) { 157 scope = deserializeScope(serializedState); 158 } else { 159 const stateAttr = (el as HTMLElement).dataset.voltState; 160 if (stateAttr) { 161 try { 162 const stateData = JSON.parse(stateAttr); 163 164 if (typeof stateData !== "object" || isSignal(stateData) || Array.isArray(stateData)) { 165 console.error(`data-volt-state must be a JSON object, got ${typeof stateData}:`, el); 166 } else { 167 for (const [key, value] of Object.entries(stateData)) { 168 scope[key] = signal(value); 169 } 170 } 171 } catch (error) { 172 console.error("Failed to parse data-volt-state JSON:", stateAttr, error); 173 console.error("Element:", el); 174 } 175 } 176 } 177 178 const computedAttrs = getComputedAttributes(el); 179 for (const [name, expression] of computedAttrs) { 180 try { 181 scope[name] = computed(() => evaluate(expression, scope)); 182 } catch (error) { 183 console.error(`Failed to create computed "${name}" with expression "${expression}":`, error); 184 } 185 } 186 187 return scope; 188} 189 190/** 191 * Hydrate server-rendered Volt roots without re-rendering 192 * 193 * Similar to charge(), but designed for server-rendered content. 194 * Preserves existing DOM structure and only attaches reactive bindings. 195 * 196 * @param options - Hydration options 197 * @returns HydrateResult containing mounted roots and cleanup function 198 * 199 * @example 200 * Server renders: 201 * ```html 202 * <div id="app" data-volt> 203 * <script type="application/json" id="volt-state-app"> 204 * {"count": 0} 205 * </script> 206 * <p data-volt-text="count">0</p> 207 * </div> 208 * ``` 209 * 210 * Client hydrates: 211 * ```ts 212 * const { cleanup } = hydrate(); 213 * // DOM is preserved, bindings are attached 214 * ``` 215 */ 216export function hydrate(options: HydrateOptions = {}): HydrateResult { 217 const { rootSelector = "[data-volt]", skipHydrated = true } = options; 218 219 const elements = document.querySelectorAll(rootSelector); 220 const chargedRoots: Array<{ element: Element; scope: Scope; cleanup: () => void }> = []; 221 222 for (const element of elements) { 223 if (skipHydrated && isHydrated(element)) { 224 continue; 225 } 226 227 try { 228 const scope = createHydrationScope(element); 229 const cleanup = mount(element, scope); 230 231 markHydrated(element); 232 chargedRoots.push({ element, scope, cleanup }); 233 } catch (error) { 234 console.error("Error hydrating Volt root:", element, error); 235 } 236 } 237 238 return { 239 roots: chargedRoots, 240 cleanup: () => { 241 for (const root of chargedRoots) { 242 try { 243 root.cleanup(); 244 } catch (error) { 245 console.error("Error cleaning up hydrated Volt root:", root.element, error); 246 } 247 } 248 }, 249 }; 250}