a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
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}