@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/dist/cjs/no.js +7 -7
- package/dist/cjs/no.js.map +3 -3
- package/dist/esm/no.js +7 -7
- package/dist/esm/no.js.map +3 -3
- package/dist/iife/no.js +7 -7
- package/dist/iife/no.js.map +3 -3
- package/package.json +2 -1
- package/src/animations.js +22 -7
- package/src/context.js +9 -1
- package/src/devtools.js +23 -0
- package/src/directives/binding.js +66 -1
- package/src/directives/conditionals.js +8 -2
- package/src/directives/events.js +4 -0
- package/src/directives/http.js +27 -19
- package/src/directives/i18n.js +2 -2
- package/src/directives/loops.js +171 -6
- package/src/directives/state.js +39 -9
- package/src/directives/styling.js +1 -1
- package/src/dom.js +58 -7
- package/src/evaluate.js +210 -46
- package/src/fetch.js +16 -3
- package/src/filters.js +6 -1
- package/src/globals.js +20 -0
- package/src/index.js +15 -1
- package/src/router.js +22 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erickxavier/no-js",
|
|
3
|
-
"version": "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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/directives/events.js
CHANGED
|
@@ -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 });
|
package/src/directives/http.js
CHANGED
|
@@ -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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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(
|
|
314
|
+
const id = setInterval(() => {
|
|
315
|
+
if (!el.isConnected) { clearInterval(id); return; }
|
|
316
|
+
doRequest();
|
|
317
|
+
}, refreshInterval);
|
|
310
318
|
_onDispose(() => clearInterval(id));
|
|
311
319
|
}
|
|
312
320
|
},
|
package/src/directives/i18n.js
CHANGED
|
@@ -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
|
}
|
package/src/directives/loops.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
},
|