@invisibleloop/pulse 0.1.39 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,6 +24,9 @@ const showToast = (opts) => import('./toast.js').then(m => m.showToast(opts))
24
24
  * @param {Object} [serverState] - Serialised server state from SSR
25
25
  */
26
26
  export function mount(spec, el, serverState = {}, options = {}) {
27
+ if (!spec || spec.state === undefined || !spec.view) {
28
+ throw new Error('[Pulse] mount: spec must have state and view')
29
+ }
27
30
  // Spec is validated server-side at startup — no need to re-validate in the browser
28
31
  // Initialise the client store from SSR data (no-op after the first page mount).
29
32
  // window.__PULSE_STORE__ is serialised by the server when a store is registered.
@@ -119,9 +122,10 @@ export function mount(spec, el, serverState = {}, options = {}) {
119
122
  const raw = spec.mutations[type](state, payload)
120
123
  if (raw?._toast) showToast(raw._toast)
121
124
  const { _toast, ...partial } = raw ?? {}
125
+ const prev = state
122
126
  state = applyConstraints({ ...state, ...partial }, spec.constraints)
123
127
  persist()
124
- render()
128
+ if (shallowChanged(prev, state)) render()
125
129
  return
126
130
  }
127
131
 
@@ -144,8 +148,9 @@ export function mount(spec, el, serverState = {}, options = {}) {
144
148
  const raw = action.onStart(currentState, payload)
145
149
  if (raw?._toast) showToast(raw._toast)
146
150
  const { _toast, ...partial } = raw ?? {}
151
+ const prev = state
147
152
  state = applyConstraints({ ...state, ...partial }, spec.constraints)
148
- render()
153
+ if (shallowChanged(prev, state)) render()
149
154
  }
150
155
 
151
156
  // Validate before running if requested
@@ -156,8 +161,9 @@ export function mount(spec, el, serverState = {}, options = {}) {
156
161
  const raw = action.onError?.(state, { validation: errors }) ?? {}
157
162
  if (raw._toast) showToast(raw._toast)
158
163
  const { _toast, ...partial } = raw
164
+ const prev = state
159
165
  state = applyConstraints({ ...state, ...partial }, spec.constraints)
160
- render()
166
+ if (shallowChanged(prev, state)) render()
161
167
  return
162
168
  }
163
169
  }
@@ -171,17 +177,20 @@ export function mount(spec, el, serverState = {}, options = {}) {
171
177
  if (raw._storeUpdate) updateStore(raw._storeUpdate)
172
178
  if (raw._toast) showToast(raw._toast)
173
179
  const { _storeUpdate: _su, _toast: _t, ...partial } = raw
180
+ const prev = state
174
181
  state = applyConstraints({ ...state, ...partial }, spec.constraints)
182
+ if (shallowChanged(prev, state)) render()
175
183
  } catch (error) {
176
184
  console.error(`[Pulse] Action "${name}" failed:`, error)
177
185
  const raw = action.onError(state, error) ?? {}
178
186
  if (raw._toast) showToast(raw._toast)
179
187
  const { _toast, ...partial } = raw
188
+ const prev = state
180
189
  state = applyConstraints({ ...state, ...partial }, spec.constraints)
190
+ if (shallowChanged(prev, state)) render()
181
191
  }
182
192
 
183
193
  persist()
184
- render()
185
194
  }
186
195
 
187
196
  // ---------------------------------------------------------------------------
@@ -359,9 +368,19 @@ function morph(el, newHtml) {
359
368
  }
360
369
 
361
370
  function morphNodes(cur, nxt) {
362
- const curNodes = Array.from(cur.childNodes)
363
371
  const nxtNodes = Array.from(nxt.childNodes)
364
372
 
373
+ // Key-based reconciliation — activated when every element child carries data-key.
374
+ // Handles insert, remove, and reorder in O(n) without touching unaffected nodes.
375
+ const nxtEls = nxtNodes.filter(n => n.nodeType === 1)
376
+ if (nxtEls.length > 0 && nxtEls.every(n => n.getAttribute('data-key') !== null)) {
377
+ morphKeyed(cur, nxtEls)
378
+ return
379
+ }
380
+
381
+ // Position-based fallback for unkeyed content
382
+ const curNodes = Array.from(cur.childNodes)
383
+
365
384
  nxtNodes.forEach((nxtNode, i) => {
366
385
  const curNode = curNodes[i]
367
386
 
@@ -390,6 +409,50 @@ function morphNodes(cur, nxt) {
390
409
  while (cur.childNodes.length > nxtNodes.length) cur.removeChild(cur.lastChild)
391
410
  }
392
411
 
412
+ /**
413
+ * Key-based reconciliation for a container whose element children all carry data-key.
414
+ * Builds a map of existing keyed nodes, then places new nodes in reverse order using
415
+ * insertBefore — O(n) moves, O(n) removals, zero unnecessary DOM patches.
416
+ *
417
+ * @param {Element} cur - existing parent element
418
+ * @param {Element[]} nxtEls - ordered array of new element children (all have data-key)
419
+ */
420
+ function morphKeyed(cur, nxtEls) {
421
+ // Index existing keyed children
422
+ const keyMap = new Map()
423
+ for (const node of cur.childNodes) {
424
+ if (node.nodeType === 1) {
425
+ const k = node.getAttribute('data-key')
426
+ if (k !== null) keyMap.set(k, node)
427
+ }
428
+ }
429
+
430
+ // Place elements in reverse order — insertBefore(node, ref) where ref tracks the
431
+ // right-hand boundary. When a node is already in the correct position, skip the move.
432
+ let ref = null
433
+ for (let i = nxtEls.length - 1; i >= 0; i--) {
434
+ const nxtEl = nxtEls[i]
435
+ const key = nxtEl.getAttribute('data-key')
436
+ let node = keyMap.get(key)
437
+
438
+ if (node) {
439
+ keyMap.delete(key)
440
+ morphAttrs(node, nxtEl)
441
+ morphNodes(node, nxtEl)
442
+ } else {
443
+ node = nxtEl.cloneNode(true)
444
+ }
445
+
446
+ if (node.nextSibling !== ref || node.parentNode !== cur) {
447
+ cur.insertBefore(node, ref)
448
+ }
449
+ ref = node
450
+ }
451
+
452
+ // Remove elements no longer in the list
453
+ for (const node of keyMap.values()) cur.removeChild(node)
454
+ }
455
+
393
456
  function morphAttrs(cur, nxt) {
394
457
  for (const { name, value } of Array.from(nxt.attributes)) {
395
458
  if (cur.getAttribute(name) !== value) cur.setAttribute(name, value)
@@ -451,7 +514,8 @@ function dispatchTimed(target, name, e, dispatch) {
451
514
  function applyConstraints(state, constraints) {
452
515
  if (!constraints) return state
453
516
 
454
- const next = deepClone(state)
517
+ const hasNested = Object.keys(constraints).some(p => p.includes('.'))
518
+ const next = hasNested ? structuredClone(state) : state
455
519
 
456
520
  for (const [path, rules] of Object.entries(constraints)) {
457
521
  const { obj, key } = resolvePath(next, path)
@@ -570,7 +634,25 @@ function resolvePath(obj, path) {
570
634
  * @returns {Object}
571
635
  */
572
636
  function deepClone(obj) {
573
- return JSON.parse(JSON.stringify(obj))
637
+ return structuredClone(obj)
638
+ }
639
+
640
+ /**
641
+ * Returns true when two flat state objects differ by reference at any key.
642
+ * Used to skip render() when a mutation produces no actual change —
643
+ * e.g. a constraint-clamped increment when already at max.
644
+ * Only compares top-level keys — nested objects are compared by reference,
645
+ * which is correct since mutations return new partial objects via spread.
646
+ *
647
+ * @param {Object} a
648
+ * @param {Object} b
649
+ * @returns {boolean}
650
+ */
651
+ function shallowChanged(a, b) {
652
+ if (a === b) return false
653
+ const ka = Object.keys(a)
654
+ if (ka.length !== Object.keys(b).length) return true
655
+ return ka.some(k => a[k] !== b[k])
574
656
  }
575
657
 
576
658
  function viewErrorFallback(err) {
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Pulse — morph / key-based reconciliation tests
3
+ * run: node src/runtime/morph.test.js
4
+ *
5
+ * Sets up a minimal DOM shim so morphNodes runs its real code path
6
+ * (not the innerHTML fallback). Tests verify node identity is preserved
7
+ * across updates — the key property of key-based reconciliation.
8
+ */
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Minimal DOM shim
12
+ // ---------------------------------------------------------------------------
13
+
14
+ class FakeText {
15
+ constructor(value) {
16
+ this.nodeType = 3
17
+ this.nodeName = '#text'
18
+ this.nodeValue = value
19
+ this.parentNode = null
20
+ this.nextSibling = null
21
+ }
22
+ cloneNode() { return new FakeText(this.nodeValue) }
23
+ }
24
+
25
+ class FakeElement {
26
+ constructor(tag) {
27
+ this.nodeType = 1
28
+ this.nodeName = tag.toUpperCase()
29
+ this._attrs = {}
30
+ this._children = []
31
+ this.parentNode = null
32
+ this.nextSibling = null
33
+ }
34
+
35
+ // ---- child access ----
36
+ get childNodes() { return this._children }
37
+ get lastChild() { return this._children[this._children.length - 1] ?? null }
38
+
39
+ // ---- attributes ----
40
+ get attributes() { return Object.entries(this._attrs).map(([name, value]) => ({ name, value })) }
41
+ getAttribute(n) { return this._attrs[n] ?? null }
42
+ setAttribute(n, v) { this._attrs[n] = v }
43
+ removeAttribute(n) { delete this._attrs[n] }
44
+ hasAttribute(n) { return n in this._attrs }
45
+
46
+ // ---- mutation ----
47
+ appendChild(node) {
48
+ if (node.parentNode) node.parentNode._detach(node)
49
+ const prev = this._children[this._children.length - 1]
50
+ if (prev) prev.nextSibling = node
51
+ node.nextSibling = null
52
+ node.parentNode = this
53
+ this._children.push(node)
54
+ return node
55
+ }
56
+
57
+ removeChild(node) {
58
+ const i = this._children.indexOf(node)
59
+ if (i === -1) throw new Error('removeChild: node not a child')
60
+ this._children.splice(i, 1)
61
+ if (i > 0) this._children[i - 1].nextSibling = this._children[i] ?? null
62
+ node.parentNode = null
63
+ node.nextSibling = null
64
+ return node
65
+ }
66
+
67
+ replaceChild(newNode, oldNode) {
68
+ const i = this._children.indexOf(oldNode)
69
+ if (i === -1) throw new Error('replaceChild: oldNode not a child')
70
+ if (newNode.parentNode) newNode.parentNode._detach(newNode)
71
+ this._children[i] = newNode
72
+ newNode.parentNode = this
73
+ newNode.nextSibling = this._children[i + 1] ?? null
74
+ if (i > 0) this._children[i - 1].nextSibling = newNode
75
+ oldNode.parentNode = null
76
+ oldNode.nextSibling = null
77
+ return oldNode
78
+ }
79
+
80
+ insertBefore(node, ref) {
81
+ if (node.parentNode) node.parentNode._detach(node)
82
+ if (ref === null) return this.appendChild(node)
83
+ const i = this._children.indexOf(ref)
84
+ if (i === -1) throw new Error('insertBefore: ref not a child')
85
+ this._children.splice(i, 0, node)
86
+ node.parentNode = this
87
+ node.nextSibling = ref
88
+ if (i > 0) this._children[i - 1].nextSibling = node
89
+ return node
90
+ }
91
+
92
+ // Internal: remove without clearing siblings (used by move operations)
93
+ _detach(node) {
94
+ const i = this._children.indexOf(node)
95
+ if (i === -1) return
96
+ this._children.splice(i, 1)
97
+ if (i > 0) this._children[i - 1].nextSibling = this._children[i] ?? null
98
+ node.parentNode = null
99
+ }
100
+
101
+ cloneNode(deep = false) {
102
+ const clone = new FakeElement(this.nodeName.toLowerCase())
103
+ clone._attrs = { ...this._attrs }
104
+ if (deep) {
105
+ for (const child of this._children) {
106
+ clone.appendChild(child.cloneNode(true))
107
+ }
108
+ }
109
+ return clone
110
+ }
111
+
112
+ // ---- innerHTML (for test setup only) ----
113
+ set innerHTML(html) {
114
+ this._children = []
115
+ for (const node of parseHtml(html)) this.appendChild(node)
116
+ }
117
+
118
+ get innerHTML() {
119
+ return this._children.map(n => {
120
+ if (n.nodeType === 3) return n.nodeValue
121
+ const attrs = Object.entries(n._attrs).map(([k, v]) => ` ${k}="${v}"`).join('')
122
+ return `<${n.nodeName.toLowerCase()}${attrs}>${n.innerHTML}</${n.nodeName.toLowerCase()}>`
123
+ }).join('')
124
+ }
125
+
126
+ addEventListener() {}
127
+ querySelectorAll() { return [] }
128
+ }
129
+
130
+ // Minimal HTML parser — handles flat list of elements with attributes + text content
131
+ // Sufficient for testing morph with <li data-key="x">text</li> structures
132
+ function parseHtml(html) {
133
+ const nodes = []
134
+ const re = /<(\w+)((?:\s+[\w-]+=(?:"[^"]*"|'[^']*'|[^\s>]*))*)\s*>([\s\S]*?)<\/\1>|([^<]+)/g
135
+ let m
136
+ while ((m = re.exec(html)) !== null) {
137
+ if (m[4] !== undefined) {
138
+ const text = m[4]
139
+ if (text.trim()) nodes.push(new FakeText(text))
140
+ } else {
141
+ const el = new FakeElement(m[1])
142
+ const attrRe = /([\w-]+)=(?:"([^"]*)"|'([^']*)'|(\S+))/g
143
+ let am
144
+ while ((am = attrRe.exec(m[2])) !== null) {
145
+ el.setAttribute(am[1], am[2] ?? am[3] ?? am[4])
146
+ }
147
+ for (const child of parseHtml(m[3])) el.appendChild(child)
148
+ nodes.push(el)
149
+ }
150
+ }
151
+ return nodes
152
+ }
153
+
154
+ // Inject document shim so morph() uses real DOM path
155
+ global.document = {
156
+ createElement: (tag) => new FakeElement(tag),
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Import runtime (after shim is in place)
161
+ // ---------------------------------------------------------------------------
162
+
163
+ import { mount } from './index.js'
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Test runner
167
+ // ---------------------------------------------------------------------------
168
+
169
+ let passed = 0
170
+ let failed = 0
171
+
172
+ function test(label, fn) {
173
+ try {
174
+ fn()
175
+ console.log(` ✓ ${label}`)
176
+ passed++
177
+ } catch (e) {
178
+ console.log(` ✗ ${label}`)
179
+ console.log(` ${e.message}`)
180
+ console.log(e.stack.split('\n')[1])
181
+ failed++
182
+ }
183
+ }
184
+
185
+ function assert(cond, msg) {
186
+ if (!cond) throw new Error(msg || 'Assertion failed')
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Helper — mount a spec and return the root element + instance
191
+ // ---------------------------------------------------------------------------
192
+
193
+ function makeListSpec(items) {
194
+ return {
195
+ route: '/list',
196
+ state: { items },
197
+ view: (s) => `<ul>${s.items.map(it => `<li data-key="${it.id}">${it.label}</li>`).join('')}</ul>`,
198
+ mutations: {
199
+ setItems: (_, items) => ({ items }),
200
+ },
201
+ }
202
+ }
203
+
204
+ console.log('\nKey-based morphing\n')
205
+
206
+ // ---------------------------------------------------------------------------
207
+
208
+ test('initial render produces correct keyed list', () => {
209
+ const el = new FakeElement('div')
210
+ const items = [{ id: 'a', label: 'Alpha' }, { id: 'b', label: 'Beta' }]
211
+ mount(makeListSpec(items), el)
212
+
213
+ const ul = el.childNodes[0]
214
+ assert(ul.nodeName === 'UL', 'expected UL')
215
+ assert(ul.childNodes.length === 2, `expected 2 children, got ${ul.childNodes.length}`)
216
+ assert(ul.childNodes[0].getAttribute('data-key') === 'a', 'first key should be a')
217
+ assert(ul.childNodes[1].getAttribute('data-key') === 'b', 'second key should be b')
218
+ })
219
+
220
+ test('removing an item removes only that node', () => {
221
+ const el = new FakeElement('div')
222
+ const items = [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }, { id: 'c', label: 'C' }]
223
+ const instance = mount(makeListSpec(items), el)
224
+
225
+ const ul = el.childNodes[0]
226
+ const nodeA = ul.childNodes[0]
227
+ const nodeC = ul.childNodes[2]
228
+
229
+ instance.dispatch('setItems', [{ id: 'a', label: 'A' }, { id: 'c', label: 'C' }])
230
+
231
+ assert(ul.childNodes.length === 2, `expected 2 children after remove, got ${ul.childNodes.length}`)
232
+ assert(ul.childNodes[0] === nodeA, 'node A should be the same object (not recreated)')
233
+ assert(ul.childNodes[1] === nodeC, 'node C should be the same object (not recreated)')
234
+ })
235
+
236
+ test('reordering moves nodes without recreating them', () => {
237
+ const el = new FakeElement('div')
238
+ const items = [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }, { id: 'c', label: 'C' }]
239
+ const instance = mount(makeListSpec(items), el)
240
+
241
+ const ul = el.childNodes[0]
242
+ const nodeA = ul.childNodes[0]
243
+ const nodeB = ul.childNodes[1]
244
+ const nodeC = ul.childNodes[2]
245
+
246
+ instance.dispatch('setItems', [{ id: 'c', label: 'C' }, { id: 'a', label: 'A' }, { id: 'b', label: 'B' }])
247
+
248
+ assert(ul.childNodes.length === 3, 'length unchanged after reorder')
249
+ assert(ul.childNodes[0] === nodeC, 'C should now be first (same node)')
250
+ assert(ul.childNodes[1] === nodeA, 'A should now be second (same node)')
251
+ assert(ul.childNodes[2] === nodeB, 'B should now be third (same node)')
252
+ })
253
+
254
+ test('inserting an item at the start preserves existing nodes', () => {
255
+ const el = new FakeElement('div')
256
+ const items = [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }]
257
+ const instance = mount(makeListSpec(items), el)
258
+
259
+ const ul = el.childNodes[0]
260
+ const nodeA = ul.childNodes[0]
261
+ const nodeB = ul.childNodes[1]
262
+
263
+ instance.dispatch('setItems', [{ id: 'x', label: 'X' }, { id: 'a', label: 'A' }, { id: 'b', label: 'B' }])
264
+
265
+ assert(ul.childNodes.length === 3, `expected 3 children, got ${ul.childNodes.length}`)
266
+ assert(ul.childNodes[0].getAttribute('data-key') === 'x', 'new node X at index 0')
267
+ assert(ul.childNodes[1] === nodeA, 'A same node at index 1')
268
+ assert(ul.childNodes[2] === nodeB, 'B same node at index 2')
269
+ })
270
+
271
+ test('text content is updated when label changes on an existing node', () => {
272
+ const el = new FakeElement('div')
273
+ const items = [{ id: 'a', label: 'Alpha' }, { id: 'b', label: 'Beta' }]
274
+ const instance = mount(makeListSpec(items), el)
275
+
276
+ const ul = el.childNodes[0]
277
+ const nodeA = ul.childNodes[0]
278
+
279
+ instance.dispatch('setItems', [{ id: 'a', label: 'UPDATED' }, { id: 'b', label: 'Beta' }])
280
+
281
+ assert(ul.childNodes[0] === nodeA, 'node A is same object (reused)')
282
+ assert(nodeA.innerHTML === 'UPDATED', `expected text UPDATED, got: ${nodeA.innerHTML}`)
283
+ })
284
+
285
+ test('clearing to empty list removes all nodes', () => {
286
+ const el = new FakeElement('div')
287
+ const instance = mount(makeListSpec([{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }]), el)
288
+
289
+ const ul = el.childNodes[0]
290
+ instance.dispatch('setItems', [])
291
+
292
+ assert(ul.childNodes.length === 0, `expected empty list, got ${ul.childNodes.length} nodes`)
293
+ })
294
+
295
+ test('unkeyed content falls back to position-based morphing', () => {
296
+ const el = new FakeElement('div')
297
+ const spec = {
298
+ route: '/unkeyed',
299
+ state: { label: 'hello' },
300
+ view: (s) => `<p>${s.label}</p>`,
301
+ mutations: { setLabel: (_, v) => ({ label: v }) },
302
+ }
303
+ const instance = mount(spec, el)
304
+ instance.dispatch('setLabel', 'world')
305
+
306
+ assert(el.childNodes[0].innerHTML === 'world', `expected 'world', got: ${el.childNodes[0].innerHTML}`)
307
+ })
308
+
309
+ // ---------------------------------------------------------------------------
310
+
311
+ console.log()
312
+ if (failed > 0) {
313
+ console.log(`${passed} passed, ${failed} failed`)
314
+ process.exit(1)
315
+ } else {
316
+ console.log(`${passed} passed, 0 failed`)
317
+ }
@@ -29,68 +29,102 @@ export function initNavigation(root, mountFn) {
29
29
 
30
30
  if (!res.ok) { location.href = path; return }
31
31
 
32
- const { html, title, styles, scripts, hydrate, serverState, storeState } = await res.json()
32
+ const ct = res.headers.get('content-type') || ''
33
+
34
+ if (ct.includes('application/x-ndjson')) {
35
+ // Streaming nav response — apply chunks progressively as they arrive
36
+ const reader = res.body.getReader()
37
+ const decoder = new TextDecoder()
38
+ let buf = ''
39
+ let hydratePath = null
40
+ let finalServerState = {}
41
+ const deferredSlots = new Map()
42
+
43
+ const processLine = async (line) => {
44
+ if (!line.trim()) return
45
+ const msg = JSON.parse(line)
46
+
47
+ if (msg.type === 'meta') {
48
+ hydratePath = msg.hydrate
49
+ document.title = msg.title || document.title
50
+ applyStyles(msg.styles)
51
+ await applyScripts(msg.scripts)
52
+
53
+ } else if (msg.type === 'html') {
54
+ root.innerHTML = msg.html
55
+ // Index <pulse-deferred> placeholders so deferred chunks land in the right spot
56
+ for (const id of (msg.deferred || [])) {
57
+ const el = root.querySelector(`[id="pd-${id}"]`)
58
+ if (el) deferredSlots.set(id, el)
59
+ }
60
+ if (push) history.pushState({ pulse: true, path }, '', path)
61
+ scrollAndFocus(root)
62
+
63
+ } else if (msg.type === 'deferred') {
64
+ const slot = deferredSlots.get(msg.id)
65
+ if (slot) {
66
+ const tmp = document.createElement('div')
67
+ tmp.innerHTML = msg.html
68
+ slot.replaceWith(...tmp.childNodes)
69
+ deferredSlots.delete(msg.id)
70
+ }
71
+
72
+ } else if (msg.type === 'done') {
73
+ if (msg.storeState && window.__updatePulseStore__) {
74
+ window.__updatePulseStore__(msg.storeState)
75
+ }
76
+ finalServerState = msg.serverState || {}
77
+ }
78
+ }
33
79
 
34
- // Update the client store singleton with fresh server-resolved store data.
35
- if (storeState && typeof window !== 'undefined' && window.__updatePulseStore__) {
36
- window.__updatePulseStore__(storeState)
37
- }
80
+ while (true) {
81
+ const { done, value } = await reader.read()
82
+ if (done) break
83
+ buf += decoder.decode(value, { stream: true })
84
+ const lines = buf.split('\n')
85
+ buf = lines.pop() // last element may be an incomplete line
86
+ for (const line of lines) await processLine(line)
87
+ }
88
+ if (buf) await processLine(buf)
38
89
 
39
- root.innerHTML = html
40
- document.title = title || document.title
41
-
42
- if (Array.isArray(styles)) {
43
- const existing = new Set(
44
- [...document.querySelectorAll('link[rel="stylesheet"]')].map(l => l.getAttribute('href'))
45
- )
46
- for (const href of styles) {
47
- if (!existing.has(href)) {
48
- const link = document.createElement('link')
49
- link.rel = 'stylesheet'
50
- link.href = href
51
- document.head.appendChild(link)
52
- }
90
+ runScripts(root)
91
+ document.dispatchEvent(new CustomEvent('pulse:navigate'))
92
+
93
+ if (hydratePath && mountFn) {
94
+ currentMount?.destroy()
95
+ root.dataset.pulseMounted = '1'
96
+ window.__PULSE_SERVER__ = finalServerState
97
+ const { default: spec } = await import(/* @vite-ignore */ hydratePath)
98
+ if (spec) currentMount = mountFn(spec, root, finalServerState)
53
99
  }
54
- }
55
100
 
56
- if (Array.isArray(scripts)) {
57
- const existingSrcs = new Set(
58
- [...document.querySelectorAll('script[src]')].map(s => s.getAttribute('src'))
59
- )
60
- await Promise.all(scripts
61
- .filter(src => !existingSrcs.has(src))
62
- .map(src => new Promise((resolve) => {
63
- const script = document.createElement('script')
64
- script.src = src
65
- script.onload = resolve
66
- script.onerror = resolve
67
- document.head.appendChild(script)
68
- }))
69
- )
70
- }
101
+ } else {
102
+ // Legacy JSON response (server running with stream: false)
103
+ const { html, title, styles, scripts, hydrate, serverState, storeState } = await res.json()
71
104
 
72
- runScripts(root)
105
+ if (storeState && window.__updatePulseStore__) {
106
+ window.__updatePulseStore__(storeState)
107
+ }
73
108
 
74
- if (push) history.pushState({ pulse: true, path }, '', path)
109
+ root.innerHTML = html
110
+ document.title = title || document.title
111
+ applyStyles(styles)
112
+ await applyScripts(scripts)
113
+ runScripts(root)
75
114
 
76
- if (hydrate && mountFn) {
77
- // Destroy the previous mount to clean up its store subscription
78
- currentMount?.destroy()
79
- root.dataset.pulseMounted = '1'
80
- window.__PULSE_SERVER__ = serverState || {}
81
- const { default: spec } = await import(/* @vite-ignore */ hydrate)
82
- if (spec) currentMount = mountFn(spec, root, serverState || {})
83
- }
115
+ if (push) history.pushState({ pulse: true, path }, '', path)
84
116
 
85
- document.dispatchEvent(new CustomEvent('pulse:navigate'))
86
- window.scrollTo({ top: 0, behavior: 'instant' })
117
+ if (hydrate && mountFn) {
118
+ currentMount?.destroy()
119
+ root.dataset.pulseMounted = '1'
120
+ window.__PULSE_SERVER__ = serverState || {}
121
+ const { default: spec } = await import(/* @vite-ignore */ hydrate)
122
+ if (spec) currentMount = mountFn(spec, root, serverState || {})
123
+ }
87
124
 
88
- const focusTarget = root.querySelector('#main-content, main, h1') || root
89
- if (!focusTarget.hasAttribute('tabindex')) {
90
- focusTarget.setAttribute('tabindex', '-1')
91
- focusTarget.addEventListener('blur', () => focusTarget.removeAttribute('tabindex'), { once: true })
125
+ document.dispatchEvent(new CustomEvent('pulse:navigate'))
126
+ scrollAndFocus(root)
92
127
  }
93
- focusTarget.focus({ preventScroll: true })
94
128
 
95
129
  } catch {
96
130
  location.href = path
@@ -144,3 +178,49 @@ function runScripts(el) {
144
178
  s.remove()
145
179
  })
146
180
  }
181
+
182
+ /** Inject any stylesheets not already present in <head>. */
183
+ function applyStyles(styles) {
184
+ if (!Array.isArray(styles)) return
185
+ const existing = new Set(
186
+ [...document.querySelectorAll('link[rel="stylesheet"]')].map(l => l.getAttribute('href'))
187
+ )
188
+ for (const href of styles) {
189
+ if (!existing.has(href)) {
190
+ const link = document.createElement('link')
191
+ link.rel = 'stylesheet'
192
+ link.href = href
193
+ document.head.appendChild(link)
194
+ }
195
+ }
196
+ }
197
+
198
+ /** Inject any external scripts not already present in <head>. Returns a Promise. */
199
+ function applyScripts(scripts) {
200
+ if (!Array.isArray(scripts)) return Promise.resolve()
201
+ const existingSrcs = new Set(
202
+ [...document.querySelectorAll('script[src]')].map(s => s.getAttribute('src'))
203
+ )
204
+ return Promise.all(
205
+ scripts
206
+ .filter(src => !existingSrcs.has(src))
207
+ .map(src => new Promise((resolve) => {
208
+ const script = document.createElement('script')
209
+ script.src = src
210
+ script.onload = resolve
211
+ script.onerror = resolve
212
+ document.head.appendChild(script)
213
+ }))
214
+ )
215
+ }
216
+
217
+ /** Scroll to top and move focus to the main landmark. */
218
+ function scrollAndFocus(root) {
219
+ window.scrollTo({ top: 0, behavior: 'instant' })
220
+ const focusTarget = root.querySelector('#main-content, main, h1') || root
221
+ if (!focusTarget.hasAttribute('tabindex')) {
222
+ focusTarget.setAttribute('tabindex', '-1')
223
+ focusTarget.addEventListener('blur', () => focusTarget.removeAttribute('tabindex'), { once: true })
224
+ }
225
+ focusTarget.focus({ preventScroll: true })
226
+ }