fix(csr): run for/while loops and keep declarations out of state
The client runtime had no loop support, so any shared function using one returned early -- Pagination and ButtonGroup were broken client-side, not just in tests. Adding loops exposed two further faults: - A var reaching writeScope creates a signal and triggers a render sweep. A declaration inside a function called during a render therefore looped forever. Declarations now bind into the handler locals instead. - A control block removed from the DOM keeps its effect in the renderers list. Running it against a detached node threw, aborting the sweep and leaving every later effect stale. Also raises the reactive runtime budget to 53,000: the runtime had already grown past 49,000 before this change, and 52,570 minified is 16,803 gzipped. Two deferred minors: html-service leaves absent documentation undefined rather than an empty string, and the extension declines tag auto-close on multi-cursor edits rather than closing only the first cursor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1675,3 +1675,86 @@ test("control blocks created by a client rerender render their own content", ()
|
||||
expect(win.document.querySelector(".no-rows")?.textContent).toBe("NONE");
|
||||
expect(win.document.querySelector(".has-rows")).toBeNull();
|
||||
});
|
||||
|
||||
test("a for loop with a declaration initialiser runs in a handler", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="total: 0">` +
|
||||
`<button data-on-click="for (var i = 1; i <= 3; i += 1) { total = total + i }">go</button>` +
|
||||
`<span data-text="total">0</span>` +
|
||||
`</div>`,
|
||||
);
|
||||
win.document.querySelector("button")!.click();
|
||||
expect(win.document.querySelector("span")?.textContent).toBe("6");
|
||||
});
|
||||
|
||||
test("a while loop runs in a handler", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="n: 1">` +
|
||||
`<button data-on-click="while (n < 10) { n = n * 2 }">go</button>` +
|
||||
`<span data-text="n">1</span>` +
|
||||
`</div>`,
|
||||
);
|
||||
win.document.querySelector("button")!.click();
|
||||
expect(win.document.querySelector("span")?.textContent).toBe("16");
|
||||
});
|
||||
|
||||
test("a declaration stays local instead of becoming reactive state", () => {
|
||||
// An unknown name reaching writeScope becomes a signal and triggers a render
|
||||
// sweep. A var inside a function called during a render would then loop
|
||||
// forever, so declarations must bind locally.
|
||||
const win = mount(
|
||||
`<div data-scope="out: 0">` +
|
||||
`<button data-on-click="var step = 5; out = step + 1">go</button>` +
|
||||
`<span class="out" data-text="out">0</span>` +
|
||||
`<span class="leak" data-text="step"></span>` +
|
||||
`</div>`,
|
||||
);
|
||||
win.document.querySelector("button")!.click();
|
||||
expect(win.document.querySelector(".out")?.textContent).toBe("6");
|
||||
expect(win.document.querySelector(".leak")?.textContent).toBe("");
|
||||
});
|
||||
|
||||
test("a control block removed from the DOM does not abort later renders", () => {
|
||||
// Its effect stays in the renderers list. Running it against a detached node
|
||||
// throws, which would abort the sweep and leave every later effect stale.
|
||||
const definition = Buffer.from(
|
||||
JSON.stringify([{ cond: "n < 100", body: '<i class="gone"></i>' }]),
|
||||
).toString("base64");
|
||||
const win = mount(
|
||||
`<div data-scope="n: 0">` +
|
||||
`<template data-wrn-if="${definition}"></template><i class="gone"></i><template data-wrn-control-end></template>` +
|
||||
`<button data-on-click="n = n + 1">go</button>` +
|
||||
`<span data-text="n">0</span>` +
|
||||
`</div>`,
|
||||
);
|
||||
const block = win.document.querySelector("[data-wrn-if]")!;
|
||||
block.parentNode!.removeChild(block);
|
||||
win.document.querySelector("button")!.click();
|
||||
expect(win.document.querySelector("span")?.textContent).toBe("1");
|
||||
});
|
||||
|
||||
test("a declaration statement assigns into scope", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="out: 0">` +
|
||||
`<button data-on-click="var step = 5; out = step + 1">go</button>` +
|
||||
`<span data-text="out">0</span>` +
|
||||
`</div>`,
|
||||
);
|
||||
win.document.querySelector("button")!.click();
|
||||
expect(win.document.querySelector("span")?.textContent).toBe("6");
|
||||
});
|
||||
|
||||
test("an unbounded loop stops instead of hanging the page", () => {
|
||||
// Handler source is author-controlled and runs in the browser. Without a cap
|
||||
// a mistaken condition freezes the tab with no way back.
|
||||
const win = mount(
|
||||
`<div data-scope="n: 0">` +
|
||||
`<button data-on-click="while (true) { n = n + 1 }">go</button>` +
|
||||
`<span data-text="n">0</span>` +
|
||||
`</div>`,
|
||||
);
|
||||
win.document.querySelector("button")!.click();
|
||||
const value = Number(win.document.querySelector("span")?.textContent);
|
||||
expect(value).toBeGreaterThan(0);
|
||||
expect(Number.isFinite(value)).toBe(true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user