@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.
- package/.github/workflows/publish.yml +3 -2
- package/CLAUDE.md +21 -1
- package/benchmark/results/2026-03-27-09-20.json +188 -0
- package/benchmark/results/2026-03-27-09-21.json +188 -0
- package/benchmark/results/2026-03-27-09-29.json +188 -0
- package/benchmark/results/2026-03-27-10-14.json +188 -0
- package/benchmark/results/2026-03-27-10-18.json +232 -0
- package/docs/public/.pulse-ui-version +1 -1
- package/package.json +2 -2
- package/public/.pulse-ui-version +1 -1
- package/scripts/bench.js +483 -0
- package/scripts/release-version.js +56 -0
- package/src/cli/index.js +30 -0
- package/src/runtime/index.js +89 -7
- package/src/runtime/morph.test.js +317 -0
- package/src/runtime/navigate.js +132 -52
- package/src/runtime/runtime.test.js +24 -0
- package/src/runtime/ssr.js +168 -23
- package/src/runtime/ssr.test.js +255 -1
- package/src/server/index.js +88 -6
- package/src/server/server.test.js +104 -0
- package/src/spec/schema.js +32 -1
- package/src/spec/schema.test.js +55 -0
package/src/runtime/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
package/src/runtime/navigate.js
CHANGED
|
@@ -29,68 +29,102 @@ export function initNavigation(root, mountFn) {
|
|
|
29
29
|
|
|
30
30
|
if (!res.ok) { location.href = path; return }
|
|
31
31
|
|
|
32
|
-
const
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
105
|
+
if (storeState && window.__updatePulseStore__) {
|
|
106
|
+
window.__updatePulseStore__(storeState)
|
|
107
|
+
}
|
|
73
108
|
|
|
74
|
-
|
|
109
|
+
root.innerHTML = html
|
|
110
|
+
document.title = title || document.title
|
|
111
|
+
applyStyles(styles)
|
|
112
|
+
await applyScripts(scripts)
|
|
113
|
+
runScripts(root)
|
|
75
114
|
|
|
76
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
+
}
|