fix: support assigning to destructured tag variables

This commit is contained in:
dpiercey 2025-07-02 14:31:12 -07:00 committed by Dylan Piercey
parent 1721ef1f81
commit 79cfb0fbd1
17 changed files with 516 additions and 41 deletions

View File

@ -0,0 +1,5 @@
---
"@marko/runtime-tags": patch
---
Fix support for assigning to tag variables from a destructure.

View File

@ -0,0 +1,12 @@
{
"vars": {
"props": {
"$_$": "t",
"$init": "o",
"$$bar_effect": "r",
"$$bar": "n",
"$$expr_bar_$fooChange_effect": "i",
"$$expr_bar_$fooChange": "a"
}
}
}

View File

@ -0,0 +1,39 @@
# Render
```html
<button>
1:0
</button>
```
# Render
```js
container.querySelector("button").click();
```
```html
<button>
1:1
</button>
```
# Render
```js
container.querySelector("button").click();
```
```html
<button>
1:2
</button>
```
# Render
```js
container.querySelector("button").click();
```
```html
<button>
1:3
</button>
```

View File

@ -0,0 +1,56 @@
# Render
```html
<button>
1:0
</button>
```
# Mutations
```
INSERT button
```
# Render
```js
container.querySelector("button").click();
```
```html
<button>
1:1
</button>
```
# Mutations
```
UPDATE button/#text2 "0" => "1"
```
# Render
```js
container.querySelector("button").click();
```
```html
<button>
1:2
</button>
```
# Mutations
```
UPDATE button/#text2 "1" => "2"
```
# Render
```js
container.querySelector("button").click();
```
```html
<button>
1:3
</button>
```
# Mutations
```
UPDATE button/#text2 "2" => "3"
```

View File

@ -0,0 +1,18 @@
// size: 209 (min) 147 (brotli)
const $expr_bar_$fooChange_effect = _$.effect(
"a1",
($scope, { 3: bar, 6: $fooChange }) =>
_$.on($scope[0], "click", function () {
$fooChange(bar + 1);
}),
),
$expr_bar_$fooChange = _$.intersection(7, $expr_bar_$fooChange_effect),
$bar = _$.state(3, ($scope, bar) => {
_$.data($scope[2], bar), $expr_bar_$fooChange($scope);
});
_$.register("a0", function ($scope) {
return function (v) {
$bar($scope, v);
};
}),
init();

View File

@ -0,0 +1,34 @@
export const $template = "<button><!>:<!></button>";
export const $walks = /* get, next(1), replace, over(2), replace, out(1) */" D%c%l";
import * as _$ from "@marko/runtime-tags/debug/dom";
const $expr_bar_$fooChange_effect = _$.effect("__tests__/template.marko_0_bar_$fooChange", ($scope, {
bar,
$fooChange
}) => _$.on($scope["#button/0"], "click", function () {
$fooChange(bar + 1);
}));
const $expr_bar_$fooChange = /* @__PURE__ */_$.intersection(7, $expr_bar_$fooChange_effect);
const $bar = /* @__PURE__ */_$.state("bar/3", ($scope, bar) => {
_$.data($scope["#text/2"], bar);
$expr_bar_$fooChange($scope);
});
const $pattern2 = /* @__PURE__ */_$.value("$pattern", ($scope, $pattern) => {
$foo2($scope, $pattern.foo);
$fooChange2($scope, $pattern.fooChange);
});
export function $setup($scope) {
$bar($scope, 0);
$pattern2($scope, {
foo: 1,
fooChange: $foo($scope)
});
}
const $foo2 = /* @__PURE__ */_$.value("foo", ($scope, foo) => _$.data($scope["#text/1"], foo));
const $fooChange2 = /* @__PURE__ */_$.value("$fooChange", $expr_bar_$fooChange);
function $foo($scope) {
return function (v) {
$bar($scope, v);
};
}
_$.register("__tests__/template.marko_0/foo", $foo);
export default /* @__PURE__ */_$.createTemplate("__tests__/template.marko", $template, $walks, $setup);

View File

@ -0,0 +1,24 @@
import * as _$ from "@marko/runtime-tags/debug/html";
export default _$.createTemplate("__tests__/template.marko", input => {
const $scope0_id = _$.nextScopeId();
let bar = 0;
const {
foo,
fooChange: $fooChange
} = {
foo: 1,
fooChange: _$.register(function (v) {
bar = v;
}, "__tests__/template.marko_0/foo", $scope0_id)
};
_$.write(`<button>${_$.escapeXML(foo)}:<!>${_$.escapeXML(bar)}${_$.markResumeNode($scope0_id, "#text/2")}</button>${_$.markResumeNode($scope0_id, "#button/0")}`);
_$.writeEffect($scope0_id, "__tests__/template.marko_0_bar_$fooChange");
_$.writeScope($scope0_id, {
bar,
$fooChange
}, "__tests__/template.marko", 0, {
bar: "1:5",
$fooChange: "9:20"
});
_$.resumeClosestBranch($scope0_id);
});

View File

@ -0,0 +1,39 @@
# Render
```html
<button>
1:0
</button>
```
# Render
```js
container.querySelector("button").click();
```
```html
<button>
1:1
</button>
```
# Render
```js
container.querySelector("button").click();
```
```html
<button>
1:2
</button>
```
# Render
```js
container.querySelector("button").click();
```
```html
<button>
1:3
</button>
```

View File

@ -0,0 +1,100 @@
# Render
```html
<html>
<head />
<body>
<button>
1:
<!---->
0
<!--M_*1 #text/2-->
</button>
<!--M_*1 #button/0-->
<script>
WALKER_RUNTIME("M")("_");M._.r=[_=&gt;(_.b=[0,_.a={bar:0}],_.a.$fooChange=_._["__tests__/template.marko_0/foo"](_.a),_.b),"__tests__/template.marko_0_bar_$fooChange",1];M._.w()
</script>
</body>
</html>
```
# Render
```js
container.querySelector("button").click();
```
```html
<html>
<head />
<body>
<button>
1:
<!---->
1
<!--M_*1 #text/2-->
</button>
<!--M_*1 #button/0-->
<script>
WALKER_RUNTIME("M")("_");M._.r=[_=&gt;(_.b=[0,_.a={bar:0}],_.a.$fooChange=_._["__tests__/template.marko_0/foo"](_.a),_.b),"__tests__/template.marko_0_bar_$fooChange",1];M._.w()
</script>
</body>
</html>
```
# Mutations
```
UPDATE html/body/button/#text1 "0" => "1"
```
# Render
```js
container.querySelector("button").click();
```
```html
<html>
<head />
<body>
<button>
1:
<!---->
2
<!--M_*1 #text/2-->
</button>
<!--M_*1 #button/0-->
<script>
WALKER_RUNTIME("M")("_");M._.r=[_=&gt;(_.b=[0,_.a={bar:0}],_.a.$fooChange=_._["__tests__/template.marko_0/foo"](_.a),_.b),"__tests__/template.marko_0_bar_$fooChange",1];M._.w()
</script>
</body>
</html>
```
# Mutations
```
UPDATE html/body/button/#text1 "1" => "2"
```
# Render
```js
container.querySelector("button").click();
```
```html
<html>
<head />
<body>
<button>
1:
<!---->
3
<!--M_*1 #text/2-->
</button>
<!--M_*1 #button/0-->
<script>
WALKER_RUNTIME("M")("_");M._.r=[_=&gt;(_.b=[0,_.a={bar:0}],_.a.$fooChange=_._["__tests__/template.marko_0/foo"](_.a),_.b),"__tests__/template.marko_0_bar_$fooChange",1];M._.w()
</script>
</body>
</html>
```
# Mutations
```
UPDATE html/body/button/#text1 "2" => "3"
```

View File

@ -0,0 +1,6 @@
# Render End
```html
<button>
1:0
</button>
```

View File

@ -0,0 +1,38 @@
# Write
```html
<button>1:<!>0<!--M_*1 #text/2--></button><!--M_*1 #button/0--><script>WALKER_RUNTIME("M")("_");M._.r=[_=>(_.b=[0,_.a={bar:0}],_.a.$fooChange=_._["__tests__/template.marko_0/foo"](_.a),_.b),"__tests__/template.marko_0_bar_$fooChange",1];M._.w()</script>
```
# Render End
```html
<html>
<head />
<body>
<button>
1:
<!---->
0
<!--M_*1 #text/2-->
</button>
<!--M_*1 #button/0-->
<script>
WALKER_RUNTIME("M")("_");M._.r=[_=&gt;(_.b=[0,_.a={bar:0}],_.a.$fooChange=_._["__tests__/template.marko_0/foo"](_.a),_.b),"__tests__/template.marko_0_bar_$fooChange",1];M._.w()
</script>
</body>
</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/#comment
INSERT html/body/script
INSERT html/body/script/#text
```

View File

@ -0,0 +1,10 @@
let/bar=0
const/{ foo }={
foo: 1,
fooChange(v) {
bar = v;
}
}
button onClick() { foo = bar + 1 }
-- ${foo}:${bar}

View File

@ -0,0 +1,5 @@
export const steps = [{}, click, click, click];
function click(container: Element) {
container.querySelector("button")!.click();
}

View File

@ -42,3 +42,37 @@ export function forEachIdentifier(
break;
}
}
export function forEachIdentifierPath(
nodePath: t.NodePath<any>,
cb: (identifier: t.NodePath<t.Identifier>) => void,
) {
if (nodePath.isIdentifier()) {
cb(nodePath);
} else if (nodePath.isObjectPattern()) {
for (const prop of nodePath.get("properties")) {
if (prop.isObjectProperty()) {
const value = prop.get("value");
if (value.isAssignmentPattern()) {
forEachIdentifierPath(value.get("left"), cb);
} else {
forEachIdentifierPath(value, cb);
}
} else if (prop.isRestElement()) {
forEachIdentifierPath(prop.get("argument"), cb);
}
}
} else if (nodePath.isArrayPattern()) {
for (const el of nodePath.get("elements")) {
if (el) {
if (el.isRestElement()) {
forEachIdentifierPath(el.get("argument"), cb);
} else if (el.isAssignmentPattern()) {
forEachIdentifierPath(el.get("left"), cb);
} else {
forEachIdentifierPath(el, cb);
}
}
}
}
}

View File

@ -3,7 +3,7 @@ import { getProgram } from "@marko/compiler/babel-utils";
import { toAccess } from "../../html/serializer";
import type { InputSerializeReasons } from "../visitors/program";
import { forEachIdentifier } from "./for-each-identifier";
import { forEachIdentifierPath } from "./for-each-identifier";
import { generateUid } from "./generate-uid";
import { getAccessorPrefix, getAccessorProp } from "./get-accessor-char";
import { getExprRoot, getFnRoot } from "./get-root";
@ -121,6 +121,7 @@ declare module "@marko/compiler/dist/types" {
referencedBindings?: ReferencedBindings;
binding?: Binding;
assignment?: Binding;
assignmentTo?: Binding;
read?: { binding: Binding; props: Opt<string> };
pruned?: true;
isEffect?: true;
@ -359,9 +360,27 @@ function trackAssignment(
) {
const section = getOrCreateSection(assignment);
setReferencesScope(assignment);
forEachIdentifier(assignment.node, (id) => {
if (id.name === binding.name) {
const extra = (id.extra ??= {});
forEachIdentifierPath(assignment, (id) => {
if (id.node.name === binding.name) {
const extra = (id.node.extra ??= {});
if (binding.upstreamAlias && binding.property !== undefined) {
const changePropName = binding.property + "Change";
const changeBinding =
binding.upstreamAlias.propertyAliases.get(changePropName) ||
createBinding(
generateUid(changePropName),
BindingType.derived,
binding.section,
binding.upstreamAlias,
changePropName,
id.node.loc,
true,
);
extra.assignmentTo = changeBinding;
addReadToExpression(id, changeBinding);
}
binding.assignmentSections = sectionUtil.add(
binding.assignmentSections,
section,
@ -547,22 +566,7 @@ function trackReference(
);
}
const fnRoot = getFnRoot(root);
const exprRoot = getExprRoot(fnRoot || root);
const { section } = addReadToExpression(exprRoot, reference, root.node);
if (fnRoot) {
const readsByFn = getReadsByFunction();
const fnExtra = (fnRoot.node.extra ??= {}) as FnExtra;
fnExtra.section = section;
readsByFn.set(
fnExtra,
push(readsByFn.get(fnExtra), {
binding: reference,
node: root.node,
}),
);
}
addReadToExpression(root, reference);
}
const [getMergedReferences] = createProgramState(
@ -1162,19 +1166,28 @@ const [getReadsByFunction] = createProgramState(
() => new Map<FnExtra, Opt<Read>>(),
);
export function addReadToExpression(
path: t.NodePath,
function addReadToExpression(
root: t.NodePath<t.Identifier> | t.NodePath<t.MemberExpression>,
binding: Binding,
node?: t.Identifier | t.MemberExpression,
) {
const exprExtra = (path.node.extra ??= {}) as ReferencedExtra;
const { node } = root;
const fnRoot = getFnRoot(root);
const exprRoot = getExprRoot(fnRoot || root);
const exprExtra = (exprRoot.node.extra ??= {}) as ReferencedExtra;
const readsByExpression = getReadsByExpression();
exprExtra.section = getOrCreateSection(path);
const section = (exprExtra.section = getOrCreateSection(exprRoot));
const read: Read = { binding, node };
readsByExpression.set(
exprExtra,
push(readsByExpression.get(exprExtra), { binding, node }),
push(readsByExpression.get(exprExtra), read),
);
return exprExtra;
if (fnRoot) {
const readsByFn = getReadsByFunction();
const fnExtra = (fnRoot.node.extra ??= {}) as FnExtra;
fnExtra.section = section;
readsByFn.set(fnExtra, push(readsByFn.get(fnExtra), read));
}
}
export function dropReferences(node: t.Node | t.Node[]) {

View File

@ -19,6 +19,7 @@ import { getDeclaredBindingExpression } from "./get-defined-binding-expression";
import { isOptimize, isOutputHTML } from "./marko-config";
import { find, forEach, type Opt, push } from "./optional";
import {
type AssignedBindingExtra,
type Binding,
BindingType,
bindingUtil,
@ -1283,10 +1284,7 @@ function replaceAssignedNode(node: t.Node) {
case "UpdateExpression": {
const { extra } = node.argument;
if (isAssignedBindingExtra(extra)) {
const { buildAssignment } = getSignal(
extra.assignment.section,
extra.assignment,
);
const buildAssignment = getBuildAssignment(extra);
if (buildAssignment) {
const replacement = buildAssignment(
extra.section,
@ -1310,10 +1308,7 @@ function replaceAssignedNode(node: t.Node) {
case "Identifier": {
const { extra } = node.left;
if (isAssignedBindingExtra(extra)) {
const { buildAssignment } = getSignal(
extra.assignment.section,
extra.assignment,
);
const buildAssignment = getBuildAssignment(extra);
if (buildAssignment) {
return buildAssignment(
extra.section,
@ -1339,15 +1334,12 @@ function replaceAssignedNode(node: t.Node) {
forEachIdentifier(node.left, (id) => {
const { extra } = id;
if (isAssignedBindingExtra(extra)) {
const signal = getSignal(
extra.assignment.section,
extra.assignment,
);
if (signal?.buildAssignment) {
const buildAssignment = getBuildAssignment(extra);
if (buildAssignment) {
id.name = generateUid(id.name);
(params ||= []).push(t.identifier(id.name));
(assignments ||= []).push(
signal.buildAssignment(extra.section, t.identifier(id.name)),
buildAssignment(extra.section, t.identifier(id.name)),
);
}
}
@ -1377,6 +1369,17 @@ function replaceAssignedNode(node: t.Node) {
}
}
function getBuildAssignment(extra: AssignedBindingExtra) {
const { assignmentTo, assignment } = extra;
if (assignmentTo) {
return (_section: Section, value: t.Expression) => {
return t.callExpression(t.identifier(assignmentTo.name), [value]);
};
}
return getSignal(assignment.section, assignment).buildAssignment;
}
const registeredFnsForProgram = new WeakMap<
t.Program,
{

View File

@ -1,5 +1,7 @@
import { types as t } from "@marko/compiler";
import { forEachIdentifierPath } from "./for-each-identifier";
export default function translateVar(
tag: t.NodePath<t.MarkoTag>,
initialValue: t.Expression,
@ -13,7 +15,44 @@ export default function translateVar(
return;
}
forEachIdentifierPath(tag.get("var"), (id) => {
const binding = id.node.extra?.binding;
if (
!binding ||
!binding.upstreamAlias ||
!binding.assignmentSections ||
id.node === tagVar
) {
return;
}
const changeName = binding.name + "Change";
const changeBinding = binding.upstreamAlias.propertyAliases.get(changeName);
if (changeBinding && changeName !== changeBinding.name) {
// add a new property to the destructure list when a change handler is implicitly added
// eg by assigning to a destructured property.
getDestructurePattern(id)?.pushContainer(
"properties",
t.objectProperty(
t.identifier(changeName),
t.identifier(changeBinding.name),
),
);
}
});
tag.insertBefore(
t.variableDeclaration(kind, [t.variableDeclarator(tagVar, initialValue)]),
);
}
function getDestructurePattern(id: t.NodePath<t.Identifier>) {
let cur: t.NodePath | null = id;
while (cur) {
if (cur.node.type === "ObjectPattern") {
return cur as t.NodePath<t.ObjectPattern>;
}
cur = cur.parentPath;
}
}