Make if and each blocks reactive on the client
Quality / quality (ubuntu-latest) (push) Failing after 9m49s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-19 00:22:00 +05:30
parent c0c2fa4595
commit f199385204
8 changed files with 330 additions and 27 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.8.22",
"version": "0.8.23",
"type": "module",
"main": "src/index.ts",
"exports": {
+138 -18
View File
@@ -1611,24 +1611,21 @@ export const REACTIVE_RUNTIME = String.raw`
if (
attribute.name === "data-text"
) {
try {
var textValue =
itemEval(attribute.value);
node.textContent =
textValue == null
? ""
: String(textValue);
} catch (error) {
console.error(
"[wrnexus] data-for text binding failed for '" +
attribute.value +
"'",
error,
);
node.textContent = "";
}
(function (textNode, textExpression) {
var runText = reactive(function () {
try {
var textValue = itemEval(textExpression);
textNode.textContent = textValue == null ? "" : String(textValue);
} catch (error) {
console.error(
"[wrnexus] data-for text binding failed for '" + textExpression + "'",
error,
);
textNode.textContent = "";
}
});
runText();
})(node, attribute.value);
return;
}
@@ -1954,6 +1951,129 @@ export const REACTIVE_RUNTIME = String.raw`
}
}
// Compiled if/each blocks keep their SSR result in the custom
// element and carry an inert, base64-encoded template for later browser
// updates. The first reactive pass only subscribes to dependencies, so
// hydration does not throw away server DOM. Subsequent state changes
// materialize the appropriate branch/rows and hydrate their bindings.
function decodeControlDefinition(value) {
try {
var binary = window.atob(value || "");
var bytes = new Uint8Array(binary.length);
for (var index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return JSON.parse(new TextDecoder("utf-8").decode(bytes));
} catch (error) {
reportDiagnostic("WRN-CONTROL-DECODE", "Failed to decode a client control block.", el, error);
return null;
}
}
function setupControlBlock(block, outerLocals) {
if (!block || block.__wrnexusControl || (!outerLocals && !owns(block))) return;
block.__wrnexusControl = true;
var inherited = outerLocals || decodeLoopLocals(block);
var rangeEnd = null;
if (block.tagName && block.tagName.toLowerCase() === "template") {
var depth = 0;
for (var sibling = block.nextSibling; sibling; sibling = sibling.nextSibling) {
if (sibling.nodeType !== 1 || sibling.tagName.toLowerCase() !== "template") continue;
if (sibling.hasAttribute("data-wrn-if") || sibling.hasAttribute("data-wrn-each")) depth++;
if (sibling.hasAttribute("data-wrn-control-end")) {
if (depth === 0) { rangeEnd = sibling; break; }
depth--;
}
}
if (!rangeEnd) return;
}
var ifDefinition = block.hasAttribute("data-wrn-if")
? decodeControlDefinition(block.getAttribute("data-wrn-if"))
: null;
var eachDefinition = block.hasAttribute("data-wrn-each")
? decodeControlDefinition(block.getAttribute("data-wrn-each"))
: null;
var firstRun = true;
function controlRead(name) {
return Object.prototype.hasOwnProperty.call(inherited, name) ? inherited[name] : readScope(name);
}
function controlEval(expression, locals) {
return evaluateExpression(expression, function (name) {
return locals && Object.prototype.hasOwnProperty.call(locals, name)
? locals[name]
: controlRead(name);
});
}
function clearControlContent() {
if (!rangeEnd) { block.innerHTML = ""; return; }
while (block.nextSibling && block.nextSibling !== rangeEnd) {
block.parentNode.removeChild(block.nextSibling);
}
}
function appendControlContent(markup, locals) {
var template = document.createElement("template");
template.innerHTML = markup || "";
var fragment = template.content;
var elements = Array.prototype.slice.call(fragment.childNodes).filter(function (node) {
return node.nodeType === 1;
});
if (rangeEnd) block.parentNode.insertBefore(fragment, rangeEnd);
else block.appendChild(fragment);
elements.forEach(function (node) { hydrateItem(node, locals || inherited); });
elements.forEach(function (node) {
var controls = [];
if (node.matches && node.matches("[data-wrn-if],[data-wrn-each]")) controls.push(node);
if (node.querySelectorAll) Array.prototype.push.apply(controls, node.querySelectorAll("[data-wrn-if],[data-wrn-each]"));
controls.forEach(function (nested) {
if (!nested.__wrnexusControl) setupControlBlock(nested, locals || inherited);
});
});
}
reactive(function () {
if (ifDefinition) {
var selected = null;
for (var branchIndex = 0; branchIndex < ifDefinition.length; branchIndex++) {
var branch = ifDefinition[branchIndex];
if (branch.cond === null || !!controlEval(branch.cond, inherited)) {
selected = branch;
break;
}
}
if (firstRun) { firstRun = false; return; }
clearControlContent();
appendControlContent(selected ? selected.body : "", inherited);
return;
}
if (!eachDefinition) return;
var list = controlEval(eachDefinition.list, inherited);
if (!Array.isArray(list)) list = [];
if (firstRun) { firstRun = false; return; }
clearControlContent();
if (list.length === 0) {
appendControlContent(eachDefinition.empty || "", inherited);
return;
}
for (var itemIndex = 0; itemIndex < list.length; itemIndex++) {
var rowLocals = {};
Object.keys(inherited).forEach(function (name) { rowLocals[name] = inherited[name]; });
rowLocals[eachDefinition.item] = list[itemIndex];
if (eachDefinition.index) rowLocals[eachDefinition.index] = itemIndex;
appendControlContent(eachDefinition.body || "", rowLocals);
}
});
}
Array.prototype.slice.call(el.querySelectorAll("[data-wrn-if],[data-wrn-each]")).forEach(function (block) {
if (block.parentElement && block.parentElement.closest("[data-wrn-if],[data-wrn-each]")) return;
setupControlBlock(block, null);
});
/*
* Set up one [data-for] template. Extracted from an inline forEach so it
* can recurse: hydrateItem calls it for every loop nested inside a rendered
+49
View File
@@ -117,6 +117,55 @@ test("@event (data-on-click) mutates a signal and re-renders", () => {
expect(btn.textContent).toBe("2");
});
test("compiled if blocks switch branches after hydration", () => {
const definition = Buffer.from(
JSON.stringify([
{ cond: "open", body: '<p class="open">Open <span data-text="count">{count}</span></p>' },
{ cond: null, body: '<p class="closed">Closed</p>' },
]),
).toString("base64");
const win = mount(
`<div data-scope="open: false, count: 2">` +
`<button data-on-click="open = !open">toggle</button>` +
`<button data-on-click="count++">increment</button>` +
`<template data-wrn-if="${definition}"></template><p class="closed">Closed</p><template data-wrn-control-end></template>` +
`</div>`,
);
win.document.querySelector("button")!.click();
expect(win.document.querySelector(".closed")).toBeNull();
expect(win.document.querySelector(".open")?.textContent).toBe("Open 2");
win.document.querySelectorAll("button")[1]!.click();
expect(win.document.querySelector(".open")?.textContent).toBe("Open 3");
});
test("compiled each blocks rerender rows and their empty branch", () => {
const definition = Buffer.from(
JSON.stringify({
list: "items",
item: "item",
index: "index",
body: '<p class="row">{index}:{item}</p>',
empty: '<p class="empty">Empty</p>',
}),
).toString("base64");
const win = mount(
`<div data-scope="items: ['a']">` +
`<button data-on-click="items = ['b', 'c']">more</button>` +
`<button data-on-click="items = []">clear</button>` +
`<template data-wrn-each="${definition}"></template><p class="row">0:a</p><template data-wrn-control-end></template>` +
`</div>`,
);
win.document.querySelectorAll("button")[0]!.click();
expect(Array.from(win.document.querySelectorAll(".row")).map((node) => node.textContent)).toEqual(
["0:b", "1:c"],
);
win.document.querySelectorAll("button")[1]!.click();
expect(win.document.querySelector(".row")).toBeNull();
expect(win.document.querySelector(".empty")?.textContent).toBe("Empty");
});
test("component functions support formatted multiline assignments and ternaries", () => {
const behavior = Buffer.from(
JSON.stringify({