diff --git a/.changeset/branch-closure-repush.md b/.changeset/branch-closure-repush.md new file mode 100644 index 00000000000..174412d72e8 --- /dev/null +++ b/.changeset/branch-closure-repush.md @@ -0,0 +1,6 @@ +--- +"@marko/runtime-tags": patch +--- + +Stop serializing a closure value for an ``/`` body when the branch condition only changes together with the value: the owner signal pushes the fresh value before the branch is created, so the serialized copy was never read. Content passed through such a branch no longer needs a resume registration, letting otherwise static templates tree-shake out of the client bundle. +Branch sources excused this way still count toward a separate instantiation-flavored reason, so closures inside content that those branches can instantiate after resume keep their serialization. diff --git a/agent-feedback/items/2026-08-31-dynamic-tag-content-in-loop-unserialized-closures.md b/agent-feedback/items/2026-08-31-dynamic-tag-content-in-loop-unserialized-closures.md new file mode 100644 index 00000000000..070d3505768 --- /dev/null +++ b/agent-feedback/items/2026-08-31-dynamic-tag-content-in-loop-unserialized-closures.md @@ -0,0 +1,12 @@ +--- +type: bug +impact: med +effort: high +site: packages/runtime-tags/src/translator/visitors/tag/dynamic-tag.ts › analyze +--- + +# Closures inside dynamic-tag content rendered directly in a `` body are not serialized + +When attribute-tag content is rendered by a bare `<${content}/>` in a `` body (no wrapping ``), a closure inside that content over unrelated outer state is never serialized, so an item added client-side after resume renders the closure value as empty. Wrapping the dynamic tag in `` serializes correctly. Repro: `template.marko` with `<@section>${count}` and `tags/sections.marko` of `
<${content}/>
` — after hydrate, clicking renders `
` where CSR renders `
5
`, and SSR writes neither `count` nor a content registration. Present on `main` (pre-dates the branch-closure serialization work); likely the direct dynamic-tag-content path skips the downstream reason chain that the `` closure shape flows through. + +Check: add the two files above as a fixture with `steps: [{}, click]` and `equivalent: false`; `render-ssr.md` and `render-csr.md` diverge on the appended item's text. diff --git a/packages/runtime-tags/src/__tests__/fixtures/attr-class/__snapshots__/html.bundle.debug.js b/packages/runtime-tags/src/__tests__/fixtures/attr-class/__snapshots__/html.bundle.debug.js index 224a55f7468..ce2bebc39f7 100644 --- a/packages/runtime-tags/src/__tests__/fixtures/attr-class/__snapshots__/html.bundle.debug.js +++ b/packages/runtime-tags/src/__tests__/fixtures/attr-class/__snapshots__/html.bundle.debug.js @@ -1,6 +1,6 @@ // tags/custom-tag.marko var custom_tag_default = _template("__tests__/tags/custom-tag.marko", (input) => { - const $scope0_reason = _scope_reason(), $sg__input_test = _serialize_guard($scope0_reason, 3), $si__input_test = _serialize_if($scope0_reason, 3); + const $scope0_reason = _scope_reason(), $sg__input_test = _serialize_guard($scope0_reason, 3); const $scope0_id = _scope_id(); _html(`
${_el_resume($scope0_id, "#div/0", _serialize_guard($scope0_reason, 2))}`); _if(() => { @@ -9,17 +9,11 @@ var custom_tag_default = _template("__tests__/tags/custom-tag.marko", (input) => _html(``); _dynamic_tag($scope1_id, "#text/1", input.test.content, {}, 0, 0, _serialize_guard($scope0_reason, 5)); _html(`${_el_resume($scope1_id, "#div/0", _serialize_guard($scope0_reason, 4))}`); - $si__input_test && writeScope($scope1_id, { _: _serialize_if($scope0_reason, 1) && _scope_with_id($scope0_id) }, "__tests__/tags/custom-tag.marko", "3:2"); + _serialize_if($scope0_reason, 3) && writeScope($scope1_id, { _: _serialize_if($scope0_reason, 1) && _scope_with_id($scope0_id) }, "__tests__/tags/custom-tag.marko", "3:2"); return 0; } }, $scope0_id, "#text/1", $sg__input_test, $sg__input_test, $sg__input_test, 0, 1); - _serialize_if($scope0_reason, 0) && writeScope($scope0_id, { - input_test_class: $si__input_test && input.test?.class, - input_test_content: $si__input_test && input.test?.content - }, "__tests__/tags/custom-tag.marko", 0, { - input_test_class: ["input.test.class"], - input_test_content: ["input.test.content"] - }); + _serialize_if($scope0_reason, 0) && writeScope($scope0_id, {}, "__tests__/tags/custom-tag.marko", 0); }); // template.marko diff --git a/packages/runtime-tags/src/__tests__/fixtures/attr-class/__snapshots__/html.bundle.js b/packages/runtime-tags/src/__tests__/fixtures/attr-class/__snapshots__/html.bundle.js index dd96653a6d5..decf08e01b1 100644 --- a/packages/runtime-tags/src/__tests__/fixtures/attr-class/__snapshots__/html.bundle.js +++ b/packages/runtime-tags/src/__tests__/fixtures/attr-class/__snapshots__/html.bundle.js @@ -1,6 +1,6 @@ // tags/custom-tag.marko var custom_tag_default = _template("b", (input) => { - const $scope0_reason = _scope_reason(), $sg__input_test = _serialize_guard($scope0_reason, 3), $si__input_test = _serialize_if($scope0_reason, 3); + const $scope0_reason = _scope_reason(), $sg__input_test = _serialize_guard($scope0_reason, 3); const $scope0_id = _scope_id(); _html(`${_el_resume($scope0_id, "a", _serialize_guard($scope0_reason, 2))}`); _if(() => { @@ -9,14 +9,11 @@ var custom_tag_default = _template("b", (input) => { _html(``); _dynamic_tag($scope1_id, "b", input.test.content, {}, 0, 0, _serialize_guard($scope0_reason, 5)); _html(`${_el_resume($scope1_id, "a", _serialize_guard($scope0_reason, 4))}`); - $si__input_test && writeScope($scope1_id, { _: _serialize_if($scope0_reason, 1) && _scope_with_id($scope0_id) }); + _serialize_if($scope0_reason, 3) && writeScope($scope1_id, { _: _serialize_if($scope0_reason, 1) && _scope_with_id($scope0_id) }); return 0; } }, $scope0_id, "b", $sg__input_test, $sg__input_test, $sg__input_test, 0, 1); - _serialize_if($scope0_reason, 0) && writeScope($scope0_id, { - g: $si__input_test && input.test?.class, - h: $si__input_test && input.test?.content - }); + _serialize_if($scope0_reason, 0) && writeScope($scope0_id, {}); }); // template.marko diff --git a/packages/runtime-tags/src/__tests__/fixtures/attr-style/__snapshots__/html.bundle.debug.js b/packages/runtime-tags/src/__tests__/fixtures/attr-style/__snapshots__/html.bundle.debug.js index e0542dff886..181d30aad7c 100644 --- a/packages/runtime-tags/src/__tests__/fixtures/attr-style/__snapshots__/html.bundle.debug.js +++ b/packages/runtime-tags/src/__tests__/fixtures/attr-style/__snapshots__/html.bundle.debug.js @@ -1,6 +1,6 @@ // tags/custom-tag.marko var custom_tag_default = _template("__tests__/tags/custom-tag.marko", (input) => { - const $scope0_reason = _scope_reason(), $sg__input_test = _serialize_guard($scope0_reason, 3), $si__input_test = _serialize_if($scope0_reason, 3); + const $scope0_reason = _scope_reason(), $sg__input_test = _serialize_guard($scope0_reason, 3); const $scope0_id = _scope_id(); _html(`${_el_resume($scope0_id, "#div/0", _serialize_guard($scope0_reason, 2))}`); _if(() => { @@ -9,17 +9,11 @@ var custom_tag_default = _template("__tests__/tags/custom-tag.marko", (input) => _html(``); _dynamic_tag($scope1_id, "#text/1", input.test.content, {}, 0, 0, _serialize_guard($scope0_reason, 5)); _html(`${_el_resume($scope1_id, "#div/0", _serialize_guard($scope0_reason, 4))}`); - $si__input_test && writeScope($scope1_id, { _: _serialize_if($scope0_reason, 1) && _scope_with_id($scope0_id) }, "__tests__/tags/custom-tag.marko", "3:2"); + _serialize_if($scope0_reason, 3) && writeScope($scope1_id, { _: _serialize_if($scope0_reason, 1) && _scope_with_id($scope0_id) }, "__tests__/tags/custom-tag.marko", "3:2"); return 0; } }, $scope0_id, "#text/1", $sg__input_test, $sg__input_test, $sg__input_test, 0, 1); - _serialize_if($scope0_reason, 0) && writeScope($scope0_id, { - input_test_style: $si__input_test && input.test?.style, - input_test_content: $si__input_test && input.test?.content - }, "__tests__/tags/custom-tag.marko", 0, { - input_test_style: ["input.test.style"], - input_test_content: ["input.test.content"] - }); + _serialize_if($scope0_reason, 0) && writeScope($scope0_id, {}, "__tests__/tags/custom-tag.marko", 0); }); // template.marko diff --git a/packages/runtime-tags/src/__tests__/fixtures/attr-style/__snapshots__/html.bundle.js b/packages/runtime-tags/src/__tests__/fixtures/attr-style/__snapshots__/html.bundle.js index 7be5f3e9d04..bd64e01646b 100644 --- a/packages/runtime-tags/src/__tests__/fixtures/attr-style/__snapshots__/html.bundle.js +++ b/packages/runtime-tags/src/__tests__/fixtures/attr-style/__snapshots__/html.bundle.js @@ -1,6 +1,6 @@ // tags/custom-tag.marko var custom_tag_default = _template("b", (input) => { - const $scope0_reason = _scope_reason(), $sg__input_test = _serialize_guard($scope0_reason, 3), $si__input_test = _serialize_if($scope0_reason, 3); + const $scope0_reason = _scope_reason(), $sg__input_test = _serialize_guard($scope0_reason, 3); const $scope0_id = _scope_id(); _html(`${_el_resume($scope0_id, "a", _serialize_guard($scope0_reason, 2))}`); _if(() => { @@ -9,14 +9,11 @@ var custom_tag_default = _template("b", (input) => { _html(``); _dynamic_tag($scope1_id, "b", input.test.content, {}, 0, 0, _serialize_guard($scope0_reason, 5)); _html(`${_el_resume($scope1_id, "a", _serialize_guard($scope0_reason, 4))}`); - $si__input_test && writeScope($scope1_id, { _: _serialize_if($scope0_reason, 1) && _scope_with_id($scope0_id) }); + _serialize_if($scope0_reason, 3) && writeScope($scope1_id, { _: _serialize_if($scope0_reason, 1) && _scope_with_id($scope0_id) }); return 0; } }, $scope0_id, "b", $sg__input_test, $sg__input_test, $sg__input_test, 0, 1); - _serialize_if($scope0_reason, 0) && writeScope($scope0_id, { - g: $si__input_test && input.test?.style, - h: $si__input_test && input.test?.content - }); + _serialize_if($scope0_reason, 0) && writeScope($scope0_id, {}); }); // template.marko diff --git a/packages/runtime-tags/src/__tests__/fixtures/attr-tag-branch-closure-dynamic/__snapshots__/dom.bundle.debug.js b/packages/runtime-tags/src/__tests__/fixtures/attr-tag-branch-closure-dynamic/__snapshots__/dom.bundle.debug.js new file mode 100644 index 00000000000..23d019e66d4 --- /dev/null +++ b/packages/runtime-tags/src/__tests__/fixtures/attr-tag-branch-closure-dynamic/__snapshots__/dom.bundle.debug.js @@ -0,0 +1,52 @@ +// tags/pager.marko +const $template$2 = ""; +const $walks$2 = " b"; +const $setup$2 = () => {}; +const $if_content__dynamicTag = /*@__PURE__*/ _dynamic_tag("#text/0"); +const $if_content__input_start_content = /*@__PURE__*/ _if_closure("#nav/0", 0, ($scope) => $if_content__dynamicTag($scope, $scope._.input_start_content)); +const $if_content__setup = $if_content__input_start_content; +const $if = /*@__PURE__*/ _if("#nav/0", "", "D%", $if_content__setup); +const $input_start = ($scope, input_start) => { + $input_start_content($scope, input_start?.content); + $if($scope, input_start ? 0 : 1); +}; +const $input$1 = ($scope, input) => $input_start($scope, input.start); +const $input_start_content = /*@__PURE__*/ _const("input_start_content", $if_content__input_start_content); +var pager_default = /*@__PURE__*/ _template("__tests__/tags/pager.marko", $template$2, " b", 0, $input$1); + +// tags/page.marko +const $template$1 = $template$2; +const $walks$1 = /*@__PURE__*/ ((_w0) => `/${_w0}&`)(" b"); +const $setup$1 = () => {}; +const items$1 = [{ title: "First" }, { title: "Second" }]; +const $start_content__next_title = /*@__PURE__*/ _closure_get("next_title", ($scope) => _text($scope["#text/0"], $scope._.next_title)); +const $start_content__setup = $start_content__next_title; +const $start_content = /*@__PURE__*/ _content("__tests__/tags/page.marko_1*content", " ", " ", $start_content__setup); +const $next = /*@__PURE__*/ _const("next", ($scope) => { + let $start; + if ($scope.next) { + $start = attrTag({ content: $start_content($scope) }); + } + $input_start($scope["#childScope/0"], $start); + $next_title($scope, $scope.next?.title); +}); +const $next_title__closure = /*@__PURE__*/ _closure($start_content__next_title); +const $next_title = /*@__PURE__*/ _const("next_title", $next_title__closure); +const $input_index = ($scope, input_index) => $next($scope, items$1[input_index]); +const $input = ($scope, input) => $input_index($scope, input.index); +var page_default = /*@__PURE__*/ _template("__tests__/tags/page.marko", $template$1, $walks$1, $setup$1, $input); + +// template.marko +const $template = /*@__PURE__*/ ((_w0) => `${_w0}`)($template$1); +const $walks = /*@__PURE__*/ ((_w0) => ` b/${_w0}&`)($walks$1); +const items = [{ title: "First" }, { title: "Second" }]; +const $index = /*@__PURE__*/ _let("index/2", ($scope) => $input_index($scope["#childScope/1"], $scope.index)); +const $setup__script = _script("__tests__/template.marko_0", ($scope) => _on($scope["#button/0"], "click", function() { + $index($scope, ($scope.index + 1) % 3); +})); +function $setup($scope) { + /* @__PURE__ */ $setup$1($scope["#childScope/1"]); + $index($scope, 0); + $setup__script($scope); +} +var template_default = /*@__PURE__*/ _template("__tests__/template.marko", $template, $walks, $setup); diff --git a/packages/runtime-tags/src/__tests__/fixtures/attr-tag-branch-closure-dynamic/__snapshots__/dom.bundle.js b/packages/runtime-tags/src/__tests__/fixtures/attr-tag-branch-closure-dynamic/__snapshots__/dom.bundle.js new file mode 100644 index 00000000000..a5b13496349 --- /dev/null +++ b/packages/runtime-tags/src/__tests__/fixtures/attr-tag-branch-closure-dynamic/__snapshots__/dom.bundle.js @@ -0,0 +1,28 @@ +// tags/pager.marko +const $if_content__dynamicTag = /*@__PURE__*/ _dynamic_tag(0); +const $if_content__input_start_content = /*@__PURE__*/ _if_closure(0, 0, ($scope) => $if_content__dynamicTag($scope, $scope._.e)); +const $if = /*@__PURE__*/ _if(0, "", "D%", $if_content__input_start_content); +const $input_start = ($scope, input_start) => { + $input_start_content($scope, input_start?.content); + $if($scope, input_start ? 0 : 1); +}; +const $input_start_content = /*@__PURE__*/ _const(4, $if_content__input_start_content); + +// tags/page.marko +const items = [{ title: "First" }, { title: "Second" }]; +const $start_content__next_title = /*@__PURE__*/ _closure_get(6, ($scope) => _text($scope.a, $scope._.f)); +const $start_content = /*@__PURE__*/ _content("b0", " ", " ", $start_content__next_title); +const $next = /*@__PURE__*/ _const(4, ($scope) => { + let $start; + if ($scope.e) $start = attrTag({ content: $start_content($scope) }); + $input_start($scope.a, $start); + $next_title($scope, $scope.e?.title); +}); +const $next_title = /*@__PURE__*/ _const(5, /* @__PURE__ */ _closure($start_content__next_title)); +const $input_index = ($scope, input_index) => $next($scope, items[input_index]); + +// template.marko +const $index = /*@__PURE__*/ _let(2, ($scope) => $input_index($scope.b, $scope.c)); +const $setup__script = _script("a0", ($scope) => _on($scope.a, "click", function() { + $index($scope, ($scope.c + 1) % 3); +})); diff --git a/packages/runtime-tags/src/__tests__/fixtures/attr-tag-branch-closure-dynamic/__snapshots__/html.bundle.debug.js b/packages/runtime-tags/src/__tests__/fixtures/attr-tag-branch-closure-dynamic/__snapshots__/html.bundle.debug.js new file mode 100644 index 00000000000..0a81b46a3a2 --- /dev/null +++ b/packages/runtime-tags/src/__tests__/fixtures/attr-tag-branch-closure-dynamic/__snapshots__/html.bundle.debug.js @@ -0,0 +1,65 @@ +// tags/pager.marko +var pager_default = _template("__tests__/tags/pager.marko", (input) => { + const $scope0_reason = _scope_reason(), $sg__input_start = _serialize_guard($scope0_reason, 0), $si__input_start = _serialize_if($scope0_reason, 0); + const $scope0_id = _scope_id(); + _html("", 1); + $si__input_start && writeScope($scope0_id, {}, "__tests__/tags/pager.marko", 0); +}); + +// tags/page.marko +const items$1 = [{ title: "First" }, { title: "Second" }]; +var page_default = _template("__tests__/tags/page.marko", (input) => { + const $scope0_reason = _scope_reason(), $sg__input_index = _serialize_guard($scope0_reason, 0), $si__input_index = _serialize_if($scope0_reason, 0); + const $scope0_id = _scope_id(); + const $next_title__closures = new Set(); + const next = items$1[input.index]; + _set_serialize_reason({ + 0: $sg__input_index, + 1: $sg__input_index + }); + let $start; + if (next) { + $start = attrTag({ content: _content("__tests__/tags/page.marko_1*content", () => { + const $scope1_reason = _scope_reason(); + const $scope1_id = _scope_id(); + _html(`${_escape(next.title)}${_el_resume($scope1_id, "#text/0", $sg__input_index)}`); + $si__input_index && _subscribe($next_title__closures, writeScope($scope1_id, { _: _scope_with_id($scope0_id) }, "__tests__/tags/page.marko", "6:6")); + _resume_branch($scope1_id); + }, $scope0_id) }); + } + const $childScope = _peek_scope_id(); + pager_default({ start: $start }); + $si__input_index && writeScope($scope0_id, { + next_title: next?.title, + "ClosureScopes:next_title": $next_title__closures, + "#childScope/0": _existing_scope($childScope) + }, "__tests__/tags/page.marko", 0, { next_title: ["next.title", "3:8"] }); +}); + +// template.marko +const items = [{ title: "First" }, { title: "Second" }]; +var template_default = _template("__tests__/template.marko", (input) => { + _scope_reason(); + const $scope0_id = _scope_id(); + let index = 0; + _html(`${_el_resume($scope0_id, "#button/0")}`); + _set_serialize_reason(1); + const $childScope = _peek_scope_id(); + page_default({ index }); + _script($scope0_id, "__tests__/template.marko_0"); + writeScope($scope0_id, { + index, + "#childScope/1": _existing_scope($childScope) + }, "__tests__/template.marko", 0, { index: "2:6" }); + _resume_branch($scope0_id); +}, 1); diff --git a/packages/runtime-tags/src/__tests__/fixtures/attr-tag-branch-closure-dynamic/__snapshots__/html.bundle.js b/packages/runtime-tags/src/__tests__/fixtures/attr-tag-branch-closure-dynamic/__snapshots__/html.bundle.js new file mode 100644 index 00000000000..d97f3cfe62e --- /dev/null +++ b/packages/runtime-tags/src/__tests__/fixtures/attr-tag-branch-closure-dynamic/__snapshots__/html.bundle.js @@ -0,0 +1,62 @@ +// tags/pager.marko +var pager_default = _template("c", (input) => { + const $scope0_reason = _scope_reason(), $sg__input_start = _serialize_guard($scope0_reason, 0), $si__input_start = _serialize_if($scope0_reason, 0); + const $scope0_id = _scope_id(); + _html("", 1); + $si__input_start && writeScope($scope0_id, {}); +}); + +// tags/page.marko +const items = [{ title: "First" }, { title: "Second" }]; +var page_default = _template("b", (input) => { + const $scope0_reason = _scope_reason(), $sg__input_index = _serialize_guard($scope0_reason, 0), $si__input_index = _serialize_if($scope0_reason, 0); + const $scope0_id = _scope_id(); + const $next_title__closures = /* @__PURE__ */ new Set(); + const next = items[input.index]; + _set_serialize_reason({ + 0: $sg__input_index, + 1: $sg__input_index + }); + let $start; + if (next) $start = attrTag({ content: _content("b0", () => { + _scope_reason(); + const $scope1_id = _scope_id(); + _html(`${_escape(next.title)}${_el_resume($scope1_id, "a", $sg__input_index)}`); + $si__input_index && _subscribe($next_title__closures, writeScope($scope1_id, { _: _scope_with_id($scope0_id) })); + _resume_branch($scope1_id); + }, $scope0_id) }); + const $childScope = _peek_scope_id(); + pager_default({ start: $start }); + $si__input_index && writeScope($scope0_id, { + f: next?.title, + g: $next_title__closures, + a: _existing_scope($childScope) + }); +}); + +// template.marko +var template_default = _template("a", (input) => { + _scope_reason(); + const $scope0_id = _scope_id(); + let index = 0; + _html(`${_el_resume($scope0_id, "a")}`); + _set_serialize_reason(1); + const $childScope = _peek_scope_id(); + page_default({ index }); + _script($scope0_id, "a0"); + writeScope($scope0_id, { + c: index, + b: _existing_scope($childScope) + }); + _resume_branch($scope0_id); +}, 1); diff --git a/packages/runtime-tags/src/__tests__/fixtures/attr-tag-branch-closure-dynamic/__snapshots__/render.debug.md b/packages/runtime-tags/src/__tests__/fixtures/attr-tag-branch-closure-dynamic/__snapshots__/render.debug.md new file mode 100644 index 00000000000..c79787a847f --- /dev/null +++ b/packages/runtime-tags/src/__tests__/fixtures/attr-tag-branch-closure-dynamic/__snapshots__/render.debug.md @@ -0,0 +1,66 @@ +# Render +```html + + +``` + +# Update +```js +document.querySelector("button").click(); +``` +```html + + +``` +## Change +``` +UPDATE: nav > span::text "First" => "Second" +``` + +# Update +```js +document.querySelector("button").click(); +``` +```html + +