From f179589857b27693846fa5a005e4f4b99d6f1e9b Mon Sep 17 00:00:00 2001 From: Ryan Turnquist Date: Wed, 5 Aug 2026 23:59:08 +0000 Subject: [PATCH 1/3] fix(runtime-tags): serialize sourceless spread bindings in stateful intersections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A binding spread onto a native tag is marked noSerialize since its value is normally baked into the HTML. When that binding intersects with stateful spread attrs, the intersection re-runs client side and reads the value from scope — but a sourceless binding (e.g. a static tag return) has no client signal to recompute it, so its attrs were dropped on update. Clear noSerialize for such bindings so their value resumes. --- .changeset/lucky-pandas-clap.md | 5 ++ .../__snapshots__/dom.bundle.debug.js | 32 ++++++++++ .../__snapshots__/dom.bundle.js | 21 +++++++ .../__snapshots__/html.bundle.debug.js | 38 ++++++++++++ .../__snapshots__/html.bundle.js | 37 ++++++++++++ .../__snapshots__/render.debug.md | 60 +++++++++++++++++++ .../__snapshots__/render.md | 60 +++++++++++++++++++ .../__snapshots__/writes.debug.html | 24 ++++++++ .../__snapshots__/writes.html | 18 ++++++ .../sizes.json | 12 ++++ .../template.marko | 23 +++++++ .../test.ts | 9 +++ .../__snapshots__/html.bundle.debug.js | 2 +- .../__snapshots__/html.bundle.js | 2 +- .../__snapshots__/writes.debug.html | 5 +- .../__snapshots__/writes.html | 5 +- .../sizes.json | 4 +- .../src/translator/util/references.ts | 48 +++++++++------ 18 files changed, 380 insertions(+), 25 deletions(-) create mode 100644 .changeset/lucky-pandas-clap.md create mode 100644 packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/dom.bundle.debug.js create mode 100644 packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/dom.bundle.js create mode 100644 packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/html.bundle.debug.js create mode 100644 packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/html.bundle.js create mode 100644 packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/render.debug.md create mode 100644 packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/render.md create mode 100644 packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/writes.debug.html create mode 100644 packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/writes.html create mode 100644 packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/sizes.json create mode 100644 packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/template.marko create mode 100644 packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/test.ts diff --git a/.changeset/lucky-pandas-clap.md b/.changeset/lucky-pandas-clap.md new file mode 100644 index 00000000000..84c7cbdd88c --- /dev/null +++ b/.changeset/lucky-pandas-clap.md @@ -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. diff --git a/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/dom.bundle.debug.js b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/dom.bundle.debug.js new file mode 100644 index 00000000000..2e3c9ed5814 --- /dev/null +++ b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/dom.bundle.debug.js @@ -0,0 +1,32 @@ +// template.marko +const $template = ""; +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); diff --git a/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/dom.bundle.js b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/dom.bundle.js new file mode 100644 index 00000000000..57953c069f4 --- /dev/null +++ b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/dom.bundle.js @@ -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); diff --git a/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/html.bundle.debug.js b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/html.bundle.debug.js new file mode 100644 index 00000000000..8783b11fe0b --- /dev/null +++ b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/html.bundle.debug.js @@ -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(`Click${_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); diff --git a/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/html.bundle.js b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/html.bundle.js new file mode 100644 index 00000000000..47c96ab98df --- /dev/null +++ b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/html.bundle.js @@ -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(`Click${_el_resume($scope0_id, "e")}`); + _script($scope0_id, "a4"); + writeScope($scope0_id, { + f: staticAttrs, + c: _existing_scope($childScope) + }); +}, 1); diff --git a/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/render.debug.md b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/render.debug.md new file mode 100644 index 00000000000..93e024e4345 --- /dev/null +++ b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/render.debug.md @@ -0,0 +1,60 @@ +# Render +```html + +``` + +# Update +```js +document.querySelector("button").click(); +``` +```html + +``` +## Change +``` +UPDATE: .foo[data-count] "0" => "1" +``` + +# Update +```js +document.querySelector("button").click(); +``` +```html + +``` +## Change +``` +UPDATE: .foo[data-count] "1" => "2" +``` + +# Update +```js +document.querySelector("button").click(); +``` +```html + +``` +## Change +``` +UPDATE: .foo[data-count] "2" => "3" +``` diff --git a/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/render.md b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/render.md new file mode 100644 index 00000000000..93e024e4345 --- /dev/null +++ b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/render.md @@ -0,0 +1,60 @@ +# Render +```html + +``` + +# Update +```js +document.querySelector("button").click(); +``` +```html + +``` +## Change +``` +UPDATE: .foo[data-count] "0" => "1" +``` + +# Update +```js +document.querySelector("button").click(); +``` +```html + +``` +## Change +``` +UPDATE: .foo[data-count] "1" => "2" +``` + +# Update +```js +document.querySelector("button").click(); +``` +```html + +``` +## Change +``` +UPDATE: .foo[data-count] "2" => "3" +``` diff --git a/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/writes.debug.html b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/writes.debug.html new file mode 100644 index 00000000000..707b9bbcfef --- /dev/null +++ b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/writes.debug.html @@ -0,0 +1,24 @@ + + diff --git a/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/writes.html b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/writes.html new file mode 100644 index 00000000000..dd8ac2f216d --- /dev/null +++ b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/__snapshots__/writes.html @@ -0,0 +1,18 @@ + + diff --git a/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/sizes.json b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/sizes.json new file mode 100644 index 00000000000..4ce74c836c4 --- /dev/null +++ b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/sizes.json @@ -0,0 +1,12 @@ +{ + "dom": { + "template.marko.page.mjs": { + "min": 4304, + "brotli": 2026 + } + }, + "html": { + "min": 430, + "brotli": 300 + } +} diff --git a/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/template.marko b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/template.marko new file mode 100644 index 00000000000..6bc8e1782fa --- /dev/null +++ b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/template.marko @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + diff --git a/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/test.ts b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/test.ts new file mode 100644 index 00000000000..cc3dd964f79 --- /dev/null +++ b/packages/runtime-tags/src/__tests__/fixtures/attr-spread-static-dynamic-intersection/test.ts @@ -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], +}; diff --git a/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/html.bundle.debug.js b/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/html.bundle.debug.js index 80a2386d3cb..88ec88b9afc 100644 --- a/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/html.bundle.debug.js +++ b/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/html.bundle.debug.js @@ -10,7 +10,7 @@ var my_box_default = _template("__tests__/tags/my-box.marko", (input) => { }, "#div/0", $scope0_id, "div"); _html(`${_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 }, "__tests__/tags/my-box.marko", 0, { extra: "1:8" }); }); // template.marko diff --git a/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/html.bundle.js b/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/html.bundle.js index 4a6715d4804..20ce15b7706 100644 --- a/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/html.bundle.js +++ b/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/html.bundle.js @@ -10,7 +10,7 @@ var my_box_default = _template("b", (input) => { }, "a", $scope0_id, "div"); _html(`${_el_resume($scope0_id, "a")}`); _script($scope0_id, "b0"); - writeScope($scope0_id, {}); + writeScope($scope0_id, { d: extra }); }); // template.marko diff --git a/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/writes.debug.html b/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/writes.debug.html index 075e194594e..4e35e31bde9 100644 --- a/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/writes.debug.html +++ b/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/writes.debug.html @@ -3,7 +3,10 @@ WALKER_RUNTIME("M")("_"); M._.r = [_ => [2, { "BranchScopes:#div/0": _(3), - "ConditionalRenderer:#div/0": "packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/template.marko_1_content" + "ConditionalRenderer:#div/0": "packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/template.marko_1_content", + extra: { + id: "x" + } }], "packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/tags/my-box.marko_0_input_extra 2" ]; diff --git a/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/writes.html b/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/writes.html index 321721052e8..6e3315ee75b 100644 --- a/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/writes.html +++ b/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/writes.html @@ -3,7 +3,10 @@ WALKER_RUNTIME("M")("_"); M._.r = [_ => [2, { Aa: _(3), - Da: "a0" + Da: "a0", + d: { + id: "x" + } }], "b0 2"]; M._.w() diff --git a/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/sizes.json b/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/sizes.json index 0b60d61b4f9..fa09d439bd8 100644 --- a/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/sizes.json +++ b/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/sizes.json @@ -6,7 +6,7 @@ } }, "html": { - "min": 365, - "brotli": 264 + "min": 376, + "brotli": 272 } } diff --git a/packages/runtime-tags/src/translator/util/references.ts b/packages/runtime-tags/src/translator/util/references.ts index 2302499f89f..1967ef8f32d 100644 --- a/packages/runtime-tags/src/translator/util/references.ts +++ b/packages/runtime-tags/src/translator/util/references.ts @@ -1145,32 +1145,42 @@ export function finalizeReferences() { for (let j = i + 1; j < numReferences; j++) { const binding1 = intersection[i]; const binding2 = intersection[j]; - if ( - !isForceSerialized(section, binding1) && - !isSupersetSources(binding1, binding2) - ) { - if (!isSameOrChildSection(section, binding1.section)) { - addOwnerSerializeReason( - section, + if (!isSupersetSources(binding1, binding2)) { + // A sourceless binding has no client signal to recompute it, so + // once the intersection re-runs its value must resume from scope. + if (!binding1.sources) binding1.noSerialize = false; + if (!isForceSerialized(section, binding1)) { + if (!isSameOrChildSection(section, binding1.section)) { + addOwnerSerializeReason( + section, + binding1.section, + mergeSources(binding1.sources, binding2.sources), + ); + } + + addSerializeReason( binding1.section, - mergeSources(binding1.sources, binding2.sources), + binding2.sources, + binding1, ); } - - addSerializeReason(binding1.section, binding2.sources, binding1); } - if ( - !isForceSerialized(section, binding2) && - !isSupersetSources(binding2, binding1) - ) { - if (!isSameOrChildSection(section, binding2.section)) { - addOwnerSerializeReason( - section, + if (!isSupersetSources(binding2, binding1)) { + if (!binding2.sources) binding2.noSerialize = false; + if (!isForceSerialized(section, binding2)) { + if (!isSameOrChildSection(section, binding2.section)) { + addOwnerSerializeReason( + section, + binding2.section, + mergeSources(binding1.sources, binding2.sources), + ); + } + addSerializeReason( binding2.section, - mergeSources(binding1.sources, binding2.sources), + binding1.sources, + binding2, ); } - addSerializeReason(binding2.section, binding1.sources, binding2); } } } From 4371d46b16abd35565dde715728a61d223b36316 Mon Sep 17 00:00:00 2001 From: Ryan Turnquist Date: Thu, 6 Aug 2026 02:19:02 +0000 Subject: [PATCH 2/3] fix(runtime-tags): guard intersection value serialization on partner sources Instead of unconditionally clearing noSerialize for sourceless bindings in an intersection, key their value serialization to the partner's sources. State-backed partners still serialize unconditionally, while input-backed partners emit a runtime guard so the value only serializes when the caller actually passes stateful input. --- .../__snapshots__/html.bundle.debug.js | 4 ++-- .../__snapshots__/html.bundle.js | 4 ++-- .../__snapshots__/writes.debug.html | 5 +---- .../__snapshots__/writes.html | 5 +---- .../sizes.json | 4 ++-- .../src/translator/util/references.ts | 22 ++++++++++++++++--- .../src/translator/util/serialize-reasons.ts | 4 ++++ .../src/translator/util/signals.ts | 17 +++++++++++--- 8 files changed, 45 insertions(+), 20 deletions(-) diff --git a/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/html.bundle.debug.js b/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/html.bundle.debug.js index 88ec88b9afc..7ab34d1a23b 100644 --- a/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/html.bundle.debug.js +++ b/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/html.bundle.debug.js @@ -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/0", $scope0_id, "div"); _html(`${_el_resume($scope0_id, "#div/0")}`); _script($scope0_id, "__tests__/tags/my-box.marko_0_input_extra"); - writeScope($scope0_id, { extra }, "__tests__/tags/my-box.marko", 0, { extra: "1:8" }); + writeScope($scope0_id, { extra: _serialize_if($scope0_reason, 0) && extra }, "__tests__/tags/my-box.marko", 0, { extra: "1:8" }); }); // template.marko diff --git a/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/html.bundle.js b/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/html.bundle.js index 20ce15b7706..80609ea76b5 100644 --- a/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/html.bundle.js +++ b/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/html.bundle.js @@ -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(" { }, "a", $scope0_id, "div"); _html(`${_el_resume($scope0_id, "a")}`); _script($scope0_id, "b0"); - writeScope($scope0_id, { d: extra }); + writeScope($scope0_id, { d: _serialize_if($scope0_reason, 0) && extra }); }); // template.marko diff --git a/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/writes.debug.html b/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/writes.debug.html index 4e35e31bde9..075e194594e 100644 --- a/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/writes.debug.html +++ b/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/writes.debug.html @@ -3,10 +3,7 @@ WALKER_RUNTIME("M")("_"); M._.r = [_ => [2, { "BranchScopes:#div/0": _(3), - "ConditionalRenderer:#div/0": "packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/template.marko_1_content", - extra: { - id: "x" - } + "ConditionalRenderer:#div/0": "packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/template.marko_1_content" }], "packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/tags/my-box.marko_0_input_extra 2" ]; diff --git a/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/writes.html b/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/writes.html index 6e3315ee75b..321721052e8 100644 --- a/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/writes.html +++ b/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/__snapshots__/writes.html @@ -3,10 +3,7 @@ WALKER_RUNTIME("M")("_"); M._.r = [_ => [2, { Aa: _(3), - Da: "a0", - d: { - id: "x" - } + Da: "a0" }], "b0 2"]; M._.w() diff --git a/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/sizes.json b/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/sizes.json index fa09d439bd8..0b60d61b4f9 100644 --- a/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/sizes.json +++ b/packages/runtime-tags/src/__tests__/fixtures/native-tag-spread-content-multi/sizes.json @@ -6,7 +6,7 @@ } }, "html": { - "min": 376, - "brotli": 272 + "min": 365, + "brotli": 264 } } diff --git a/packages/runtime-tags/src/translator/util/references.ts b/packages/runtime-tags/src/translator/util/references.ts index 1967ef8f32d..bcace1bc511 100644 --- a/packages/runtime-tags/src/translator/util/references.ts +++ b/packages/runtime-tags/src/translator/util/references.ts @@ -57,6 +57,7 @@ import { getSerializeSourcesForExpr, getSerializeSourcesForRef, isForceSerialized, + kSerializedValueReason, mergeSerializeReasons, type SerializeReason, } from "./serialize-reasons"; @@ -1147,8 +1148,16 @@ export function finalizeReferences() { const binding2 = intersection[j]; if (!isSupersetSources(binding1, binding2)) { // A sourceless binding has no client signal to recompute it, so - // once the intersection re-runs its value must resume from scope. - if (!binding1.sources) binding1.noSerialize = false; + // once the intersection re-runs its value must resume from scope + // even when a spread otherwise skips serializing it. + if (!binding1.sources && binding1.noSerialize) { + addSerializeReason( + binding1.section, + binding2.sources, + binding1, + kSerializedValueReason, + ); + } if (!isForceSerialized(section, binding1)) { if (!isSameOrChildSection(section, binding1.section)) { addOwnerSerializeReason( @@ -1166,7 +1175,14 @@ export function finalizeReferences() { } } if (!isSupersetSources(binding2, binding1)) { - if (!binding2.sources) binding2.noSerialize = false; + if (!binding2.sources && binding2.noSerialize) { + addSerializeReason( + binding2.section, + binding1.sources, + binding2, + kSerializedValueReason, + ); + } if (!isForceSerialized(section, binding2)) { if (!isSameOrChildSection(section, binding2.section)) { addOwnerSerializeReason( diff --git a/packages/runtime-tags/src/translator/util/serialize-reasons.ts b/packages/runtime-tags/src/translator/util/serialize-reasons.ts index f8468253ed2..5104c1b802e 100644 --- a/packages/runtime-tags/src/translator/util/serialize-reasons.ts +++ b/packages/runtime-tags/src/translator/util/serialize-reasons.ts @@ -20,6 +20,10 @@ import { } from "./references"; import type { Section } from "./sections"; +// Keys a binding value that must serialize despite `noSerialize` because an +// intersection reads it from scope and nothing client side recomputes it. +export const kSerializedValueReason = Symbol("serializedValue"); + export type SerializeReasons = true | [Sources, ...Sources[]]; export type SerializeReason = true | Sources; type SerializeKey = symbol & { __serialize_key__: 1 }; diff --git a/packages/runtime-tags/src/translator/util/signals.ts b/packages/runtime-tags/src/translator/util/signals.ts index 3d523002757..47f16850a1e 100644 --- a/packages/runtime-tags/src/translator/util/signals.ts +++ b/packages/runtime-tags/src/translator/util/signals.ts @@ -55,6 +55,7 @@ import { getSerializeReason, isReasonDynamic, isSameReason, + kSerializedValueReason, type SerializeReason, } from "./serialize-reasons"; import { simplifyFunction } from "./simplify-fn"; @@ -1285,11 +1286,21 @@ export function writeHTMLResumeStatements( let debugVars: t.ObjectProperty[] | undefined; const writeSerializedBinding = (binding: Binding) => { - const reason = getSerializeReason(section, binding); + let reason = getSerializeReason(section, binding); if (!reason) return; if (binding.noSerialize) { - serializedLookup.delete(getScopeAccessor(binding)); - return; + // An intersection needing the value from scope overrides the spread's + // value skip, guarded by the sources that re-run the intersection. + const valueReason = getSerializeReason( + section, + binding, + kSerializedValueReason, + ); + if (!valueReason) { + serializedLookup.delete(getScopeAccessor(binding)); + return; + } + reason = valueReason; } const accessor = getScopeAccessor(binding); serializedLookup.delete(accessor); From ff30265baf264a79d5b6bbe882a056aadddba4b8 Mon Sep 17 00:00:00 2001 From: Ryan Turnquist Date: Thu, 6 Aug 2026 04:03:23 +0000 Subject: [PATCH 3/3] chore: record serialize-reason slot/value split in agent feedback --- agent-feedback/cleanup.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/agent-feedback/cleanup.md b/agent-feedback/cleanup.md index 560405f8395..017e00ecd6b 100644 --- a/agent-feedback/cleanup.md +++ b/agent-feedback/cleanup.md @@ -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(, …, -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 ``. 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`.