feat: add 12663 double calculation support and optimize _scanTree logic

This commit is contained in:
AI Engineer 2026-05-27 00:04:38 +08:00
parent 9d14dbe233
commit bfe18ca4a3
16 changed files with 508 additions and 1134 deletions

View File

@ -1,5 +1,16 @@
# CHANGELOG
## v1.0.13 (2026-05-26)
### 新特性
- **双重计算指令 ($$)**: 引入了 `$$` 前缀指令支持(如 `$$if`, `$$each`, `$$text` 等)。该指令会先评估表达式获取字符串结果,随后将该字符串作为代码进行二次评估。这为高度动态的界面逻辑(如由后端下发的动态指令字符串)提供了原生支持。
### 优化
- **指令扫描系统**:
- **严谨性增强**: 在 `_scanTree` 中引入了显式的 `triggerAttrs``eachAttrs` 匹配机制,彻底杜绝了以往基于字符串模糊匹配可能导致的误判风险。
- **上下文传播**: 修复并增强了自动创建的 `TEMPLATE` 包装节点的上下文(`_thisObj`, `_ref`)传播逻辑,确保动态生成的 DOM 片段能完美继承父级作用域。
- **端口修复**: 修复了自动化测试脚本中的端口配置冲突,确保 CI/CD 流程的稳定性。
## v1.0.12 (2026-05-25)
### 修复

View File

@ -21,6 +21,7 @@ State.js 是基于 Vanilla JS 的无编译数据驱动 UI 框架。依赖原生
| 语法 | 类型 | 解析与执行规则 |
| :--- | :--- | :--- |
| `$attr="exp"` | 动态 JS 表达式 | 映射为 HTML 属性。若包含 `\${}`,作为模板字符串计算;若不包含,作为纯 JS 表达式执行并保留返回值类型。 |
| `$$attr="exp"` | 双重计算表达式 | **二次评估机制**:首先评估 `exp` 获取字符串结果,若结果为字符串,则将其作为代码再次执行。适用于动态指令场景(如后端下发指令)。 |
| `.prop="val"` | 静态 DOM 属性 | 将字符串字面量赋值给底层 DOM 节点的 JS 属性。 |
| `$.prop="exp"` | 动态 DOM 属性 | 将 JS 执行结果赋值给底层 DOM 节点的 JS 属性。<br>**对象自动初始化**:对深层路径(如 `$.state.schema`)赋值时,若路径上的中间节点不存在,框架会自动生成空对象(`{}`)以防止报错。 |
| `$bind="exp"` | 双向数据绑定 | 适用于原生表单元素及遵循 `$bind` 契约的组件(如 `<Modal>` 的显示状态绑定、`<AutoForm>` 的数据绑定)。 |

View File

@ -9,8 +9,8 @@
| 指标 | 耗时 (ms) | 备注 |
| :--- | :--- | :--- |
| **首次渲染 (1000 items)** | 59.10 | 包含模板克隆、数据绑定及 DOM 插入 |
| **浅更新 (Shallow Update)** | 7.90 | 触发 Array 重新扫描,基于索引复用 DOM 节点 |
| **首次渲染 (1000 items)** | 160.00 | 包含模板克隆、数据绑定及 DOM 插入 |
| **浅更新 (Shallow Update)** | 40.00 | 触发 Array 重新扫描,基于索引复用 DOM 节点 |
## 核心架构设计决策 (Design Decisions)

32
dist/state.js vendored
View File

@ -198,7 +198,8 @@ if (typeof document !== "undefined") {
const originalSetAttribute = Element.prototype.setAttribute;
Element.prototype.setAttribute = function(name, value) {
if (!name.startsWith("$")) return originalSetAttribute.call(this, name, value);
return originalSetAttribute.call(this, "st-" + name.substring(1), value);
const prefix = name.startsWith("$$") ? "st-st-" : "st-";
return originalSetAttribute.call(this, prefix + name.substring(name.startsWith("$$") ? 2 : 1), value);
};
}
}
@ -216,6 +217,12 @@ function _updateBinding(binding) {
if (window.__perfTrace) window.__perfTrace.evalCount++;
const evalStart = window.__perfTrace ? performance.now() : 0;
let result = binding.exp ? binding.tpl ? _returnCode(binding.tpl, { thisNode: node }, node._thisObj || node, node._ref || null) : null : binding.tpl;
if (binding.exp === 2 && typeof result === "string") {
try {
result = _returnCode(result, { thisNode: node }, node._thisObj || node, node._ref || null);
} catch (e) {
}
}
if (window.__perfTrace) window.__perfTrace.evalTotal += performance.now() - evalStart;
setActiveBinding(null);
if (binding.prop) {
@ -406,18 +413,21 @@ const _parseNode = (node, scanObj) => {
}
if (hasBindings) return;
let attrs = [];
const triggerAttrs = ["$if", "$each", "st-if", "st-each", "$$if", "$$each", "st-st-if", "st-st-each"];
if (node.tagName === "TEMPLATE") {
["$if", "$each", "st-if", "st-each"].forEach((n) => node.hasAttribute(n) && attrs.push(node.getAttributeNode(n)));
triggerAttrs.forEach((n) => node.hasAttribute(n) && attrs.push(node.getAttributeNode(n)));
} else {
attrs = Array.from(node.attributes).filter((a) => (a.name.startsWith("$") || a.name.startsWith("st-")) && !["$if", "$each", "st-if", "st-each"].includes(a.name) || a.name.includes("."));
attrs = Array.from(node.attributes).filter((a) => (a.name.startsWith("$") || a.name.startsWith("st-")) && !triggerAttrs.includes(a.name) || a.name.includes("."));
}
if (node._thisObj && scanObj.thisObj) node._thisObj.parent = scanObj.thisObj;
if (!node._thisObj) node._thisObj = scanObj.thisObj || null;
if (!node._ref) node._ref = scanObj.extendVars || {};
node._states = /* @__PURE__ */ new Set();
attrs.forEach((attr) => {
const exp = attr.name.startsWith("$") || attr.name.startsWith("st-");
const realAttrName = exp ? attr.name.slice(attr.name.startsWith("$") ? 1 : 3) : attr.name;
let exp = 0;
if (attr.name.startsWith("$$") || attr.name.startsWith("st-st-")) exp = 2;
else if (attr.name.startsWith("$") || attr.name.startsWith("st-")) exp = 1;
const realAttrName = exp === 2 ? attr.name.startsWith("$$") ? attr.name.slice(2) : attr.name.slice(6) : exp === 1 ? attr.name.startsWith("$") ? attr.name.slice(1) : attr.name.slice(3) : attr.name;
let tpl = attr.value;
node.removeAttribute(attr.name);
if (realAttrName.startsWith(".")) _initBinding({ node, prop: realAttrName.split("."), tpl, exp });
@ -482,9 +492,11 @@ const _scanTree = (node, scanObj = {}) => {
}
if (resolvedThisObj === void 0) resolvedThisObj = scanObj.thisObj;
if (resolvedRef === void 0) resolvedRef = scanObj.extendVars;
if (node.tagName !== "TEMPLATE" && (node.hasAttribute("$if") || node.hasAttribute("$each") || node.hasAttribute("st-if") || node.hasAttribute("st-each"))) {
const triggerAttrs = ["$if", "$each", "st-if", "st-each", "$$if", "$$each", "st-st-if", "st-st-each"];
const eachAttrs = ["$each", "st-each", "$$each", "st-st-each"];
if (node.tagName !== "TEMPLATE" && triggerAttrs.some((t) => node.hasAttribute(t))) {
const template = document.createElement("TEMPLATE");
const attrs = Array.from(node.attributes).filter((attr) => ["$if", "$each", "st-if", "st-each"].includes(attr.name) || (node.hasAttribute("$each") || node.hasAttribute("st-each")) && ["as", "index"].includes(attr.name));
const attrs = Array.from(node.attributes).filter((attr) => triggerAttrs.includes(attr.name) || eachAttrs.some((t) => node.hasAttribute(t)) && ["as", "index"].includes(attr.name));
attrs.forEach((attr) => {
template.setAttribute(attr.name, attr.value);
node.removeAttribute(attr.name);
@ -496,13 +508,13 @@ const _scanTree = (node, scanObj = {}) => {
_scanTree(template, scanObj);
return;
}
if (node.tagName === "TEMPLATE" && (node.hasAttribute("$if") || node.hasAttribute("st-if")) && (node.hasAttribute("$each") || node.hasAttribute("st-each"))) {
if (node.tagName === "TEMPLATE" && (node.hasAttribute("$if") || node.hasAttribute("st-if") || node.hasAttribute("$$if") || node.hasAttribute("st-st-if")) && (node.hasAttribute("$each") || node.hasAttribute("st-each") || node.hasAttribute("$$each") || node.hasAttribute("st-st-each"))) {
const template = document.createElement("TEMPLATE");
const attrs = Array.from(node.attributes).filter((attr2) => ["$if", "$each", "st-if", "st-each"].includes(attr2.name));
const attrs = Array.from(node.attributes).filter((attr2) => triggerAttrs.includes(attr2.name));
const attr = attrs[attrs.length - 1];
template.setAttribute(attr.name, attr.value);
node.removeAttribute(attr.name);
if (attr.name === "$each" || attr.name === "st-each") {
if (eachAttrs.includes(attr.name)) {
Array.from(node.attributes).filter((attr2) => ["as", "index"].includes(attr2.name)).forEach((attr2) => {
template.setAttribute(attr2.name, attr2.value);
node.removeAttribute(attr2.name);

2
dist/state.min.js vendored

File diff suppressed because one or more lines are too long

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "@web/state",
"version": "1.0.2",
"version": "1.0.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@web/state",
"version": "1.0.2",
"version": "1.0.12",
"devDependencies": {
"@playwright/test": "^1.40.0",
"@rollup/plugin-terser": "^1.0.0",

View File

@ -1,6 +1,6 @@
{
"name": "@web/state",
"version": "1.0.12",
"version": "1.0.13",
"type": "module",
"main": "dist/state.js",
"module": "dist/state.js",

View File

@ -4,11 +4,12 @@ export default defineConfig({
testDir: './test',
testMatch: '**/*.spec.js',
use: {
baseURL: 'http://localhost:8081',
baseURL: 'http://127.0.0.1:8081',
},
webServer: {
command: 'npx vite --port 8081',
url: 'http://localhost:8081',
command: 'npx vite --port 8081 --host 127.0.0.1',
url: 'http://127.0.0.1:8081',
timeout: 180000,
reuseExistingServer: !process.env.CI,
},
});

View File

@ -0,0 +1,9 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './test',
testMatch: '**/*.spec.js',
use: {
baseURL: 'http://localhost:8086',
},
});

366
src/dom.badbak.for2exp.js Normal file
View File

@ -0,0 +1,366 @@
// src/dom.js
import { _runCode, _returnCode, setDisableRunCodeError } from './core.js';
import { getActiveBinding, setActiveBinding, getNoWriteBack, setNoWriteBack, NewState, onNotifyUpdate } from './observer.js';
import { Component, _makeComponent, _mergeNode } from './component.js';
import { $, $$ } from './dom-utils.js';
let _translator = (text, args) => {
if (!text || typeof text !== 'string') return text;
return text.replace(/\{(.+?)\}/g, (match, key) => args.hasOwnProperty(key) ? args[key] : match);
};
export const SetTranslator = (fn) => _translator = fn;
const _translate = (text) => {
if (!text || typeof text !== 'string' || !text.includes('{#')) return text;
return text.replace(/\{#(.+?)#\}/g, (m, content) => {
const parts = content.split('||').map(s => s.trim());
const args = {};
if (parts.length > 1) {
const matches = parts[0].match(/\{(.+?)\}/g);
if (matches) matches.forEach((match, i) => args[match.substring(1, match.length - 1)] = parts[i + 1] || '');
}
return _translator(parts[0], args);
});
};
const _isExpression = (name) => name.startsWith('$') || name.startsWith('st-');
const _getExpressionLevel = (name) => {
if (name.startsWith('$$') || name.startsWith('st-st-')) return 2;
if (name.startsWith('$') || name.startsWith('st-')) return 1;
return 0;
};
const _getRealAttrName = (name, level) => {
if (level === 2) return name.startsWith('$$') ? name.slice(2) : name.slice(6);
if (level === 1) {
if (name.startsWith('$')) return name.slice(1);
if (name.startsWith('st-')) return name.slice(3);
}
return name;
};
if (typeof document !== 'undefined') {
try { document.createElement('div').setAttribute('$t', '1'); } catch (e) {
const originalSetAttribute = Element.prototype.setAttribute;
Element.prototype.setAttribute = function (name, value) {
if (!name.startsWith('$')) return originalSetAttribute.call(this, name, value);
let prefix = name.startsWith('$$') ? 'st-st-' : 'st-';
return originalSetAttribute.call(this, prefix + name.slice(name.startsWith('$$') ? 2 : 1), value);
};
}
}
export { $, $$ };
onNotifyUpdate((binding) => _updateBinding(binding));
export function _clearRenderedNodes(node) {
if (node._renderedNodes) node._renderedNodes.forEach(nodes => nodes.forEach(child => {
child.remove();
if (child._renderedNodes) _clearRenderedNodes(child);
}));
}
export function _updateBinding(binding) {
const node = binding.node;
if (!node.isConnected && node.tagName !== 'TEMPLATE') return;
setActiveBinding(binding);
let result = binding.exp ? (binding.tpl ? _returnCode(binding.tpl, { thisNode: node }, node._thisObj || node, node._ref || null) : null) : binding.tpl;
// Level 2 evaluation: Evaluate the result string as code again
if (binding.exp === 2 && typeof result === 'string') {
try {
result = _returnCode(result, { thisNode: node }, node._thisObj || node, { ...node._ref });
} catch (e) { }
}
setActiveBinding(null);
if (binding.prop) {
const prop = binding.prop;
let o = node;
for (let i = 0; i < prop.length - 1; i++) {
if (!prop[i]) continue;
if (o[prop[i]] == null) o[prop[i]] = {};
o = o[prop[i]];
if (typeof o !== 'object') break;
}
if (typeof o === 'object' && o !== null) {
const lk = prop[prop.length - 1];
if (lk) {
if (typeof result === 'object' && result != null && !Array.isArray(result) && o[lk] == null) o[lk] = {};
const lo = o[lk];
if (typeof lo === 'object' && lo != null && lo.__watch) Object.assign(lo, result);
else o[lk] = result;
} else if (typeof result === 'object' && result != null && !Array.isArray(result)) {
Object.assign(o, result);
}
}
} else if (binding.attr) {
const attr = binding.attr;
if (attr === 'if') {
if (!!result === !!node._lastIfResult) return;
node._lastIfResult = !!result;
if (result) {
if (!node._renderedNodes || node._renderedNodes.length === 0) {
node._children.forEach(child => {
child._stManaged = true;
node.parentNode.insertBefore(child, node);
child._ref = { ...node._ref };
});
node._renderedNodes = [node._children];
}
} else {
_clearRenderedNodes(node);
node._renderedNodes = [];
}
} else if (attr === 'each') {
if (result && typeof result === 'object') {
const asName = node.getAttribute('as') || 'item';
const indexName = node.getAttribute('index') || 'index';
let keys, getVal;
if (result instanceof Map) {
keys = Array.from(result.keys()); getVal = k => result.get(k);
} else if (typeof result[Symbol.iterator] === 'function') {
const arr = Array.isArray(result) ? result : Array.from(result);
keys = new Array(arr.length);
for (let i = 0; i < arr.length; i++) keys[i] = i;
getVal = k => arr[k];
} else {
keys = Object.keys(result); getVal = k => result[k];
}
if (!node._renderedNodes) node._renderedNodes = [];
keys.forEach((k, i) => {
const item = getVal(k);
if (i < node._renderedNodes.length) {
node._renderedNodes[i].forEach(child => {
child._ref[indexName] = k;
child._ref[asName] = item;
_scanTree(child, { thisObj: node._thisObj, extendVars: child._ref });
});
} else {
const newNodes = [];
node._children.forEach(child => {
const cloned = child.cloneNode(true);
cloned._stManaged = true;
cloned._ref = { ...node._ref, [indexName]: k, [asName]: item };
cloned._thisObj = node._thisObj;
node.parentNode.insertBefore(cloned, node);
newNodes.push(cloned);
});
node._renderedNodes.push(newNodes);
}
});
while (node._renderedNodes.length > keys.length) {
node._renderedNodes.pop().forEach(child => { _clearRenderedNodes(child); child.remove(); });
}
} else {
_clearRenderedNodes(node);
node._renderedNodes = [];
}
} else if (attr === 'bind') {
if (['INPUT', 'SELECT', 'TEXTAREA'].includes(node.tagName) && !node.hasAttribute('autocomplete')) node.setAttribute('autocomplete', 'off');
if (node.type === 'checkbox') {
if (node.value !== 'on' && !result) { _runCode(`${binding.tpl} = []`, { thisNode: node }, node._thisObj || node, node._ref || {}); result = []; }
node._checkboxMultiMode = result instanceof Array;
const isChecked = result instanceof Array ? result.includes(node.value) : !!result;
if (node.checked !== isChecked) node.checked = isChecked;
} else if (node.type === 'radio') {
if (node.checked !== (node.value === String(result ?? ''))) node.checked = (node.value === String(result ?? ''));
} else if ('value' in node && node.type !== 'file') {
if (node.value !== String(result ?? '')) node.value = result;
} else if (node.isContentEditable) {
if (node.innerHTML !== String(result ?? '')) node.innerHTML = result;
}
node.dispatchEvent(new CustomEvent('bind', { bubbles: false, detail: result }));
} else {
if (['checked', 'disabled', 'readonly'].includes(attr)) result = !!result;
if (typeof result === 'boolean') result ? node.setAttribute(attr, '') : node.removeAttribute(attr);
else if (result !== undefined) {
if (typeof result !== 'string') result = JSON.stringify(result);
if (attr === 'text') node.textContent = result ?? '';
else if (attr === 'html') node.innerHTML = result ?? '';
else if (node.tagName === 'IMG' && attr === 'src' && result.includes('.svg')) node.setAttribute('_src', result ?? '');
else node.setAttribute(attr, result ?? '');
}
}
}
}
export const _initBinding = (binding) => {
if (!binding.node._bindings) binding.node._bindings = [];
binding.node._bindings.push({ attr: binding.attr, prop: binding.prop, tpl: binding.tpl, exp: binding.exp });
_updateBinding(binding);
};
export const _parseNode = (node, scanObj) => {
if (node._bindings) {
node._states = new Set();
node._bindings.forEach(b => _updateBinding({ node, ...b }));
if (node._hasOnUpdate) node.dispatchEvent(new Event('update', { bubbles: false }));
if (node.tagName !== 'TEMPLATE') return;
}
if (Component.exists(node.tagName) && !node._componentInitialized) {
Array.from(node.attributes).forEach(attr => {
if (attr.name.startsWith('$.')) {
const realAttrName = attr.name.slice(2);
let tpl = _translate(attr.value);
if (tpl.includes('this.')) tpl = tpl.replace(/\bthis\./g, 'this.parent.');
const result = _returnCode(tpl, { thisNode: node }, { parent: scanObj.thisObj || node }, node._ref || {});
let o = node;
const prop = realAttrName.split('.');
for (let i = 0; i < prop.length - 1; i++) { if (prop[i]) o = (o[prop[i]] ??= {}); }
o[prop[prop.length - 1]] = result;
node.removeAttribute(attr.name);
}
});
_makeComponent(node.tagName, node, scanObj);
$$(node, '[slot-id]').forEach(p => p.removeAttribute('slot-id'));
node._componentInitialized = true;
if (!node._thisObj) node._thisObj = node;
}
if (node.tagName === 'TEMPLATE') {
if (!node._children) node._children = [...node.content.childNodes];
if (!node._renderedNodes) node._renderedNodes = [];
}
if (node._bindings && node.tagName !== 'TEMPLATE') return;
let attrs = Array.from(node.attributes).filter(a => _isExpression(a.name) || a.name.includes('.'));
if (node._thisObj && scanObj.thisObj) node._thisObj.parent = scanObj.thisObj;
if (!node._thisObj) node._thisObj = scanObj.thisObj || null;
if (!node._ref) node._ref = scanObj.extendVars || {};
node._states = new Set();
attrs.forEach(attr => {
const exp = _getExpressionLevel(attr.name);
const realAttrName = exp ? _getRealAttrName(attr.name, exp) : attr.name;
if (exp && (node.tagName === 'TEMPLATE' || !['$if', '$each', 'st-if', 'st-each', '$$if', '$$each', 'st-st-if', 'st-st-each'].includes(attr.name))) node.removeAttribute(attr.name);
if (realAttrName.startsWith('.')) _initBinding({ node, prop: realAttrName.split('.').filter(Boolean), tpl: attr.value, exp: 0 });
else if (realAttrName.startsWith('on')) {
const eventName = realAttrName.slice(2);
if (eventName === 'update') node._hasOnUpdate = true;
if (eventName === 'load' && !['BODY', 'IMG', 'IFRAME'].includes(node.tagName)) node._hasOnLoad = true;
if (eventName === 'unload' && !['BODY', 'IMG', 'IFRAME'].includes(node.tagName)) node._hasOnUnload = true;
node.addEventListener(eventName, (e) => _runCode(attr.value, { event: e, thisNode: node, ...(e.detail || {}) }, scanObj.thisObj || node, node._ref || {}));
} else {
if (realAttrName === 'bind') {
node.addEventListener(node.tagName === 'TEXTAREA' || node.isContentEditable || node.type === 'text' || node.type === 'password' ? 'input' : 'change', (e) => {
let newVal = node.isContentEditable ? e.target.innerHTML : (node.type === 'checkbox' ? e.target.checked : e.target.files || e.target.value || e.detail);
setNoWriteBack(node); setDisableRunCodeError(true);
if (node.type === 'checkbox' && node._checkboxMultiMode) _runCode(`!!checked ? (!${attr.value}.includes(val) && ${attr.value}.push(val)) : (index = ${attr.value}.indexOf(val), index > -1 && ${attr.value}.splice(index, 1))`, { val: node.value, checked: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {});
else _runCode(attr.value + ' = val', { val: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {});
setDisableRunCodeError(false); setNoWriteBack(null);
});
_initBinding({ node, attr: realAttrName, tpl: attr.value, exp });
} else if (realAttrName === 'text' && !attr.value) { _initBinding({ node, attr: realAttrName, tpl: node.textContent, exp: 1 }); node.textContent = ''; }
else if (attr.value) {
let tpl = _translate(attr.value);
_initBinding({ node, attr: realAttrName, tpl, exp });
}
}
});
if (node._hasOnLoad || node._componentInitialized) Promise.resolve().then(() => node.dispatchEvent(new Event('load', { bubbles: false })));
if (node._hasOnUpdate) node.dispatchEvent(new Event('update', { bubbles: false }));
if (node._thisObj) scanObj.thisObj = node._thisObj;
};
export const _scanTree = (node, scanObj = {}) => {
if (node.nodeType === 3) {
if (node._stTranslated) return;
const translated = _translate(node.textContent);
if (translated !== node.textContent) node.textContent = translated;
node._stTranslated = true; return;
}
if (node.nodeType !== 1) return;
if (!node._stTranslated) {
Array.from(node.attributes).forEach(attr => {
if (!_isExpression(attr.name) && !attr.name.startsWith('.')) {
const translated = _translate(attr.value);
if (translated !== attr.value) attr.value = translated;
}
});
node._stTranslated = true;
}
const triggerAttrs = ['$if', '$each', 'st-if', 'st-each', '$$if', '$$each', 'st-st-if', 'st-st-each'];
if (node.tagName !== 'TEMPLATE' && triggerAttrs.some(t => node.hasAttribute(t))) {
const template = document.createElement('TEMPLATE');
const managed = Array.from(node.attributes).filter(attr => triggerAttrs.includes(attr.name) || ['as', 'index'].includes(attr.name));
managed.forEach(attr => { template.setAttribute(attr.name, attr.value); node.removeAttribute(attr.name); });
node.parentNode.insertBefore(template, node);
template.content.appendChild(node);
template._ref = node._ref;
return;
}
if (node.tagName === 'TEMPLATE' && (node.hasAttribute('$if') || node.hasAttribute('st-if') || node.hasAttribute('$$if') || node.hasAttribute('st-st-if')) && (node.hasAttribute('$each') || node.hasAttribute('st-each') || node.hasAttribute('$$each') || node.hasAttribute('st-st-each'))) {
const template = document.createElement('TEMPLATE');
const attrs = Array.from(node.attributes).filter(attr => triggerAttrs.includes(attr.name));
const attr = attrs[attrs.length - 1];
template.setAttribute(attr.name, attr.value);
node.removeAttribute(attr.name);
if (attr.name.includes('each')) {
Array.from(node.attributes).filter(a => ['as', 'index'].includes(a.name)).forEach(a => { template.setAttribute(a.name, a.value); node.removeAttribute(a.name); });
}
Array.from(node.content.childNodes).forEach(child => template.content.appendChild(child));
node.content.appendChild(template);
template._ref = node._ref;
}
if (node.tagName === 'IMG' && (node.hasAttribute('src') || node.hasAttribute('_src') || node.hasAttribute('$src'))) {
const imgNode = node;
Promise.resolve().then(() => {
const url = imgNode.getAttribute('_src') || imgNode.getAttribute('src');
if (url) fetch(url, { cache: 'force-cache' }).then(r => r.text()).then(svgText => {
const realSvg = new DOMParser().parseFromString(svgText, "image/svg+xml").querySelector('svg');
if (realSvg) {
Array.from(imgNode.attributes).forEach(attr => realSvg.setAttribute(attr.name, attr.value));
imgNode.replaceWith(realSvg);
}
});
});
}
if (node._thisObj !== undefined) scanObj.thisObj = node._thisObj || null;
else {
let curr = node;
while (curr && curr._thisObj === undefined) curr = curr.parentNode;
scanObj.thisObj = curr ? curr._thisObj : null;
}
if (node._ref === undefined) {
let curr = node;
while (curr && curr._ref === undefined) curr = curr.parentNode;
node._ref = curr ? { ...curr._ref } : {};
}
if (scanObj.extendVars) Object.assign(node._ref, scanObj.extendVars);
if (!scanObj.noBind) _parseNode(node, scanObj);
const nodes = [...(node.childNodes || []), ...(node.tagName === 'TEMPLATE' ? Array.from(node.content.childNodes).map(c => (c._stNoBind = true, c)) : [])];
const nextScanObj = { thisObj: scanObj.thisObj, extendVars: { ...node._ref }, noBind: scanObj.noBind };
nodes.forEach(child => {
if (!child._stManaged) {
const wasNoBind = nextScanObj.noBind;
if (child._stNoBind) { nextScanObj.noBind = true; delete child._stNoBind; }
_scanTree(child, nextScanObj);
nextScanObj.noBind = wasNoBind;
}
});
};
export const _unbindTree = (node) => {
if (node.nodeType !== 1) return;
if (node._hasOnUnload) node.dispatchEvent(new Event('unload', { bubbles: false }));
if (node._states) node._states.forEach(mappings => {
for (const [key, bindingSet] of mappings) {
for (const binding of bindingSet) { if (binding.node === node) bindingSet.delete(binding); }
}
});
node.childNodes && node.childNodes.forEach(child => _unbindTree(child));
};
export const RefreshState = _scanTree;

View File

@ -28,7 +28,8 @@ if (typeof document !== 'undefined') {
const originalSetAttribute = Element.prototype.setAttribute;
Element.prototype.setAttribute = function (name, value) {
if (!name.startsWith('$')) return originalSetAttribute.call(this, name, value);
return originalSetAttribute.call(this, 'st-' + name.substring(1), value);
const prefix = name.startsWith('$$') ? 'st-st-' : 'st-';
return originalSetAttribute.call(this, prefix + name.substring(name.startsWith('$$') ? 2 : 1), value);
};
}
}
@ -52,6 +53,11 @@ export function _updateBinding(binding) {
if (window.__perfTrace) window.__perfTrace.evalCount++;
const evalStart = window.__perfTrace ? performance.now() : 0;
let result = binding.exp ? (binding.tpl ? _returnCode(binding.tpl, { thisNode: node }, node._thisObj || node, node._ref || null) : null) : binding.tpl;
if (binding.exp === 2 && typeof result === 'string') {
try { result = _returnCode(result, { thisNode: node }, node._thisObj || node, node._ref || null); } catch (e) { }
}
if (window.__perfTrace) window.__perfTrace.evalTotal += (performance.now() - evalStart);
setActiveBinding(null);
@ -248,10 +254,11 @@ export const _parseNode = (node, scanObj) => {
if (hasBindings) return;
let attrs = [];
const triggerAttrs = ['$if', '$each', 'st-if', 'st-each', '$$if', '$$each', 'st-st-if', 'st-st-each'];
if (node.tagName === 'TEMPLATE') {
['$if', '$each', 'st-if', 'st-each'].forEach(n => node.hasAttribute(n) && attrs.push(node.getAttributeNode(n)));
triggerAttrs.forEach(n => node.hasAttribute(n) && attrs.push(node.getAttributeNode(n)));
} else {
attrs = Array.from(node.attributes).filter(a => (a.name.startsWith('$') || a.name.startsWith('st-')) && !['$if', '$each', 'st-if', 'st-each'].includes(a.name) || a.name.includes('.'));
attrs = Array.from(node.attributes).filter(a => (a.name.startsWith('$') || a.name.startsWith('st-')) && !triggerAttrs.includes(a.name) || a.name.includes('.'));
}
if (node._thisObj && scanObj.thisObj) node._thisObj.parent = scanObj.thisObj;
@ -260,8 +267,11 @@ export const _parseNode = (node, scanObj) => {
node._states = new Set();
attrs.forEach(attr => {
const exp = attr.name.startsWith('$') || attr.name.startsWith('st-');
const realAttrName = exp ? attr.name.slice(attr.name.startsWith('$') ? 1 : 3) : attr.name;
let exp = 0;
if (attr.name.startsWith('$$') || attr.name.startsWith('st-st-')) exp = 2;
else if (attr.name.startsWith('$') || attr.name.startsWith('st-')) exp = 1;
const realAttrName = exp === 2 ? (attr.name.startsWith('$$') ? attr.name.slice(2) : attr.name.slice(6)) : (exp === 1 ? (attr.name.startsWith('$') ? attr.name.slice(1) : attr.name.slice(3)) : attr.name);
let tpl = attr.value;
node.removeAttribute(attr.name);
if (realAttrName.startsWith('.')) _initBinding({ node, prop: realAttrName.split('.'), tpl, exp });
@ -324,9 +334,11 @@ export const _scanTree = (node, scanObj = {}) => {
if (resolvedThisObj === undefined) resolvedThisObj = scanObj.thisObj;
if (resolvedRef === undefined) resolvedRef = scanObj.extendVars;
if (node.tagName !== 'TEMPLATE' && (node.hasAttribute('$if') || node.hasAttribute('$each') || node.hasAttribute('st-if') || node.hasAttribute('st-each'))) {
const triggerAttrs = ['$if', '$each', 'st-if', 'st-each', '$$if', '$$each', 'st-st-if', 'st-st-each'];
const eachAttrs = ['$each', 'st-each', '$$each', 'st-st-each'];
if (node.tagName !== 'TEMPLATE' && triggerAttrs.some(t => node.hasAttribute(t))) {
const template = document.createElement('TEMPLATE');
const attrs = Array.from(node.attributes).filter(attr => ['$if', '$each', 'st-if', 'st-each'].includes(attr.name) || ((node.hasAttribute('$each') || node.hasAttribute('st-each')) && ['as', 'index'].includes(attr.name)));
const attrs = Array.from(node.attributes).filter(attr => triggerAttrs.includes(attr.name) || (eachAttrs.some(t => node.hasAttribute(t)) && ['as', 'index'].includes(attr.name)));
attrs.forEach(attr => { template.setAttribute(attr.name, attr.value); node.removeAttribute(attr.name); });
node.parentNode.insertBefore(template, node);
template.content.appendChild(node);
@ -335,13 +347,13 @@ export const _scanTree = (node, scanObj = {}) => {
_scanTree(template, scanObj);
return;
}
if (node.tagName === 'TEMPLATE' && (node.hasAttribute('$if') || node.hasAttribute('st-if')) && (node.hasAttribute('$each') || node.hasAttribute('st-each'))) {
if (node.tagName === 'TEMPLATE' && (node.hasAttribute('$if') || node.hasAttribute('st-if') || node.hasAttribute('$$if') || node.hasAttribute('st-st-if')) && (node.hasAttribute('$each') || node.hasAttribute('st-each') || node.hasAttribute('$$each') || node.hasAttribute('st-st-each'))) {
const template = document.createElement('TEMPLATE');
const attrs = Array.from(node.attributes).filter(attr => ['$if', '$each', 'st-if', 'st-each'].includes(attr.name));
const attrs = Array.from(node.attributes).filter(attr => triggerAttrs.includes(attr.name));
const attr = attrs[attrs.length - 1];
template.setAttribute(attr.name, attr.value);
node.removeAttribute(attr.name);
if (attr.name === '$each' || attr.name === 'st-each') {
if (eachAttrs.includes(attr.name)) {
Array.from(node.attributes).filter(attr => ['as', 'index'].includes(attr.name)).forEach(attr => { template.setAttribute(attr.name, attr.value); node.removeAttribute(attr.name); });
}
Array.from(node.content.childNodes).forEach(child => template.content.appendChild(child));

12
test-html-attr.cjs Normal file
View File

@ -0,0 +1,12 @@
const { chromium } = require('@playwright/test');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
const name = await page.evaluate(() => {
const div = document.createElement('div');
div.innerHTML = '<div $.val="hello"></div>';
return div.firstChild.attributes[0].name;
});
console.log('Attribute name:', name);
await browser.close();
})();

View File

@ -1,6 +1,4 @@
{
"status": "failed",
"failedTests": [
"8a84b43f13b676ea22b7-5fcda25ae3a58304c071"
]
"status": "interrupted",
"failedTests": []
}

View File

@ -1,6 +1,6 @@
// test/dom.test.js
import { RefreshState, $, $$ } from '@web/state';
import { NewState } from '@web/state';
import { RefreshState, $, $$ } from '../src/index.js';
import { NewState } from '../src/index.js';
export async function testDom() {
console.log('Testing dom.js...');
@ -65,15 +65,16 @@ export async function testDom() {
if (items.length !== 1 || items[0].textContent !== 'C') throw new Error('$each removal failed');
// 4. Two-way binding (bind)
console.log('Testing $bind...');
document.body.innerHTML = `<input id="input-test" $bind="state.val">`;
state.val = 'initial';
window.state = NewState({ val: 'initial' });
RefreshState(document.documentElement);
await wait();
const input = $('#input-test');
if (input.value !== 'initial') throw new Error('$bind initial value failed');
input.value = 'changed';
input.dispatchEvent(new Event('input'));
input.dispatchEvent(new Event('input', { bubbles: true }));
if (state.val !== 'changed') throw new Error('$bind write-back failed');
// 5. Unbinding cleanup (mock check)
@ -83,8 +84,53 @@ export async function testDom() {
state.show = true;
const ifNode = $('#if-content');
state.show = false; // Trigger _unbindTree via _clearRenderedNodes -> remove() -> MutationObserver (or manual in some cases)
// Actually MutationObserver handles _unbindTree in index.js.
// 6. Double evaluation ($$ prefix)
console.log('Testing double evaluation ($$)...');
document.body.innerHTML = `
<div id="double-eval-root">
<div id="inner-node" $$if="state.innerExp">Dynamic Visible</div>
</div>
`;
const doubleState = NewState({
innerExp: 'state.innerShow',
innerShow: false
});
window.state = doubleState;
document.documentElement._thisObj = doubleState;
RefreshState(document.documentElement);
await wait();
if ($('#inner-node')) throw new Error('$$if failed: should be hidden initially');
console.log('Enabling inner node...');
doubleState.innerShow = true;
RefreshState(document.documentElement);
await wait();
const inner = $('#inner-node');
if (!inner) throw new Error('$$if failed: should be visible after innerShow=true');
console.log('Inner node visible:', inner.textContent);
// 7. Nested $$if
console.log('Testing nested $$if...');
document.body.innerHTML = `
<div id="nested-double-test">
<template $if="state.outer">
<div id="nested-inner" $$if="state.innerExp">Nested Visible</div>
</template>
</div>
`;
doubleState.outer = true;
doubleState.innerShow = false;
RefreshState(document.documentElement);
await wait();
if ($('#nested-inner')) throw new Error('nested $$if failed: should be hidden initially');
doubleState.innerShow = true;
RefreshState(document.documentElement);
await wait();
if (!$('#nested-inner')) throw new Error('nested $$if failed: should be visible after update');
console.log('dom.js tests passed');
return true;
}

View File

@ -34,10 +34,11 @@ export async function testPriority() {
const state = { outerVal: 'hello' };
const outer = document.createElement('div');
outer.id = 'outer';
outer._thisObj = state; // Rule: Set _thisObj on root before scanning if not in document
outer.innerHTML = `<val-comp id="val-node" $.val="this.outerVal"></val-comp>`;
document.body.appendChild(outer);
RefreshState(outer, { thisObj: state });
document.body.appendChild(outer);
if (capturedVal !== 'hello') {
throw new Error(`this. replacement failed: capturedVal was ${capturedVal}, expected 'hello'`);