@srfnstack/fntags 1.1.2 → 1.2.1
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/package.json +1 -1
- package/src/fntags.d.mts +0 -3
- package/src/fntags.d.mts.map +1 -1
- package/src/fntags.mjs +152 -39
package/package.json
CHANGED
package/src/fntags.d.mts
CHANGED
package/src/fntags.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fntags.d.mts","sourceRoot":"","sources":["fntags.mjs"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"fntags.d.mts","sourceRoot":"","sources":["fntags.mjs"],"names":[],"mappings":"AAcA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,2DALW,MAAM,eACH,CAAC,IAAI,MAAO,CAAC,OA6C1B;AAUD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH;;;GAGG;AAEH;;;;;;;;;;GAUG;AACH,iEAPgB,GAAG,cAoIlB;AA8cD;;;;GAIG;AACH,iCAHW,GAAG,GACD,IAAI,CAuBhB;AAqFD;;;;GAIG;AACH,6BAHW,GAAG,GACD,OAAO,CAInB;AAED;;;;GAIG;AACH,mCAHW,GAAG,GACF,MAAM,CAIjB;AAED;;;;;;;;;;GAUG;AACH,kEALW,MAAM,GAAC,MAAM,OACb,MAAM,YACN,MAAM,EAAE,GAAC,IAAI,EAAE,KAezB;AAOD;;;;;;;;;;;;;;;GAeG;AACH,qDAJkB,IAAI,iBACH,IAAI,CA+DtB;;;;;;;;;kCA/1BmC,CAAC,YAAY,CAAC,KAAG,CAAC,IAAI,GAAC,GAAG,CAAC,KAAG,IAAI;;;;;;2BAE/C,CAAC,MAAI,CAAC,IAAI,GAAC,GAAG,CAAC,CAAC,GAAC,GAAG,GAAC,IAAI,gCAAkC,CAAC,IAAI,GAAC,GAAG,CAAC,KAAG,IAAI;;;;;qBAG9E,MAAM,KAAG,IAAI;;;;sCAEI,CAAC,YAAY,CAAC,KAAG,CAAC,MAAM,GAAC,GAAG,CAAC,KAAG,GAAG;;;;mCACvC,CAAC,YAAY,CAAC,KAAG,MAAM,KAAK,MAAM;;;;yCAC7B,GAAG,KAAG,CAAC,IAAI,GAAC,GAAG,CAAC,KAAG,IAAI;;;;+CACrB,GAAG,KAAG,CAAC,MAAM,GAAC,GAAG,CAAC,KAAG,GAAG;;;;;;kBAC7C,GAAG,KAAG,IAAI;;;;cAGhB,MAAK,GAAG;;;;;qBACC,CAAC,KAAG,IAAI;;;;;;oBAEV,MAAM,KAAG,GAAG;;;;oBAGZ,MAAM,SAAS,GAAG,mBAAmB,OAAO,KAAG,IAAI;;;;uCAClC,CAAC,YAAY,CAAC,KAAG,IAAI,KAAK,IAAI;;;;eACtD,OAAO;;;;;sDAKqB,CAAC,KAAG,CAAC"}
|
package/src/fntags.mjs
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module fntags
|
|
3
3
|
*/
|
|
4
|
+
|
|
5
|
+
// Tracks subscriptions created during a bindAs render so they can be cleaned
|
|
6
|
+
// up when the bindAs re-renders. null means we are not inside a render.
|
|
7
|
+
let activeRenderCleanups = null
|
|
8
|
+
|
|
9
|
+
function runCleanups (elCtx) {
|
|
10
|
+
if (!elCtx.cleanups) return
|
|
11
|
+
for (const unsub of elCtx.cleanups) unsub()
|
|
12
|
+
elCtx.cleanups = null
|
|
13
|
+
}
|
|
14
|
+
|
|
4
15
|
/**
|
|
5
16
|
* A function to create dom elements with the given attributes and children.
|
|
6
17
|
*
|
|
@@ -247,9 +258,21 @@ function doSubscribe (callback) {
|
|
|
247
258
|
const ctx = this._ctx
|
|
248
259
|
const id = ctx.nextId++
|
|
249
260
|
ctx.observers.push({ id, fn: callback })
|
|
250
|
-
|
|
251
|
-
|
|
261
|
+
// Idempotent unsub — safe to call multiple times
|
|
262
|
+
let active = true
|
|
263
|
+
const unsub = () => {
|
|
264
|
+
if (!active) return
|
|
265
|
+
active = false
|
|
266
|
+
const idx = ctx.observers.findIndex(l => l.id === id)
|
|
267
|
+
if (idx !== -1) ctx.observers.splice(idx, 1)
|
|
268
|
+
}
|
|
269
|
+
// If we're inside a bindAs render, register this subscription for cleanup
|
|
270
|
+
// on the next re-render. This prevents nested bindAs/bindAttr/bindStyle
|
|
271
|
+
// subscriptions from accumulating when the parent bindAs re-renders.
|
|
272
|
+
if (activeRenderCleanups !== null) {
|
|
273
|
+
activeRenderCleanups.push(unsub)
|
|
252
274
|
}
|
|
275
|
+
return unsub
|
|
253
276
|
}
|
|
254
277
|
|
|
255
278
|
const subscribeSelect = (ctx, callback) => {
|
|
@@ -393,7 +416,7 @@ function doBindChildren (parent, element) {
|
|
|
393
416
|
if (!Array.isArray(ctx.currentValue)) {
|
|
394
417
|
throw new Error('You can only use bindChildren with a state that contains an array. try myState([mystate]) before calling this function.')
|
|
395
418
|
}
|
|
396
|
-
|
|
419
|
+
|
|
397
420
|
ctx.bindContexts.push({ element, parent })
|
|
398
421
|
this.subscribe((_, oldState) => {
|
|
399
422
|
if (!Array.isArray(ctx.currentValue)) {
|
|
@@ -420,13 +443,47 @@ const doBind = function (ctx, element, handleReplace) {
|
|
|
420
443
|
if (typeof element !== 'function') {
|
|
421
444
|
throw new Error('You must pass a function to bind with')
|
|
422
445
|
}
|
|
423
|
-
|
|
446
|
+
|
|
447
|
+
// Track subscriptions created during the initial render so they can be
|
|
448
|
+
// cleaned up if this bindAs itself re-renders (updateReplacer) or when
|
|
449
|
+
// a parent bindAs re-renders and tears us down entirely.
|
|
450
|
+
const prevRenderCleanups = activeRenderCleanups
|
|
451
|
+
const thisRenderCleanups = []
|
|
452
|
+
activeRenderCleanups = thisRenderCleanups
|
|
453
|
+
const elCtx = { current: renderNode(evaluateElement(element, ctx.currentValue)), cleanups: thisRenderCleanups }
|
|
454
|
+
// Suppress tracking during handleReplace — the driving subscription is
|
|
455
|
+
// registered separately below as part of a cascading cleanup.
|
|
456
|
+
activeRenderCleanups = null
|
|
424
457
|
handleReplace(elCtx)
|
|
458
|
+
activeRenderCleanups = prevRenderCleanups
|
|
459
|
+
|
|
460
|
+
// If inside a parent bindAs render, register a cascading cleanup that tears
|
|
461
|
+
// down both the driving subscription and all subscriptions created inside
|
|
462
|
+
// this bindAs's render, so they don't outlive the parent's re-render.
|
|
463
|
+
if (prevRenderCleanups !== null && elCtx.drivingUnsub) {
|
|
464
|
+
prevRenderCleanups.push(() => {
|
|
465
|
+
elCtx.drivingUnsub()
|
|
466
|
+
runCleanups(elCtx)
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
|
|
425
470
|
return () => elCtx.current
|
|
426
471
|
}
|
|
427
472
|
|
|
428
473
|
const updateReplacer = (ctx, element, elCtx) => (_, oldValue) => {
|
|
474
|
+
// Clean up subscriptions from the previous render before re-rendering.
|
|
475
|
+
// This prevents nested bindAs/bindAttr/bindStyle subscriptions from
|
|
476
|
+
// accumulating each time the parent state changes.
|
|
477
|
+
runCleanups(elCtx)
|
|
478
|
+
|
|
479
|
+
// Track subscriptions created during this re-render for cleanup next time.
|
|
480
|
+
const prevRenderCleanups = activeRenderCleanups
|
|
481
|
+
const thisRenderCleanups = []
|
|
482
|
+
activeRenderCleanups = thisRenderCleanups
|
|
429
483
|
let rendered = renderNode(evaluateElement(element, ctx.currentValue, oldValue))
|
|
484
|
+
activeRenderCleanups = prevRenderCleanups
|
|
485
|
+
elCtx.cleanups = thisRenderCleanups
|
|
486
|
+
|
|
430
487
|
if (rendered !== undefined) {
|
|
431
488
|
if (elCtx.current.key !== undefined) {
|
|
432
489
|
rendered.key = elCtx.current.key
|
|
@@ -438,8 +495,13 @@ const updateReplacer = (ctx, element, elCtx) => (_, oldValue) => {
|
|
|
438
495
|
}
|
|
439
496
|
// Perform this action on the next event loop to give the parent a chance to render
|
|
440
497
|
new Promise((resolve) => {
|
|
441
|
-
|
|
442
|
-
|
|
498
|
+
// Guard: skip replaceWith when the same element is returned (e.g. when
|
|
499
|
+
// toggling between two pre-built element references). el.replaceWith(el)
|
|
500
|
+
// is a no-op in the DOM but wastes a microtask.
|
|
501
|
+
if (rendered !== elCtx.current) {
|
|
502
|
+
elCtx.current.replaceWith(rendered)
|
|
503
|
+
elCtx.current = rendered
|
|
504
|
+
}
|
|
443
505
|
rendered = null
|
|
444
506
|
resolve()
|
|
445
507
|
}).catch(e => {
|
|
@@ -461,7 +523,10 @@ function doBindSelect (element) {
|
|
|
461
523
|
function doBindAs (element) {
|
|
462
524
|
const ctx = this._ctx
|
|
463
525
|
const el = element ?? this
|
|
464
|
-
return doBind(ctx, el, (elCtx) =>
|
|
526
|
+
return doBind(ctx, el, (elCtx) => {
|
|
527
|
+
// Store so doBind can build a cascading cleanup when nested inside another bindAs
|
|
528
|
+
elCtx.drivingUnsub = this.subscribe(updateReplacer(ctx, el, elCtx))
|
|
529
|
+
})
|
|
465
530
|
}
|
|
466
531
|
|
|
467
532
|
/**
|
|
@@ -486,64 +551,105 @@ function keyMapper (mapKey, value) {
|
|
|
486
551
|
}
|
|
487
552
|
}
|
|
488
553
|
|
|
554
|
+
function cleanElementSubscriptions (bindContext, key) {
|
|
555
|
+
const cleanups = bindContext.elementCleanups?.[key]
|
|
556
|
+
if (!cleanups) return
|
|
557
|
+
for (const unsub of cleanups) unsub()
|
|
558
|
+
delete bindContext.elementCleanups[key]
|
|
559
|
+
}
|
|
560
|
+
|
|
489
561
|
function arrangeElements (ctx, bindContext, oldState) {
|
|
490
562
|
if (!ctx?.currentValue?.length) {
|
|
563
|
+
// Clean up subscriptions for all existing elements before clearing
|
|
564
|
+
if (bindContext.elementCleanups) {
|
|
565
|
+
for (const key in bindContext.elementCleanups) {
|
|
566
|
+
for (const unsub of bindContext.elementCleanups[key]) unsub()
|
|
567
|
+
}
|
|
568
|
+
bindContext.elementCleanups = {}
|
|
569
|
+
}
|
|
491
570
|
bindContext.parent.textContent = ''
|
|
492
571
|
bindContext.boundElementByKey = {}
|
|
572
|
+
bindContext.elementStates = {}
|
|
493
573
|
ctx.selectObservers = {}
|
|
494
574
|
return
|
|
495
575
|
}
|
|
496
576
|
|
|
577
|
+
// Initialize the element state map if it doesn't exist
|
|
578
|
+
if (!bindContext.elementStates) {
|
|
579
|
+
bindContext.elementStates = {}
|
|
580
|
+
}
|
|
581
|
+
|
|
497
582
|
const keys = {}
|
|
498
583
|
const keysArr = []
|
|
499
|
-
|
|
500
|
-
for (
|
|
501
|
-
|
|
502
|
-
//
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
oldStateMap = oldState && oldState.reduce((acc, v) => {
|
|
506
|
-
const key = keyMapper(ctx.mapKey, v.isFnState ? v() : v)
|
|
507
|
-
acc[key] = v
|
|
508
|
-
return acc
|
|
509
|
-
}, {})
|
|
510
|
-
}
|
|
511
|
-
// check if we have an old state for this key
|
|
512
|
-
const key = keyMapper(ctx.mapKey, valueState)
|
|
513
|
-
if (oldStateMap && oldStateMap[key]) {
|
|
514
|
-
const newValue = valueState
|
|
515
|
-
valueState = ctx.currentValue[i] = oldStateMap[key]
|
|
516
|
-
valueState(newValue)
|
|
517
|
-
} else {
|
|
518
|
-
valueState = ctx.currentValue[i] = fnstate(valueState)
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
const key = keyMapper(ctx.mapKey, valueState())
|
|
584
|
+
|
|
585
|
+
for (let i = 0; i < ctx.currentValue.length; i++) {
|
|
586
|
+
const rawValue = ctx.currentValue[i]
|
|
587
|
+
// Use the raw value to get the key
|
|
588
|
+
const key = keyMapper(ctx.mapKey, rawValue)
|
|
589
|
+
|
|
522
590
|
if (keys[key]) {
|
|
523
591
|
if (oldState) ctx.state(oldState)
|
|
524
592
|
throw new Error('Duplicate keys in a bound array are not allowed, state reset to previous value.')
|
|
525
593
|
}
|
|
594
|
+
|
|
526
595
|
keys[key] = i
|
|
527
596
|
keysArr[i] = key
|
|
597
|
+
|
|
598
|
+
// We look up the persistent fnstate for this key, or create it if new
|
|
599
|
+
let elementState = bindContext.elementStates[key]
|
|
600
|
+
if (!elementState) {
|
|
601
|
+
elementState = fnstate(rawValue)
|
|
602
|
+
// Ensure the parent state is set so child states can listen to selection changes
|
|
603
|
+
elementState.parentCtx = ctx
|
|
604
|
+
bindContext.elementStates[key] = elementState
|
|
605
|
+
// If the user replaces the value of this elementState (e.g. elementState(newObj)),
|
|
606
|
+
// we must update the original array to keep it in sync.
|
|
607
|
+
elementState.subscribe((newItem) => {
|
|
608
|
+
const currentArr = ctx.state()
|
|
609
|
+
// If the parent is no longer an array, abort
|
|
610
|
+
if (!Array.isArray(currentArr)) return
|
|
611
|
+
|
|
612
|
+
// find the element dynamically because sorting/filtering might have shifted the index
|
|
613
|
+
const idx = currentArr.findIndex(item => keyMapper(ctx.mapKey, item) === key)
|
|
614
|
+
|
|
615
|
+
if (idx > -1 && currentArr[idx] !== newItem) {
|
|
616
|
+
currentArr[idx] = newItem
|
|
617
|
+
}
|
|
618
|
+
})
|
|
619
|
+
} else {
|
|
620
|
+
// Update existing state with new raw value.
|
|
621
|
+
// fnstate handles the equality check (newValue !== oldValue) internally.
|
|
622
|
+
elementState(rawValue)
|
|
623
|
+
}
|
|
528
624
|
}
|
|
529
625
|
|
|
530
626
|
let prev = null
|
|
531
627
|
const parent = bindContext.parent
|
|
532
628
|
|
|
629
|
+
// 2. Render Loop
|
|
533
630
|
for (let i = ctx.currentValue.length - 1; i >= 0; i--) {
|
|
534
631
|
const key = keysArr[i]
|
|
535
|
-
|
|
632
|
+
// Retrieve the element state to pass to the builder
|
|
633
|
+
const valueState = bindContext.elementStates[key]
|
|
634
|
+
|
|
536
635
|
let current = bindContext.boundElementByKey[key]
|
|
537
636
|
let isNew = false
|
|
538
|
-
|
|
539
|
-
if (valueState.parentCtx === undefined) {
|
|
540
|
-
valueState.parentCtx = ctx
|
|
541
|
-
}
|
|
637
|
+
|
|
542
638
|
if (current === undefined) {
|
|
543
639
|
isNew = true
|
|
640
|
+
// Track subscriptions created during the builder call so they can be
|
|
641
|
+
// cleaned up when this item is removed from the array.
|
|
642
|
+
if (!bindContext.elementCleanups) bindContext.elementCleanups = {}
|
|
643
|
+
const prevCleanups = activeRenderCleanups
|
|
644
|
+
const thisElementCleanups = []
|
|
645
|
+
activeRenderCleanups = thisElementCleanups
|
|
646
|
+
// Pass the valueState (the fnstate wrapper) to the user's element builder
|
|
544
647
|
current = bindContext.boundElementByKey[key] = renderNode(evaluateElement(bindContext.element, valueState))
|
|
648
|
+
activeRenderCleanups = prevCleanups
|
|
649
|
+
bindContext.elementCleanups[key] = thisElementCleanups
|
|
545
650
|
current.key = key
|
|
546
651
|
}
|
|
652
|
+
|
|
547
653
|
// place the element in the parent
|
|
548
654
|
if (prev == null) {
|
|
549
655
|
if (!parent.lastChild || parent.lastChild.key !== current.key) {
|
|
@@ -560,9 +666,13 @@ function arrangeElements (ctx, bindContext, oldState) {
|
|
|
560
666
|
} else if (prev.previousSibling.key !== current.key) {
|
|
561
667
|
// the previous was deleted all together, so we will delete it and replace the element
|
|
562
668
|
if (keys[prev.previousSibling.key] === undefined) {
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
669
|
+
const keyToDelete = prev.previousSibling.key
|
|
670
|
+
cleanElementSubscriptions(bindContext, keyToDelete)
|
|
671
|
+
delete bindContext.boundElementByKey[keyToDelete]
|
|
672
|
+
delete bindContext.elementStates[keyToDelete] // Cleanup element state
|
|
673
|
+
|
|
674
|
+
if (ctx.selectObservers[keyToDelete] !== undefined && current.insertAdjacentElement !== undefined) {
|
|
675
|
+
delete ctx.selectObservers[keyToDelete]
|
|
566
676
|
}
|
|
567
677
|
prev.previousSibling.replaceWith(current)
|
|
568
678
|
} else if (isNew) {
|
|
@@ -581,11 +691,14 @@ function arrangeElements (ctx, bindContext, oldState) {
|
|
|
581
691
|
prev = current
|
|
582
692
|
}
|
|
583
693
|
|
|
584
|
-
//
|
|
694
|
+
// 3. Catch any strays (Cleanup)
|
|
585
695
|
for (const key in bindContext.boundElementByKey) {
|
|
586
696
|
if (keys[key] === undefined) {
|
|
697
|
+
cleanElementSubscriptions(bindContext, key)
|
|
587
698
|
bindContext.boundElementByKey[key].remove()
|
|
588
699
|
delete bindContext.boundElementByKey[key]
|
|
700
|
+
delete bindContext.elementStates[key] // Cleanup element state
|
|
701
|
+
|
|
589
702
|
if (ctx.selectObservers[key] !== undefined) {
|
|
590
703
|
delete ctx.selectObservers[key]
|
|
591
704
|
}
|