@srfnstack/fntags 1.2.2 → 1.2.4

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.2",
3
+ "version": "1.2.4",
4
4
  "author": "Robert Kempton <r@snow87.com>",
5
5
  "private": false,
6
6
  "homepage": "https://github.com/srfnstack/fntags",
@@ -50,10 +50,10 @@
50
50
  "typescript": "^5.3.3"
51
51
  },
52
52
  "scripts": {
53
- "test": "cp src/*.mjs docs/lib/ && npm run lint && cypress run --spec \"test/**\" --headless -b chrome",
54
- "cypress": "cypress run --headed --spec test/** -b chrome",
55
- "lint": "standard --env browser src && standard --env browser --env jest --global Prism --global cy test",
56
- "lint:fix": "standard --env browser --fix src && standard --env browser --env jest --global Prism --global cy --fix test",
53
+ "test": "cp src/*.mjs docs/lib/ && npm run lint && npm test --prefix packages/fntags-testing && cypress run --spec \"test/docs/**\" --headless -b chrome",
54
+ "cypress": "cypress run --headed --spec test/docs/** -b chrome",
55
+ "lint": "standard --env browser src && standard --env browser --env jest --global Prism --global cy test/docs",
56
+ "lint:fix": "standard --env browser --fix src && standard --env browser --env jest --global Prism --global cy --fix test/docs",
57
57
  "typedef": "rm -rf src/*.mts* && tsc",
58
58
  "docs": "typedoc --plugin typedoc-plugin-markdown --out docs/types --json docs/types.json ./src/*.mjs && node scripts/generateApi.js",
59
59
  "generate-api": "node scripts/generateApi.js",
package/src/fntags.d.mts CHANGED
@@ -92,6 +92,30 @@ export function getAttrs(children: any): object;
92
92
  * @return {T} The styled element
93
93
  */
94
94
  export function styled<T extends HTMLElement | SVGElement>(style: object | string, tag: string, children: object[] | Node[]): T;
95
+ /**
96
+ * Get or create a state instance from the global HMR registry.
97
+ * During development with HMR, the Vite plugin rewrites fnstate() calls to use this function,
98
+ * ensuring state instances survive module reloads.
99
+ *
100
+ * @param {string} id A stable identifier for this state instance (typically 'filepath:varName')
101
+ * @param {any} initialValue The initial value, used only when creating a new state
102
+ * @param {((v: any) => string)?} [mapKey] Optional key function, used only when creating a new state
103
+ * @returns {FnState} The state instance from the registry
104
+ */
105
+ export function registeredState(id: string, initialValue: any, mapKey?: (v: any) => string): any;
106
+ /**
107
+ * Create an HMR-aware root mount point. Call this in your entry file to mount your app.
108
+ * The returned `rerender` function can be called from an HMR accept callback to re-render
109
+ * the app with new code while preserving state (via registeredState).
110
+ *
111
+ * @param {HTMLElement} container The DOM element to mount into
112
+ * @param {(()=>Node)|Node} appFn A function that returns the app's root element, or the element itself
113
+ * @returns {{ container: HTMLElement, rerender: () => void }}
114
+ */
115
+ export function hmrRoot(container: HTMLElement, appFn: (() => Node) | Node): {
116
+ container: HTMLElement;
117
+ rerender: () => void;
118
+ };
95
119
  /**
96
120
  * Create a compiled template function. The returned function takes a single object that contains the properties
97
121
  * defined in the template.
@@ -1 +1 @@
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"}
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,cAqIlB;AAqfD;;;;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;;;;;;;;;GASG;AACH,oCALW,MAAM,gBACN,GAAG,eACE,GAAG,KAAK,MAAM,OAc7B;AAED;;;;;;;;GAQG;AACH,mCAJW,WAAW,SACX,CAAC,MAAI,IAAI,CAAC,GAAC,IAAI,GACb;IAAE,SAAS,EAAE,WAAW,CAAC;IAAC,QAAQ,EAAE,MAAM,IAAI,CAAA;CAAE,CAU5D;AAED;;;;;;;;;;;;;;;GAeG;AACH,qDAJkB,IAAI,iBACH,IAAI,CA+DtB;;;;;;;;;kCAj7BmC,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
@@ -141,7 +141,8 @@ export function fnstate (initialValue, mapKey) {
141
141
  } else {
142
142
  const oldState = ctx.currentValue
143
143
  ctx.currentValue = newState
144
- for (const observer of ctx.observers) {
144
+ const snapshot = ctx.observers.slice()
145
+ for (const observer of snapshot) {
145
146
  observer.fn(newState, oldState)
146
147
  }
147
148
  }
@@ -282,6 +283,24 @@ const subscribeSelect = (ctx, callback) => {
282
283
  parentCtx.selectObservers[key] = []
283
284
  }
284
285
  parentCtx.selectObservers[key].push(callback)
286
+
287
+ // Return an unsub that removes this callback, and register it in
288
+ // activeRenderCleanups so parent bindAs re-renders clean it up.
289
+ let active = true
290
+ const unsub = () => {
291
+ if (!active) return
292
+ active = false
293
+ const arr = parentCtx.selectObservers[key]
294
+ if (arr) {
295
+ const idx = arr.indexOf(callback)
296
+ if (idx !== -1) arr.splice(idx, 1)
297
+ if (arr.length === 0) delete parentCtx.selectObservers[key]
298
+ }
299
+ }
300
+ if (activeRenderCleanups !== null) {
301
+ activeRenderCleanups.push(unsub)
302
+ }
303
+ return unsub
285
304
  }
286
305
 
287
306
  function doBindSelectAttr (attribute) {
@@ -335,10 +354,10 @@ function doSelect (key) {
335
354
  const currentSelected = ctx.selected
336
355
  ctx.selected = key
337
356
  if (ctx.selectObservers[currentSelected] !== undefined) {
338
- for (const obs of ctx.selectObservers[currentSelected]) obs(ctx.selected)
357
+ for (const obs of ctx.selectObservers[currentSelected].slice()) obs(ctx.selected)
339
358
  }
340
359
  if (ctx.selectObservers[ctx.selected] !== undefined) {
341
- for (const obs of ctx.selectObservers[ctx.selected]) obs(ctx.selected)
360
+ for (const obs of ctx.selectObservers[ctx.selected].slice()) obs(ctx.selected)
342
361
  }
343
362
  }
344
363
 
@@ -417,7 +436,27 @@ function doBindChildren (parent, element) {
417
436
  throw new Error('You can only use bindChildren with a state that contains an array. try myState([mystate]) before calling this function.')
418
437
  }
419
438
 
420
- ctx.bindContexts.push({ element, parent })
439
+ const bindContext = { element, parent }
440
+ ctx.bindContexts.push(bindContext)
441
+
442
+ // If inside a parent bindAs render, register a cleanup that tears down all
443
+ // per-item subscriptions when the parent re-renders or is itself torn down.
444
+ // Without this, nested bindAttr/bindStyle/bindAs subscriptions created for
445
+ // each array item would be orphaned when the parent replaces this tree.
446
+ if (activeRenderCleanups !== null) {
447
+ activeRenderCleanups.push(() => {
448
+ if (bindContext.elementCleanups) {
449
+ for (const key in bindContext.elementCleanups) {
450
+ for (const unsub of bindContext.elementCleanups[key]) unsub()
451
+ }
452
+ bindContext.elementCleanups = {}
453
+ }
454
+ // Remove this bindContext so reconcile doesn't operate on stale state
455
+ const idx = ctx.bindContexts.indexOf(bindContext)
456
+ if (idx !== -1) ctx.bindContexts.splice(idx, 1)
457
+ })
458
+ }
459
+
421
460
  this.subscribe((_, oldState) => {
422
461
  if (!Array.isArray(ctx.currentValue)) {
423
462
  console.warn('A state used with bindChildren was updated to a non array value. This will be converted to an array of 1 and the state will be updated.')
@@ -460,9 +499,10 @@ const doBind = function (ctx, element, handleReplace) {
460
499
  // If inside a parent bindAs render, register a cascading cleanup that tears
461
500
  // down both the driving subscription and all subscriptions created inside
462
501
  // this bindAs's render, so they don't outlive the parent's re-render.
463
- if (prevRenderCleanups !== null && elCtx.drivingUnsub) {
502
+ if (prevRenderCleanups !== null && (elCtx.drivingUnsub || elCtx.selectUnsub)) {
464
503
  prevRenderCleanups.push(() => {
465
- elCtx.drivingUnsub()
504
+ if (elCtx.drivingUnsub) elCtx.drivingUnsub()
505
+ if (elCtx.selectUnsub) elCtx.selectUnsub()
466
506
  runCleanups(elCtx)
467
507
  })
468
508
  }
@@ -517,7 +557,9 @@ const updateReplacer = (ctx, element, elCtx) => (_, oldValue) => {
517
557
  function doBindSelect (element) {
518
558
  element = element ?? this
519
559
  const ctx = this._ctx
520
- return doBind(ctx, element, (elCtx) => subscribeSelect(ctx, updateReplacer(ctx, element, elCtx)))
560
+ return doBind(ctx, element, (elCtx) => {
561
+ elCtx.selectUnsub = subscribeSelect(ctx, updateReplacer(ctx, element, elCtx))
562
+ })
521
563
  }
522
564
 
523
565
  function doBindAs (element) {
@@ -671,9 +713,7 @@ function arrangeElements (ctx, bindContext, oldState) {
671
713
  delete bindContext.boundElementByKey[keyToDelete]
672
714
  delete bindContext.elementStates[keyToDelete] // Cleanup element state
673
715
 
674
- if (ctx.selectObservers[keyToDelete] !== undefined && current.insertAdjacentElement !== undefined) {
675
- delete ctx.selectObservers[keyToDelete]
676
- }
716
+ delete ctx.selectObservers[keyToDelete]
677
717
  prev.previousSibling.replaceWith(current)
678
718
  } else if (isNew) {
679
719
  // insertAdjacentElement is faster, but some nodes don't have it (lookin' at you text)
@@ -873,6 +913,48 @@ const stringifyStyle = style =>
873
913
  ? style
874
914
  : Object.keys(style).map(prop => `${prop}:${style[prop]}`).join(';')
875
915
 
916
+ /**
917
+ * Get or create a state instance from the global HMR registry.
918
+ * During development with HMR, the Vite plugin rewrites fnstate() calls to use this function,
919
+ * ensuring state instances survive module reloads.
920
+ *
921
+ * @param {string} id A stable identifier for this state instance (typically 'filepath:varName')
922
+ * @param {any} initialValue The initial value, used only when creating a new state
923
+ * @param {((v: any) => string)?} [mapKey] Optional key function, used only when creating a new state
924
+ * @returns {FnState} The state instance from the registry
925
+ */
926
+ export function registeredState (id, initialValue, mapKey) {
927
+ if (!globalThis.__fntags_registry) {
928
+ globalThis.__fntags_registry = new Map()
929
+ }
930
+ const registry = globalThis.__fntags_registry
931
+ if (registry.has(id)) {
932
+ return registry.get(id)
933
+ }
934
+ const state = fnstate(initialValue, mapKey)
935
+ registry.set(id, state)
936
+ return state
937
+ }
938
+
939
+ /**
940
+ * Create an HMR-aware root mount point. Call this in your entry file to mount your app.
941
+ * The returned `rerender` function can be called from an HMR accept callback to re-render
942
+ * the app with new code while preserving state (via registeredState).
943
+ *
944
+ * @param {HTMLElement} container The DOM element to mount into
945
+ * @param {(()=>Node)|Node} appFn A function that returns the app's root element, or the element itself
946
+ * @returns {{ container: HTMLElement, rerender: () => void }}
947
+ */
948
+ export function hmrRoot (container, appFn) {
949
+ const render = () => {
950
+ container.innerHTML = ''
951
+ const result = typeof appFn === 'function' ? appFn() : appFn
952
+ container.appendChild(renderNode(result))
953
+ }
954
+ render()
955
+ return { container, rerender: render }
956
+ }
957
+
876
958
  /**
877
959
  * Create a compiled template function. The returned function takes a single object that contains the properties
878
960
  * defined in the template.