a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1import { signal } from "$core/signal";
2import { deserializeScope, getSerializedState, hydrate, isHydrated, isServerRendered, serializeScope } from "$core/ssr";
3import { beforeEach, describe, expect, it } from "vitest";
4
5describe("ssr", () => {
6 describe("serializeScope", () => {
7 it("serializes signals to their values", () => {
8 const scope = { count: signal(42), name: signal("Alice") };
9 const json = serializeScope(scope);
10 expect(JSON.parse(json)).toEqual({ count: 42, name: "Alice" });
11 });
12
13 it("handles primitives without signals", () => {
14 const scope = { static: "value", number: 123 };
15 const json = serializeScope(scope);
16 expect(JSON.parse(json)).toEqual({ static: "value", number: 123 });
17 });
18
19 it("handles mixed signals and primitives", () => {
20 const scope = { reactive: signal(true), static: false };
21 const json = serializeScope(scope);
22 expect(JSON.parse(json)).toEqual({ reactive: true, static: false });
23 });
24
25 it("handles empty scope", () => {
26 const scope = {};
27 const json = serializeScope(scope);
28 expect(JSON.parse(json)).toEqual({});
29 });
30 });
31
32 describe("deserializeScope", () => {
33 it("creates signals from plain values", () => {
34 const data = { count: 42, name: "Bob" };
35 const scope = deserializeScope(data);
36
37 expect(scope.count).toBeDefined();
38 expect(scope.name).toBeDefined();
39 expect(typeof scope.count).toBe("object");
40 expect((scope.count as { get: () => number }).get()).toBe(42);
41 expect((scope.name as { get: () => string }).get()).toBe("Bob");
42 });
43
44 it("handles empty data", () => {
45 const scope = deserializeScope({});
46 expect(Object.keys(scope)).toHaveLength(0);
47 });
48
49 it("handles various data types", () => {
50 const data = { string: "hello", number: 123, boolean: true, nullValue: null };
51
52 const scope = deserializeScope(data);
53 expect((scope.string as { get: () => string }).get()).toBe("hello");
54 expect((scope.number as { get: () => number }).get()).toBe(123);
55 expect((scope.boolean as { get: () => boolean }).get()).toBe(true);
56 expect((scope.nullValue as { get: () => null }).get()).toBe(null);
57 });
58 });
59
60 describe("isHydrated", () => {
61 it("returns false for non-hydrated elements", () => {
62 const el = document.createElement("div");
63 expect(isHydrated(el)).toBe(false);
64 });
65
66 it("returns true for hydrated elements", () => {
67 const el = document.createElement("div");
68 el.dataset.voltHydrated = "true";
69 expect(isHydrated(el)).toBe(true);
70 });
71 });
72
73 describe("isServerRendered", () => {
74 it("returns false when no serialized state exists", () => {
75 const el = document.createElement("div");
76 el.id = "app";
77 expect(isServerRendered(el)).toBe(false);
78 });
79
80 it("returns true when serialized state exists", () => {
81 const el = document.createElement("div");
82 el.id = "app";
83 el.innerHTML = `
84 <script type="application/json" id="volt-state-app">
85 {"count": 0}
86 </script>
87 `;
88 expect(isServerRendered(el)).toBe(true);
89 });
90
91 it("returns false when element has no id", () => {
92 const el = document.createElement("div");
93 expect(isServerRendered(el)).toBe(false);
94 });
95 });
96
97 describe("getSerializedState", () => {
98 it("extracts state from script tag", () => {
99 const el = document.createElement("div");
100 el.id = "app";
101 el.innerHTML = `
102 <script type="application/json" id="volt-state-app">
103 {"count": 42, "name": "Charlie"}
104 </script>
105 `;
106
107 const state = getSerializedState(el);
108 expect(state).toEqual({ count: 42, name: "Charlie" });
109 });
110
111 it("returns null when no script tag exists", () => {
112 const el = document.createElement("div");
113 el.id = "app";
114 const state = getSerializedState(el);
115 expect(state).toBeNull();
116 });
117
118 it("returns null when element has no id", () => {
119 const el = document.createElement("div");
120 const state = getSerializedState(el);
121 expect(state).toBeNull();
122 });
123
124 it("returns null for malformed JSON", () => {
125 const el = document.createElement("div");
126 el.id = "app";
127 el.innerHTML = `
128 <script type="application/json" id="volt-state-app">
129 {invalid json}
130 </script>
131 `;
132
133 const state = getSerializedState(el);
134 expect(state).toBeNull();
135 });
136
137 it("returns null for empty script tag", () => {
138 const el = document.createElement("div");
139 el.id = "app";
140 el.innerHTML = `
141 <script type="application/json" id="volt-state-app"></script>
142 `;
143
144 const state = getSerializedState(el);
145 expect(state).toBeNull();
146 });
147 });
148
149 describe("hydrate", () => {
150 beforeEach(() => {
151 document.body.innerHTML = "";
152 });
153
154 it("hydrates element with serialized state", () => {
155 document.body.innerHTML = `
156 <div id="app" data-volt>
157 <script type="application/json" id="volt-state-app">
158 {"count": 5}
159 </script>
160 <p data-volt-text="count">5</p>
161 </div>
162 `;
163
164 const result = hydrate();
165
166 expect(result.roots).toHaveLength(1);
167 expect(result.roots[0].element.id).toBe("app");
168 expect(result.roots[0].scope.count).toBeDefined();
169 expect((result.roots[0].scope.count as { get: () => number }).get()).toBe(5);
170 });
171
172 it("marks elements as hydrated", () => {
173 document.body.innerHTML = `
174 <div id="app" data-volt>
175 <script type="application/json" id="volt-state-app">
176 {"count": 0}
177 </script>
178 </div>
179 `;
180
181 hydrate();
182
183 const el = document.querySelector("#app")!;
184 expect(isHydrated(el)).toBe(true);
185 });
186
187 it("skips already hydrated elements", () => {
188 document.body.innerHTML = `
189 <div id="app" data-volt data-volt-hydrated="true">
190 <script type="application/json" id="volt-state-app">
191 {"count": 0}
192 </script>
193 </div>
194 `;
195
196 const result = hydrate();
197 expect(result.roots).toHaveLength(0);
198 });
199
200 it("hydrates already hydrated elements when skipHydrated is false", () => {
201 document.body.innerHTML = `
202 <div id="app" data-volt data-volt-hydrated="true">
203 <script type="application/json" id="volt-state-app">
204 {"count": 0}
205 </script>
206 </div>
207 `;
208
209 const result = hydrate({ skipHydrated: false });
210 expect(result.roots).toHaveLength(1);
211 });
212
213 it("hydrates multiple roots", () => {
214 document.body.innerHTML = `
215 <div id="app1" data-volt>
216 <script type="application/json" id="volt-state-app1">
217 {"count": 1}
218 </script>
219 </div>
220 <div id="app2" data-volt>
221 <script type="application/json" id="volt-state-app2">
222 {"count": 2}
223 </script>
224 </div>
225 `;
226
227 const result = hydrate();
228 expect(result.roots).toHaveLength(2);
229 expect((result.roots[0].scope.count as { get: () => number }).get()).toBe(1);
230 expect((result.roots[1].scope.count as { get: () => number }).get()).toBe(2);
231 });
232
233 it("uses custom root selector", () => {
234 document.body.innerHTML = `
235 <div id="app1" data-volt></div>
236 <div id="app2" class="custom"></div>
237 `;
238
239 const result = hydrate({ rootSelector: ".custom" });
240 expect(result.roots).toHaveLength(1);
241 expect(result.roots[0].element.id).toBe("app2");
242 });
243
244 it("falls back to data-volt-state when no serialized state", () => {
245 document.body.innerHTML = `
246 <div id="app" data-volt data-volt-state='{"count": 10}'>
247 <p data-volt-text="count">10</p>
248 </div>
249 `;
250
251 const result = hydrate();
252 expect(result.roots).toHaveLength(1);
253 expect((result.roots[0].scope.count as { get: () => number }).get()).toBe(10);
254 });
255
256 it("handles data-volt-computed attributes", () => {
257 document.body.innerHTML = `
258 <div id="app" data-volt data-volt-state='{"count": 5}' data-volt-computed:double="count * 2">
259 <p data-volt-text="double">10</p>
260 </div>
261 `;
262
263 const result = hydrate();
264 expect(result.roots).toHaveLength(1);
265 expect(result.roots[0].scope.double).toBeDefined();
266 expect((result.roots[0].scope.double as { get: () => number }).get()).toBe(10);
267 });
268
269 it("cleanup unmounts all roots", () => {
270 document.body.innerHTML = `
271 <div id="app" data-volt data-volt-state='{"count": 0}'></div>
272 `;
273
274 const result = hydrate();
275 expect(result.roots).toHaveLength(1);
276
277 result.cleanup();
278 });
279
280 it("handles errors gracefully", () => {
281 document.body.innerHTML = `
282 <div id="app1" data-volt data-volt-state='{"count": 0}'></div>
283 <div id="app2" data-volt data-volt-state='invalid json'></div>
284 <div id="app3" data-volt data-volt-state='{"count": 1}'></div>
285 `;
286
287 const result = hydrate();
288 expect(result.roots.length).toBeGreaterThan(0);
289 });
290 });
291});