@invisibleloop/pulse 0.20.60 → 0.21.0

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": "@invisibleloop/pulse",
3
- "version": "0.20.60",
3
+ "version": "0.21.0",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- 0.20.60
1
+ 0.21.0
@@ -120,6 +120,7 @@ export default {
120
120
  - data-event="change:mutationName" to fire on input change
121
121
  - data-action="actionName" on <form> elements only — submits pass FormData to the action
122
122
  - Do NOT use data-event on text inputs to mirror value into state — innerHTML re-renders destroy focus. Capture values from FormData in onStart or run instead.
123
+ - **`data-preserve`** on a `<textarea>`, `<input>` or checkbox — a re-render that renders the same value as before leaves what the user typed or ticked alone (without it, any re-render resets a non-focused control to the rendered value). Use it on long-form editors and any field a re-render elsewhere on the page must not wipe; the control still follows the view when the rendered value changes. Not needed on forms that should clear after submit. Children are matched by position, so the control must keep its place: an element that appears above it (an error banner) rebuilds the field and loses the text — render an empty placeholder in that slot, or wrap the field in a `data-key` container. A focused control is never touched, and a marked field won't reset if the view re-renders the same value on purpose.
123
124
  - **`data-debounce="300"`** alongside `data-event="input:search"` — fires the mutation 300ms after the last keystroke. Use for live search, filtering. No per-spec timer code needed.
124
125
  - **`data-throttle="100"`** alongside `data-event` — fires at most once per 100ms. Use for scroll or resize-like inputs. Both attributes apply to `input` and `change` events. Value is milliseconds.
125
126
  - onSuccess AND onError are both required in every action (missing either will cause a runtime error)
@@ -598,6 +598,9 @@ function morphKeyed(cur, nxtEls) {
598
598
  }
599
599
 
600
600
  function morphAttrs(cur, nxt) {
601
+ // What the previous render said this form control's value/checkedness was — read before the
602
+ // attributes below overwrite it. data-preserve compares against it (see syncFormProperty).
603
+ const prevRendered = renderedFormState(cur)
601
604
  for (const { name, value } of Array.from(nxt.attributes)) {
602
605
  if (cur.getAttribute(name) !== value) {
603
606
  // Trusted Types: script[src] is a TrustedScriptURL sink
@@ -612,7 +615,7 @@ function morphAttrs(cur, nxt) {
612
615
  if (cur.tagName === 'DIALOG' && name === 'open') continue
613
616
  if (!nxt.hasAttribute(name)) cur.removeAttribute(name)
614
617
  }
615
- syncFormProperty(cur, nxt)
618
+ syncFormProperty(cur, nxt, prevRendered)
616
619
  }
617
620
 
618
621
  /**
@@ -631,17 +634,31 @@ function morphAttrs(cur, nxt) {
631
634
  * has its own guidance (use FormData, not a controlled value) but this
632
635
  * covers every other legitimate re-render source.
633
636
  */
634
- function syncFormProperty(cur, nxt) {
637
+ function renderedFormState(el) {
638
+ const tag = el.tagName
639
+ if (tag === 'TEXTAREA') return el.textContent
640
+ if (tag === 'INPUT') return (el.type === 'checkbox' || el.type === 'radio') ? el.hasAttribute('checked') : (el.getAttribute('value') ?? '')
641
+ return null
642
+ }
643
+
644
+ function syncFormProperty(cur, nxt, prevRendered = null) {
635
645
  const tag = cur.tagName
646
+ // data-preserve: the view only changes this control when the value it *renders* changes. A re-render
647
+ // that renders the same value as last time (a button elsewhere on the page, a counter) leaves what
648
+ // the user typed or ticked alone. Without it every re-render resets a non-focused control to the
649
+ // rendered value — fine for forms that clear on submit, destructive for a long-form editor.
650
+ const preserve = nxt.hasAttribute('data-preserve')
636
651
  if (tag === 'INPUT' || tag === 'TEXTAREA') {
637
652
  const type = cur.type
638
653
  if (type === 'checkbox' || type === 'radio') {
639
654
  const next = nxt.hasAttribute('checked')
655
+ if (preserve && next === prevRendered) return
640
656
  if (cur.checked !== next) cur.checked = next
641
657
  } else if (document.activeElement !== cur) {
642
658
  // A textarea has no value attribute: its default value is its child text, which
643
659
  // nxt.value reads (and which the HTML parser has already normalised).
644
660
  const next = tag === 'TEXTAREA' ? nxt.value : (nxt.getAttribute('value') ?? '')
661
+ if (preserve && next === prevRendered) return
645
662
  if (cur.value !== next) cur.value = next
646
663
  }
647
664
  } else if (tag === 'SELECT' && document.activeElement !== cur) {