Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lucky-pandas-clap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@marko/runtime-tags": patch
---

Fix static attribute values being dropped after hydration when spread onto a native tag alongside stateful spread attributes. Sourceless bindings in an intersection are now serialized so their values resume correctly.
6 changes: 6 additions & 0 deletions agent-feedback/cleanup.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,9 @@ Every section's params binding comes from `trackParamsReferences`, but callers p
`packages/runtime-tags/src/dom/control-flow.ts` › `addAwaitCounter` | 2026-07-23 | impact:low | effort:med

`da6433e520` already factored the counter literal into `createAwaitCounter`, but the frame scheduler is still written twice: `if (!counter.i++) requestAnimationFrame(() => counter.i && runEffects(prepareEffects(() => queueRender(<scope>, …, -1))))` appears in `addAwaitCounter` (~~:280-307) and in the `else` arm of `_await_promise` (~~:139-165). Only the queued body (create and insert the placeholder branch vs. re-insert the await anchor, then `tempDetachBranch`) and the `queueRender` scope (`tryBranch` vs. `scope`) differ, so the `requestAnimationFrame`/`prepareEffects`/`queueRender` chain ships twice in every app using `<await>`. Extract a `scheduleFrame(counter, scope, onFrame)` helper alongside `createAwaitCounter`. Re-verify with the root `.sizes.json` and the `await-*`/`try-*` fixture `sizes.json` after the change.

## Split binding serialize reasons into slot and value channels, retiring `noSerialize`

`packages/runtime-tags/src/translator/util/serialize-reasons.ts` › `kSerializedValueReason` | 2026-08-06 | impact:med | effort:high

The plain binding key in `section.serializeReasons` conflates two demands: "the scope slot must resume" (effect exprs force `true` per referenced binding in `finalizeReferences`) and "the serialized value must resume" (closures, intersections). `native-tag` spread analysis papers over this by setting `binding.noSerialize` to mask the value write, and `kSerializedValueReason` was added as a second keyed channel so intersections can override that mask with a source-guarded value write (see `writeSerializedBinding` in `signals.ts`). The cleaner model: make the plain key mean value-must-resume, add a slot-only channel, and have producers classify — effect reads through a `nativeTagSpread` expr's `spreadFrom` add slot-only reasons (deleting the `noSerialize` assignment in `native-tag.ts`), everything else keeps value reasons; `writeSerializedBinding` then writes iff the value channel is non-empty, guarded by it, with no mask or override. `noSerializeProperties` (controllable/content carving) is orthogonal and stays. Risks: mis-classifying an effect read as slot-only silently drops resumed values, and the current wholesale revoke of `noSerialize` for non-pure spreads (`isPureSpreadResolved` block in `finalizeReferences`) must be replaced by a conservative "any non-spread read adds a value reason" shim before attempting to tighten it. Land as a byte-identical mechanical split first, then tighten classification in separate commits reviewed via snapshot/size diffs. Re-verify: `rg -n "noSerialize\b" packages/runtime-tags/src/translator` shows the mask set in `native-tag.ts`, revoked in `references.ts`, and overridden via `kSerializedValueReason` in `signals.ts`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// template.marko
const $template = "<!><button>Click</button>";
const $walks = "b0&0& b";
const $Dyn_content__count = /*@__PURE__*/ _let("count/0", ($scope) => _return($scope, {
"data-count": $scope.count,
onClick: $_return($scope)
}));
const $Dyn_content__setup = /*@__PURE__*/ _child_setup(($scope) => $Dyn_content__count($scope, 0));
const $Stat_content__setup = /*@__PURE__*/ _child_setup(($scope) => _return($scope, { class: "foo" }));
const $staticAttrs__OR__dynamicAttrs__script = _script("__tests__/template.marko_0_staticAttrs_dynamicAttrs", ($scope) => _attrs_script($scope, "#button/4"));
const $staticAttrs__OR__dynamicAttrs = /*@__PURE__*/ _or(7, ($scope) => {
_attrs($scope, "#button/4", {
...$scope.staticAttrs,
...$scope.dynamicAttrs
});
$staticAttrs__OR__dynamicAttrs__script($scope);
});
const $staticAttrs = _var_resume("__tests__/template.marko_0_staticAttrs/var", /*@__PURE__*/ _const("staticAttrs", $staticAttrs__OR__dynamicAttrs));
function $setup($scope) {
_var($scope, "#childScope/0", $staticAttrs);
$Stat_content__setup._($scope["#childScope/0"], $scope);
_var($scope, "#childScope/2", $dynamicAttrs);
$Dyn_content__setup._($scope["#childScope/2"], $scope);
}
const $dynamicAttrs = _var_resume("__tests__/template.marko_0_dynamicAttrs/var", /*@__PURE__*/ _const("dynamicAttrs", $staticAttrs__OR__dynamicAttrs));
function $_return($scope) {
return function() {
$Dyn_content__count($scope, $scope.count + 1);
};
}
_resume("__tests__/template.marko_2/_return", $_return);
var template_default = /*@__PURE__*/ _template("__tests__/template.marko", $template, $walks, $setup);
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// template.marko
const $Dyn_content__count = /*@__PURE__*/ _let(0, ($scope) => _return($scope, {
"data-count": $scope.a,
onClick: $_return($scope)
}));
const $staticAttrs__OR__dynamicAttrs__script = _script("a4", ($scope) => _attrs_script($scope, "e"));
const $staticAttrs__OR__dynamicAttrs = /*@__PURE__*/ _or(7, ($scope) => {
_attrs($scope, "e", {
...$scope.f,
...$scope.g
});
$staticAttrs__OR__dynamicAttrs__script($scope);
});
const $staticAttrs = _var_resume("a5", /*@__PURE__*/ _const(5, $staticAttrs__OR__dynamicAttrs));
const $dynamicAttrs = _var_resume("a3", /*@__PURE__*/ _const(6, $staticAttrs__OR__dynamicAttrs));
function $_return($scope) {
return function() {
$Dyn_content__count($scope, $scope.a + 1);
};
}
_resume("a0", $_return);
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// template.marko
var template_default = _template("__tests__/template.marko", (input) => {
_scope_reason();
const $scope0_id = _scope_id();
const Stat = { content: _content("__tests__/template.marko_1_content", () => {
const $scope1_id = _scope_id();
_scope_reason();
const $return = { class: "foo" };
return $return;
}) };
const Dyn = { content: _content("__tests__/template.marko_2_content", () => {
const $scope2_id = _scope_id();
_scope_reason();
let count = 0;
const $return2 = {
"data-count": count,
onClick: _resume(function() {
count++;
}, "__tests__/template.marko_2/_return", $scope2_id)
};
writeScope($scope2_id, { count }, "__tests__/template.marko", "7:2", { count: "8:8" });
_resume_branch($scope2_id);
return $return2;
}) };
let staticAttrs = Stat.content({});
const $childScope = _peek_scope_id();
let dynamicAttrs = Dyn.content({});
_var($scope0_id, "#scopeOffset/3", $childScope, "__tests__/template.marko_0_dynamicAttrs/var");
_html(`<button${_attrs({
...staticAttrs,
...dynamicAttrs
}, "#button/4", $scope0_id, "button")}>Click</button>${_el_resume($scope0_id, "#button/4")}`);
_script($scope0_id, "__tests__/template.marko_0_staticAttrs_dynamicAttrs");
writeScope($scope0_id, {
staticAttrs,
"#childScope/2": _existing_scope($childScope)
}, "__tests__/template.marko", 0, { staticAttrs: "18:7" });
}, 1);
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// template.marko
var template_default = _template("a", (input) => {
_scope_reason();
const $scope0_id = _scope_id();
const Stat = { content: _content("a1", () => {
_scope_id();
_scope_reason();
return { class: "foo" };
}) };
const Dyn = { content: _content("a2", () => {
const $scope2_id = _scope_id();
_scope_reason();
let count = 0;
const $return2 = {
"data-count": count,
onClick: _resume(function() {
count++;
}, "a0", $scope2_id)
};
writeScope($scope2_id, { a: count });
_resume_branch($scope2_id);
return $return2;
}) };
let staticAttrs = Stat.content({});
const $childScope = _peek_scope_id();
let dynamicAttrs = Dyn.content({});
_var($scope0_id, "d", $childScope, "a3");
_html(`<button${_attrs({
...staticAttrs,
...dynamicAttrs
}, "e", $scope0_id, "button")}>Click</button>${_el_resume($scope0_id, "e")}`);
_script($scope0_id, "a4");
writeScope($scope0_id, {
f: staticAttrs,
c: _existing_scope($childScope)
});
}, 1);
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Render
```html
<button
class="foo"
data-count="0"
>
Click
</button>
```

# Update
```js
document.querySelector("button").click();
```
```html
<button
class="foo"
data-count="1"
>
Click
</button>
```
## Change
```
UPDATE: .foo[data-count] "0" => "1"
```

# Update
```js
document.querySelector("button").click();
```
```html
<button
class="foo"
data-count="2"
>
Click
</button>
```
## Change
```
UPDATE: .foo[data-count] "1" => "2"
```

# Update
```js
document.querySelector("button").click();
```
```html
<button
class="foo"
data-count="3"
>
Click
</button>
```
## Change
```
UPDATE: .foo[data-count] "2" => "3"
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Render
```html
<button
class="foo"
data-count="0"
>
Click
</button>
```

# Update
```js
document.querySelector("button").click();
```
```html
<button
class="foo"
data-count="1"
>
Click
</button>
```
## Change
```
UPDATE: .foo[data-count] "0" => "1"
```

# Update
```js
document.querySelector("button").click();
```
```html
<button
class="foo"
data-count="2"
>
Click
</button>
```
## Change
```
UPDATE: .foo[data-count] "1" => "2"
```

# Update
```js
document.querySelector("button").click();
```
```html
<button
class="foo"
data-count="3"
>
Click
</button>
```
## Change
```
UPDATE: .foo[data-count] "2" => "3"
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<button class=foo data-count=0>Click</button><!--M_*1 #button/4-->
<script>
WALKER_RUNTIME("M")("_");
M._.r = [_ => [1, {
"#scopeOffset/3": 4,
"EventAttributes:#button/4": {
click: _(3,
"packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/template.marko_2/_return"
)
},
staticAttrs: {
class: "foo"
},
"#childScope/2": _(3)
}, 1, {
"#TagVariable": _(1,
"packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/template.marko_0_dynamicAttrs/var"
),
count: 0
}],
"packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/template.marko_0_staticAttrs_dynamicAttrs 1"
];
M._.w()
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<button class=foo data-count=0>Click</button><!--M_*1 e-->
<script>
WALKER_RUNTIME("M")("_");
M._.r = [_ => [1, {
d: 4,
Ie: {
click: _(3, "a0")
},
f: {
class: "foo"
},
c: _(3)
}, 1, {
T: _(1, "a3"),
a: 0
}], "a4 1"];
M._.w()
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"dom": {
"template.marko.page.mjs": {
"min": 4304,
"brotli": 2026
}
},
"html": {
"min": 430,
"brotli": 300
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<define/Stat>
<return={
class: "foo",
}>
</define>

<define/Dyn>
<let/count=0/>

<return={
"data-count": count,
onClick() {
count++;
},
}>
</define>

<Stat/staticAttrs/>
<Dyn/dynamicAttrs/>

<button ...staticAttrs ...dynamicAttrs>
Click
</button>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { TestConfig } from "../../main.test";

function click(document: Document) {
document.querySelector("button")!.click();
}

export const config: TestConfig = {
steps: [{}, click, click, click],
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// tags/my-box.marko
var my_box_default = _template("__tests__/tags/my-box.marko", (input) => {
_scope_reason();
const $scope0_reason = _scope_reason();
const $scope0_id = _scope_id();
const extra = { id: "x" };
_html("<div");
Expand All @@ -10,7 +10,7 @@ var my_box_default = _template("__tests__/tags/my-box.marko", (input) => {
}, "#div/0", $scope0_id, "div");
_html(`</div>${_el_resume($scope0_id, "#div/0")}`);
_script($scope0_id, "__tests__/tags/my-box.marko_0_input_extra");
writeScope($scope0_id, {}, "__tests__/tags/my-box.marko", 0);
writeScope($scope0_id, { extra: _serialize_if($scope0_reason, 0) && extra }, "__tests__/tags/my-box.marko", 0, { extra: "1:8" });
});

// template.marko
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// tags/my-box.marko
var my_box_default = _template("b", (input) => {
_scope_reason();
const $scope0_reason = _scope_reason();
const $scope0_id = _scope_id();
const extra = { id: "x" };
_html("<div");
Expand All @@ -10,7 +10,7 @@ var my_box_default = _template("b", (input) => {
}, "a", $scope0_id, "div");
_html(`</div>${_el_resume($scope0_id, "a")}`);
_script($scope0_id, "b0");
writeScope($scope0_id, {});
writeScope($scope0_id, { d: _serialize_if($scope0_reason, 0) && extra });
});

// template.marko
Expand Down
Loading