fusee-framework 2.7.1 → 2.7.2

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.
Files changed (3) hide show
  1. package/README.md +42 -24
  2. package/framework/core/h.js +632 -588
  3. package/package.json +1 -1
@@ -1,589 +1,633 @@
1
- import { effect, batch, signal, microEffect, untrack } from '../core/signal.js'
2
- import { createEventHandler, registerDelegatedEvent, isDelegatedEvent } from '../core/event-delegation.js'
3
- import { resolveElementProperty } from '../core/element-props.js'
4
-
5
- export { effect as _effect, batch as _batch }
6
-
7
- /** Reports whether a function should be evaluated as a plain getter. */
8
- function isGetter(v) {
9
- return typeof v === 'function' && !v.isSignal
10
- }
11
-
12
- const SENSITIVE_ATTRS = new Set(['href', 'src', 'srcset', 'formaction', 'xlink:href', 'data'])
13
- const DANGEROUS_SCHEME = /^(javascript|data|vbscript|file):/i
14
-
15
- /** Sanitizes URL-bearing DOM properties before they reach the browser. */
16
- function sanitize(name, value) {
17
- if (SENSITIVE_ATTRS.has(name.toLowerCase())) {
18
- const trimmed = String(value).trim()
19
- if (DANGEROUS_SCHEME.test(trimmed)) {
20
- console.warn(`[fusée] Blocked potential XSS on "${name}": "${value}"`)
21
- return 'about:blank'
22
- }
23
- }
24
- return value
25
- }
26
-
27
- /** Creates a framework node for an element and mounts its child nodes. */
28
- export function h(tag, props = {}, children = [], isStatic = false) {
29
- const el = document.createElement(tag)
30
- const effects = []
31
-
32
- if (!isStatic) {
33
- _applyProps(el, props, effects)
34
- } else {
35
- _applyStaticProps(el, props)
36
- }
37
-
38
- const childNodes = _mountChildren(el, children, effects)
39
-
40
- return {
41
- node: el,
42
- effects,
43
- props,
44
- children: childNodes,
45
- _tag: tag,
46
- }
47
- }
48
-
49
- /** Creates a text node that optionally updates from a reactive getter. */
50
- export function hText(value) {
51
- const node = document.createTextNode('')
52
- const effects = []
53
-
54
- if (typeof value === 'function') {
55
- const cleanup = microEffect(() => {
56
- node.textContent = String(value() ?? '')
57
- })
58
- effects.push(cleanup)
59
- } else {
60
- node.textContent = String(value ?? '')
61
- }
62
-
63
- return { node, effects }
64
- }
65
-
66
- /** Creates a reactive conditional anchored by a comment node. */
67
- export function hIf(branches) {
68
- const anchor = document.createComment('f-if')
69
- const effects = []
70
-
71
- let currentNodes = []
72
- let currentBranch = -1
73
- const mounted = _createMountTracker()
74
-
75
- const cleanup = effect(() => {
76
- mounted.track()
77
- let matchIdx = -1
78
- for (let i = 0; i < branches.length; i++) {
79
- const [cond] = branches[i]
80
- if (cond === null || cond()) { matchIdx = i; break }
81
- }
82
-
83
- const parent = anchor.parentNode
84
- if (!parent) return mounted.defer()
85
-
86
- if (matchIdx === currentBranch) return
87
-
88
- _unmountNodes(currentNodes)
89
- currentNodes = []
90
- currentBranch = matchIdx
91
-
92
- if (matchIdx === -1) return
93
-
94
- const [, getChildren] = branches[matchIdx]
95
- const children = untrack(getChildren)
96
-
97
- for (const fnode of children) {
98
- if (!fnode?.node) continue
99
- insertFNode(parent, fnode, anchor)
100
- currentNodes.push(fnode)
101
- }
102
- })
103
-
104
- effects.push(cleanup)
105
-
106
- return {
107
- node: anchor,
108
- effects,
109
- _ifBranchNodes: () => currentNodes,
110
- _isIf: true,
111
- _afterInsert: mounted.resume,
112
- }
113
- }
114
-
115
- /** Creates a keyed reactive list anchored by a comment node. */
116
- export function hFor(sourceGetter, renderItem, keyFn) {
117
- const anchor = document.createComment('f-for')
118
- const effects = []
119
-
120
- let keyedMap = new Map()
121
- let oldKeys = []
122
- let _keyEvalCurrent = null
123
- const keyEvaluatorGetter = () => _keyEvalCurrent
124
- keyEvaluatorGetter.isSignal = true
125
- const mounted = _createMountTracker()
126
-
127
- const cleanup = effect(() => {
128
- mounted.track()
129
- const list = sourceGetter() ?? []
130
- const parent = anchor.parentNode
131
-
132
- if (!parent) return mounted.defer()
133
-
134
- const newKeys = new Array(list.length)
135
- for (let i = 0; i < list.length; i++) {
136
- _keyEvalCurrent = list[i]
137
- const key = keyFn(keyEvaluatorGetter, i)
138
- // Loops without :key compile to a key of undefined; key them by position, as SSR does.
139
- newKeys[i] = key === undefined ? i : key
140
- }
141
-
142
- if (oldKeys.length === newKeys.length) {
143
- let identical = true
144
- for (let i = 0; i < newKeys.length; i++) {
145
- if (oldKeys[i] !== newKeys[i]) { identical = false; break }
146
- }
147
- if (identical) {
148
- for (let i = 0; i < list.length; i++) {
149
- _setItem(keyedMap.get(newKeys[i]), list[i])
150
- }
151
- return
152
- }
153
- }
154
-
155
- if (oldKeys.length === newKeys.length) {
156
- let diffA = -1, diffB = -1, diffCount = 0
157
- for (let i = 0; i < newKeys.length; i++) {
158
- if (oldKeys[i] !== newKeys[i]) {
159
- if (diffCount === 0) {
160
- diffA = i
161
- } else if (diffCount === 1) {
162
- diffB = i
163
- }
164
- diffCount++
165
- if (diffCount > 2)
166
- break
167
- }
168
- }
169
- if (diffCount === 2 && oldKeys[diffA] === newKeys[diffB] && oldKeys[diffB] === newKeys[diffA]) {
170
- const entryA = keyedMap.get(oldKeys[diffA])
171
- const entryB = keyedMap.get(oldKeys[diffB])
172
- const nodeA = entryA.fnode.node
173
- const nodeB = entryB.fnode.node
174
- const refA = nodeA.nextSibling
175
- const parentEl = nodeA.parentNode
176
- parentEl.insertBefore(nodeA, nodeB.nextSibling)
177
- parentEl.insertBefore(nodeB, refA)
178
- // The two rows traded places: each keeps its key and receives the item now at its new index.
179
- _setItem(entryA, list[diffB])
180
- _setItem(entryB, list[diffA])
181
- oldKeys = newKeys
182
- return
183
- }
184
- }
185
-
186
- const newMap = new Map()
187
-
188
- for (let i = 0; i < list.length; i++) {
189
- const item = list[i]
190
- const key = newKeys[i]
191
-
192
- const exists = keyedMap.get(key)
193
- if (exists) {
194
- _setItem(exists, item)
195
- newMap.set(key, exists)
196
- } else {
197
- const itemSignal = signal(item)
198
- const fnode = untrack(() => renderItem(itemSignal, i))
199
- newMap.set(key, { fnode, key, itemSignal })
200
- }
201
- }
202
-
203
- for (const [key, entry] of keyedMap) {
204
- if (!newMap.has(key)) {
205
- _unmountNodes([entry.fnode])
206
- }
207
- }
208
-
209
- const newEntries = newKeys.map(k => newMap.get(k))
210
- const oldIndexOf = new Map()
211
- oldKeys.forEach((k, i) => oldIndexOf.set(k, i))
212
-
213
- const posMap = newEntries.map(entry => {
214
- const idx = oldIndexOf.get(entry.key)
215
- return idx === undefined ? -1 : idx
216
- })
217
-
218
- const lisIndices = _getLIS(posMap)
219
- let lisPtr = lisIndices.length - 1
220
-
221
- for (let i = newEntries.length - 1; i >= 0; i--) {
222
- const entry = newEntries[i]
223
- const ref = i === newEntries.length - 1 ? anchor : newEntries[i + 1].fnode.node
224
- const inLis = lisPtr >= 0 && i === lisIndices[lisPtr]
225
-
226
- if (inLis) {
227
- lisPtr--
228
- if (!entry.fnode.node.parentNode) {
229
- insertFNode(parent, entry.fnode, ref)
230
- }
231
- } else if (entry.fnode.node.nextSibling !== ref || !entry.fnode.node.parentNode) {
232
- insertFNode(parent, entry.fnode, ref)
233
- }
234
- }
235
-
236
- keyedMap = newMap
237
- oldKeys = newKeys
238
- })
239
-
240
- effects.push(cleanup)
241
-
242
- return {
243
- node: anchor,
244
- effects,
245
- _isFor: true,
246
- _getNodes: () => [...keyedMap.values()].map(e => e.fnode),
247
- _afterInsert: mounted.resume,
248
- }
249
- }
250
-
251
- /** Updates a keyed row's item signal without subscribing the list effect to it. */
252
- function _setItem(entry, item) {
253
- if (untrack(entry.itemSignal) !== item) entry.itemSignal(item)
254
- }
255
-
256
- /**
257
- * hIf/hFor are built before their anchor has a parent (children are created before the
258
- * element that holds them), so their first render waits until insertFNode() places the
259
- * anchor and calls _afterInsert.
260
- */
261
- function _createMountTracker() {
262
- const tick = signal(0)
263
- let pending = false
264
- return {
265
- track: () => tick(),
266
- defer: () => { pending = true },
267
- resume: () => {
268
- if (!pending) return
269
- pending = false
270
- tick(untrack(tick) + 1)
271
- }
272
- }
273
- }
274
-
275
- /**
276
- * Inserts a framework node (and slot nodes it carries) before ref, then lets anchor-based
277
- * nodes (hIf/hFor) render now that they have a parent.
278
- */
279
- export function insertFNode(parent, fnode, ref = null) {
280
- parent.insertBefore(fnode.node, ref)
281
- if (fnode._isSlot && fnode._slotNodes) {
282
- for (const sn of fnode._slotNodes) {
283
- if (sn?.node) insertFNode(parent, sn, ref)
284
- }
285
- }
286
- if (typeof fnode._afterInsert === 'function') fnode._afterInsert()
287
- }
288
-
289
- function _getLIS(arr) {
290
- const p = arr.slice()
291
- const result = []
292
-
293
- for (let i = 0; i < arr.length; i++) {
294
- if (arr[i] === -1) continue
295
-
296
- if (result.length === 0 || arr[result[result.length - 1]] < arr[i]) {
297
- p[i] = result.length ? result[result.length - 1] : -1
298
- result.push(i)
299
- continue
300
- }
301
-
302
- let lo = 0, hi = result.length - 1
303
- while (lo < hi) {
304
- const mid = (lo + hi) >> 1
305
- if (arr[result[mid]] < arr[i]) lo = mid + 1
306
- else hi = mid
307
- }
308
- if (arr[i] < arr[result[lo]]) {
309
- p[i] = lo > 0 ? result[lo - 1] : -1
310
- result[lo] = i
311
- }
312
- }
313
-
314
- let u = result.length
315
- let v = u > 0 ? result[u - 1] : -1
316
- const seq = new Array(u)
317
- while (u-- > 0) {
318
- seq[u] = v
319
- v = p[v]
320
- }
321
- return seq
322
- }
323
-
324
- /** Marks a slots object whose outlets must stay native `<slot>` elements (shadow DOM). */
325
- export const NATIVE_SLOTS = Symbol.for('fusee.nativeSlots')
326
-
327
- export function hSlot(slots, name, fallback = []) {
328
- if (slots?.[NATIVE_SLOTS]) {
329
- return h('slot', name === 'default' ? {} : { name }, fallback)
330
- }
331
-
332
- const anchor = document.createComment(`slot:${name}`)
333
- const effects = []
334
- const slotFn = slots?.[name]
335
- const nodes = typeof slotFn === 'function' ? slotFn() : fallback
336
- return {
337
- node: anchor,
338
- effects,
339
- _slotNodes: nodes,
340
- _isSlot: true,
341
- }
342
- }
343
-
344
- export function createComponent(name, ComponentFn, rawProps = {}, rawSlots = {}) {
345
- if (!ComponentFn) {
346
- console.warn(`[fusée] Component "${name}" is not registered`)
347
- const placeholder = document.createComment(`missing:${name}`)
348
- return { node: placeholder, effects: [] }
349
- }
350
-
351
- const resolvedProps = _resolveComponentProps(rawProps)
352
- const listeners = {}
353
- const slots = {}
354
-
355
- for (const [key, value] of Object.entries(rawProps)) {
356
- if (key.startsWith('on:')) {
357
- listeners[key.slice(3)] = value
358
- }
359
- }
360
-
361
- for (const [slotName, slotFn] of Object.entries(rawSlots)) {
362
- slots[slotName] = slotFn
363
- }
364
-
365
- const container = document.createElement('div')
366
- container.setAttribute('data-fusee-component', name)
367
-
368
- const api = ComponentFn(resolvedProps, { listeners, slots })
369
- api.render(container)
370
- const root = container.firstElementChild ?? container
371
-
372
- const effects = []
373
- effects.push(() => api.unmount())
374
-
375
- return { node: root, effects, _component: api }
376
- }
377
-
378
- function _applyProps(el, props, effects) {
379
- for (const key in props) {
380
- const value = props[key]
381
- if (key.startsWith('@')) {
382
- const eventName = key.slice(1)
383
- const { handler, modifiers = [] } = value
384
- const handlerState = { timeoutId: null, throttleTimeoutId: null, lastRun: 0 }
385
- const wrappedHandler = createEventHandler(handler, modifiers, null, null, handlerState)
386
-
387
- let cleanup
388
- if (isDelegatedEvent(eventName)) {
389
- cleanup = registerDelegatedEvent(el, eventName, wrappedHandler, { modifiers, handlerState })
390
- } else {
391
- el.addEventListener(eventName, wrappedHandler)
392
- cleanup = () => el.removeEventListener(eventName, wrappedHandler)
393
- }
394
- effects.push(cleanup)
395
- continue
396
- }
397
-
398
- if (key === 'f-show') {
399
- const cleanup = effect(() => {
400
- el.style.display = value() ? '' : 'none'
401
- })
402
- effects.push(cleanup)
403
- continue
404
- }
405
-
406
- if (key === 'f-html') {
407
- const cleanup = effect(() => {
408
- el.innerHTML = String(value() ?? '')
409
- })
410
- effects.push(cleanup)
411
- continue
412
- }
413
-
414
- if (key === 'f-model') {
415
- const sig = value()
416
- const cleanup = effect(() => {
417
- const v = typeof sig === 'function' ? sig() : sig
418
- if (el.value !== String(v ?? '')) el.value = String(v ?? '')
419
- })
420
- effects.push(cleanup)
421
- continue
422
- }
423
-
424
- if (key === 'f-ref') {
425
- el.dispatchEvent(new CustomEvent('fusee:ref', {
426
- detail: { name: value },
427
- bubbles: true,
428
- }))
429
- continue
430
- }
431
-
432
- if (key === 'f-once') continue
433
-
434
- if (typeof value !== 'function') {
435
- _applyAttr(el, key, value)
436
- continue
437
- }
438
-
439
- const attrName = key
440
- const getter = value
441
- const cleanup = effect(() => {
442
- _applyAttr(el, attrName, getter())
443
- })
444
- effects.push(cleanup)
445
- }
446
- }
447
-
448
- function _applyStaticProps(el, props) {
449
- for (const key in props) {
450
- const value = props[key]
451
- if (key.startsWith('@') || key.startsWith('f-'))
452
- continue
453
- if (typeof value !== 'function')
454
- _applyAttr(el, key, value)
455
- }
456
- }
457
-
458
- function _applyAttr(el, name, value) {
459
- if (name === 'class') {
460
- const cls = _resolveClass(value)
461
- if (cls) el.className = cls
462
- else el.removeAttribute('class')
463
- return
464
- }
465
-
466
- if (name === 'style') {
467
- if (value !== null && typeof value === 'object') {
468
- for (const k in value) el.style[k] = value[k]
469
- } else {
470
- el.style.cssText = String(value ?? '')
471
- }
472
- return
473
- }
474
-
475
- const elementProp = resolveElementProperty(el, name, value)
476
- if (elementProp) {
477
- el[elementProp] = value
478
- return
479
- }
480
-
481
- if (name in el && typeof el[name] !== 'undefined' && name !== 'type') {
482
- try {
483
- el[name] = value
484
- return
485
- } catch (_) {
486
- return
487
- }
488
- }
489
-
490
- if (typeof value === 'boolean') {
491
- value ? el.setAttribute(name, '') : el.removeAttribute(name)
492
- return
493
- }
494
-
495
- if (value == null) {
496
- el.removeAttribute(name)
497
- return
498
- }
499
-
500
- el.setAttribute(name, sanitize(name, String(value)))
501
- }
502
-
503
- function _resolveClass(value) {
504
- if (typeof value === 'string')
505
- return value
506
- if (Array.isArray(value))
507
- return value.filter(Boolean).join(' ')
508
- if (value && typeof value === 'object')
509
- return Object.entries(value).filter(([, v]) => !!v).map(([k]) => k).join(' ')
510
- return String(value ?? '')
511
- }
512
-
513
- function _mountChildren(parent, children, parentEffects) {
514
- const mounted = []
515
-
516
- for (const fnode of children) {
517
- if (!fnode || !fnode.node) continue
518
-
519
- insertFNode(parent, fnode)
520
-
521
- if (fnode.effects) parentEffects.push(...fnode.effects)
522
- mounted.push(fnode)
523
- }
524
-
525
- return mounted
526
- }
527
-
528
- function _unmountNodes(fnodes) {
529
- for (const fnode of fnodes) {
530
- if (!fnode) continue
531
-
532
- if (fnode.effects) {
533
- for (let i = fnode.effects.length - 1; i >= 0; i--) {
534
- const cleanup = fnode.effects[i]
535
- if (typeof cleanup === 'function') cleanup()
536
- }
537
- }
538
-
539
- if (fnode.node?.parentNode) {
540
- fnode.node.parentNode.removeChild(fnode.node)
541
- }
542
-
543
- if (fnode._ifBranchNodes) {
544
- _unmountNodes(fnode._ifBranchNodes())
545
- }
546
-
547
- if (fnode._getNodes) {
548
- _unmountNodes(fnode._getNodes())
549
- }
550
- }
551
- }
552
-
553
- function _resolveComponentProps(rawProps) {
554
- const resolved = {}
555
-
556
- for (const [key, value] of Object.entries(rawProps)) {
557
- if (key.startsWith('on:')) continue
558
-
559
- if (isGetter(value)) {
560
- Object.defineProperty(resolved, key, {
561
- get: value,
562
- enumerable: true,
563
- configurable: true,
564
- })
565
- } else {
566
- resolved[key] = value
567
- }
568
- }
569
-
570
- return resolved
571
- }
572
-
573
- export function mount(renderFn, ctx, components, container) {
574
- const fnodes = renderFn(ctx, components)
575
- const effects = []
576
-
577
- for (const fnode of fnodes) {
578
- if (!fnode?.node) continue
579
- insertFNode(container, fnode)
580
- if (fnode.effects) effects.push(...fnode.effects)
581
- }
582
-
583
- return {
584
- unmount() {
585
- _unmountNodes(fnodes)
586
- container.innerHTML = ''
587
- }
588
- }
1
+ import { effect, batch, signal, microEffect, untrack, runDetached } from '../core/signal.js'
2
+ import { createEventHandler, registerDelegatedEvent, isDelegatedEvent } from '../core/event-delegation.js'
3
+ import { resolveElementProperty } from '../core/element-props.js'
4
+
5
+ export { effect as _effect, batch as _batch }
6
+
7
+ /** Reports whether a function should be evaluated as a plain getter. */
8
+ function isGetter(v) {
9
+ return typeof v === 'function' && !v.isSignal
10
+ }
11
+
12
+ const SENSITIVE_ATTRS = new Set(['href', 'src', 'srcset', 'formaction', 'xlink:href', 'data'])
13
+ const DANGEROUS_SCHEME = /^(javascript|data|vbscript|file):/i
14
+
15
+ /** Sanitizes URL-bearing DOM properties before they reach the browser. */
16
+ function sanitize(name, value) {
17
+ if (SENSITIVE_ATTRS.has(name.toLowerCase())) {
18
+ const trimmed = String(value).trim()
19
+ if (DANGEROUS_SCHEME.test(trimmed)) {
20
+ console.warn(`[fusée] Blocked potential XSS on "${name}": "${value}"`)
21
+ return 'about:blank'
22
+ }
23
+ }
24
+ return value
25
+ }
26
+
27
+ /** Creates a framework node for an element and mounts its child nodes. */
28
+ export function h(tag, props = {}, children = [], isStatic = false) {
29
+ const el = document.createElement(tag)
30
+ const effects = []
31
+
32
+ if (!isStatic) {
33
+ _applyProps(el, props, effects)
34
+ } else {
35
+ _applyStaticProps(el, props)
36
+ }
37
+
38
+ const childNodes = _mountChildren(el, children, effects)
39
+
40
+ return {
41
+ node: el,
42
+ effects,
43
+ props,
44
+ children: childNodes,
45
+ _tag: tag,
46
+ }
47
+ }
48
+
49
+ /** Creates a text node that optionally updates from a reactive getter. */
50
+ export function hText(value) {
51
+ const node = document.createTextNode('')
52
+ const effects = []
53
+
54
+ if (typeof value === 'function') {
55
+ const cleanup = microEffect(() => {
56
+ node.textContent = String(value() ?? '')
57
+ })
58
+ effects.push(cleanup)
59
+ } else {
60
+ node.textContent = String(value ?? '')
61
+ }
62
+
63
+ return { node, effects }
64
+ }
65
+
66
+ /** Creates a reactive conditional anchored by a comment node. */
67
+ export function hIf(branches) {
68
+ const anchor = document.createComment('f-if')
69
+ const effects = []
70
+
71
+ let currentNodes = []
72
+ let currentBranch = -1
73
+ const mounted = _createMountTracker()
74
+
75
+ const cleanup = effect(() => {
76
+ mounted.track()
77
+ let matchIdx = -1
78
+ for (let i = 0; i < branches.length; i++) {
79
+ const [cond] = branches[i]
80
+ if (cond === null || cond()) { matchIdx = i; break }
81
+ }
82
+
83
+ const parent = anchor.parentNode
84
+ if (!parent) return mounted.defer()
85
+
86
+ if (matchIdx === currentBranch) return
87
+
88
+ _unmountNodes(currentNodes)
89
+ currentNodes = []
90
+ currentBranch = matchIdx
91
+
92
+ if (matchIdx === -1) return
93
+
94
+ const [, getChildren] = branches[matchIdx]
95
+ // Detached: branch effects belong to the branch, which is unmounted explicitly below.
96
+ const children = runDetached(getChildren)
97
+
98
+ for (const fnode of children) {
99
+ if (!fnode?.node) continue
100
+ insertFNode(parent, fnode, anchor)
101
+ currentNodes.push(fnode)
102
+ }
103
+ })
104
+
105
+ effects.push(() => {
106
+ cleanup()
107
+ _unmountNodes(currentNodes)
108
+ currentNodes = []
109
+ })
110
+
111
+ return {
112
+ node: anchor,
113
+ effects,
114
+ _ifBranchNodes: () => currentNodes,
115
+ _isIf: true,
116
+ _afterInsert: mounted.resume,
117
+ }
118
+ }
119
+
120
+ /** Creates a keyed reactive list anchored by a comment node. */
121
+ export function hFor(sourceGetter, renderItem, keyFn) {
122
+ const anchor = document.createComment('f-for')
123
+ const effects = []
124
+
125
+ let keyedMap = new Map()
126
+ let oldKeys = []
127
+ let _keyEvalCurrent = null
128
+ const keyEvaluatorGetter = () => _keyEvalCurrent
129
+ keyEvaluatorGetter.isSignal = true
130
+ const mounted = _createMountTracker()
131
+
132
+ const cleanup = effect(() => {
133
+ mounted.track()
134
+ const list = sourceGetter() ?? []
135
+ const parent = anchor.parentNode
136
+
137
+ if (!parent) return mounted.defer()
138
+
139
+ const newKeys = new Array(list.length)
140
+ for (let i = 0; i < list.length; i++) {
141
+ _keyEvalCurrent = list[i]
142
+ const key = keyFn(keyEvaluatorGetter, i)
143
+ // Loops without :key compile to a key of undefined; key them by position, as SSR does.
144
+ newKeys[i] = key === undefined ? i : key
145
+ }
146
+
147
+ if (newKeys.length === 0 && keyedMap.size > 0) {
148
+ _clearRows(parent, keyedMap)
149
+ keyedMap = new Map()
150
+ oldKeys = newKeys
151
+ return
152
+ }
153
+
154
+ if (oldKeys.length === newKeys.length) {
155
+ let identical = true
156
+ for (let i = 0; i < newKeys.length; i++) {
157
+ if (oldKeys[i] !== newKeys[i]) { identical = false; break }
158
+ }
159
+ if (identical) {
160
+ for (let i = 0; i < list.length; i++) {
161
+ _setItem(keyedMap.get(newKeys[i]), list[i])
162
+ }
163
+ return
164
+ }
165
+ }
166
+
167
+ if (oldKeys.length === newKeys.length) {
168
+ let diffA = -1, diffB = -1, diffCount = 0
169
+ for (let i = 0; i < newKeys.length; i++) {
170
+ if (oldKeys[i] !== newKeys[i]) {
171
+ if (diffCount === 0) {
172
+ diffA = i
173
+ } else if (diffCount === 1) {
174
+ diffB = i
175
+ }
176
+ diffCount++
177
+ if (diffCount > 2)
178
+ break
179
+ }
180
+ }
181
+ if (diffCount === 2 && oldKeys[diffA] === newKeys[diffB] && oldKeys[diffB] === newKeys[diffA]) {
182
+ const entryA = keyedMap.get(oldKeys[diffA])
183
+ const entryB = keyedMap.get(oldKeys[diffB])
184
+ const nodeA = entryA.fnode.node
185
+ const nodeB = entryB.fnode.node
186
+ const refA = nodeA.nextSibling
187
+ const parentEl = nodeA.parentNode
188
+ parentEl.insertBefore(nodeA, nodeB.nextSibling)
189
+ parentEl.insertBefore(nodeB, refA)
190
+ // The two rows traded places: each keeps its key and receives the item now at its new index.
191
+ _setItem(entryA, list[diffB])
192
+ _setItem(entryB, list[diffA])
193
+ oldKeys = newKeys
194
+ return
195
+ }
196
+ }
197
+
198
+ const newMap = new Map()
199
+
200
+ for (let i = 0; i < list.length; i++) {
201
+ const item = list[i]
202
+ const key = newKeys[i]
203
+
204
+ const exists = keyedMap.get(key)
205
+ if (exists) {
206
+ _setItem(exists, item)
207
+ newMap.set(key, exists)
208
+ } else {
209
+ const itemSignal = signal(item)
210
+ // Detached: otherwise every row's effects are registered as children of this
211
+ // list effect and stay reachable after the row is removed.
212
+ const fnode = runDetached(() => renderItem(itemSignal, i))
213
+ newMap.set(key, { fnode, key, itemSignal })
214
+ }
215
+ }
216
+
217
+ for (const [key, entry] of keyedMap) {
218
+ if (!newMap.has(key)) {
219
+ _unmountNodes([entry.fnode])
220
+ }
221
+ }
222
+
223
+ const newEntries = newKeys.map(k => newMap.get(k))
224
+ const oldIndexOf = new Map()
225
+ oldKeys.forEach((k, i) => oldIndexOf.set(k, i))
226
+
227
+ const posMap = newEntries.map(entry => {
228
+ const idx = oldIndexOf.get(entry.key)
229
+ return idx === undefined ? -1 : idx
230
+ })
231
+
232
+ const lisIndices = _getLIS(posMap)
233
+ let lisPtr = lisIndices.length - 1
234
+
235
+ for (let i = newEntries.length - 1; i >= 0; i--) {
236
+ const entry = newEntries[i]
237
+ const ref = i === newEntries.length - 1 ? anchor : newEntries[i + 1].fnode.node
238
+ const inLis = lisPtr >= 0 && i === lisIndices[lisPtr]
239
+
240
+ if (inLis) {
241
+ lisPtr--
242
+ if (!entry.fnode.node.parentNode) {
243
+ insertFNode(parent, entry.fnode, ref)
244
+ }
245
+ } else if (entry.fnode.node.nextSibling !== ref || !entry.fnode.node.parentNode) {
246
+ insertFNode(parent, entry.fnode, ref)
247
+ }
248
+ }
249
+
250
+ keyedMap = newMap
251
+ oldKeys = newKeys
252
+ })
253
+
254
+ effects.push(() => {
255
+ cleanup()
256
+ _unmountNodes([...keyedMap.values()].map(e => e.fnode))
257
+ keyedMap = new Map()
258
+ oldKeys = []
259
+ })
260
+
261
+ return {
262
+ node: anchor,
263
+ effects,
264
+ _isFor: true,
265
+ _getNodes: () => [...keyedMap.values()].map(e => e.fnode),
266
+ _afterInsert: mounted.resume,
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Unmounts every row. When the rows are the only elements in the parent, the parent is emptied
272
+ * in one operation instead of one removeChild per row, and its other nodes (the anchor, template
273
+ * whitespace, text) are put back in order.
274
+ */
275
+ function _clearRows(parent, keyedMap) {
276
+ const fnodes = [...keyedMap.values()].map(e => e.fnode)
277
+ const rowsAreElements = fnodes.every(f => f.node.nodeType === 1)
278
+
279
+ if (rowsAreElements && parent.childElementCount === fnodes.length) {
280
+ const kept = []
281
+ for (let n = parent.firstChild; n; n = n.nextSibling) {
282
+ if (n.nodeType !== 1) kept.push(n)
283
+ }
284
+ for (const fnode of fnodes) _disposeEffects(fnode)
285
+ parent.textContent = ''
286
+ parent.append(...kept)
287
+ return
288
+ }
289
+ _unmountNodes(fnodes)
290
+ }
291
+
292
+ /** Updates a keyed row's item signal without subscribing the list effect to it. */
293
+ function _setItem(entry, item) {
294
+ if (untrack(entry.itemSignal) !== item) entry.itemSignal(item)
295
+ }
296
+
297
+ /**
298
+ * hIf/hFor are built before their anchor has a parent (children are created before the
299
+ * element that holds them), so their first render waits until insertFNode() places the
300
+ * anchor and calls _afterInsert.
301
+ */
302
+ function _createMountTracker() {
303
+ const tick = signal(0)
304
+ let pending = false
305
+ return {
306
+ track: () => tick(),
307
+ defer: () => { pending = true },
308
+ resume: () => {
309
+ if (!pending) return
310
+ pending = false
311
+ tick(untrack(tick) + 1)
312
+ }
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Inserts a framework node (and slot nodes it carries) before ref, then lets anchor-based
318
+ * nodes (hIf/hFor) render now that they have a parent.
319
+ */
320
+ export function insertFNode(parent, fnode, ref = null) {
321
+ parent.insertBefore(fnode.node, ref)
322
+ if (fnode._isSlot && fnode._slotNodes) {
323
+ for (const sn of fnode._slotNodes) {
324
+ if (sn?.node) insertFNode(parent, sn, ref)
325
+ }
326
+ }
327
+ if (typeof fnode._afterInsert === 'function') fnode._afterInsert()
328
+ }
329
+
330
+ function _getLIS(arr) {
331
+ const p = arr.slice()
332
+ const result = []
333
+
334
+ for (let i = 0; i < arr.length; i++) {
335
+ if (arr[i] === -1) continue
336
+
337
+ if (result.length === 0 || arr[result[result.length - 1]] < arr[i]) {
338
+ p[i] = result.length ? result[result.length - 1] : -1
339
+ result.push(i)
340
+ continue
341
+ }
342
+
343
+ let lo = 0, hi = result.length - 1
344
+ while (lo < hi) {
345
+ const mid = (lo + hi) >> 1
346
+ if (arr[result[mid]] < arr[i]) lo = mid + 1
347
+ else hi = mid
348
+ }
349
+ if (arr[i] < arr[result[lo]]) {
350
+ p[i] = lo > 0 ? result[lo - 1] : -1
351
+ result[lo] = i
352
+ }
353
+ }
354
+
355
+ let u = result.length
356
+ let v = u > 0 ? result[u - 1] : -1
357
+ const seq = new Array(u)
358
+ while (u-- > 0) {
359
+ seq[u] = v
360
+ v = p[v]
361
+ }
362
+ return seq
363
+ }
364
+
365
+ /** Marks a slots object whose outlets must stay native `<slot>` elements (shadow DOM). */
366
+ export const NATIVE_SLOTS = Symbol.for('fusee.nativeSlots')
367
+
368
+ export function hSlot(slots, name, fallback = []) {
369
+ if (slots?.[NATIVE_SLOTS]) {
370
+ return h('slot', name === 'default' ? {} : { name }, fallback)
371
+ }
372
+
373
+ const anchor = document.createComment(`slot:${name}`)
374
+ const effects = []
375
+ const slotFn = slots?.[name]
376
+ const nodes = typeof slotFn === 'function' ? slotFn() : fallback
377
+ return {
378
+ node: anchor,
379
+ effects,
380
+ _slotNodes: nodes,
381
+ _isSlot: true,
382
+ }
383
+ }
384
+
385
+ export function createComponent(name, ComponentFn, rawProps = {}, rawSlots = {}) {
386
+ if (!ComponentFn) {
387
+ console.warn(`[fusée] Component "${name}" is not registered`)
388
+ const placeholder = document.createComment(`missing:${name}`)
389
+ return { node: placeholder, effects: [] }
390
+ }
391
+
392
+ const resolvedProps = _resolveComponentProps(rawProps)
393
+ const listeners = {}
394
+ const slots = {}
395
+
396
+ for (const [key, value] of Object.entries(rawProps)) {
397
+ if (key.startsWith('on:')) {
398
+ listeners[key.slice(3)] = value
399
+ }
400
+ }
401
+
402
+ for (const [slotName, slotFn] of Object.entries(rawSlots)) {
403
+ slots[slotName] = slotFn
404
+ }
405
+
406
+ const container = document.createElement('div')
407
+ container.setAttribute('data-fusee-component', name)
408
+
409
+ const api = ComponentFn(resolvedProps, { listeners, slots })
410
+ api.render(container)
411
+ const root = container.firstElementChild ?? container
412
+
413
+ const effects = []
414
+ effects.push(() => api.unmount())
415
+
416
+ return { node: root, effects, _component: api }
417
+ }
418
+
419
+ function _applyProps(el, props, effects) {
420
+ for (const key in props) {
421
+ const value = props[key]
422
+ if (key.startsWith('@')) {
423
+ const eventName = key.slice(1)
424
+ const { handler, modifiers = [] } = value
425
+ const handlerState = { timeoutId: null, throttleTimeoutId: null, lastRun: 0 }
426
+ const wrappedHandler = createEventHandler(handler, modifiers, null, null, handlerState)
427
+
428
+ let cleanup
429
+ if (isDelegatedEvent(eventName)) {
430
+ cleanup = registerDelegatedEvent(el, eventName, wrappedHandler, { modifiers, handlerState })
431
+ } else {
432
+ el.addEventListener(eventName, wrappedHandler)
433
+ cleanup = () => el.removeEventListener(eventName, wrappedHandler)
434
+ }
435
+ effects.push(cleanup)
436
+ continue
437
+ }
438
+
439
+ if (key === 'f-show') {
440
+ const cleanup = effect(() => {
441
+ el.style.display = value() ? '' : 'none'
442
+ })
443
+ effects.push(cleanup)
444
+ continue
445
+ }
446
+
447
+ if (key === 'f-html') {
448
+ const cleanup = effect(() => {
449
+ el.innerHTML = String(value() ?? '')
450
+ })
451
+ effects.push(cleanup)
452
+ continue
453
+ }
454
+
455
+ if (key === 'f-model') {
456
+ const sig = value()
457
+ const cleanup = effect(() => {
458
+ const v = typeof sig === 'function' ? sig() : sig
459
+ if (el.value !== String(v ?? '')) el.value = String(v ?? '')
460
+ })
461
+ effects.push(cleanup)
462
+ continue
463
+ }
464
+
465
+ if (key === 'f-ref') {
466
+ el.dispatchEvent(new CustomEvent('fusee:ref', {
467
+ detail: { name: value },
468
+ bubbles: true,
469
+ }))
470
+ continue
471
+ }
472
+
473
+ if (key === 'f-once') continue
474
+
475
+ if (typeof value !== 'function') {
476
+ _applyAttr(el, key, value)
477
+ continue
478
+ }
479
+
480
+ const attrName = key
481
+ const getter = value
482
+ const cleanup = effect(() => {
483
+ _applyAttr(el, attrName, getter())
484
+ })
485
+ effects.push(cleanup)
486
+ }
487
+ }
488
+
489
+ function _applyStaticProps(el, props) {
490
+ for (const key in props) {
491
+ const value = props[key]
492
+ if (key.startsWith('@') || key.startsWith('f-'))
493
+ continue
494
+ if (typeof value !== 'function')
495
+ _applyAttr(el, key, value)
496
+ }
497
+ }
498
+
499
+ function _applyAttr(el, name, value) {
500
+ if (name === 'class') {
501
+ const cls = _resolveClass(value)
502
+ if (cls) el.className = cls
503
+ else el.removeAttribute('class')
504
+ return
505
+ }
506
+
507
+ if (name === 'style') {
508
+ if (value !== null && typeof value === 'object') {
509
+ for (const k in value) el.style[k] = value[k]
510
+ } else {
511
+ el.style.cssText = String(value ?? '')
512
+ }
513
+ return
514
+ }
515
+
516
+ const elementProp = resolveElementProperty(el, name, value)
517
+ if (elementProp) {
518
+ el[elementProp] = value
519
+ return
520
+ }
521
+
522
+ if (name in el && typeof el[name] !== 'undefined' && name !== 'type') {
523
+ try {
524
+ el[name] = value
525
+ return
526
+ } catch (_) {
527
+ return
528
+ }
529
+ }
530
+
531
+ if (typeof value === 'boolean') {
532
+ value ? el.setAttribute(name, '') : el.removeAttribute(name)
533
+ return
534
+ }
535
+
536
+ if (value == null) {
537
+ el.removeAttribute(name)
538
+ return
539
+ }
540
+
541
+ el.setAttribute(name, sanitize(name, String(value)))
542
+ }
543
+
544
+ function _resolveClass(value) {
545
+ if (typeof value === 'string')
546
+ return value
547
+ if (Array.isArray(value))
548
+ return value.filter(Boolean).join(' ')
549
+ if (value && typeof value === 'object')
550
+ return Object.entries(value).filter(([, v]) => !!v).map(([k]) => k).join(' ')
551
+ return String(value ?? '')
552
+ }
553
+
554
+ function _mountChildren(parent, children, parentEffects) {
555
+ const mounted = []
556
+
557
+ for (const fnode of children) {
558
+ if (!fnode || !fnode.node) continue
559
+
560
+ insertFNode(parent, fnode)
561
+
562
+ if (fnode.effects) parentEffects.push(...fnode.effects)
563
+ mounted.push(fnode)
564
+ }
565
+
566
+ return mounted
567
+ }
568
+
569
+ function _disposeEffects(fnode) {
570
+ if (!fnode.effects) return
571
+ for (let i = fnode.effects.length - 1; i >= 0; i--) {
572
+ const cleanup = fnode.effects[i]
573
+ if (typeof cleanup === 'function') cleanup()
574
+ }
575
+ }
576
+
577
+ function _unmountNodes(fnodes) {
578
+ for (const fnode of fnodes) {
579
+ if (!fnode) continue
580
+
581
+ _disposeEffects(fnode)
582
+
583
+ if (fnode.node?.parentNode) {
584
+ fnode.node.parentNode.removeChild(fnode.node)
585
+ }
586
+
587
+ if (fnode._ifBranchNodes) {
588
+ _unmountNodes(fnode._ifBranchNodes())
589
+ }
590
+
591
+ if (fnode._getNodes) {
592
+ _unmountNodes(fnode._getNodes())
593
+ }
594
+ }
595
+ }
596
+
597
+ function _resolveComponentProps(rawProps) {
598
+ const resolved = {}
599
+
600
+ for (const [key, value] of Object.entries(rawProps)) {
601
+ if (key.startsWith('on:')) continue
602
+
603
+ if (isGetter(value)) {
604
+ Object.defineProperty(resolved, key, {
605
+ get: value,
606
+ enumerable: true,
607
+ configurable: true,
608
+ })
609
+ } else {
610
+ resolved[key] = value
611
+ }
612
+ }
613
+
614
+ return resolved
615
+ }
616
+
617
+ export function mount(renderFn, ctx, components, container) {
618
+ const fnodes = renderFn(ctx, components)
619
+ const effects = []
620
+
621
+ for (const fnode of fnodes) {
622
+ if (!fnode?.node) continue
623
+ insertFNode(container, fnode)
624
+ if (fnode.effects) effects.push(...fnode.effects)
625
+ }
626
+
627
+ return {
628
+ unmount() {
629
+ _unmountNodes(fnodes)
630
+ container.innerHTML = ''
631
+ }
632
+ }
589
633
  }