ADR 155: the render model
Status: accepted 2026-10-05 (decision 155 in the decisions log). Scope: the html family: the html target, and Astro’s .mx leaf components and pages, which use its emitter. The data target has no output and is unchanged. The JSX hosts return elements and keep their own /var mechanism until the multi-host plan revisits it.
Context
A compiled html unit returned its output as a string, or, when it declared <return>, the pair { value, output }. The call site had to know which shape it would get, and it could only know for a callee the compiler could see: a discovered tag, or a default import of a .mx file. Every other route to a returning unit broke silently:
- a dynamic tag (
<${Counter}/>) or a.tsbarrel re-export concatenated the pair and rendered[object Object](TODOdynamic-tag-return-unit-object-object); /varon a dynamic tag was dropped, so reading the binding threw at render time (TODOdynamic-tag-var-silent-drop).
The value was travelling inside the output. Marko never has this problem: a tag writes into a shared writer, and <return> travels separately as the tag’s own return value. MX follows Marko’s semantics (Marko 6.3.51 binds a dynamic tag’s return value and renders a barrel-re-exported tag’s body), so the question was how far to follow its model.
Decision
A compiled unit has two entries:
render(input, out)writes its HTML tooutand returns its<return>value (undefinedwithout one).- The default export keeps its public signature,
(input) => string: it creates anout, callsrender, and returns the string.renderis reachable from it asName.render, and is also the namedrenderexport.
Between units the emitter always passes the caller’s sink down, and /var is render’s return value, dynamic tags included. A callee that the compiler cannot see is dispatched at run time: a callee with .render is a template, anything else is a function returning a string. Nothing inspects what a callee returns.
out is @mxlang/html/runtime’s Out, which has two members, write and toString, so a streaming implementation can replace it later without touching emitted code. <try> renders its body into a buffered sub-sink (createBufferedOut) that is committed when the body finishes and dropped when it throws.
Rendered HTML is byte-identical except where the old emitter diverged from Marko. There are three such cases, and this PR locks each one against Marko:
- A
<try>body that throws after writing output now drops that partial output and renders<@catch>, which is what Marko renders. The old emitter kept the partial body. Oracle-locked bytry-catch-partial,try-nestedandtry-child-throw. /varon a dynamic tag binds the callee’s return value, including when the tag is called with arguments. The old emitter dropped it. Oracle-locked bydynamic-tag-var.- A
<try>without<@catch>now rethrows, as Marko does. The old emitter swallowed the error withcatch {}. Locked by a unit test (translate.test.ts, “rethrows from a<try>without<@catch>”) against measured Marko 6.3.51 output. The oracle compares HTML only and cannot express a throw; itstry-no-catchfixture locks only the path that does not throw.
The fixtures live in packages/targets/html/fixtures-marko, and their expected.html is Marko’s own output. The JSX hosts skip three try-* fixtures under TODO jsx-try-ssr-error-boundary, and dynamic-tag-var because they refuse /var on a dynamic tag (TODO jsx-hosts-return-channel).
Alternatives considered
| Option | Why it was rejected |
|---|---|
| Keep returning a string, and put the value in a side slot (a module-level or context variable the callee sets and the caller reads) | Sound only while rendering is synchronous and strictly nested. Streaming, or any async boundary, interleaves callers and corrupts the slot. It also leaves the shape problem in place for every consumer that holds a callee. |
One unified { value, output } object from every unit |
A façade over the same string building. Every caller and every host still has to unwrap the pair, the sink rewrite is only deferred, and a hand-written function tag returning a string still needs a shape check. |
| Brand the returning unit’s result (or the unit), and unwrap branded pairs in the dynamic-render helpers | A patch for the two TODOs. It keeps the value inside the output, adds a runtime convention every consumer must know about, and does nothing for streaming. |
The Marko model: output to a sink, <return> as the return value |
Chosen. It is what the language already means. A tag’s output and its value are different channels, so no consumer ever has to tell them apart. The sink is also the seam streaming needs. |
The operator chose the Marko model on long-term cost. The one-off cost of rewriting the emitted-code goldens did not decide it.
Consequences
- Every compiled module imports the runtime (
createOut) at run time. Before, a template with no interpolation compiled with an unusedescapeimport that TypeScript elided, so it had no run-time dependency on@mxlang/html. A consumer always has the package installed (@mxlang/astroresolves it for the modules it compiles, so an Astro project does not list it; the type-check projection imports its runtime types from@mxlang/astro/typecheck, somx-tscand the TypeScript plugin resolve them the same way), so this changes nothing in practice. Test fixtures that nest their ownpackage.jsonnow link it. - Emitted code writes with
__mxOut.write(…)instead of__mxOut += …. Tools that match the emitted text (Astro’s page wrapper, its type surface, the html brand pass) anchor on the default export and the brand tail, which are unchanged apart from theName.render = …line that Astro’s page wrapper now carries along with its rename. - A statically named tag that the compiler does not know is a template (a hand-written function, a barrel re-export, a
.mximport without<return>) is called through__mxRenderTag(out, Callee)(props). TypeScript seesCalleeitself, so the props of a callee declared in the same file (a localstatic function) are checked, generics included. A tag imported from a.tsmodule (a barrel re-export, a hand-written function) lowers to__mxRenderDynamic(…, Record<string, any>), whose props are not checked; that was already so before this decision. contentblocks,<define>calls and renderable attribute tags stay(…params) => string, which is what hand-written tags receive and call.- Out of scope, and PR 2 of this decision: the Astro renderer’s now-dead
{ value, output }unwrap, and the Hono, Bun loader,mx-tscand language-server consumers.