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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
62 changes: 62 additions & 0 deletions .changeset/context-core-tag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
"marko": minor
---

Add the `<context>` core tag: a provider/consumer mechanism resolved
through the render tree (dynamic extent), not lexical authoring structure,
so a provider works correctly even when consumers are reached through
passed content.

```marko
<context=settings>
<header/> // sees settings
<main/> // sees settings
</context>
```

```marko
<context/settings from="<settings-provider>"/>
```

A provider whose value is server-only (literals, module/`static` values,
`$global`-derived expressions) contributes zero bytes to the client
bundle: no provider import, no registration, no subscription machinery.

Mutable providers (values derived from `let`/`input` state) fan changes
out to consumers fine-grained: each consumer re-runs individually when
the provided value changes, resumed pages reattach subscriptions without
re-rendering, and consumers created client-side (eg inside an `<if>`
toggled after resume) resolve the provider through its serialized branch
entry. Subscription wiring is only serialized for consumers that can
observe the value client-side (an unread consumer costs nothing), and
same-scope consumers of one provider share a single composed fan-out
entry. The bind shorthand makes a provider writable:

```marko
<let/cart=[]/>
<context:=cart>
<checkout-panel/> // may assign to its consumed `cart` variable
</context>
```

A writable consumer's assignment invokes the provider's change handler,
so the write lands in provider state and fans back out to every
consumer.

A template may consume its own context (`from=` resolving to the file
itself): the consume resolves the nearest instance in the render tree,
which enables recursive components and consume-inside-provide patterns.
A self-consume of a server-only value stays in the zero-bytes tier.

A server-only context consumed by content that first renders in the
browser (a toggled `<if>`, appended `<for>` items, re-rendered dynamic
content) still works: compiler reach analysis spots client-re-renderable
regions that may consume it and lazily serializes the provider box (the
server-computed value plus a resolution link) only on those pages, so
every other page serializes nothing. A provider created in the browser
evaluates its value there (under the usual client `$global` rules), so
full client rendering works end to end.

Also fixes a serializer slot-encoding bug (surfaced by root providers
with `serializedGlobals`): two partials for the same slot now encode a
signed delta instead of silently shifting every later scope by one.
6 changes: 6 additions & 0 deletions agent-feedback/dx.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,9 @@ The dependency upgrade took everything to latest except two majors that are true
`package.json:9` | 2026-07-07 | impact:low | effort:low

Bare `npm audit` shows 3 advisories (`serialize-javascript` high, `js-yaml`/mocha moderate, `diff` low), all transitively under `mocha` and `@changesets/cli` — dev tooling that never ships. They can't be resolved by version bumps: the fixes live in higher majors than mocha's ranges allow (`serialize-javascript ^6`→fix in 7.x, `diff ^7`→8.x, `js-yaml ^4`→5.x), mocha 11.7.6 is the newest stable, and the latest `@changesets/parse` still pins `js-yaml ^4.1.1`. Rather than pin them via `overrides`, the repo audits production deps only: **`npm run audit`** (`npm audit --omit=dev`) is the gate and returns 0 — that's what consumers of the published packages actually receive. Revisit and drop the distinction once mocha/changesets update their transitive deps upstream.

## Optimize-mode resumed behavior is never cross-checked against debug

`packages/runtime-tags/src/__tests__/main.test.ts:127` | 2026-07-09 | impact:med | effort:med

CSR is skipped in optimize mode (`skipCSR = optimize || ...`), and optimize ssr render logs snapshot to `render.md` independently of debug's `render.debug.md`, so nothing compares optimize-mode resumed interactions against known-good behavior. A mode-conditional runtime bug therefore gets immortalized by `npm run test:update` instead of caught: an argument-arity mismatch in `_context_link` (truthy in debug, undefined in optimize) shipped a `render.md` where update steps produced zero DOM mutations, and the suite stayed green because each mode only compares against its own snapshot. Suggested direction: after both modes run, assert the optimize ssr render log equals the debug one (modulo debug-only output), or enable the `equivalent` csr/ssr comparison in optimize mode.
12 changes: 12 additions & 0 deletions agent-feedback/perf.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,15 @@ Existing TODO: `<${input.component}/>` style dynamic tags always compile against
`scripts/test-parallel.js:40` | 2026-07-02 | impact:low | effort:med

`npm run test:parallel` runs one mocha process per core, but each fixture bundle already drives rolldown's own multi-threaded build (`packages/runtime-tags/src/__tests__/utils/bundle.ts`), so even a serial run uses ~1.4 cores. On a 4-core box the whole suite lands at ~87s vs ~238s serial (2.7×), short of the ~4× the core count implies, because the workers contend for the same native threads. `RAYON_NUM_THREADS=1` in the worker env made no measurable difference, so rolldown isn't honoring it. If rolldown (or its native binding) exposes a per-build thread cap, pinning workers to 1 bundler thread each — so N workers ≈ N threads total — could recover much of the lost efficiency and let the runner scale closer to linearly on higher core counts.

## `<context>` write-only consumers ship more wiring than the link they need

`packages/runtime-tags/src/translator/core/context.ts` (`consumerNeedsWiring`) | 2026-07-09 | impact:low | effort:low

A writable consumer that only assigns (no reads anywhere, so the binding prunes) still serializes full wiring: link, registered fan-out signal (an empty composed arrow), and fan-out `Set` membership. `_context_write` only needs the `ContextLink` entry on the consumer scope; the signal and `Set` membership are dead. Splitting `consumerNeedsWiring` into link-only vs full wiring would save the registered-signal bytes and a registry id for this (rare) shape.

## Mutable `<context>` provider double-serializes primitive values

`packages/runtime-tags/src/html/context.ts` (`_context_provide`) | 2026-07-09 | impact:low | effort:med

A mutable provider serializes its value into the provider scope's `ContextValue` slot for fan-out comparison, alongside the backing state binding's own slot (eg `I: "light"` and the `<let>`'s `g: "light"` in `context-mutable-inert`'s `writes.html`). Objects dedupe by serializer identity; primitives ship twice. When the provide value is a bare state-binding reference the `ContextValue` slot could alias the binding's accessor instead of re-serializing, at the cost of the dom runtime knowing which accessor to compare against.
12 changes: 12 additions & 0 deletions agent-feedback/unclear.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,15 @@ Things that were hard to understand, and what would have clarified them. Format
`packages/runtime-tags/src/translator/util/runtime.ts:21` | 2026-07-02 | impact:low | effort:low

`pureDOMFunctions` includes `_template`, `_await_promise`, `_await_content`, `_load_template`, and `_load_setup`, yet those factories have observable side effects at call time: `_template` calls `_resume(id, renderer)` (`packages/runtime-tags/src/dom/template.ts:42`) and the await/load factories call `_enable_catch()`/`enableBranches()` latches. The annotations are sound only because of a non-obvious invariant: registration is needed exactly when the value can be referenced by a serialized register id, which requires the value to be reachable in the client module graph anyway, and the enable latches are re-triggered by whichever construct survives tree-shaking. Two independent reviews flagged these as possibly-unsound; a comment on `pureDOMFunctions` stating the invariant would prevent repeated re-derivation.

## Nested same-template `<context>` providers need an intervening resumable branch

`packages/runtime-tags/src/dom/context.ts` (`_context_branch`) / `html/context.ts` | 2026-07-09 | impact:low | effort:high

Same-template provider instances nested through content that never resumes (no state/closures between them) collide on one branch scope and hit the debug "share the same branch" error, because the caller-side `kBranchSerializeReason` set for a mutable provider's section does not thread through the content/dynamic-tag runtime guard (`_serialize_guard($scope_reason, holeIndex)`) the way `<if>`/`<for>` read it directly. A static parent-side `addSerializeReason(section.parent, true, sectionAccessor.binding)` in `finalizeReferences` was tried and does not reach the child's hole reason. Real recursive components carry per-instance state (which resumes the branch and resolves this naturally -- see the `context-self` fixture), so the gap is narrow; closing it is the cross-template serialize-reason edge the persisted-pages reach analysis owns.

## `<context>` reserve reach analysis has two conservative escape hatches

`packages/runtime-tags/src/translator/core/context.ts` (`buildContextReserve`) | 2026-07-09 | impact:low | effort:high

Server-only context boxes serialize when a client-re-renderable region may consume them: `<if>`/`<for>` sites compile `_context_reserve(...ids)` from statically known subtree sets, and the html `_dynamic_tag`/`_attr_content` runtimes reserve every open box when a resumable hole actually renders. Two shapes fall through both nets and hit the `_context_read` debug error instead: (1) a consumer reached only through a dynamic renderer value (passed content/component) inside a branch that never rendered on the server, and (2) consumers inside mutually recursive templates (self-recursion resolves via the program exit fixed-point; mutual cycles contribute nothing). `<await>`/`<try>` bodies also emit no compiled reserve (their rendered content is covered by the runtime holes). Closing these needs consumption metadata carried on content/renderer values plus a cycle fixed-point across templates. Note also that client-side resolution is branch granular where the server is extent granular: an inner instance of a provider that shares a branch with trailing consumers of the outer instance resolves for them too (see `context-shadowing`, csr skipped) -- the documented rule, enforced by the debug same-branch guard where detectable, is to wrap each instance in its own control flow.
3 changes: 2 additions & 1 deletion cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,8 @@
"vmax",
"cqmin",
"cqmax",
"whib"
"whib",
"reparenting"
],
"ignoreRegExpList": [],
"files": ["*"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"col",
"colgroup",
"const",
"context",
"data",
"datalist",
"dd",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"dom": {
"template.marko.page.mjs": {
"total": {
"min": 65847,
"brotli": 20208
"min": 65835,
"brotli": 20207
},
"files": {
"components/tags-counter.marko": 681,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"dom": {
"template.marko.page.mjs": {
"total": {
"min": 65847,
"brotli": 20208
"min": 65835,
"brotli": 20207
},
"files": {
"components/tags-counter.marko": 681,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"dom": {
"template.marko.page.mjs": {
"total": {
"min": 65745,
"brotli": 20176
"min": 65733,
"brotli": 20160
},
"files": {
"components/tags-child.marko": 509,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"dom": {
"template.marko.page.mjs": {
"total": {
"min": 65786,
"brotli": 20187
"min": 65774,
"brotli": 20200
},
"files": {
"components/tags-child.marko": 509,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"dom": {
"template.marko.page.mjs": {
"total": {
"min": 66152,
"brotli": 20390
"min": 66140,
"brotli": 20358
},
"files": {
"components/tags-layout.marko": 838,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"dom": {
"template.marko.page.mjs": {
"total": {
"min": 65824,
"brotli": 20220
"min": 65812,
"brotli": 20211
},
"files": {
"components/tags-layout.marko": 696,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"dom": {
"template.marko.page.mjs": {
"total": {
"min": 66203,
"brotli": 20371
"min": 66191,
"brotli": 20397
},
"files": {
"components/tags-layout.marko": 912,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"dom": {
"template.marko.page.mjs": {
"min": 10131,
"brotli": 3983
"min": 10334,
"brotli": 4070
}
},
"html": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// settings.marko
const $template$3 = "<!><!><!>";
const $walks$3 = "b%c";
const $input_content_direct$1 = /*@__PURE__*/ _dynamic_tag_content("#text/0");
function $setup$3($scope) {
$scope["#ContextValue"] = "dark";
_context_branch($scope, "__tests__/settings.marko");
}
const $dynamicTag$1 = /*@__PURE__*/ _dynamic_tag("#text/0");
const $input_content$1 = $dynamicTag$1;
const $input$1 = ($scope, input) => $input_content$1($scope, input.content);
var settings_default = /*@__PURE__*/ _template("__tests__/settings.marko", $template$3, "b%c", $setup$3, $input$1);

// tags/cart-size.marko
const $template$2 = "<!><span><!> items</span>";
const $walks$2 = "bD%l";
const $items = ($scope, items) => $items_length($scope, items?.length);
const $items_length = ($scope, items_length) => _text($scope["#text/0"], items_length);
function $setup$2($scope) {
$items($scope, _context_read($scope, "items", "__tests__/tags/cart-provider.marko", "<cart-provider>"));
}
var cart_size_default = /*@__PURE__*/ _template("__tests__/tags/cart-size.marko", $template$2, $walks$2, $setup$2);

// tags/cart-provider.marko
const $template$1 = "<!><!><!>";
const $walks$1 = "b%c";
const $input_content_direct = /*@__PURE__*/ _dynamic_tag_content("#text/0");
function $setup$1($scope) {
$scope["#ContextValue"] = [
"a",
"b",
"c"
];
_context_branch($scope, "__tests__/tags/cart-provider.marko");
}
const $dynamicTag = /*@__PURE__*/ _dynamic_tag("#text/0");
const $input_content = $dynamicTag;
const $input = ($scope, input) => $input_content($scope, input.content);
var cart_provider_default = /*@__PURE__*/ _template("__tests__/tags/cart-provider.marko", $template$1, "b%c", $setup$1, $input);

// template.marko
const $template = /*@__PURE__*/ ((_w0) => `<!>${_w0}<!>`)($template$1);
const $walks = /*@__PURE__*/ ((_w0) => `b/${_w0}&b`)("b%c");
const $Settings_content__items = /*@__PURE__*/ _closure_get("items", ($scope) => _text($scope["#text/0"], $scope._.items.join(",")));
const $Settings_content__theme = ($scope, theme) => _text($scope["#text/1"], theme);
const $Settings_content__setup = ($scope) => {
$Settings_content__items($scope);
$Settings_content__theme($scope, _context_read($scope, "theme", "__tests__/settings.marko", "./settings.marko"));
};
const $Settings_content = /*@__PURE__*/ _content("__tests__/template.marko_2_content", "<!><div><!>-<!></div>", "bD%c%l", $Settings_content__setup);
const $cartprovider_content__items__closure = /*@__PURE__*/ _closure($Settings_content__items);
const $cartprovider_content__items = /*@__PURE__*/ _const("items", $cartprovider_content__items__closure);
const $cartprovider_content__setup = ($scope) => {
$setup$2($scope["#childScope/0"]);
$setup$3($scope["#childScope/1"]);
$input_content_direct$1($scope["#childScope/1"], $Settings_content($scope));
$cartprovider_content__items($scope, _context_read($scope, "items", "__tests__/tags/cart-provider.marko", "<cart-provider>"));
};
const $cartprovider_content = /*@__PURE__*/ _content("__tests__/template.marko_1_content", /*@__PURE__*/ ((_w0, _w1) => `<!>${_w0}${_w1}<!>`)($template$2, $template$3), /*@__PURE__*/ ((_w0, _w1) => `b/${_w0}&/${_w1}&b`)($walks$2, "b%c"), $cartprovider_content__setup);
function $setup($scope) {
$setup$1($scope["#childScope/0"]);
$input_content_direct($scope["#childScope/0"], $cartprovider_content($scope));
}
var template_default = /*@__PURE__*/ _template("__tests__/template.marko", $template, $walks, $setup);
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// settings.marko
var settings_default = _template("__tests__/settings.marko", (input) => {
const $scope0_reason = _scope_reason();
const $scope0_id = _scope_id();
_context_provide("__tests__/settings.marko", "dark", () => {
_dynamic_tag($scope0_id, "#text/0", input.content, {}, 0, 0, _serialize_guard($scope0_reason, 0));
});
_serialize_if($scope0_reason, 0) && writeScope($scope0_id, {}, "__tests__/settings.marko", 0);
});

// tags/cart-size.marko
var cart_size_default = _template("__tests__/tags/cart-size.marko", (input) => {
_scope_reason();
const $scope0_id = _scope_id();
const items = _context_get("__tests__/tags/cart-provider.marko", "<cart-provider>");
_html(`<span>${_escape(items.length)}${_el_resume($scope0_id, "#text/0")} items</span>`);
writeScope($scope0_id, {}, "__tests__/tags/cart-size.marko", 0);
});

// tags/cart-provider.marko
var cart_provider_default = _template("__tests__/tags/cart-provider.marko", (input) => {
const $scope0_reason = _scope_reason();
const $scope0_id = _scope_id();
_context_provide("__tests__/tags/cart-provider.marko", [
"a",
"b",
"c"
], () => {
_dynamic_tag($scope0_id, "#text/0", input.content, {}, 0, 0, _serialize_guard($scope0_reason, 0));
});
_serialize_if($scope0_reason, 0) && writeScope($scope0_id, {}, "__tests__/tags/cart-provider.marko", 0);
});

// template.marko
var template_default = _template("__tests__/template.marko", (input) => {
_scope_reason();
const $scope0_id = _scope_id();
cart_provider_default({ content: _content("__tests__/template.marko_1_content", () => {
_scope_reason();
const $scope1_id = _scope_id();
const $cartprovider_content__items__closures = new Set();
const items = _context_get("__tests__/tags/cart-provider.marko", "<cart-provider>");
cart_size_default({});
settings_default({ content: _content("__tests__/template.marko_2_content", () => {
_scope_reason();
const $scope2_id = _scope_id();
const theme = _context_get("__tests__/settings.marko", "./settings.marko");
_html(`<div>${_escape(items.join(","))}${_el_resume($scope2_id, "#text/0")}-<!>${_escape(theme)}${_el_resume($scope2_id, "#text/1")}</div>`);
_subscribe($cartprovider_content__items__closures, writeScope($scope2_id, { _: _scope_with_id($scope1_id) }, "__tests__/template.marko", "6:4"));
_resume_branch($scope2_id);
}) });
writeScope($scope1_id, { "ClosureScopes:items": $cartprovider_content__items__closures }, "__tests__/template.marko", "3:2");
}) });
}, 1);
Loading
Loading