@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@srfnstack/fntags",
3
- "version": "1.1.2",
3
+ "version": "1.2.1",
4
4
  "author": "Robert Kempton <r@snow87.com>",
5
5
  "private": false,
6
6
  "homepage": "https://github.com/srfnstack/fntags",
package/src/fntags.d.mts CHANGED
@@ -1,6 +1,3 @@
1
- /**
2
- * @module fntags
3
- */
4
1
  /**
5
2
  * A function to create dom elements with the given attributes and children.
6
3
  *
@@ -1 +1 @@
1
- {"version":3,"file":"fntags.d.mts","sourceRoot":"","sources":["fntags.mjs"],"names":[],"mappings":"AAAA;;GAEG;AACH;;;;;;;;;;;;;;;;;;;GAmBG;AACH,2DALW,MAAM,eACH,CAAC,IAAI,MAAO,CAAC,OA6C1B;AAUD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH;;;GAGG;AAEH;;;;;;;;;;GAUG;AACH,iEAPgB,GAAG,cAoIlB;AAwWD;;;;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;;;;;;;;;kCAzvBmC,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"}
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
- return () => {
251
- ctx.observers.splice(ctx.observers.findIndex(l => l.id === id), 1)
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
- ctx.currentValue = ctx.currentValue.map(v => v.isFnState ? v : fnstate(v))
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
- const elCtx = { current: renderNode(evaluateElement(element, ctx.currentValue)) }
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
- elCtx.current.replaceWith(rendered)
442
- elCtx.current = rendered
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) => this.subscribe(updateReplacer(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
- let oldStateMap = null
500
- for (const i in ctx.currentValue) {
501
- let valueState = ctx.currentValue[i]
502
- // if the value is not a fnstate, we need to wrap it
503
- if (valueState === null || valueState === undefined || !valueState.isFnState) {
504
- if (oldStateMap === null) {
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
- const valueState = ctx.currentValue[i]
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
- // ensure the parent state is always set and can be accessed by the child states to listen to the selection change and such
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
- delete bindContext.boundElementByKey[prev.previousSibling.key]
564
- if (ctx.selectObservers[prev.previousSibling.key] !== undefined && current.insertAdjacentElement !== undefined) {
565
- delete ctx.selectObservers[prev.previousSibling.key]
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
- // catch any strays
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
  }