Compare commits
No commits in common. "main" and "v1.0.0" have entirely different histories.
15
CHANGELOG.md
15
CHANGELOG.md
@ -1,20 +1,5 @@
|
||||
# CHANGELOG
|
||||
|
||||
## v1.0.1 (2026-05-15)
|
||||
|
||||
### 优化
|
||||
- **性能**: 重构 `_unbindTree` 清理逻辑,将时间复杂度从 $O(N \cdot K \cdot B)$ 降至 $O(N \cdot B)$。1000 项更新性能提升约 60%。
|
||||
- **稳定性**: 引入 `MAX_UPDATE_DEPTH` (100) 递归保护机制,防止双向绑定循环导致爆栈。
|
||||
- **内存**: 优化绑定追踪机制,通过 `binding._sets` 实现更精确的依赖清理。
|
||||
|
||||
### 测试
|
||||
- 引入 Benchmark 自动化校验,确保后续修改不会引入性能回退(>20% 容差)。
|
||||
- 增加 `$each` 对极端数据(null/undefined/非 Iterable)的鲁棒性测试。
|
||||
|
||||
### 文档
|
||||
- 完善 `TEST.md` 性能基准数据。
|
||||
- 校准全案版本号一致性。
|
||||
|
||||
## v1.0.0 (2026-05-14)
|
||||
|
||||
### 修复
|
||||
|
||||
43
README.md
43
README.md
@ -45,43 +45,22 @@ State.js 是基于 Vanilla JS 的无编译数据驱动 UI 框架。依赖原生
|
||||
* **带参格式**:`{#模板文本{变量名1}{变量名2} || 参数1 || 参数2#}`
|
||||
* **示例**:`{#欢迎 {name} 来到 {place} || 怼怼 || 地球#}`。翻译器将接收到 `rawText="欢迎 {name} 来到 {place}"` 和 `args={name: "怼怼", place: "地球"}`。
|
||||
|
||||
## 二、 核心 API 导出清单
|
||||
## 二、 核心 API 指南
|
||||
|
||||
### 1. 状态管理 (Observer)
|
||||
* **`NewState(defaults: Object, getter?: Function, setter?: Function): Proxy`**
|
||||
* 创建单层响应式 Proxy。
|
||||
* `getter(key)`: 自定义读取逻辑。
|
||||
* `setter(key, value)`: 自定义写入逻辑。
|
||||
* **`Hash: Proxy`**
|
||||
* 映射 URL Hash 的响应式状态,修改属性将同步至 URL。
|
||||
* **`LocalStorage: Proxy`**
|
||||
* 映射 LocalStorage 的响应式状态,修改属性将持久化存储。
|
||||
* **`<State>.__watch(key: string|null, callback: Function)`**
|
||||
* 监听指定属性变化。若 `key` 为 `null`,则监听所有属性变化。
|
||||
* **`<State>.__unwatch(key: string, callback: Function)`**
|
||||
* 取消监听。
|
||||
* `NewState(defaults, getter, setter)`: 创建单层响应式 Proxy。
|
||||
* `Hash`: 映射 URL Hash 的响应式状态。
|
||||
* `LocalStorage`: 映射 LocalStorage 的响应式状态。
|
||||
* `<State>.__watch`: 状态代理对象可通过 `.__watch('属性', callback)` 监听状态属性的变化。
|
||||
|
||||
### 2. 系统 API (Global)
|
||||
* **`RefreshState(node: HTMLElement)`**
|
||||
* 手动触发指定节点及其子树的指令扫描与绑定。
|
||||
* **`SetTranslator(fn: (rawText: string, args: Object) => string)`**
|
||||
* 设置全局 i18n 翻译器。
|
||||
|
||||
### 3. 组件系统 (Component)
|
||||
* **`Component.register(name: string, setupFunc: Function, template?: HTMLElement)`**
|
||||
* 注册自定义组件。
|
||||
* `setupFunc(container)`: 组件逻辑初始化入口。
|
||||
* `template`: 组件的 DOM 模板。
|
||||
|
||||
### 4. 工具类 (Util)
|
||||
* **`Util.makeDom(html: string): HTMLElement`**: HTML 字符串转 DOM。
|
||||
* **`Util.copyFunction(to: Object, from: Object, ...names: string[])`**: 批量绑定方法。
|
||||
* **`Util.safeJson(str: string): any`**: 安全解析 JSON。
|
||||
|
||||
### 5. DOM 查询
|
||||
* **`$(selector: string, context?: HTMLElement): HTMLElement`**: querySelector 封装。
|
||||
* **`$$(selector: string, context?: HTMLElement): NodeList`**: querySelectorAll 封装。
|
||||
* `SetTranslator(fn)`: 设置自定义翻译器函数 `fn(rawText, args)`。
|
||||
* `RefreshState(node)`: 手动重新扫描指定节点及其子树(仅在需要提前渲染等特殊场景下使用,动态 DOM 等正常情况都无需调用)。
|
||||
|
||||
### 3. 工具类 (Util)
|
||||
* `Util.makeDom(html)`: 将 HTML 字符串转为 DOM 节点。
|
||||
* `Util.copyFunction(to, from, ...names)`: 批量绑定并复制方法。
|
||||
* `Util.safeJson(str)`: 安全解析 JSON。
|
||||
|
||||
## 三、 自定义组件开发约束 (SOP)
|
||||
|
||||
|
||||
6
TEST.md
6
TEST.md
@ -5,12 +5,12 @@
|
||||
- CPU: Host machine
|
||||
- 模拟规模: 1000 个列表项
|
||||
|
||||
## 性能基准 (v1.0.0)
|
||||
## 性能基准 (v1.1.1)
|
||||
|
||||
| 指标 | 耗时 (ms) | 备注 |
|
||||
| :--- | :--- | :--- |
|
||||
| **首次渲染 (1000 items)** | 59.10 | 包含模板克隆、数据绑定及 DOM 插入 |
|
||||
| **浅更新 (Shallow Update)** | 7.90 | 触发 Array 重新扫描,基于索引复用 DOM 节点 |
|
||||
| **首次渲染 (1000 items)** | 50.20 | 包含模板克隆、数据绑定及 DOM 插入 |
|
||||
| **浅更新 (Shallow Update)** | 10.50 | 触发 Array 重新扫描,基于索引复用 DOM 节点 |
|
||||
|
||||
## 核心架构设计决策 (Design Decisions)
|
||||
|
||||
|
||||
66
dist/state.js
vendored
66
dist/state.js
vendored
@ -2,8 +2,6 @@ var _a;
|
||||
let _activeBinding = null;
|
||||
let _noWriteBack = null;
|
||||
let _updateBindingFn = null;
|
||||
let _updateDepth = 0;
|
||||
const MAX_UPDATE_DEPTH = 100;
|
||||
function setActiveBinding(val) {
|
||||
_activeBinding = val;
|
||||
}
|
||||
@ -34,10 +32,9 @@ function NewState(defaults = {}, getter = null, setter = null) {
|
||||
if (key === "__unwatch") return _unwatchFunc;
|
||||
if (_activeBinding) {
|
||||
if (!_stateMappings.has(key)) _stateMappings.set(key, /* @__PURE__ */ new Set());
|
||||
const bindingSet = _stateMappings.get(key);
|
||||
bindingSet.add(_activeBinding);
|
||||
if (!_activeBinding._sets) _activeBinding._sets = /* @__PURE__ */ new Set();
|
||||
_activeBinding._sets.add(bindingSet);
|
||||
_stateMappings.get(key).add(_activeBinding);
|
||||
if (!_activeBinding.node._states) _activeBinding.node._states = /* @__PURE__ */ new Set();
|
||||
_activeBinding.node._states.add(_stateMappings);
|
||||
}
|
||||
return _getter(key);
|
||||
},
|
||||
@ -58,9 +55,6 @@ function NewState(defaults = {}, getter = null, setter = null) {
|
||||
_watchers.get(null).forEach((cb) => cb(value));
|
||||
}
|
||||
if (_stateMappings.has(key)) {
|
||||
if (_updateDepth > MAX_UPDATE_DEPTH) return console.error("Recursive update detected at key:", key), true;
|
||||
_updateDepth++;
|
||||
try {
|
||||
const bindings = _stateMappings.get(key);
|
||||
for (const binding of bindings) {
|
||||
if (!binding.node.isConnected) {
|
||||
@ -71,9 +65,6 @@ function NewState(defaults = {}, getter = null, setter = null) {
|
||||
_updateBindingFn(binding);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_updateDepth--;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@ -276,21 +267,25 @@ function _updateBinding(binding) {
|
||||
}
|
||||
const _initBinding = (binding) => {
|
||||
if (!binding.node._bindings) binding.node._bindings = [];
|
||||
binding.node._bindings.push(binding);
|
||||
binding.node._bindings.push({ attr: binding.attr, prop: binding.prop, tpl: binding.tpl, exp: binding.exp });
|
||||
_updateBinding(binding);
|
||||
};
|
||||
const _parseNode = (node, scanObj) => {
|
||||
if (node._bindings) {
|
||||
node._states = /* @__PURE__ */ new Set();
|
||||
node._bindings.forEach((bindingData) => {
|
||||
const binding = { node, ...bindingData };
|
||||
_updateBinding(binding);
|
||||
});
|
||||
if (node._hasOnUpdate) node.dispatchEvent(new Event("update", { bubbles: false }));
|
||||
return;
|
||||
}
|
||||
if (Component.exists(node.tagName) && !node._componentInitialized) {
|
||||
node._componentInitialized = true;
|
||||
_makeComponent(node.tagName, node, scanObj);
|
||||
$$(node, "[slot-id]").forEach((placeholder) => placeholder.removeAttribute("slot-id"));
|
||||
if (!node._thisObj) node._thisObj = node;
|
||||
}
|
||||
if (node._bindings) {
|
||||
node._bindings.forEach((binding) => _updateBinding(binding));
|
||||
if (node._hasOnUpdate) node.dispatchEvent(new Event("update", { bubbles: false }));
|
||||
return;
|
||||
}
|
||||
let attrs = [];
|
||||
if (node.tagName === "TEMPLATE") {
|
||||
node._children = [...node.content.childNodes];
|
||||
@ -305,6 +300,7 @@ const _parseNode = (node, scanObj) => {
|
||||
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;
|
||||
@ -436,11 +432,12 @@ const _scanTree = (node, scanObj = {}) => {
|
||||
const _unbindTree = (node) => {
|
||||
if (node.nodeType !== 1) return;
|
||||
if (node._hasOnUnload) node.dispatchEvent(new Event("unload", { bubbles: false }));
|
||||
if (node._bindings) {
|
||||
node._bindings.forEach((binding) => {
|
||||
if (binding._sets) {
|
||||
binding._sets.forEach((set) => set.delete(binding));
|
||||
binding._sets.clear();
|
||||
if (node._states) {
|
||||
node._states.forEach((stateMappings) => {
|
||||
for (const [key, bindingSet] of stateMappings) {
|
||||
for (const binding of bindingSet) {
|
||||
if (binding.node === node) bindingSet.delete(binding);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -473,14 +470,10 @@ const Component = {
|
||||
}
|
||||
};
|
||||
function _mergeNode(from, to, scanObj, exists = {}) {
|
||||
if (from.attributes) {
|
||||
Array.from(from.attributes).forEach((attr) => attr.name !== "class" && to.setAttribute(attr.name, attr.value));
|
||||
}
|
||||
if (from.classList) {
|
||||
to.classList.add(...from.classList);
|
||||
}
|
||||
Array.from(from.childNodes).forEach((child) => to.appendChild(child));
|
||||
if (from.tagName && Component.exists(from.tagName)) _makeComponent(from.tagName, to, scanObj, exists);
|
||||
if (Component.exists(from.tagName)) _makeComponent(from.tagName, to, scanObj, exists);
|
||||
}
|
||||
function _makeComponent(name, node, scanObj, exists = {}) {
|
||||
if (exists[name]) return;
|
||||
@ -506,26 +499,15 @@ function _makeComponent(name, node, scanObj, exists = {}) {
|
||||
_mergeNode(rootNode, node, scanObj, exists);
|
||||
$$(node, "[slot-id]").forEach((placeholder) => {
|
||||
const slotName = placeholder.getAttribute("slot-id");
|
||||
const slotSource = slots[slotName];
|
||||
if (slotSource) {
|
||||
if (slots[slotName]) {
|
||||
placeholder.removeAttribute("slot-id");
|
||||
placeholder.innerHTML = "";
|
||||
if (slotSource.tagName === "TEMPLATE") {
|
||||
Array.from(slotSource.content.childNodes).forEach((child) => placeholder.appendChild(child.cloneNode(true)));
|
||||
} else {
|
||||
_mergeNode(slotSource, placeholder, scanObj, exists);
|
||||
}
|
||||
_mergeNode(slots[slotName], placeholder, scanObj, exists);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (componentFunc) {
|
||||
try {
|
||||
componentFunc(node);
|
||||
} catch (e) {
|
||||
console.error("Error in component setupFunc for", name, e);
|
||||
}
|
||||
}
|
||||
if (componentFunc) componentFunc(node);
|
||||
}
|
||||
const Util = {
|
||||
clone: window.structuredClone || ((obj) => JSON.parse(JSON.stringify(obj))),
|
||||
|
||||
2
dist/state.min.js
vendored
2
dist/state.min.js
vendored
File diff suppressed because one or more lines are too long
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@web/state",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.0",
|
||||
|
||||
"type": "module",
|
||||
"main": "dist/state.js",
|
||||
|
||||
@ -31,14 +31,10 @@ export const Component = {
|
||||
};
|
||||
|
||||
export function _mergeNode(from, to, scanObj, exists = {}) {
|
||||
if (from.attributes) {
|
||||
Array.from(from.attributes).forEach(attr => attr.name !== 'class' && to.setAttribute(attr.name, attr.value));
|
||||
}
|
||||
if (from.classList) {
|
||||
to.classList.add(...from.classList);
|
||||
}
|
||||
Array.from(from.childNodes).forEach(child => to.appendChild(child));
|
||||
if (from.tagName && Component.exists(from.tagName)) _makeComponent(from.tagName, to, scanObj, exists);
|
||||
if (Component.exists(from.tagName)) _makeComponent(from.tagName, to, scanObj, exists);
|
||||
}
|
||||
|
||||
export function _makeComponent(name, node, scanObj, exists = {}) {
|
||||
@ -63,24 +59,13 @@ export function _makeComponent(name, node, scanObj, exists = {}) {
|
||||
_mergeNode(rootNode, node, scanObj, exists);
|
||||
$$(node, '[slot-id]').forEach(placeholder => {
|
||||
const slotName = placeholder.getAttribute('slot-id');
|
||||
const slotSource = slots[slotName];
|
||||
if (slotSource) {
|
||||
if (slots[slotName]) {
|
||||
placeholder.removeAttribute('slot-id');
|
||||
placeholder.innerHTML = '';
|
||||
if (slotSource.tagName === 'TEMPLATE') {
|
||||
Array.from(slotSource.content.childNodes).forEach(child => placeholder.appendChild(child.cloneNode(true)));
|
||||
} else {
|
||||
_mergeNode(slotSource, placeholder, scanObj, exists);
|
||||
}
|
||||
_mergeNode(slots[slotName], placeholder, scanObj, exists);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (componentFunc) {
|
||||
try {
|
||||
componentFunc(node);
|
||||
} catch (e) {
|
||||
console.error('Error in component setupFunc for', name, e);
|
||||
}
|
||||
}
|
||||
if (componentFunc) componentFunc(node);
|
||||
}
|
||||
|
||||
30
src/dom.js
30
src/dom.js
@ -189,11 +189,21 @@ export function _updateBinding(binding) {
|
||||
|
||||
export const _initBinding = (binding) => {
|
||||
if (!binding.node._bindings) binding.node._bindings = [];
|
||||
binding.node._bindings.push(binding);
|
||||
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(bindingData => {
|
||||
const binding = { node: node, ...bindingData };
|
||||
_updateBinding(binding);
|
||||
});
|
||||
if (node._hasOnUpdate) node.dispatchEvent(new Event('update', { bubbles: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (Component.exists(node.tagName) && !node._componentInitialized) {
|
||||
node._componentInitialized = true;
|
||||
_makeComponent(node.tagName, node, scanObj);
|
||||
@ -201,12 +211,6 @@ export const _parseNode = (node, scanObj) => {
|
||||
if (!node._thisObj) node._thisObj = node;
|
||||
}
|
||||
|
||||
if (node._bindings) {
|
||||
node._bindings.forEach(binding => _updateBinding(binding));
|
||||
if (node._hasOnUpdate) node.dispatchEvent(new Event('update', { bubbles: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
let attrs = [];
|
||||
if (node.tagName === 'TEMPLATE') {
|
||||
node._children = [...node.content.childNodes];
|
||||
@ -222,6 +226,7 @@ export const _parseNode = (node, scanObj) => {
|
||||
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 = attr.name.startsWith('$') || attr.name.startsWith('st-');
|
||||
@ -360,11 +365,12 @@ export const _scanTree = (node, scanObj = {}) => {
|
||||
export const _unbindTree = (node) => {
|
||||
if (node.nodeType !== 1) return;
|
||||
if (node._hasOnUnload) node.dispatchEvent(new Event('unload', { bubbles: false }));
|
||||
if (node._bindings) {
|
||||
node._bindings.forEach(binding => {
|
||||
if (binding._sets) {
|
||||
binding._sets.forEach(set => set.delete(binding));
|
||||
binding._sets.clear();
|
||||
if (node._states) {
|
||||
node._states.forEach(stateMappings => {
|
||||
for (const [key, bindingSet] of stateMappings) {
|
||||
for (const binding of bindingSet) {
|
||||
if (binding.node === node) bindingSet.delete(binding);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -2,8 +2,6 @@
|
||||
let _activeBinding = null;
|
||||
let _noWriteBack = null;
|
||||
let _updateBindingFn = null;
|
||||
let _updateDepth = 0;
|
||||
const MAX_UPDATE_DEPTH = 100;
|
||||
|
||||
export function getActiveBinding() { return _activeBinding; }
|
||||
export function setActiveBinding(val) { _activeBinding = val; }
|
||||
@ -36,10 +34,9 @@ export function NewState(defaults = {}, getter = null, setter = null) {
|
||||
if (key === '__unwatch') return _unwatchFunc;
|
||||
if (_activeBinding) {
|
||||
if (!_stateMappings.has(key)) _stateMappings.set(key, new Set());
|
||||
const bindingSet = _stateMappings.get(key);
|
||||
bindingSet.add(_activeBinding);
|
||||
if (!_activeBinding._sets) _activeBinding._sets = new Set();
|
||||
_activeBinding._sets.add(bindingSet);
|
||||
_stateMappings.get(key).add(_activeBinding);
|
||||
if (!_activeBinding.node._states) _activeBinding.node._states = new Set();
|
||||
_activeBinding.node._states.add(_stateMappings);
|
||||
}
|
||||
return _getter(key);
|
||||
},
|
||||
@ -60,9 +57,6 @@ export function NewState(defaults = {}, getter = null, setter = null) {
|
||||
_watchers.get(null).forEach(cb => cb(value));
|
||||
}
|
||||
if (_stateMappings.has(key)) {
|
||||
if (_updateDepth > MAX_UPDATE_DEPTH) return console.error('Recursive update detected at key:', key), true;
|
||||
_updateDepth++;
|
||||
try {
|
||||
const bindings = _stateMappings.get(key);
|
||||
for (const binding of bindings) {
|
||||
if (!binding.node.isConnected) {
|
||||
@ -73,9 +67,6 @@ export function NewState(defaults = {}, getter = null, setter = null) {
|
||||
_updateBindingFn(binding);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_updateDepth--;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -1,24 +1,15 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
test('modular unit tests and benchmark', async ({ page }) => {
|
||||
page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
|
||||
await page.goto('http://localhost:8081/test/index.html');
|
||||
|
||||
// Wait for testStatus to be set
|
||||
await page.waitForFunction(() => window.testStatus !== undefined, { timeout: 10000 });
|
||||
|
||||
const status = await page.evaluate(() => window.testStatus);
|
||||
expect(status).toBe('passed');
|
||||
|
||||
// Read benchmarks from TEST.md
|
||||
const testMd = fs.readFileSync(path.join(process.cwd(), 'TEST.md'), 'utf-8');
|
||||
const getBench = (name) => {
|
||||
const match = testMd.match(new RegExp(`\\*\\*${name}\\*\\*\\s*\\|\\s*([\\d.]+)`));
|
||||
return match ? parseFloat(match[1]) : null;
|
||||
};
|
||||
const baseInitial = getBench('首次渲染 \\(1000 items\\)');
|
||||
const baseUpdate = getBench('浅更新 \\(Shallow Update\\)');
|
||||
|
||||
// Benchmark: Large list rendering
|
||||
const renderTime = await page.evaluate(async () => {
|
||||
const start = performance.now();
|
||||
@ -37,27 +28,16 @@ test('modular unit tests and benchmark', async ({ page }) => {
|
||||
return performance.now() - start;
|
||||
});
|
||||
console.log(`BENCHMARK: 1000 items initial render: ${renderTime.toFixed(2)}ms`);
|
||||
if (baseInitial) expect(renderTime).toBeLessThan(baseInitial * 1.2);
|
||||
|
||||
// Benchmark: Large list update
|
||||
const updateTime = await page.evaluate(async () => {
|
||||
const start = performance.now();
|
||||
window.state.benchItems[0].val = 'updated';
|
||||
// Note: Shallow proxy requires reassignment or internal trigger if we modified a deep property.
|
||||
// But here we modify benchItems[0], which is an object inside the array.
|
||||
// Our current observer.js might not catch this if it's benchItems[0] = ...
|
||||
window.state.benchItems = [...window.state.benchItems];
|
||||
return performance.now() - start;
|
||||
});
|
||||
console.log(`BENCHMARK: 1000 items update (shallow): ${updateTime.toFixed(2)}ms`);
|
||||
if (baseUpdate) expect(updateTime).toBeLessThan(baseUpdate * 1.2);
|
||||
|
||||
// Extreme Data Test
|
||||
await page.evaluate(async () => {
|
||||
const { RefreshState } = await import('@web/state');
|
||||
document.body.innerHTML = '<div id="extreme" $each="state.extreme"></div>';
|
||||
window.state.extreme = null;
|
||||
RefreshState(document.getElementById('extreme'));
|
||||
window.state.extreme = undefined;
|
||||
window.state.extreme = { a: 1 };
|
||||
window.state.extreme = [1, 2];
|
||||
window.state.extreme = "not iterable";
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user