a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 291 lines 9.2 kB view raw
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});