release: WRNexusJS 0.3.0
This commit is contained in:
+10
-9
@@ -49,15 +49,16 @@ getRealtimeRuntime(): string // → REALTIME_RUNTIME
|
||||
|
||||
Applied to any subtree containing `data-scope`. Expressions are parsed by a tiny eval-free evaluator, so a strict CSP with no `unsafe-eval` works.
|
||||
|
||||
| Directive | Purpose |
|
||||
| -------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `data-scope="count: 0, name: 'x'"` | Declare reactive state on a subtree |
|
||||
| `data-on-<event>="count++"` | Run a statement in scope on a DOM event |
|
||||
| `data-text="expr"` | Bind an element's `textContent` to an expression |
|
||||
| `data-show="expr"` | Toggle visibility (`display`) on truthiness |
|
||||
| `data-for="item in list"` (opt. `item, i in list`) | Per-item list rendering template |
|
||||
| `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values |
|
||||
| `data-wrnexus-csr="id"` | Target for a generated CSR fetch binding (fetches `/__wrnexus/csr?...`) |
|
||||
| Directive | Purpose |
|
||||
| -------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `data-scope="count: 0, name: 'x'"` | Declare reactive state on a subtree |
|
||||
| `data-on-<event>="count++"` | Run a statement in scope on a DOM event |
|
||||
| `data-text="expr"` | Bind an element's `textContent` to an expression |
|
||||
| `data-show="expr"` | Toggle visibility (`display`) on truthiness |
|
||||
| `data-for="item in list"` (opt. index and `key item.id`) | Per-item rendering; stable keys preserve DOM identity during reorder |
|
||||
| `data-key="item.id"` | Alternative key declaration for `data-for` templates |
|
||||
| `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values |
|
||||
| `data-wrnexus-csr="id"` | Target for a generated CSR fetch binding (fetches `/__wrnexus/csr?...`) |
|
||||
|
||||
Supported expression features: literals, identifiers, member access (`a.b`, `a[b]`), function/method calls, arrays, objects, arithmetic, comparison, equality, logical (`&& ||`), unary (`! - +`), and ternary. Statements support `++`/`--`, assignment operators (`= += -= *= /= %=`), and bare expression/method calls. Rendering is dependency-tracked: a signal change only re-runs the renderers that actually read it.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -25,6 +25,21 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
var updateHooksScheduled = false;
|
||||
var behaviorObserver;
|
||||
|
||||
function reportDiagnostic(code, message, element, detail) {
|
||||
var payload = {
|
||||
code: code,
|
||||
message: message,
|
||||
hydrationId: element && element.getAttribute ? element.getAttribute("data-wrn-hydration") : null,
|
||||
detail: detail || null,
|
||||
};
|
||||
console.error("[wrnexus:" + code + "] " + message, detail || "");
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("wrnexus:diagnostic", { detail: payload }));
|
||||
} catch (_) {
|
||||
// CustomEvent can be unavailable in minimal DOM test environments.
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleUpdateHook(element, callback) {
|
||||
pendingUpdateHooks.set(element, callback);
|
||||
if (updateHooksScheduled) return;
|
||||
@@ -424,8 +439,10 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
encodedScope,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[wrnexus] failed to decode scope payload",
|
||||
reportDiagnostic(
|
||||
"WRN-HYDRATE-SCOPE",
|
||||
"Failed to decode the server-rendered scope payload.",
|
||||
el,
|
||||
error,
|
||||
);
|
||||
|
||||
@@ -445,6 +462,16 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
|
||||
var signals = {};
|
||||
Object.keys(initial).forEach(function (k) { signals[k] = signal(initial[k]); });
|
||||
var behavior = parseBehavior(el);
|
||||
var computedDefinitions = {};
|
||||
var computing = new Set();
|
||||
if (behavior && Array.isArray(behavior.computed)) {
|
||||
behavior.computed.forEach(function (entry) {
|
||||
if (entry && typeof entry.name === "string" && typeof entry.expr === "string") {
|
||||
computedDefinitions[entry.name] = entry.expr;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var renderers = [];
|
||||
// Dependency-tracked rendering: while a renderer runs, every signal it reads
|
||||
@@ -452,7 +479,41 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
// change then re-runs only the renderers that actually read it. The Set in
|
||||
// signal.subscribe dedupes, so re-subscribing each run is cheap and bounded.
|
||||
var currentRenderer = null;
|
||||
var currentRenderer = null;
|
||||
var pendingRenderers = new Set();
|
||||
var batchDepth = 0;
|
||||
var flushingRenderers = false;
|
||||
|
||||
function flushRenderers() {
|
||||
if (flushingRenderers || batchDepth > 0) return;
|
||||
|
||||
flushingRenderers = true;
|
||||
|
||||
try {
|
||||
while (pendingRenderers.size > 0) {
|
||||
var queue = Array.from(pendingRenderers);
|
||||
pendingRenderers.clear();
|
||||
queue.forEach(function (run) { run(); });
|
||||
}
|
||||
} finally {
|
||||
flushingRenderers = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRenderer(renderer) {
|
||||
pendingRenderers.add(renderer);
|
||||
flushRenderers();
|
||||
}
|
||||
|
||||
function batchUpdates(callback) {
|
||||
batchDepth++;
|
||||
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
batchDepth--;
|
||||
flushRenderers();
|
||||
}
|
||||
}
|
||||
|
||||
function reactive(fn) {
|
||||
var running = false;
|
||||
@@ -467,7 +528,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
var previousRenderer =
|
||||
currentRenderer;
|
||||
|
||||
currentRenderer = run;
|
||||
currentRenderer = schedule;
|
||||
|
||||
try {
|
||||
fn();
|
||||
@@ -479,6 +540,10 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
}
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
scheduleRenderer(run);
|
||||
}
|
||||
|
||||
renderers.push(run);
|
||||
|
||||
return run;
|
||||
@@ -577,6 +642,18 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
}
|
||||
|
||||
function readScope(name) {
|
||||
if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) {
|
||||
if (computing.has(name)) {
|
||||
reportDiagnostic("WRN-COMPUTED-CYCLE", "Computed value '" + name + "' has a dependency cycle.", el);
|
||||
return undefined;
|
||||
}
|
||||
computing.add(name);
|
||||
try {
|
||||
return evalExpr(computedDefinitions[name]);
|
||||
} finally {
|
||||
computing.delete(name);
|
||||
}
|
||||
}
|
||||
var sig = signals[name];
|
||||
if (sig) {
|
||||
if (currentRenderer) sig.subscribe(currentRenderer);
|
||||
@@ -589,6 +666,9 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
}
|
||||
|
||||
function peekScope(name) {
|
||||
if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) {
|
||||
return readScope(name);
|
||||
}
|
||||
if (signals[name]) return signals[name].get();
|
||||
if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) {
|
||||
return behaviorFunctions[name];
|
||||
@@ -664,68 +744,70 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
source,
|
||||
locals,
|
||||
) {
|
||||
var statements =
|
||||
splitStatements(source);
|
||||
return batchUpdates(function () {
|
||||
var statements =
|
||||
splitStatements(source);
|
||||
|
||||
for (
|
||||
var statementIndex = 0;
|
||||
statementIndex <
|
||||
statements.length;
|
||||
statementIndex++
|
||||
) {
|
||||
var result = runStatement(
|
||||
statements[statementIndex],
|
||||
function (expression) {
|
||||
return evalExpr(
|
||||
expression,
|
||||
locals,
|
||||
);
|
||||
},
|
||||
function (name) {
|
||||
if (
|
||||
locals &&
|
||||
Object.prototype
|
||||
.hasOwnProperty.call(
|
||||
locals,
|
||||
name,
|
||||
)
|
||||
) {
|
||||
return locals[name];
|
||||
}
|
||||
for (
|
||||
var statementIndex = 0;
|
||||
statementIndex <
|
||||
statements.length;
|
||||
statementIndex++
|
||||
) {
|
||||
var result = runStatement(
|
||||
statements[statementIndex],
|
||||
function (expression) {
|
||||
return evalExpr(
|
||||
expression,
|
||||
locals,
|
||||
);
|
||||
},
|
||||
function (name) {
|
||||
if (
|
||||
locals &&
|
||||
Object.prototype
|
||||
.hasOwnProperty.call(
|
||||
locals,
|
||||
name,
|
||||
)
|
||||
) {
|
||||
return locals[name];
|
||||
}
|
||||
|
||||
return peekScope(name);
|
||||
},
|
||||
function (name, value) {
|
||||
if (
|
||||
locals &&
|
||||
Object.prototype
|
||||
.hasOwnProperty.call(
|
||||
locals,
|
||||
name,
|
||||
)
|
||||
) {
|
||||
locals[name] = value;
|
||||
} else {
|
||||
writeScope(name, value);
|
||||
}
|
||||
},
|
||||
function (body) {
|
||||
return runStmt(
|
||||
body,
|
||||
locals,
|
||||
);
|
||||
},
|
||||
);
|
||||
return peekScope(name);
|
||||
},
|
||||
function (name, value) {
|
||||
if (
|
||||
locals &&
|
||||
Object.prototype
|
||||
.hasOwnProperty.call(
|
||||
locals,
|
||||
name,
|
||||
)
|
||||
) {
|
||||
locals[name] = value;
|
||||
} else {
|
||||
writeScope(name, value);
|
||||
}
|
||||
},
|
||||
function (body) {
|
||||
return runStmt(
|
||||
body,
|
||||
locals,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (result.returned) {
|
||||
return result;
|
||||
if (result.returned) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
returned: false,
|
||||
value: undefined,
|
||||
};
|
||||
return {
|
||||
returned: false,
|
||||
value: undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function installBehaviorFunctions(source) {
|
||||
@@ -771,13 +853,31 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
}
|
||||
|
||||
// --- data-for list rendering -------------------------------------------
|
||||
// Each [data-for="item in list"] element is a per-item template. On any
|
||||
// change to the list (or a dependency an item reads), the list re-renders.
|
||||
// Each [data-for="item in list"] element is a per-item template. Add
|
||||
// data-key="item.id" or a key item.id suffix preserves DOM nodes when
|
||||
// a list is reordered. Unkeyed loops retain the legacy full-rerender path.
|
||||
function parseFor(value) {
|
||||
var m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)\s*$/.exec(
|
||||
var m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)(?:\s+key\s+([\s\S]+?))?\s*$/.exec(
|
||||
value || "",
|
||||
);
|
||||
return m ? { item: m[1], index: m[2], list: m[3] } : null;
|
||||
return m ? { item: m[1], index: m[2], list: m[3].trim(), key: m[4] && m[4].trim() } : null;
|
||||
}
|
||||
|
||||
function unwrapForKey(value) {
|
||||
var expression = String(value || "").trim();
|
||||
if (expression.charAt(0) === "{" && expression.charAt(expression.length - 1) === "}") {
|
||||
expression = expression.slice(1, -1).trim();
|
||||
}
|
||||
return expression;
|
||||
}
|
||||
|
||||
function stableForKey(value) {
|
||||
if (value === null) return "null:";
|
||||
var type = typeof value;
|
||||
if (type === "object") {
|
||||
try { return "object:" + JSON.stringify(value); } catch (_) { return "object:" + String(value); }
|
||||
}
|
||||
return type + ":" + String(value);
|
||||
}
|
||||
function fillMustache(str, itemEval) {
|
||||
return str.replace(/\{\{\s*([^}]+?)\s*\}\}|\{([^{}]+)\}/g, function (_, d, s) {
|
||||
@@ -1152,6 +1252,18 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
"data-for",
|
||||
);
|
||||
|
||||
var keyExpression =
|
||||
spec.key ||
|
||||
unwrapForKey(
|
||||
tpl.getAttribute(
|
||||
"data-key",
|
||||
),
|
||||
);
|
||||
|
||||
template.removeAttribute(
|
||||
"data-key",
|
||||
);
|
||||
|
||||
var parent =
|
||||
tpl.parentNode;
|
||||
|
||||
@@ -1168,6 +1280,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
parent.removeChild(tpl);
|
||||
|
||||
var clones = [];
|
||||
var keyedRecords = new Map();
|
||||
|
||||
reactive(function () {
|
||||
var list =
|
||||
@@ -1184,64 +1297,155 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
list = [];
|
||||
}
|
||||
|
||||
for (
|
||||
var cloneIndex = 0;
|
||||
cloneIndex <
|
||||
clones.length;
|
||||
cloneIndex++
|
||||
) {
|
||||
var existingClone =
|
||||
clones[cloneIndex];
|
||||
|
||||
if (
|
||||
existingClone.parentNode
|
||||
if (!keyExpression) {
|
||||
for (
|
||||
var cloneIndex = 0;
|
||||
cloneIndex <
|
||||
clones.length;
|
||||
cloneIndex++
|
||||
) {
|
||||
existingClone.parentNode
|
||||
.removeChild(
|
||||
existingClone,
|
||||
);
|
||||
var existingClone =
|
||||
clones[cloneIndex];
|
||||
|
||||
if (
|
||||
existingClone.parentNode
|
||||
) {
|
||||
existingClone.parentNode
|
||||
.removeChild(
|
||||
existingClone,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
clones = [];
|
||||
|
||||
var fragment =
|
||||
document.createDocumentFragment();
|
||||
|
||||
for (
|
||||
var itemIndex = 0;
|
||||
itemIndex < list.length;
|
||||
itemIndex++
|
||||
) {
|
||||
var clone =
|
||||
template.cloneNode(true);
|
||||
|
||||
var locals = {};
|
||||
|
||||
locals[spec.item] =
|
||||
list[itemIndex];
|
||||
|
||||
if (spec.index) {
|
||||
locals[spec.index] =
|
||||
itemIndex;
|
||||
}
|
||||
|
||||
hydrateItem(
|
||||
clone,
|
||||
locals,
|
||||
);
|
||||
|
||||
fragment.appendChild(
|
||||
clone,
|
||||
);
|
||||
|
||||
clones.push(clone);
|
||||
}
|
||||
|
||||
parent.insertBefore(
|
||||
fragment,
|
||||
marker.nextSibling,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
clones = [];
|
||||
|
||||
var fragment =
|
||||
document.createDocumentFragment();
|
||||
var nextRecords = new Map();
|
||||
var orderedNodes = [];
|
||||
|
||||
for (
|
||||
var itemIndex = 0;
|
||||
itemIndex < list.length;
|
||||
itemIndex++
|
||||
var keyedIndex = 0;
|
||||
keyedIndex < list.length;
|
||||
keyedIndex++
|
||||
) {
|
||||
var clone =
|
||||
template.cloneNode(true);
|
||||
var keyedItem = list[keyedIndex];
|
||||
var keyedLocals = {};
|
||||
|
||||
var locals = {};
|
||||
keyedLocals[spec.item] = keyedItem;
|
||||
if (spec.index) keyedLocals[spec.index] = keyedIndex;
|
||||
|
||||
locals[spec.item] =
|
||||
list[itemIndex];
|
||||
|
||||
if (spec.index) {
|
||||
locals[spec.index] =
|
||||
itemIndex;
|
||||
var rawKey;
|
||||
try {
|
||||
rawKey = evaluateExpression(
|
||||
keyExpression,
|
||||
function (name) {
|
||||
return Object.prototype.hasOwnProperty.call(keyedLocals, name)
|
||||
? keyedLocals[name]
|
||||
: readScope(name);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
reportDiagnostic(
|
||||
"WRN-HYDRATE-KEY-001",
|
||||
"Unable to evaluate data-for key '" + keyExpression + "'.",
|
||||
el,
|
||||
error,
|
||||
);
|
||||
rawKey = keyedIndex;
|
||||
}
|
||||
|
||||
hydrateItem(
|
||||
clone,
|
||||
locals,
|
||||
);
|
||||
var normalizedKey = stableForKey(rawKey);
|
||||
if (nextRecords.has(normalizedKey)) {
|
||||
reportDiagnostic(
|
||||
"WRN-HYDRATE-KEY-002",
|
||||
"Duplicate data-for key '" + String(rawKey) + "'; falling back to its index.",
|
||||
el,
|
||||
{ key: rawKey, index: keyedIndex },
|
||||
);
|
||||
normalizedKey += ":index:" + keyedIndex;
|
||||
}
|
||||
|
||||
fragment.appendChild(
|
||||
clone,
|
||||
);
|
||||
var record = keyedRecords.get(normalizedKey);
|
||||
if (
|
||||
!record ||
|
||||
record.item !== keyedItem ||
|
||||
(spec.index && record.index !== keyedIndex)
|
||||
) {
|
||||
if (record && record.node.parentNode) {
|
||||
record.node.parentNode.removeChild(record.node);
|
||||
}
|
||||
|
||||
clones.push(clone);
|
||||
var keyedClone = template.cloneNode(true);
|
||||
hydrateItem(keyedClone, keyedLocals);
|
||||
record = {
|
||||
node: keyedClone,
|
||||
item: keyedItem,
|
||||
index: keyedIndex,
|
||||
};
|
||||
}
|
||||
|
||||
nextRecords.set(normalizedKey, record);
|
||||
orderedNodes.push(record.node);
|
||||
}
|
||||
|
||||
keyedRecords.forEach(function (record, key) {
|
||||
if (!nextRecords.has(key) && record.node.parentNode) {
|
||||
record.node.parentNode.removeChild(record.node);
|
||||
}
|
||||
});
|
||||
|
||||
var keyedFragment = document.createDocumentFragment();
|
||||
orderedNodes.forEach(function (node) {
|
||||
keyedFragment.appendChild(node);
|
||||
});
|
||||
|
||||
parent.insertBefore(
|
||||
fragment,
|
||||
keyedFragment,
|
||||
marker.nextSibling,
|
||||
);
|
||||
|
||||
keyedRecords = nextRecords;
|
||||
clones = orderedNodes;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1512,10 +1716,18 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
});
|
||||
});
|
||||
|
||||
var behavior = parseBehavior(el);
|
||||
if (behavior) {
|
||||
installBehaviorFunctions(behavior.functions);
|
||||
|
||||
(behavior.effects || []).forEach(function (source) {
|
||||
if (typeof source !== "string" || !source.trim()) return;
|
||||
reactive(function () {
|
||||
try { runStmt(source); } catch (error) {
|
||||
reportDiagnostic("WRN-EFFECT-ERROR", "Reactive effect failed.", el, error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
(behavior.watches || []).forEach(function (watch) {
|
||||
if (!watch || typeof watch.state !== "string" || typeof watch.body !== "string") return;
|
||||
if (!stateWatchers[watch.state]) stateWatchers[watch.state] = [];
|
||||
@@ -1609,6 +1821,73 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
behaviorObserver.observe(document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
function queueScopeHydration(element) {
|
||||
if (!element || element.__wrnexusScope || element.__wrnexusHydrationQueued) return;
|
||||
var runtime = element.getAttribute("data-wrn-runtime") || "universal";
|
||||
var strategy = element.getAttribute("data-wrn-hydrate") || "load";
|
||||
if (runtime === "server" || strategy === "none") return;
|
||||
element.__wrnexusHydrationQueued = true;
|
||||
|
||||
function hydrate() {
|
||||
if (element.__wrnexusScope || !element.isConnected) return;
|
||||
setupScope(element);
|
||||
}
|
||||
|
||||
if (strategy === "load") {
|
||||
hydrate();
|
||||
return;
|
||||
}
|
||||
if (strategy === "idle") {
|
||||
var idle = window.requestIdleCallback || function (callback) { return window.setTimeout(callback, 1); };
|
||||
idle(hydrate);
|
||||
return;
|
||||
}
|
||||
if (strategy === "visible" && typeof IntersectionObserver !== "undefined") {
|
||||
var observer = new IntersectionObserver(function (entries) {
|
||||
if (!entries.some(function (entry) { return entry.isIntersecting; })) return;
|
||||
observer.disconnect();
|
||||
hydrate();
|
||||
});
|
||||
observer.observe(element);
|
||||
return;
|
||||
}
|
||||
if (strategy === "interaction") {
|
||||
var activate = function () {
|
||||
element.removeEventListener("pointerdown", activate, true);
|
||||
element.removeEventListener("keydown", activate, true);
|
||||
element.removeEventListener("focusin", activate, true);
|
||||
hydrate();
|
||||
};
|
||||
element.addEventListener("pointerdown", activate, true);
|
||||
element.addEventListener("keydown", activate, true);
|
||||
element.addEventListener("focusin", activate, true);
|
||||
return;
|
||||
}
|
||||
if (strategy.indexOf("media:") === 0 && typeof window.matchMedia === "function") {
|
||||
var query = strategy.slice(6);
|
||||
var media = window.matchMedia(query);
|
||||
if (media.matches) hydrate();
|
||||
else {
|
||||
var onChange = function (event) {
|
||||
if (!event.matches) return;
|
||||
if (media.removeEventListener) media.removeEventListener("change", onChange);
|
||||
else media.removeListener(onChange);
|
||||
hydrate();
|
||||
};
|
||||
if (media.addEventListener) media.addEventListener("change", onChange);
|
||||
else media.addListener(onChange);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
reportDiagnostic(
|
||||
"WRN-HYDRATE-STRATEGY",
|
||||
"Unknown hydration strategy '" + strategy + "'. Falling back to load.",
|
||||
element,
|
||||
);
|
||||
hydrate();
|
||||
}
|
||||
|
||||
function hydrateScopes(root) {
|
||||
var host = root || document;
|
||||
|
||||
@@ -1620,13 +1899,13 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
host.matches &&
|
||||
host.matches(scopeSelector)
|
||||
) {
|
||||
setupScope(host);
|
||||
queueScopeHydration(host);
|
||||
}
|
||||
|
||||
if (host.querySelectorAll) {
|
||||
host
|
||||
.querySelectorAll(scopeSelector)
|
||||
.forEach(setupScope);
|
||||
.forEach(queueScopeHydration);
|
||||
}
|
||||
|
||||
ensureBehaviorObserver();
|
||||
|
||||
@@ -99,6 +99,25 @@ test("data-for exposes item + index, mustaches and member access", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("keyed data-for preserves DOM identity when items reorder", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="rows: [{id: 1, name: 'a'}, {id: 2, name: 'b'}]">
|
||||
<ul><li data-for="row in rows key row.id">{row.name}</li></ul>
|
||||
<button data-on-click="rows = rows.slice().reverse()">reverse</button>
|
||||
</div>`,
|
||||
);
|
||||
|
||||
const before = Array.from(win.document.querySelectorAll("li"));
|
||||
expect(before.map((node) => node.textContent)).toEqual(["a", "b"]);
|
||||
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
|
||||
const after = Array.from(win.document.querySelectorAll("li"));
|
||||
expect(after.map((node) => node.textContent)).toEqual(["b", "a"]);
|
||||
expect(after[0]).toBe(before[1]);
|
||||
expect(after[1]).toBe(before[0]);
|
||||
});
|
||||
|
||||
test("expression evaluator: member access, ternary, comparison, calls", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="user: {name: 'Ada', age: 36}, items: [1, 2, 3]">
|
||||
|
||||
Reference in New Issue
Block a user