Skip to content

Commit ac8ad3b

Browse files
DylanPierceyclaude
authored andcommitted
feat: add <context> core tag
Provide a value to a tag's body content and consume it anywhere below in the render tree via a statically resolved from= reference (tag name or relative template path). Server-only (immutable) contexts compile to zero client code -- enforced by an executable bundle assertion -- while mutable providers fan changes out to consumers fine-grained, resume subscriptions without re-rendering, resolve from client-created branches (including providers nested in keyed loop iterations), and support a writable := round trip.
1 parent 7f786f2 commit ac8ad3b

318 files changed

Lines changed: 6830 additions & 166 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/context-core-tag.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
"marko": minor
3+
---
4+
5+
Add the `<context>` core tag: a provider/consumer mechanism resolved
6+
through the render tree (dynamic extent), not lexical authoring structure,
7+
so a provider works correctly even when consumers are reached through
8+
passed content.
9+
10+
```marko
11+
<context=settings>
12+
<header/> // sees settings
13+
<main/> // sees settings
14+
</context>
15+
```
16+
17+
```marko
18+
<context/settings from="<settings-provider>"/>
19+
```
20+
21+
A provider whose value is server-only (literals, module/`static` values,
22+
`$global`-derived expressions) contributes zero bytes to the client
23+
bundle: no provider import, no registration, no subscription machinery.
24+
25+
Mutable providers (values derived from `let`/`input` state) fan changes
26+
out to consumers fine-grained: each consumer re-runs individually when
27+
the provided value changes, resumed pages reattach subscriptions without
28+
re-rendering, and consumers created client-side (eg inside an `<if>`
29+
toggled after resume) resolve the provider through its serialized branch
30+
entry. Subscription wiring is only serialized for consumers that can
31+
observe the value client-side (an unread consumer costs nothing), and
32+
same-scope consumers of one provider share a single composed fan-out
33+
entry. The bind shorthand makes a provider writable:
34+
35+
```marko
36+
<let/cart=[]/>
37+
<context:=cart>
38+
<checkout-panel/> // may assign to its consumed `cart` variable
39+
</context>
40+
```
41+
42+
A writable consumer's assignment invokes the provider's change handler,
43+
so the write lands in provider state and fans back out to every
44+
consumer.
45+
46+
A template may consume its own context (`from=` resolving to the file
47+
itself): the consume resolves the nearest instance in the render tree,
48+
which enables recursive components and consume-inside-provide patterns.
49+
A self-consume of a server-only value stays in the zero-bytes tier.
50+
51+
A server-only context consumed by content that first renders in the
52+
browser (a toggled `<if>`, appended `<for>` items, re-rendered dynamic
53+
content) still works: compiler reach analysis spots client-re-renderable
54+
regions that may consume it and lazily serializes the provider box (the
55+
server-computed value plus a resolution link) only on those pages, so
56+
every other page serializes nothing. A server-only provider itself must
57+
originate from the server render.
58+
59+
Also fixes a serializer slot-encoding bug (surfaced by root providers
60+
with `serializedGlobals`): two partials for the same slot now encode a
61+
signed delta instead of silently shifting every later scope by one.

agent-feedback/dx.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,9 @@ The dependency upgrade took everything to latest except two majors that are true
2525
`package.json:9` | 2026-07-07 | impact:low | effort:low
2626

2727
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.
28+
29+
## Optimize-mode resumed behavior is never cross-checked against debug
30+
31+
`packages/runtime-tags/src/__tests__/main.test.ts:127` | 2026-07-09 | impact:med | effort:med
32+
33+
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.

agent-feedback/perf.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,15 @@ Existing TODO: `<${input.component}/>` style dynamic tags always compile against
4949
`scripts/test-parallel.js:40` | 2026-07-02 | impact:low | effort:med
5050

5151
`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.
52+
53+
## `<context>` write-only consumers ship more wiring than the link they need
54+
55+
`packages/runtime-tags/src/translator/core/context.ts` (`consumerNeedsWiring`) | 2026-07-09 | impact:low | effort:low
56+
57+
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.
58+
59+
## Mutable `<context>` provider double-serializes primitive values
60+
61+
`packages/runtime-tags/src/html/context.ts` (`_context_provide`) | 2026-07-09 | impact:low | effort:med
62+
63+
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.

agent-feedback/unclear.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,15 @@ Things that were hard to understand, and what would have clarified them. Format
77
`packages/runtime-tags/src/translator/util/runtime.ts:21` | 2026-07-02 | impact:low | effort:low
88

99
`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.
10+
11+
## Nested same-template `<context>` providers need an intervening resumable branch
12+
13+
`packages/runtime-tags/src/dom/context.ts` (`_context_branch`) / `html/context.ts` | 2026-07-09 | impact:low | effort:high
14+
15+
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.
16+
17+
## `<context>` reserve reach analysis has two conservative escape hatches
18+
19+
`packages/runtime-tags/src/translator/core/context.ts` (`buildContextReserve`) | 2026-07-09 | impact:low | effort:high
20+
21+
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.

cspell.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,8 @@
382382
"vmax",
383383
"cqmin",
384384
"cqmax",
385-
"whib"
385+
"whib",
386+
"reparenting"
386387
],
387388
"ignoreRegExpList": [],
388389
"files": ["*"],

packages/runtime-class/test/taglib-lookup/fixtures/getTagsSorted/expected.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"col",
4141
"colgroup",
4242
"const",
43+
"context",
4344
"data",
4445
"datalist",
4546
"dd",

packages/runtime-tags/src/__tests__/fixtures-interop/interop-basic-class-to-tags/sizes.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"dom": {
33
"template.marko.page.mjs": {
44
"total": {
5-
"min": 65847,
6-
"brotli": 20208
5+
"min": 65835,
6+
"brotli": 20207
77
},
88
"files": {
99
"components/tags-counter.marko": 681,

packages/runtime-tags/src/__tests__/fixtures-interop/interop-class-to-tags-import/sizes.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"dom": {
33
"template.marko.page.mjs": {
44
"total": {
5-
"min": 65847,
6-
"brotli": 20208
5+
"min": 65835,
6+
"brotli": 20207
77
},
88
"files": {
99
"components/tags-counter.marko": 681,

packages/runtime-tags/src/__tests__/fixtures-interop/interop-dynamic-tag-class-to-tags/sizes.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"dom": {
33
"template.marko.page.mjs": {
44
"total": {
5-
"min": 65745,
6-
"brotli": 20176
5+
"min": 65733,
6+
"brotli": 20160
77
},
88
"files": {
99
"components/tags-child.marko": 509,

packages/runtime-tags/src/__tests__/fixtures-interop/interop-dynamic-tag-conditional-class-to-tags/sizes.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"dom": {
33
"template.marko.page.mjs": {
44
"total": {
5-
"min": 65786,
6-
"brotli": 20187
5+
"min": 65774,
6+
"brotli": 20200
77
},
88
"files": {
99
"components/tags-child.marko": 509,

0 commit comments

Comments
 (0)