@erickxavier/no-js 1.9.1 → 1.10.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": "@erickxavier/no-js",
3
- "version": "1.9.1",
3
+ "version": "1.10.1",
4
4
  "description": "The HTML-first reactive framework — build dynamic web apps with just HTML attributes, no JavaScript required",
5
5
  "main": "dist/cjs/no.js",
6
6
  "module": "dist/esm/no.js",
@@ -32,6 +32,7 @@
32
32
  "test:e2e:headed": "npx playwright test --config e2e/playwright.config.ts --headed",
33
33
  "test:e2e:report": "npx playwright show-report e2e/playwright-report",
34
34
  "test:all": "npm test && npm run test:e2e",
35
+ "bench": "jest --testMatch='**/__benchmarks__/**/*.test.js' --testPathIgnorePatterns='[]'",
35
36
  "prepublishOnly": "npm run build"
36
37
  },
37
38
  "keywords": [
package/src/animations.js CHANGED
@@ -71,7 +71,7 @@ export function _animateOut(el, animName, transitionName, callback, durationMs)
71
71
  const fallback = durationMs || 2000;
72
72
  if (!el.firstElementChild && !el.childNodes.length) {
73
73
  callback();
74
- return;
74
+ return () => {};
75
75
  }
76
76
  if (animName) {
77
77
  const target = el.firstElementChild || el;
@@ -85,8 +85,12 @@ export function _animateOut(el, animName, transitionName, callback, durationMs)
85
85
  callback();
86
86
  };
87
87
  target.addEventListener("animationend", done, { once: true });
88
- setTimeout(done, fallback); // Fallback
89
- return;
88
+ const timerId = setTimeout(done, fallback); // Fallback
89
+ return () => {
90
+ called = true;
91
+ clearTimeout(timerId);
92
+ target.removeEventListener("animationend", done);
93
+ };
90
94
  }
91
95
  if (transitionName) {
92
96
  const target = el.firstElementChild || el;
@@ -94,10 +98,11 @@ export function _animateOut(el, animName, transitionName, callback, durationMs)
94
98
  transitionName + "-leave",
95
99
  transitionName + "-leave-active",
96
100
  );
97
- requestAnimationFrame(() => {
101
+ let called = false;
102
+ let timerId;
103
+ const rafId = requestAnimationFrame(() => {
98
104
  target.classList.remove(transitionName + "-leave");
99
105
  target.classList.add(transitionName + "-leave-to");
100
- let called = false;
101
106
  const done = () => {
102
107
  if (called) return;
103
108
  called = true;
@@ -108,9 +113,19 @@ export function _animateOut(el, animName, transitionName, callback, durationMs)
108
113
  callback();
109
114
  };
110
115
  target.addEventListener("transitionend", done, { once: true });
111
- setTimeout(done, fallback);
116
+ timerId = setTimeout(done, fallback);
112
117
  });
113
- return;
118
+ return () => {
119
+ called = true;
120
+ cancelAnimationFrame(rafId);
121
+ clearTimeout(timerId);
122
+ target.classList.remove(
123
+ transitionName + "-leave",
124
+ transitionName + "-leave-active",
125
+ transitionName + "-leave-to",
126
+ );
127
+ };
114
128
  }
115
129
  callback();
130
+ return () => {};
116
131
  }
package/src/context.js CHANGED
@@ -9,6 +9,7 @@ import { _devtoolsEmit, _ctxRegistry } from "./devtools.js";
9
9
  let _batchDepth = 0;
10
10
  const _batchQueue = new Set();
11
11
  let _ctxId = 0;
12
+ let _ctxGeneration = 0;
12
13
 
13
14
  export function _resetCtxId() { _ctxId = 0; }
14
15
 
@@ -102,6 +103,7 @@ export function createContext(data = {}, parent = null) {
102
103
  const old = target[key];
103
104
  target[key] = value;
104
105
  if (old !== value) {
106
+ _ctxGeneration++;
105
107
  notify();
106
108
  _devtoolsEmit("ctx:updated", {
107
109
  id: target.__devtoolsId,
@@ -135,7 +137,11 @@ export function createContext(data = {}, parent = null) {
135
137
  }
136
138
 
137
139
  // Collect all keys from a context + its parent chain
140
+ // Result is cached per context and invalidated on any reactive mutation.
138
141
  export function _collectKeys(ctx) {
142
+ const cache = ctx.__raw.__collectKeysCache;
143
+ if (cache && cache.gen === _ctxGeneration) return cache.result;
144
+
139
145
  const allKeys = new Set();
140
146
  const allVals = {};
141
147
  let c = ctx;
@@ -149,5 +155,7 @@ export function _collectKeys(ctx) {
149
155
  }
150
156
  c = c.$parent;
151
157
  }
152
- return { keys: [...allKeys], vals: allVals };
158
+ const result = { keys: [...allKeys], vals: allVals };
159
+ ctx.__raw.__collectKeysCache = { gen: _ctxGeneration, result };
160
+ return result;
153
161
  }
package/src/devtools.js CHANGED
@@ -11,6 +11,22 @@ import { _i18n } from "./i18n.js";
11
11
  // Maps __devtoolsId → Proxy reference for inspect/mutate commands.
12
12
  export const _ctxRegistry = new Map();
13
13
 
14
+ // ─── Hostname guard ─────────────────────────────────────────────────────────
15
+ // Optional `hostname` param exists for unit-testing without window.location mocking.
16
+ export function _isLocalHostname(hostname) {
17
+ if (hostname === undefined) {
18
+ hostname = (typeof window !== "undefined" && window.location) ? window.location.hostname : "";
19
+ }
20
+ return (
21
+ hostname === "" ||
22
+ hostname === "localhost" ||
23
+ hostname === "127.0.0.1" ||
24
+ hostname === "::1" ||
25
+ hostname === "0.0.0.0" ||
26
+ hostname.endsWith(".localhost")
27
+ );
28
+ }
29
+
14
30
  // ─── Emit a devtools event ──────────────────────────────────────────────────
15
31
  export function _devtoolsEmit(type, data) {
16
32
  if (!_config.devtools || typeof window === "undefined") return;
@@ -192,6 +208,8 @@ function _handleDevtoolsCommand(event) {
192
208
  break;
193
209
  case "get:config":
194
210
  result = { ..._config };
211
+ if (result.csrf) result.csrf = { ...result.csrf, token: '[REDACTED]' };
212
+ if (result.headers) result.headers = '[REDACTED]';
195
213
  break;
196
214
  case "get:routes":
197
215
  result = _routerInstance ? _routerInstance.routes || [] : [];
@@ -224,6 +242,11 @@ function _handleDevtoolsCommand(event) {
224
242
  export function initDevtools(nojs) {
225
243
  if (!_config.devtools || typeof window === "undefined") return;
226
244
 
245
+ if (!_isLocalHostname()) {
246
+ console.warn("[No.JS] devtools: true is ignored outside local environments. Remove devtools: true before deploying to production.");
247
+ return;
248
+ }
249
+
227
250
  // Listen for commands
228
251
  window.addEventListener("nojs:devtools:cmd", _handleDevtoolsCommand);
229
252
 
@@ -33,6 +33,71 @@ registerDirective("bind-html", {
33
33
  },
34
34
  });
35
35
 
36
+ const _SAFE_URL_ATTRS = new Set(["href", "src", "action", "formaction", "poster", "data"]);
37
+
38
+ // Strip JS vectors from raw SVG markup using DOMParser for robust sanitization.
39
+ // Regex-based approaches are bypassable via entity encoding and nested contexts.
40
+ function _sanitizeSvgContent(svg) {
41
+ const doc = new DOMParser().parseFromString(svg, "image/svg+xml");
42
+ const root = doc.documentElement;
43
+ // If parsing failed, DOMParser may wrap error in <parsererror> or produce a
44
+ // non-SVG root. In either case return an empty SVG for safety.
45
+ if (root.querySelector("parsererror") ||
46
+ root.nodeName !== "svg" ||
47
+ root.getElementsByTagNameNS("http://www.mozilla.org/newlayout/xml/parsererror.xml", "parsererror").length) {
48
+ return "<svg></svg>";
49
+ }
50
+
51
+ function _cleanAttrs(node) {
52
+ for (const attr of [...node.attributes]) {
53
+ const name = attr.name.toLowerCase();
54
+ // Remove on* event handlers
55
+ if (name.startsWith("on")) { node.removeAttribute(attr.name); continue; }
56
+ // Remove javascript: in href/xlink:href
57
+ if ((name === "href" || name === "xlink:href") &&
58
+ attr.value.trim().toLowerCase().startsWith("javascript:")) {
59
+ node.removeAttribute(attr.name);
60
+ }
61
+ }
62
+ }
63
+ // Remove script elements
64
+ for (const s of [...root.querySelectorAll("script")]) s.remove();
65
+ // Clean attributes on root and all descendants
66
+ _cleanAttrs(root);
67
+ for (const node of root.querySelectorAll("*")) _cleanAttrs(node);
68
+ return new XMLSerializer().serializeToString(root);
69
+ }
70
+
71
+ // Sanitize a data:image/svg+xml URI — handles both base64 and URL-encoded forms.
72
+ function _sanitizeSvgDataUri(str) {
73
+ try {
74
+ const b64 = str.match(/^data:image\/svg\+xml;base64,(.+)$/i);
75
+ if (b64) {
76
+ const clean = _sanitizeSvgContent(atob(b64[1]));
77
+ return "data:image/svg+xml;base64," + btoa(clean);
78
+ }
79
+ const comma = str.indexOf(",");
80
+ if (comma === -1) return "#";
81
+ const header = str.slice(0, comma + 1);
82
+ const clean = _sanitizeSvgContent(decodeURIComponent(str.slice(comma + 1)));
83
+ return header + encodeURIComponent(clean);
84
+ } catch (_e) {
85
+ return "#";
86
+ }
87
+ }
88
+
89
+ function _sanitizeAttrValue(attrName, value) {
90
+ if (_SAFE_URL_ATTRS.has(attrName)) {
91
+ const str = String(value).trimStart();
92
+ if (/^(javascript|vbscript):/i.test(str)) return "#";
93
+ if (/^data:/i.test(str)) {
94
+ if (/^data:image\/svg\+xml/i.test(str)) return _sanitizeSvgDataUri(str);
95
+ if (!/^data:image\//i.test(str)) return "#";
96
+ }
97
+ }
98
+ return value;
99
+ }
100
+
36
101
  registerDirective("bind-*", {
37
102
  priority: 20,
38
103
  init(el, name, expr) {
@@ -72,7 +137,7 @@ registerDirective("bind-*", {
72
137
  if (attrName in el) el[attrName] = !!val;
73
138
  return;
74
139
  }
75
- if (val != null) el.setAttribute(attrName, String(val));
140
+ if (val != null) el.setAttribute(attrName, String(_sanitizeAttrValue(attrName, val)));
76
141
  else el.removeAttribute(attrName);
77
142
  }
78
143
  _watchExpr(expr, ctx, update);
@@ -2,7 +2,7 @@
2
2
  // DIRECTIVES: if, else-if, else, show, hide, switch
3
3
  // ═══════════════════════════════════════════════════════════════════════
4
4
 
5
- import { _watchExpr } from "../globals.js";
5
+ import { _watchExpr, _onDispose } from "../globals.js";
6
6
  import { evaluate } from "../evaluate.js";
7
7
  import { findContext, _clearDeclared, _cloneTemplate } from "../dom.js";
8
8
  import { registerDirective, processTree, _disposeChildren } from "../registry.js";
@@ -21,6 +21,8 @@ registerDirective("if", {
21
21
  const animDuration = parseInt(el.getAttribute("animate-duration")) || 0;
22
22
  const originalChildren = [...el.childNodes].map((n) => n.cloneNode(true));
23
23
  let currentState = undefined;
24
+ let _cancelAnim = null;
25
+ _onDispose(() => { if (_cancelAnim) { _cancelAnim(); _cancelAnim = null; } });
24
26
 
25
27
  function update() {
26
28
  const result = !!evaluate(expr, ctx);
@@ -29,7 +31,11 @@ registerDirective("if", {
29
31
 
30
32
  // Animation leave
31
33
  if (animLeave || transition) {
32
- _animateOut(el, animLeave, transition, () => render(result), animDuration);
34
+ if (_cancelAnim) { _cancelAnim(); _cancelAnim = null; }
35
+ _cancelAnim = _animateOut(el, animLeave, transition, () => {
36
+ _cancelAnim = null;
37
+ render(result);
38
+ }, animDuration);
33
39
  } else {
34
40
  render(result);
35
41
  }
@@ -26,6 +26,10 @@ registerDirective("on:*", {
26
26
  }
27
27
  if (event === "updated") {
28
28
  const updatedObserver = new MutationObserver(() => {
29
+ if (!el.isConnected) {
30
+ updatedObserver.disconnect();
31
+ return;
32
+ }
29
33
  _execStatement(expr, ctx, { $el: el });
30
34
  });
31
35
  updatedObserver.observe(el, { childList: true, subtree: true, characterData: true, attributes: true });
@@ -20,6 +20,11 @@ import { _devtoolsEmit } from "../devtools.js";
20
20
 
21
21
  const HTTP_METHODS = ["get", "post", "put", "patch", "delete"];
22
22
 
23
+ const _SENSITIVE_HEADERS = new Set([
24
+ 'authorization', 'x-api-key', 'x-auth-token', 'cookie',
25
+ 'proxy-authorization', 'set-cookie', 'x-csrf-token',
26
+ ]);
27
+
23
28
  for (const method of HTTP_METHODS) {
24
29
  registerDirective(method, {
25
30
  priority: 1,
@@ -121,24 +126,24 @@ for (const method of HTTP_METHODS) {
121
126
  }
122
127
 
123
128
  const extraHeaders = headersAttr ? JSON.parse(headersAttr) : {};
124
- const savedRetries = _config.retries;
125
- const savedRetryDelay = _config.retryDelay;
126
- _config.retries = retryCount;
127
- _config.retryDelay = retryDelay;
128
- let data;
129
- try {
130
- data = await _doFetch(
131
- resolvedUrl,
132
- method,
133
- reqBody,
134
- extraHeaders,
135
- el,
136
- _activeAbort.signal,
137
- );
138
- } finally {
139
- _config.retries = savedRetries;
140
- _config.retryDelay = savedRetryDelay;
129
+ if (headersAttr) {
130
+ for (const k of Object.keys(extraHeaders)) {
131
+ const lower = k.toLowerCase();
132
+ if (_SENSITIVE_HEADERS.has(lower) || /^x-(auth|api)-/.test(lower)) {
133
+ _warn(`Sensitive header "${k}" is set inline on a headers attribute. Use NoJS.config({ headers }) or an interceptor to avoid exposing credentials in HTML source.`);
134
+ }
135
+ }
141
136
  }
137
+ const data = await _doFetch(
138
+ resolvedUrl,
139
+ method,
140
+ reqBody,
141
+ extraHeaders,
142
+ el,
143
+ _activeAbort.signal,
144
+ retryCount,
145
+ retryDelay,
146
+ );
142
147
 
143
148
  // Cache response
144
149
  if (method === "get") _cacheSet(cacheKey, data, cacheStrategy);
@@ -256,7 +261,7 @@ for (const method of HTTP_METHODS) {
256
261
  el.addEventListener("submit", submitHandler);
257
262
  _onDispose(() => el.removeEventListener("submit", submitHandler));
258
263
  } else if (method === "get") {
259
- doRequest();
264
+ if (el.isConnected) doRequest();
260
265
  } else {
261
266
  // Non-GET on non-FORM: attach click listener
262
267
  const clickHandler = (e) => {
@@ -306,7 +311,10 @@ for (const method of HTTP_METHODS) {
306
311
 
307
312
  // Polling
308
313
  if (refreshInterval > 0) {
309
- const id = setInterval(doRequest, refreshInterval);
314
+ const id = setInterval(() => {
315
+ if (!el.isConnected) { clearInterval(id); return; }
316
+ doRequest();
317
+ }, refreshInterval);
310
318
  _onDispose(() => clearInterval(id));
311
319
  }
312
320
  },
@@ -6,7 +6,7 @@
6
6
  import { _i18n, _watchI18n, _loadI18nNamespace, _notifyI18n } from "../i18n.js";
7
7
  import { _watchExpr } from "../globals.js";
8
8
  import { evaluate } from "../evaluate.js";
9
- import { findContext } from "../dom.js";
9
+ import { findContext, _sanitizeHtml } from "../dom.js";
10
10
  import { registerDirective, processTree } from "../registry.js";
11
11
 
12
12
  registerDirective("t", {
@@ -25,7 +25,7 @@ registerDirective("t", {
25
25
  }
26
26
  const text = _i18n.t(key, params);
27
27
  if (useHtml) {
28
- el.innerHTML = text;
28
+ el.innerHTML = _sanitizeHtml(text);
29
29
  } else {
30
30
  el.textContent = text;
31
31
  }
@@ -25,6 +25,8 @@ registerDirective("each", {
25
25
  const stagger = parseInt(el.getAttribute("animate-stagger")) || 0;
26
26
  const animDuration = parseInt(el.getAttribute("animate-duration")) || 0;
27
27
  let prevList = null;
28
+ // key → wrapper div; only populated when the `key` attribute is set.
29
+ const keyMap = new Map();
28
30
 
29
31
  function update() {
30
32
  let list = /[\[\]()\s+\-*\/!?:&|]/.test(listPath)
@@ -32,10 +34,7 @@ registerDirective("each", {
32
34
  : resolve(listPath, ctx);
33
35
  if (!Array.isArray(list)) return;
34
36
 
35
- // If same list reference and items are rendered, skip re-render
36
- // and just propagate the notification to child contexts so their
37
- // watchers (bind, show, model, etc.) can react to parent changes
38
- // without destroying/recreating the DOM (preserves input focus).
37
+ // Same-reference optimisation: propagate to children without DOM rebuild.
39
38
  if (list === prevList && list.length > 0 && el.children.length > 0) {
40
39
  for (const child of el.children) {
41
40
  if (child.__ctx && child.__ctx.$notify) child.__ctx.$notify();
@@ -49,6 +48,7 @@ registerDirective("each", {
49
48
  const clone = _cloneTemplate(elseTpl);
50
49
  if (clone) {
51
50
  _disposeChildren(el);
51
+ keyMap.clear();
52
52
  el.innerHTML = "";
53
53
  el.appendChild(clone);
54
54
  processTree(el);
@@ -80,6 +80,87 @@ registerDirective("each", {
80
80
  }
81
81
 
82
82
  function renderItems(tpl, list) {
83
+ if (keyExpr) {
84
+ reconcileItems(tpl, list);
85
+ } else {
86
+ rebuildItems(tpl, list);
87
+ }
88
+ }
89
+
90
+ // Key-based reconciliation: reuses existing wrapper divs for items whose
91
+ // key is still present in the new list, only creating/removing DOM nodes
92
+ // for items that genuinely appeared or disappeared.
93
+ function reconcileItems(tpl, list) {
94
+ const count = list.length;
95
+
96
+ // Evaluate the key for every item in the new list up-front.
97
+ const newOrder = list.map((item, i) => {
98
+ const tempCtx = createContext({ [itemName]: item, $index: i }, ctx);
99
+ return { key: evaluate(keyExpr, tempCtx), item, i };
100
+ });
101
+
102
+ const nextKeySet = new Set(newOrder.map((e) => e.key));
103
+
104
+ // Remove wrappers whose keys are no longer in the list.
105
+ for (const [key, wrapper] of keyMap) {
106
+ if (!nextKeySet.has(key)) {
107
+ _disposeChildren(wrapper);
108
+ wrapper.remove();
109
+ keyMap.delete(key);
110
+ }
111
+ }
112
+
113
+ // Create new wrappers and update existing ones.
114
+ newOrder.forEach(({ key, item, i }) => {
115
+ const childData = {
116
+ [itemName]: item,
117
+ $index: i,
118
+ $count: count,
119
+ $first: i === 0,
120
+ $last: i === count - 1,
121
+ $even: i % 2 === 0,
122
+ $odd: i % 2 !== 0,
123
+ };
124
+
125
+ if (!keyMap.has(key)) {
126
+ const clone = tpl.content.cloneNode(true);
127
+ const wrapper = document.createElement("div");
128
+ wrapper.style.display = "contents";
129
+ wrapper.__ctx = createContext(childData, ctx);
130
+ wrapper.appendChild(clone);
131
+ keyMap.set(key, wrapper);
132
+ el.appendChild(wrapper); // placed at end; reordered below
133
+ processTree(wrapper);
134
+
135
+ if (animEnter) {
136
+ const firstChild = wrapper.firstElementChild;
137
+ if (firstChild) {
138
+ firstChild.classList.add(animEnter);
139
+ firstChild.addEventListener(
140
+ "animationend",
141
+ () => firstChild.classList.remove(animEnter),
142
+ { once: true },
143
+ );
144
+ if (stagger) firstChild.style.animationDelay = i * stagger + "ms";
145
+ }
146
+ }
147
+ } else {
148
+ // Existing item: update positional metadata and notify watchers.
149
+ Object.assign(keyMap.get(key).__ctx.__raw, childData);
150
+ keyMap.get(key).__ctx.$notify();
151
+ }
152
+ });
153
+
154
+ // Reorder DOM to match the new list using a single forward pass.
155
+ for (let i = 0; i < newOrder.length; i++) {
156
+ const wrapper = keyMap.get(newOrder[i].key);
157
+ if (wrapper !== el.children[i]) el.insertBefore(wrapper, el.children[i] ?? null);
158
+ }
159
+ }
160
+
161
+ // Full rebuild: dispose all children and recreate from scratch.
162
+ // Used when no `key` attribute is set (backward-compatible behaviour).
163
+ function rebuildItems(tpl, list) {
83
164
  const count = list.length;
84
165
  _disposeChildren(el);
85
166
  el.innerHTML = "";
@@ -109,7 +190,6 @@ registerDirective("each", {
109
190
  if (firstChild) {
110
191
  firstChild.classList.add(animEnter);
111
192
  firstChild.addEventListener("animationend", () => firstChild.classList.remove(animEnter), { once: true });
112
- // Stagger animation — delay must be on the child, not the wrapper
113
193
  if (stagger) {
114
194
  firstChild.style.animationDelay = i * stagger + "ms";
115
195
  }
@@ -135,6 +215,7 @@ registerDirective("foreach", {
135
215
  const limit = parseInt(el.getAttribute("limit")) || Infinity;
136
216
  const offset = parseInt(el.getAttribute("offset")) || 0;
137
217
  const tplId = el.getAttribute("template");
218
+ const keyExpr = el.getAttribute("key");
138
219
  const animEnter = el.getAttribute("animate-enter") || el.getAttribute("animate");
139
220
  const animLeave = el.getAttribute("animate-leave");
140
221
  const stagger = parseInt(el.getAttribute("animate-stagger")) || 0;
@@ -157,6 +238,7 @@ registerDirective("foreach", {
157
238
  templateContent.removeAttribute("offset");
158
239
  templateContent.removeAttribute("else");
159
240
  templateContent.removeAttribute("template");
241
+ templateContent.removeAttribute("key");
160
242
  templateContent.removeAttribute("animate-enter");
161
243
  templateContent.removeAttribute("animate");
162
244
  templateContent.removeAttribute("animate-leave");
@@ -164,6 +246,9 @@ registerDirective("foreach", {
164
246
  templateContent.removeAttribute("animate-duration");
165
247
  }
166
248
 
249
+ // key → wrapper div; only populated when the `key` attribute is set.
250
+ const keyMap = new Map();
251
+
167
252
  function update() {
168
253
  let list = resolve(fromPath, ctx);
169
254
  if (!Array.isArray(list)) return;
@@ -199,6 +284,7 @@ registerDirective("foreach", {
199
284
  const clone = _cloneTemplate(elseTpl);
200
285
  if (clone) {
201
286
  _disposeChildren(el);
287
+ keyMap.clear();
202
288
  el.innerHTML = "";
203
289
  el.appendChild(clone);
204
290
  processTree(el);
@@ -209,6 +295,11 @@ registerDirective("foreach", {
209
295
  const tpl = tplId ? document.getElementById(tplId) : null;
210
296
  const count = list.length;
211
297
 
298
+ if (keyExpr) {
299
+ reconcileForeachItems(tpl, list, count);
300
+ return;
301
+ }
302
+
212
303
  function renderForeachItems() {
213
304
  _disposeChildren(el);
214
305
  el.innerHTML = "";
@@ -244,7 +335,6 @@ registerDirective("foreach", {
244
335
  if (firstChild) {
245
336
  firstChild.classList.add(animEnter);
246
337
  firstChild.addEventListener("animationend", () => firstChild.classList.remove(animEnter), { once: true });
247
- // Stagger animation — delay must be on the child, not the wrapper
248
338
  if (stagger) {
249
339
  firstChild.style.animationDelay = (i * stagger) + "ms";
250
340
  }
@@ -273,6 +363,81 @@ registerDirective("foreach", {
273
363
  }
274
364
  }
275
365
 
366
+ // Key-based reconciliation for foreach — mirrors each's reconcileItems.
367
+ // Applied to the final (filtered, sorted, sliced) list so keys always
368
+ // correspond to what is actually rendered.
369
+ function reconcileForeachItems(tpl, list, count) {
370
+ // On first render the element may still hold its original inline template
371
+ // markup (the same content that was captured into templateContent).
372
+ // Clear it so only managed wrappers appear as children.
373
+ if (keyMap.size === 0) el.innerHTML = "";
374
+
375
+ const newOrder = list.map((item, i) => {
376
+ const tempCtx = createContext({ [itemName]: item, [indexName]: i }, ctx);
377
+ return { key: evaluate(keyExpr, tempCtx), item, i };
378
+ });
379
+
380
+ const nextKeySet = new Set(newOrder.map((e) => e.key));
381
+
382
+ for (const [key, wrapper] of keyMap) {
383
+ if (!nextKeySet.has(key)) {
384
+ _disposeChildren(wrapper);
385
+ wrapper.remove();
386
+ keyMap.delete(key);
387
+ }
388
+ }
389
+
390
+ newOrder.forEach(({ key, item, i }) => {
391
+ const childData = {
392
+ [itemName]: item,
393
+ [indexName]: i,
394
+ $index: i,
395
+ $count: count,
396
+ $first: i === 0,
397
+ $last: i === count - 1,
398
+ $even: i % 2 === 0,
399
+ $odd: i % 2 !== 0,
400
+ };
401
+
402
+ if (!keyMap.has(key)) {
403
+ let clone;
404
+ if (tpl) {
405
+ clone = tpl.content.cloneNode(true);
406
+ } else {
407
+ clone = templateContent.cloneNode(true);
408
+ }
409
+ const wrapper = document.createElement("div");
410
+ wrapper.style.display = "contents";
411
+ wrapper.__ctx = createContext(childData, ctx);
412
+ wrapper.appendChild(clone);
413
+ keyMap.set(key, wrapper);
414
+ el.appendChild(wrapper);
415
+ processTree(wrapper);
416
+
417
+ if (animEnter) {
418
+ const firstChild = wrapper.firstElementChild;
419
+ if (firstChild) {
420
+ firstChild.classList.add(animEnter);
421
+ firstChild.addEventListener(
422
+ "animationend",
423
+ () => firstChild.classList.remove(animEnter),
424
+ { once: true },
425
+ );
426
+ if (stagger) firstChild.style.animationDelay = i * stagger + "ms";
427
+ }
428
+ }
429
+ } else {
430
+ Object.assign(keyMap.get(key).__ctx.__raw, childData);
431
+ keyMap.get(key).__ctx.$notify();
432
+ }
433
+ });
434
+
435
+ for (let i = 0; i < newOrder.length; i++) {
436
+ const wrapper = keyMap.get(newOrder[i].key);
437
+ if (wrapper !== el.children[i]) el.insertBefore(wrapper, el.children[i] ?? null);
438
+ }
439
+ }
440
+
276
441
  _watchExpr(fromPath, ctx, update);
277
442
  update();
278
443
  },