@ape-egg/vibe 1.8.0 → 1.9.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.
@@ -1,5 +1,11 @@
1
1
  import { debugLog } from './debug.js';
2
- import { PHASE_FETCH, DEHYDRATE_CLASS_OR_ATTR } from './constants.js';
2
+ import {
3
+ PHASE_FETCH,
4
+ DEHYDRATE_CLASS_OR_ATTR,
5
+ BINDING_REGEX,
6
+ THIS_PROP_REGEX,
7
+ STATE_THIS_PROP_REGEX,
8
+ } from './constants.js';
3
9
  import { evalInScope } from './utils.js';
4
10
 
5
11
  // Deterministic component counter
@@ -16,6 +22,42 @@ export const generateComponentId = () => {
16
22
  // Helper to escape regex special characters
17
23
  const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
18
24
 
25
+ // Kept as no-ops for API compatibility — path-based tracking was replaced by
26
+ // DOM-scan cleanup in releaseOrphanedComponentState. Manifest paths don't
27
+ // align 1:1 with component ownership across conditional remounts, so scanning
28
+ // the DOM subtree about to be removed is simpler and waterproof.
29
+ export const trackComponentOwnership = () => {};
30
+
31
+ // Given an iterable of DOM nodes that are about to be (or have just been)
32
+ // removed, find every `data-vibe-component-id` inside them and, for each id
33
+ // whose DOM is fully gone from the live document, evict its state.
34
+ //
35
+ // Call this AFTER the nodes have been detached from the document so the
36
+ // `document.querySelector` check sees the post-removal state.
37
+ export const releaseOrphanedComponentState = (collectedIds) => {
38
+ if (!collectedIds || collectedIds.size === 0) return;
39
+ for (const id of collectedIds) {
40
+ if (document.querySelector(`[data-vibe-component-id="${id}"]`)) continue;
41
+ delete window.__vibeComponents?.[id];
42
+ // CLEANUP OF CURRENT STATE
43
+ delete window.$[id];
44
+ }
45
+ };
46
+
47
+ // Walk a node subtree (element or node list) and collect all
48
+ // `data-vibe-component-id` values found on the node and its descendants.
49
+ export const collectComponentIds = (node, into = new Set()) => {
50
+ if (!node) return into;
51
+ if (node.nodeType === 1) {
52
+ const id = node.getAttribute('data-vibe-component-id');
53
+ if (id) into.add(id);
54
+ node.querySelectorAll?.('[data-vibe-component-id]').forEach((el) => {
55
+ into.add(el.getAttribute('data-vibe-component-id'));
56
+ });
57
+ }
58
+ return into;
59
+ };
60
+
19
61
  // Track pending fetches to cancel them if element is removed
20
62
  const pendingFetches = new WeakMap(); // element → AbortController
21
63
 
@@ -43,6 +85,162 @@ const isNestedInUnprocessedComponent = (el, rootElement) => {
43
85
  return false;
44
86
  };
45
87
 
88
+ // Rewrite `@[this.x...]` and `$.this.x...` inside element's text/attrs to use
89
+ // componentId. Shared between script-execution path and pure-render path.
90
+ //
91
+ // Within `@[...]` bindings we rewrite every `this.X` reference (preserving
92
+ // any trailing `.Y.Z` chain), so `@[this.user.name]` and `@[this.x + 1]` both
93
+ // resolve correctly. Outside bindings — i.e. event handler attribute bodies
94
+ // like `onclick="$.this.mode = 'edit'"` — only `$.this.X` writes are
95
+ // rewritten; bare `this.X` reads in event handlers are handled later by
96
+ // parse.js (which preserves DOM properties like `this.value`).
97
+ const rewriteBindingsInString = (str, componentId) =>
98
+ str.replace(BINDING_REGEX, (match, expr) => {
99
+ const rewritten = expr.replace(THIS_PROP_REGEX, `${componentId}.$1`);
100
+ return rewritten === expr ? match : `@[${rewritten}]`;
101
+ });
102
+
103
+ const rewriteThisBindings = (element, componentId) => {
104
+ Array.from(element.childNodes).forEach((node) => {
105
+ if (node.nodeType === Node.TEXT_NODE && node.textContent.includes('@[this.')) {
106
+ node.textContent = rewriteBindingsInString(node.textContent, componentId);
107
+ }
108
+ });
109
+
110
+ Array.from(element.attributes || []).forEach((attr) => {
111
+ if (attr.value.includes('@[this.')) {
112
+ attr.value = rewriteBindingsInString(attr.value, componentId);
113
+ }
114
+ if (attr.value.includes('$.this.')) {
115
+ attr.value = attr.value.replace(STATE_THIS_PROP_REGEX, `$.${componentId}.$1`);
116
+ }
117
+ });
118
+
119
+ Array.from(element.children).forEach((child) => {
120
+ rewriteThisBindings(child, componentId);
121
+ });
122
+ };
123
+
124
+ // Tag siblings of a <script> with componentId and rewrite `this.` bindings.
125
+ // Runs BEFORE script.remove() so nextElementSibling is valid. Shared by
126
+ // processSingle (executing path) and renderComponentTemplate (pure path).
127
+ const tagScriptSiblings = (script, componentId) => {
128
+ let sibling = script.nextElementSibling;
129
+ while (sibling) {
130
+ if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
131
+ break;
132
+ }
133
+ sibling.setAttribute('data-vibe-component-id', componentId);
134
+ rewriteThisBindings(sibling, componentId);
135
+ sibling = sibling.nextElementSibling;
136
+ }
137
+ };
138
+
139
+ // Substitute props + inline slot content into a pre-processed temp container.
140
+ // Returns the final processed HTML string. Shared by processSingle.finalize()
141
+ // (runtime mount) and renderComponentTemplate (surgical HMR).
142
+ const renderPropsAndSlot = (temp, props, slotHtml) => {
143
+ let transformedHtml = temp.innerHTML;
144
+
145
+ const DIRECTIVE_COMMENT_REGEX = /<!--\s*(if|else if|each)\s+([^]*?)\s*-->/g;
146
+ const EVENT_ATTR_REGEX = /\bon(\w+)="([^"]*)"/g;
147
+
148
+ Object.entries(props).forEach(([propName, propValue]) => {
149
+ const bindingMatch = propValue.match(/^@\[(.+)\]$/);
150
+ const exactPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
151
+ const idRegex = new RegExp(
152
+ `(?<![a-zA-Z0-9_$\\.])${escapeRegex(propName)}(?![a-zA-Z0-9_$])`,
153
+ 'g'
154
+ );
155
+
156
+ const substituteInExpr = (expr, replacement) => expr.replace(idRegex, replacement);
157
+
158
+ if (bindingMatch) {
159
+ const path = bindingMatch[1];
160
+ transformedHtml = transformedHtml.replace(exactPattern, `@[${path}]`);
161
+ transformedHtml = transformedHtml.replace(BINDING_REGEX, (match, expr) => {
162
+ const rewritten = substituteInExpr(expr, `(${path})`);
163
+ return rewritten === expr ? match : `@[${rewritten}]`;
164
+ });
165
+ transformedHtml = transformedHtml.replace(
166
+ DIRECTIVE_COMMENT_REGEX,
167
+ (match, kw, expr) => {
168
+ const replacement = kw === 'each' ? path : `(${path})`;
169
+ const rewritten = substituteInExpr(expr, replacement);
170
+ return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
171
+ }
172
+ );
173
+ const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'g');
174
+ transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
175
+ const rewritten = body.replace(stateRegex, `$.${path}`);
176
+ return rewritten === body ? match : `on${evName}="${rewritten}"`;
177
+ });
178
+ } else {
179
+ transformedHtml = transformedHtml.replace(exactPattern, propValue);
180
+ const isNumeric =
181
+ typeof propValue === 'string' && /^-?\d+(\.\d+)?(e[+-]?\d+)?$/i.test(propValue);
182
+ const isBooleanAttr = propValue === '';
183
+ const literal = isBooleanAttr
184
+ ? 'true'
185
+ : typeof propValue === 'string' && !isNumeric
186
+ ? JSON.stringify(propValue)
187
+ : String(propValue);
188
+ transformedHtml = transformedHtml.replace(BINDING_REGEX, (match, expr) => {
189
+ const rewritten = substituteInExpr(expr, literal);
190
+ return rewritten === expr ? match : `@[${rewritten}]`;
191
+ });
192
+ transformedHtml = transformedHtml.replace(
193
+ DIRECTIVE_COMMENT_REGEX,
194
+ (match, kw, expr) => {
195
+ const rewritten = substituteInExpr(expr, literal);
196
+ return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
197
+ }
198
+ );
199
+ const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'g');
200
+ transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
201
+ const rewritten = body.replace(stateRegex, `$.${literal}`);
202
+ return rewritten === body ? match : `on${evName}="${rewritten}"`;
203
+ });
204
+ }
205
+ });
206
+
207
+ const children = (slotHtml || '').trim();
208
+ if (children) {
209
+ transformedHtml = transformedHtml.replace(
210
+ /<slot(\s[^>]*)?>\s*<\/slot>/g,
211
+ (_, attrs) => `<slot${attrs || ''}>${children}</slot>`
212
+ );
213
+ transformedHtml = transformedHtml.replace(
214
+ /<slot(\s[^>]*)?\/>/g,
215
+ (_, attrs) => `<slot${attrs || ''}>${children}</slot>`
216
+ );
217
+ }
218
+
219
+ return transformedHtml;
220
+ };
221
+
222
+ // Pure-render path for surgical HMR. Takes a component's raw template HTML
223
+ // plus the live instance's props, slot, and existing componentIds. Returns
224
+ // the processed HTML string that $.reconcile can diff against the live
225
+ // wrapper's children. Scripts are NOT executed — callers are responsible
226
+ // for deciding whether to preserve the existing state (reuse componentIds)
227
+ // or trigger a full re-mount (new componentIds).
228
+ export const renderComponentTemplate = (rawHtml, props = {}, slotHtml = '', options = {}) => {
229
+ const { componentIds = [] } = options;
230
+ const temp = document.createElement('div');
231
+ temp.innerHTML = rawHtml;
232
+
233
+ const idsToReuse = [...componentIds];
234
+ const moduleScripts = temp.querySelectorAll('script[type="module"]');
235
+ for (const script of moduleScripts) {
236
+ const componentId = idsToReuse.shift() || generateComponentId();
237
+ tagScriptSiblings(script, componentId);
238
+ script.remove();
239
+ }
240
+
241
+ return renderPropsAndSlot(temp, props, slotHtml);
242
+ };
243
+
46
244
  // Process a single component element: fetch HTML, execute scripts, replace DOM
47
245
  const processSingle = (el, debug) => {
48
246
  // Skip dehydrated components
@@ -89,7 +287,10 @@ const processSingle = (el, debug) => {
89
287
 
90
288
  // Strip `import component from '...'` — Vibe injects the contextual
91
289
  // component() function as a parameter (it needs access to the temp DOM)
92
- scriptContent = scriptContent.replace(/import\s+component\s+from\s+['"][^'"]+['"];?\s*/g, '');
290
+ scriptContent = scriptContent.replace(
291
+ /import\s+component\s+from\s+['"][^'"]+['"];?\s*/g,
292
+ ''
293
+ );
93
294
 
94
295
  // Check for remaining imports that need rewriting
95
296
  const hasImports = /import\s/.test(scriptContent);
@@ -115,40 +316,24 @@ const processSingle = (el, debug) => {
115
316
  );
116
317
  }
117
318
 
118
- // Generate component ID for this instance
119
- const componentId = generateComponentId();
319
+ // Reuse component ID from HMR if available, otherwise generate new
320
+ const reuseIds = el._vibeReuseComponentIds;
321
+ const componentId = reuseIds && reuseIds.length ? reuseIds.shift() : generateComponentId();
120
322
 
121
- // Provide a component() function that registers state for this component
323
+ // Provide a component() function that registers state for this component.
324
+ // Sibling tagging is handled below (before script.remove()) so it works
325
+ // for both sync and async scripts.
122
326
  const componentFn = (state) => {
123
- // Register component state in both places:
124
- // 1. __vibeComponents registry (for pre-boot components)
125
- if (!window.__vibeComponents) {
126
- window.__vibeComponents = {};
127
- }
327
+ if (!window.__vibeComponents) window.__vibeComponents = {};
128
328
  window.__vibeComponents[componentId] = state;
129
-
130
- // 2. Directly in window.$ (the reactive proxy) for post-boot components
131
- if (window.$) {
132
- window.$[componentId] = state;
133
- }
134
-
135
- // Tag all siblings (everything after the script in this component)
136
- let sibling = script.nextElementSibling;
137
- while (sibling) {
138
- // Stop if we hit another module script
139
- if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
140
- break;
141
- }
142
- sibling.setAttribute('data-vibe-component-id', componentId);
143
- sibling = sibling.nextElementSibling;
144
- }
329
+ if (window.$) window.$[componentId] = state;
145
330
  };
146
331
 
147
332
  // Execute script with component() function in scope
148
333
  try {
149
334
  if (hasImports) {
150
335
  // Async execution for scripts with imports
151
- const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
336
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
152
337
  asyncTasks.push(new AsyncFunction('component', scriptContent)(componentFn));
153
338
  } else {
154
339
  // Synchronous execution for scripts without imports (preserves boot timing)
@@ -159,39 +344,9 @@ const processSingle = (el, debug) => {
159
344
  console.warn('[vibe] Failed to execute component script:', e);
160
345
  }
161
346
 
162
- // Rewrite this.property to componentId.property in siblings
163
- // This allows the runtime to resolve component-scoped bindings
164
- const rewriteThisBindings = (element) => {
165
- const thisRegex = /@\[this\.(\w+)\]/g;
166
-
167
- // Rewrite in text nodes
168
- Array.from(element.childNodes).forEach((node) => {
169
- if (node.nodeType === Node.TEXT_NODE && node.textContent.includes('@[this.')) {
170
- node.textContent = node.textContent.replace(thisRegex, `@[${componentId}.$1]`);
171
- }
172
- });
173
-
174
- // Rewrite in attributes
175
- Array.from(element.attributes || []).forEach((attr) => {
176
- if (attr.value.includes('@[this.')) {
177
- attr.value = attr.value.replace(thisRegex, `@[${componentId}.$1]`);
178
- }
179
- });
180
-
181
- // Recurse into children
182
- Array.from(element.children).forEach((child) => {
183
- rewriteThisBindings(child);
184
- });
185
- };
186
-
187
- let sibling = script.nextElementSibling;
188
- while (sibling) {
189
- if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
190
- break;
191
- }
192
- rewriteThisBindings(sibling);
193
- sibling = sibling.nextElementSibling;
194
- }
347
+ // Tag siblings + rewrite this. bindings using shared helper. Runs
348
+ // BEFORE script.remove() so nextElementSibling is valid.
349
+ tagScriptSiblings(script, componentId);
195
350
 
196
351
  // Remove script from temp (we executed it manually)
197
352
  script.remove();
@@ -199,30 +354,8 @@ const processSingle = (el, debug) => {
199
354
 
200
355
  // Finalize: props, slots, DOM replacement
201
356
  const finalize = () => {
202
- // Get transformed HTML from temp container (scripts removed)
203
- let transformedHtml = temp.innerHTML;
204
-
205
- // Replace props
206
- Object.entries(props).forEach(([propName, propValue]) => {
207
- const bindingMatch = propValue.match(/^@\[(.+)\]$/);
208
-
209
- if (bindingMatch) {
210
- // Reactive prop: replace @[propName] with @[path]
211
- const path = bindingMatch[1];
212
- const propPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
213
- transformedHtml = transformedHtml.replace(propPattern, `@[${path}]`);
214
- } else {
215
- // Static prop: replace @[propName] with literal value
216
- const propPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
217
- transformedHtml = transformedHtml.replace(propPattern, propValue);
218
- }
219
- });
220
-
221
- // Replace <slot></slot> with children
222
- if (children) {
223
- transformedHtml = transformedHtml.replace(/<slot><\/slot>/g, children);
224
- transformedHtml = transformedHtml.replace(/<slot\s*\/>/g, children);
225
- }
357
+ // Delegate prop substitution + slot inlining to shared helper.
358
+ const transformedHtml = renderPropsAndSlot(temp, props, children);
226
359
 
227
360
  // Clean up pending fetch tracker
228
361
  pendingFetches.delete(el);
@@ -241,6 +374,24 @@ const processSingle = (el, debug) => {
241
374
  }
242
375
 
243
376
  newWrapper.innerHTML = transformedHtml;
377
+ // Stash the raw source so the HMR plugin can establish a baseline
378
+ // script hash on the very first update — without this, the first
379
+ // save after page load would always fall back to re-mount (since
380
+ // the plugin would have nothing to compare against). Vibe itself
381
+ // never reads this; it's purely for the plugin spy.
382
+ newWrapper._vibeRawSource = html;
383
+ // Transfer iteration-prop registry ownership from the soon-to-be-
384
+ // detached `<component src>` to the new wrapper. The detached element
385
+ // would otherwise trigger releaseOrphanedIterationProps and free the
386
+ // registry slots that the inlined template's bindings still reference,
387
+ // causing every `@[window.__vibeIterProps._pN]` to resolve to undefined
388
+ // on the next hydrate.
389
+ if (el._vibeIterPropIds) {
390
+ newWrapper._vibeIterPropIds = el._vibeIterPropIds;
391
+ newWrapper.setAttribute('data-vibe-iter-prop', '');
392
+ el._vibeIterPropIds = null;
393
+ el.removeAttribute('data-vibe-iter-prop');
394
+ }
244
395
  el.replaceWith(newWrapper);
245
396
  debugLog(PHASE_FETCH, src, debug);
246
397
 
@@ -286,7 +437,7 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
286
437
  // unprocessed component[src] elements (they're slot content that will
287
438
  // be revealed when the parent component finalizes).
288
439
  const topLevel = Array.from(allComponents).filter(
289
- el => !isNestedInUnprocessedComponent(el, rootElement)
440
+ (el) => !isNestedInUnprocessedComponent(el, rootElement)
290
441
  );
291
442
 
292
443
  if (topLevel.length === 0) {
@@ -299,5 +450,5 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
299
450
  // processed by the MutationObserver → processComponent chain.
300
451
  // onComplete is handled by checkCleanup (which fires when no
301
452
  // component[src] elements remain).
302
- topLevel.forEach(el => processSingle(el, debug));
453
+ topLevel.forEach((el) => processSingle(el, debug));
303
454
  };
@@ -3,6 +3,7 @@ import affected from './affected.js';
3
3
  import hydrate from './hydrate.js';
4
4
  import { createScopedState, renderAllIterations, initializeBlock } from './iterate.js';
5
5
  import { evalInScope } from './utils.js';
6
+ import { collectComponentIds, releaseOrphanedComponentState } from './component.js';
6
7
 
7
8
  // Registry of DOM nodes owned by conditional branches.
8
9
  // Maps a DOM node to { nodes: array_ref, index: number } so that
@@ -30,7 +31,9 @@ const findConditionalPath = (node, manifest) => {
30
31
 
31
32
  // Register branch tree nodes in the manifest (recursive)
32
33
  const addBranchToManifest = (tree, manifest, basePath) => {
33
- if (tree.element) manifest[basePath] = tree.element;
34
+ if (tree.element) {
35
+ manifest[basePath] = tree.element;
36
+ }
34
37
  if (tree.children) {
35
38
  for (const key in tree.children) {
36
39
  addBranchToManifest(tree.children[key], manifest, `${basePath}.${key}`);
@@ -218,6 +221,11 @@ const unmountBranch = (node, manifest) => {
218
221
  }
219
222
  }
220
223
 
224
+ // Collect componentIds from the subtree BEFORE detaching so we can check
225
+ // after removal whether any live DOM still holds them.
226
+ const ids = new Set();
227
+ activeInstance.nodes.forEach((domNode) => collectComponentIds(domNode, ids));
228
+
221
229
  // Remove all nodes from DOM and deregister from branch registry
222
230
  activeInstance.nodes.forEach((domNode) => {
223
231
  branchNodeRegistry.delete(domNode);
@@ -226,6 +234,9 @@ const unmountBranch = (node, manifest) => {
226
234
  }
227
235
  });
228
236
 
237
+ // CLEANUP OF CURRENT STATE
238
+ releaseOrphanedComponentState(ids);
239
+
229
240
  // Clear active instance
230
241
  node.runtime.activeInstance = null;
231
242
  };
@@ -210,9 +210,11 @@ export const BINDING_REGEX = new RegExp(String.raw`\@\[(${BINDING_INNER})\]`, 'g
210
210
  // Regex for detecting a pure binding (entire value is just @[expression])
211
211
  export const PURE_BINDING_REGEX = new RegExp(String.raw`^\@\[(${BINDING_INNER})\]$`);
212
212
 
213
- // Regex for parsing iteration comment syntax (<!-- each items as item, index -->)
214
- // Supports nested paths like category.items and bracket notation like teams[0].combatants
215
- export const ITERATION_REGEX = /^each\s+([\w.\[\]]+)\s+as\s+(\w+)(?:\s*,\s*(\w+))?\s*$/;
213
+ // Regex for parsing iteration comment syntax (<!-- each expression as item, index -->)
214
+ // The array expression can be any JS: a state path (items), a window global
215
+ // (window.fights), a method call (items.filter(x => x.active)), or an inline
216
+ // array literal (['a', 'b']). Parsed via evalInScope at render time.
217
+ export const ITERATION_REGEX = /^each\s+(.+)\s+as\s+(\w+)(?:\s*,\s*(\w+))?\s*$/;
216
218
 
217
219
  // Regex for detecting start of iteration comment
218
220
  export const ITERATION_START_REGEX = /^each\s+/;
@@ -222,3 +224,15 @@ export const CONDITIONAL_REGEX = /^if\s+(.+)$/;
222
224
 
223
225
  // Regex for detecting start of conditional comment
224
226
  export const CONDITIONAL_START_REGEX = /^if\s+/;
227
+
228
+ // Regex for rewriting component-local `this.X` references to the component's
229
+ // state path. Captures the leading identifier only — any trailing `.Y.Z`
230
+ // chain stays attached after replacement, so `this.user.name` becomes
231
+ // `<componentId>.user.name`. Used in expression bodies (bindings, event
232
+ // handlers, conditional/iteration directives).
233
+ export const THIS_PROP_REGEX = /\bthis\.(\w+)/g;
234
+
235
+ // Regex for rewriting `$.this.X` writes (proxy assignment from event handlers)
236
+ // to the component's write path. Same prefix-only semantics as THIS_PROP_REGEX
237
+ // — `$.this.user.name = x` becomes `$.<componentId>.user.name = x`.
238
+ export const STATE_THIS_PROP_REGEX = /\$\.this\.(\w+)/g;
@@ -87,17 +87,24 @@ export default (affected, state, manifest = {}, oldState = {}) => {
87
87
  // Attribute: Truthful DOM representation, enables compiler snapshots via outerHTML
88
88
  const expr = isPureBinding[1];
89
89
  const value = evalInScope(expr, effectiveState, element);
90
- element[attrName] = value;
90
+ if (element[attrName] !== value) element[attrName] = value;
91
91
  if (value !== undefined && value !== null) {
92
- element.setAttribute(attrName, String(value));
92
+ const str = String(value);
93
+ if (element.getAttribute(attrName) !== str) element.setAttribute(attrName, str);
93
94
  }
94
95
  } else if (!isValueAttr && isPureBinding) {
95
- // Boolean-like attributes: add or remove based on truthiness
96
+ // Boolean-like attributes: add or remove based on truthiness.
97
+ // Compare both presence AND value — initial hydration starts with
98
+ // the raw `@[...]` binding text as the attribute value, so
99
+ // `hasAttribute` alone isn't enough to know the canonical state is
100
+ // already set.
96
101
  const expr = isPureBinding[1];
97
102
  const value = evalInScope(expr, effectiveState, element);
98
103
  if (value) {
99
- element.setAttribute(attrName, '');
100
- } else {
104
+ if (element.getAttribute(attrName) !== '') {
105
+ element.setAttribute(attrName, '');
106
+ }
107
+ } else if (element.hasAttribute(attrName)) {
101
108
  element.removeAttribute(attrName);
102
109
  }
103
110
  } else {
@@ -105,7 +112,9 @@ export default (affected, state, manifest = {}, oldState = {}) => {
105
112
  const newValue = attrValue.replace(BINDING_REGEX, (_, expr) => {
106
113
  return evalInScope(expr, effectiveState, element);
107
114
  });
108
- element.setAttribute(attrName, newValue);
115
+ if (element.getAttribute(attrName) !== newValue) {
116
+ element.setAttribute(attrName, newValue);
117
+ }
109
118
  }
110
119
  } catch (e) {}
111
120
  return;
@@ -128,11 +137,19 @@ export default (affected, state, manifest = {}, oldState = {}) => {
128
137
  });
129
138
 
130
139
  // If we have a direct reference to the text node, update it specifically
131
- // This prevents wiping child elements when parent has both text and element children
140
+ // This prevents wiping child elements when parent has both text and element children.
141
+ // Skip the write when the value is already correct — the browser would repaint
142
+ // (and any in-flight CSS transition on the row would jitter) even when no value
143
+ // actually changed. Reactivity coverage is unchanged: the only state changes that
144
+ // hit this path either produce a new value (still applied) or don't (now no-op).
145
+ // Skip the write when the value is already correct — the browser would
146
+ // repaint (and any in-flight CSS transition would jitter) even when no
147
+ // value actually changed. Reactivity coverage is unchanged: state changes
148
+ // that produce a new value still apply; state changes that don't are now
149
+ // proper no-ops at the DOM layer.
132
150
  if (textNode && textNode.nodeType === 3) {
133
- textNode.textContent = toReplace;
134
- } else {
135
- // Fallback: element has no children or is just a text container
151
+ if (textNode.textContent !== toReplace) textNode.textContent = toReplace;
152
+ } else if (element.textContent !== toReplace) {
136
153
  element.textContent = toReplace;
137
154
  }
138
155
  } catch (e) {}