@srfnstack/fntags 1.2.0 → 1.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@srfnstack/fntags",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
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;AAgYD;;;;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;;;;;;;;;kCAjxBmC,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,14 +258,26 @@ 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)
252
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)
274
+ }
275
+ return unsub
253
276
  }
254
277
 
255
278
  const subscribeSelect = (ctx, callback) => {
256
279
  const parentCtx = ctx.state.parentCtx
257
- const key = keyMapper(parentCtx.mapKey, ctx.currentValue)
280
+ const key = keyMapper(parentCtx.mapKey, ctx.currentValue, 0)
258
281
  if (parentCtx.selectObservers[key] === undefined) {
259
282
  parentCtx.selectObservers[key] = []
260
283
  }
@@ -387,7 +410,7 @@ function doBindChildren (parent, element) {
387
410
  }
388
411
  if (typeof ctx.mapKey !== 'function') {
389
412
  console.warn('Using value index as key, may not work correctly when moving items...')
390
- ctx.mapKey = (o, i) => i
413
+ ctx.mapKey = (_, i) => i
391
414
  }
392
415
 
393
416
  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
  /**
@@ -476,18 +541,32 @@ function reconcile (ctx, oldState) {
476
541
  }
477
542
  }
478
543
 
479
- function keyMapper (mapKey, value) {
544
+ function keyMapper (mapKey, value, i) {
480
545
  if (typeof value !== 'object') {
481
546
  return value
482
547
  } else if (typeof mapKey !== 'function') {
483
548
  return 0
484
549
  } else {
485
- return mapKey(value)
550
+ return mapKey(value, i)
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 = {}
493
572
  bindContext.elementStates = {}
@@ -506,7 +585,7 @@ function arrangeElements (ctx, bindContext, oldState) {
506
585
  for (let i = 0; i < ctx.currentValue.length; i++) {
507
586
  const rawValue = ctx.currentValue[i]
508
587
  // Use the raw value to get the key
509
- const key = keyMapper(ctx.mapKey, rawValue)
588
+ const key = keyMapper(ctx.mapKey, rawValue, i)
510
589
 
511
590
  if (keys[key]) {
512
591
  if (oldState) ctx.state(oldState)
@@ -531,7 +610,7 @@ function arrangeElements (ctx, bindContext, oldState) {
531
610
  if (!Array.isArray(currentArr)) return
532
611
 
533
612
  // find the element dynamically because sorting/filtering might have shifted the index
534
- const idx = currentArr.findIndex(item => keyMapper(ctx.mapKey, item) === key)
613
+ const idx = currentArr.findIndex((item, i) => keyMapper(ctx.mapKey, item, i) === key)
535
614
 
536
615
  if (idx > -1 && currentArr[idx] !== newItem) {
537
616
  currentArr[idx] = newItem
@@ -558,8 +637,16 @@ function arrangeElements (ctx, bindContext, oldState) {
558
637
 
559
638
  if (current === undefined) {
560
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
561
646
  // Pass the valueState (the fnstate wrapper) to the user's element builder
562
647
  current = bindContext.boundElementByKey[key] = renderNode(evaluateElement(bindContext.element, valueState))
648
+ activeRenderCleanups = prevCleanups
649
+ bindContext.elementCleanups[key] = thisElementCleanups
563
650
  current.key = key
564
651
  }
565
652
 
@@ -580,6 +667,7 @@ function arrangeElements (ctx, bindContext, oldState) {
580
667
  // the previous was deleted all together, so we will delete it and replace the element
581
668
  if (keys[prev.previousSibling.key] === undefined) {
582
669
  const keyToDelete = prev.previousSibling.key
670
+ cleanElementSubscriptions(bindContext, keyToDelete)
583
671
  delete bindContext.boundElementByKey[keyToDelete]
584
672
  delete bindContext.elementStates[keyToDelete] // Cleanup element state
585
673
 
@@ -606,6 +694,7 @@ function arrangeElements (ctx, bindContext, oldState) {
606
694
  // 3. Catch any strays (Cleanup)
607
695
  for (const key in bindContext.boundElementByKey) {
608
696
  if (keys[key] === undefined) {
697
+ cleanElementSubscriptions(bindContext, key)
609
698
  bindContext.boundElementByKey[key].remove()
610
699
  delete bindContext.boundElementByKey[key]
611
700
  delete bindContext.elementStates[key] // Cleanup element state