From 280f4d3141a3fcbd1ec2c6e95c8db6613f9eff1b Mon Sep 17 00:00:00 2001
From: Ryan Turnquist
Date: Fri, 24 Oct 2025 18:41:40 -0700
Subject: [PATCH] fix: prevent increment decrement from pruning change handler
binding (#2913)
* fix: prevent increment decrement from pruning change handler binding
---
.changeset/lemon-plums-visit.md | 5 +
.../__snapshots__/.name-cache.json | 16 ++
.../__snapshots__/csr-sanitized.expected.md | 39 +++++
.../__snapshots__/csr.expected.md | 59 +++++++
.../dom.expected/template.hydrate.js | 26 ++++
.../__snapshots__/dom.expected/template.js | 36 +++++
.../__snapshots__/html.expected/template.js | 24 +++
.../resume-sanitized.expected.md | 39 +++++
.../__snapshots__/resume.expected.md | 147 ++++++++++++++++++
.../__snapshots__/ssr-sanitized.expected.md | 6 +
.../__snapshots__/ssr.expected.md | 51 ++++++
.../template.marko | 10 ++
.../assign-destructured-increment/test.ts | 5 +
.../src/translator/util/references.ts | 5 +-
14 files changed, 465 insertions(+), 3 deletions(-)
create mode 100644 .changeset/lemon-plums-visit.md
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/.name-cache.json
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/csr-sanitized.expected.md
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/csr.expected.md
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/dom.expected/template.hydrate.js
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/dom.expected/template.js
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/html.expected/template.js
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/resume-sanitized.expected.md
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/resume.expected.md
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/ssr-sanitized.expected.md
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/ssr.expected.md
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/template.marko
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/test.ts
diff --git a/.changeset/lemon-plums-visit.md b/.changeset/lemon-plums-visit.md
new file mode 100644
index 000000000..a5736b6bf
--- /dev/null
+++ b/.changeset/lemon-plums-visit.md
@@ -0,0 +1,5 @@
+---
+"@marko/runtime-tags": patch
+---
+
+Ensure increment and decrement assignments don't prune change handler bindings
diff --git a/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/.name-cache.json b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/.name-cache.json
new file mode 100644
index 000000000..bdc959dd2
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/.name-cache.json
@@ -0,0 +1,16 @@
+{
+ "vars": {
+ "props": {
+ "$_": "o",
+ "$init": "t",
+ "$$pattern2": "n",
+ "$$bar": "r",
+ "$$foo2__script": "m",
+ "$$foo2": "e",
+ "$$foo": "a",
+ "$$foo__OR__fooChange__script": "c",
+ "$$foo__OR__fooChange": "_",
+ "$$fooChange2": "f"
+ }
+ }
+}
diff --git a/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/csr-sanitized.expected.md b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/csr-sanitized.expected.md
new file mode 100644
index 000000000..f1ad6884a
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/csr-sanitized.expected.md
@@ -0,0 +1,39 @@
+# Render
+```html
+
+```
+
+
+# Render
+```js
+container.querySelector("button").click();
+```
+```html
+
+```
+
+
+# Render
+```js
+container.querySelector("button").click();
+```
+```html
+
+```
+
+
+# Render
+```js
+container.querySelector("button").click();
+```
+```html
+
+```
diff --git a/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/csr.expected.md b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/csr.expected.md
new file mode 100644
index 000000000..8b5c089ef
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/csr.expected.md
@@ -0,0 +1,59 @@
+# Render
+```html
+
+```
+
+# Mutations
+```
+INSERT button
+```
+
+# Render
+```js
+container.querySelector("button").click();
+```
+```html
+
+```
+
+# Mutations
+```
+UPDATE button/#text2 "0" => "1"
+UPDATE button/#text0 "0" => "1"
+```
+
+# Render
+```js
+container.querySelector("button").click();
+```
+```html
+
+```
+
+# Mutations
+```
+UPDATE button/#text2 "1" => "2"
+UPDATE button/#text0 "1" => "2"
+```
+
+# Render
+```js
+container.querySelector("button").click();
+```
+```html
+
+```
+
+# Mutations
+```
+UPDATE button/#text2 "2" => "3"
+UPDATE button/#text0 "2" => "3"
+```
\ No newline at end of file
diff --git a/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/dom.expected/template.hydrate.js b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/dom.expected/template.hydrate.js
new file mode 100644
index 000000000..181e825c4
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/dom.expected/template.hydrate.js
@@ -0,0 +1,26 @@
+// size: 330 (min) 206 (brotli)
+const $pattern2 = _._const(4, ($scope, $pattern) => {
+ ($foo2($scope, $pattern.foo), $fooChange2($scope, $pattern.fooChange));
+ }),
+ $bar = _._let(3, ($scope, bar) => {
+ (_._text($scope[2], bar),
+ $pattern2($scope, { foo: bar, fooChange: $foo($scope) }));
+ }),
+ $foo__OR__fooChange__script = _._script(
+ "a1",
+ ($scope, { 5: foo, 6: $fooChange }) =>
+ _._on($scope[0], "click", function () {
+ $fooChange(++foo);
+ }),
+ ),
+ $foo__OR__fooChange = _._or(7, $foo__OR__fooChange__script),
+ $foo2 = _._const(5, ($scope, foo) => {
+ (_._text($scope[1], foo), $foo__OR__fooChange($scope));
+ }),
+ $fooChange2 = _._const(6, $foo__OR__fooChange);
+function $foo($scope) {
+ return function (v) {
+ $bar($scope, v);
+ };
+}
+(_._resume("a0", $foo), init());
diff --git a/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/dom.expected/template.js b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/dom.expected/template.js
new file mode 100644
index 000000000..0d3c27180
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/dom.expected/template.js
@@ -0,0 +1,36 @@
+export const $template = "";
+export const $walks = /* get, next(1), replace, over(2), replace, out(1) */" D%c%l";
+import * as _ from "@marko/runtime-tags/debug/dom";
+const $pattern2 = /* @__PURE__ */_._const("$pattern", ($scope, $pattern) => {
+ $foo2($scope, $pattern.foo);
+ $fooChange2($scope, $pattern.fooChange);
+});
+const $bar = /* @__PURE__ */_._let("bar/3", ($scope, bar) => {
+ _._text($scope["#text/2"], bar);
+ $pattern2($scope, {
+ foo: bar,
+ fooChange: $foo($scope)
+ });
+});
+export function $setup($scope) {
+ $bar($scope, 0);
+}
+const $foo__OR__fooChange__script = _._script("__tests__/template.marko_0_foo_$fooChange", ($scope, {
+ foo,
+ $fooChange
+}) => _._on($scope["#button/0"], "click", function () {
+ $fooChange(++foo);
+}));
+const $foo__OR__fooChange = /* @__PURE__ */_._or(7, $foo__OR__fooChange__script);
+const $foo2 = /* @__PURE__ */_._const("foo", ($scope, foo) => {
+ _._text($scope["#text/1"], foo);
+ $foo__OR__fooChange($scope);
+});
+const $fooChange2 = /* @__PURE__ */_._const("$fooChange", $foo__OR__fooChange);
+function $foo($scope) {
+ return function (v) {
+ $bar($scope, v);
+ };
+}
+_._resume("__tests__/template.marko_0/foo", $foo);
+export default /* @__PURE__ */_._template("__tests__/template.marko", $template, $walks, $setup);
\ No newline at end of file
diff --git a/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/html.expected/template.js b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/html.expected/template.js
new file mode 100644
index 000000000..d0d847c4f
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/html.expected/template.js
@@ -0,0 +1,24 @@
+import * as _ from "@marko/runtime-tags/debug/html";
+export default _._template("__tests__/template.marko", input => {
+ const $scope0_id = _._scope_id();
+ let bar = 0;
+ const {
+ foo,
+ fooChange: $fooChange
+ } = {
+ foo: bar,
+ fooChange: _._resume(function (v) {
+ bar = v;
+ }, "__tests__/template.marko_0/foo", $scope0_id)
+ };
+ _._html(`${_._el_resume($scope0_id, "#button/0")}`);
+ _._script($scope0_id, "__tests__/template.marko_0_foo_$fooChange");
+ _._scope($scope0_id, {
+ foo,
+ $fooChange
+ }, "__tests__/template.marko", 0, {
+ foo: "2:9",
+ $fooChange: "9:20"
+ });
+ _._resume_branch($scope0_id);
+});
\ No newline at end of file
diff --git a/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/resume-sanitized.expected.md b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/resume-sanitized.expected.md
new file mode 100644
index 000000000..f1ad6884a
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/resume-sanitized.expected.md
@@ -0,0 +1,39 @@
+# Render
+```html
+
+```
+
+
+# Render
+```js
+container.querySelector("button").click();
+```
+```html
+
+```
+
+
+# Render
+```js
+container.querySelector("button").click();
+```
+```html
+
+```
+
+
+# Render
+```js
+container.querySelector("button").click();
+```
+```html
+
+```
diff --git a/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/resume.expected.md b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/resume.expected.md
new file mode 100644
index 000000000..448574075
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/resume.expected.md
@@ -0,0 +1,147 @@
+# Render
+```html
+
+
+
+
+
+
+
+
+```
+
+
+# Render
+```js
+container.querySelector("button").click();
+```
+```html
+
+
+
+
+
+
+
+```
+
+# Mutations
+```
+UPDATE html/body/button/#text2 "0" => "1"
+UPDATE html/body/button/#text0 "0" => "1"
+```
+
+# Render
+```js
+container.querySelector("button").click();
+```
+```html
+
+
+
+
+
+
+
+```
+
+# Mutations
+```
+UPDATE html/body/button/#text2 "1" => "2"
+UPDATE html/body/button/#text0 "1" => "2"
+```
+
+# Render
+```js
+container.querySelector("button").click();
+```
+```html
+
+
+
+
+
+
+
+```
+
+# Mutations
+```
+UPDATE html/body/button/#text2 "2" => "3"
+UPDATE html/body/button/#text0 "2" => "3"
+```
\ No newline at end of file
diff --git a/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/ssr-sanitized.expected.md b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/ssr-sanitized.expected.md
new file mode 100644
index 000000000..d4c5ce7fd
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/ssr-sanitized.expected.md
@@ -0,0 +1,6 @@
+# Render End
+```html
+
+```
diff --git a/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/ssr.expected.md b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/ssr.expected.md
new file mode 100644
index 000000000..014a49bea
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/__snapshots__/ssr.expected.md
@@ -0,0 +1,51 @@
+# Write
+```html
+
+```
+
+# Render End
+```html
+
+
+
+
+
+
+
+```
+
+# Mutations
+```
+INSERT html
+INSERT html/head
+INSERT html/body
+INSERT html/body/button
+INSERT html/body/button/#text0
+INSERT html/body/button/#comment0
+INSERT html/body/button/#text1
+INSERT html/body/button/#comment1
+INSERT html/body/button/#text2
+INSERT html/body/button/#comment2
+INSERT html/body/#comment
+INSERT html/body/script
+INSERT html/body/script/#text
+```
\ No newline at end of file
diff --git a/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/template.marko b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/template.marko
new file mode 100644
index 000000000..379d187f6
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/template.marko
@@ -0,0 +1,10 @@
+let/bar=0
+const/{ foo }={
+ foo: bar,
+ fooChange(v) {
+ bar = v;
+ }
+}
+
+button onClick() { foo++ }
+ -- ${foo}:${bar}
diff --git a/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/test.ts b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/test.ts
new file mode 100644
index 000000000..d7fdaf5d2
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/assign-destructured-increment/test.ts
@@ -0,0 +1,5 @@
+export const steps = [{}, click, click, click];
+
+function click(container: Element) {
+ container.querySelector("button")!.click();
+}
diff --git a/packages/runtime-tags/src/translator/util/references.ts b/packages/runtime-tags/src/translator/util/references.ts
index 641ed98fb..dbe87dd72 100644
--- a/packages/runtime-tags/src/translator/util/references.ts
+++ b/packages/runtime-tags/src/translator/util/references.ts
@@ -1730,10 +1730,9 @@ function resolveReferencedBindings(
const rootBindings = getRootBindings(reads);
for (const read of reads) {
let { binding } = read;
- if (read.node) {
- const exprReference = ((read.node.extra ??= {}).read ??=
+ if (read.node && read.node.extra?.assignmentTo !== binding) {
+ ({ binding } = (read.node.extra ??= {}).read ??=
resolveExpressionReference(rootBindings, binding));
- ({ binding } = (read.node.extra ??= {}).read = exprReference);
}
referencedBindings = bindingUtil.add(referencedBindings, binding);
}