What the output looks like
Static markup becomes one hoisted template("...") string that the browser parses once.
Static markup becomes one hoisted template("...") string that the browser parses once. Everything that can change gets its own binding. Three rewrites keep the output small, and none of them changes what the page does.
Literal children are part of the template#
A literal in a child position is text, so it is written into the template instead of being inserted at run time. This covers strings, numbers, template literals without holes, arithmetic on literals (+, - and *), and the JSX space idiom {" "}. null, true and false render nothing and are dropped. Neighboring text merges into one text node.
import { compile } from "@shadow-js/compiler";
function template(code: string): string {
const match = /_\$template\(("(?:[^"\\]|\\.)*")/.exec(code);
return JSON.parse(match?.[1] ?? '""') as string;
}
const folded = compile('export const view = () => <p>Copyright {2026} <b>x</b>{" "}ShadowJS</p>;');
console.log(template(folded.code));
console.log(folded.code.includes("_$insert"));
const dynamic = compile("export const view = (p) => <p>a{p.name}b</p>;");
console.log(dynamic.code.includes("_$insert"));
<p>Copyright 2026 <b>x</b> ShadowJS</p>
false
trueThe server output folds the same text, so the server markup and the client template stay in step: a folded literal has no hydration markers around it. Folding is conservative on purpose:
- A component sees its children as written.
<Box>a{"b"}</Box>still passes["a", "b"], and a root fragment is still a list of values. - Inside
tableparts,select,pre,listing,textarea,script,styleand the other raw text elements, a literal child stays an insert, because the HTML parser reads text there differently from a text node that is inserted. - Text that the parser would rewrite or drop (a carriage return, NUL, other control characters, lone surrogates) stays an insert, so the text arrives unchanged.
- An expression that is not a plain literal (
undefined,1 / 0,"a" + name) is not folded.
The same arithmetic folds in attribute values and component props: width={1 + 2} is the static attribute width="3", and <Card size={2 * 3} /> passes size: 6.
An element that is returned does not get a function around it#
An element with bindings is built by a few statements: clone the template, find the nodes, attach the bindings, return the root. When the element is the whole body of a function (return <el />;, () => <el />, a render callback such as the child of <For>, a get children() that the compiler writes), those statements become the body, with no function created and called to run them. Anywhere else, in a variable, an argument or one branch of a conditional, the statements stay in a function, because there they are a value and not a body.
import { compile } from "@shadow-js/compiler";
const returned = compile("export function A(p) { return <p class={p.c}>{p.x}</p>; }");
console.log(returned.code.includes("})()"));
console.log(returned.code.includes("return _el$;"));
const assigned = compile("export const el = <p class={p.c}>{p.x}</p>;");
console.log(assigned.code.includes("})()"));
false
true
trueAn element without bindings is the bare template call (_tmpl$()), as before.
What is not rewritten#
The compiler leaves these alone, each for a reason that changes behavior:
- A static
styleobject literal is not moved into the template. Astyleattribute in markup is a different thing from the property writes the runtime does: it is subject to a content security policy that blocks inline styles, it accepts!important, and the server'sssrStyleserializes it its own way. disabled={false}and the other constant "off" values are not dropped. On an element with a spread they override an earlier key, and a select'svaluedecides which option is selected.- Two effects on one element are not merged. Each binding re-runs for its own dependencies, in the order written.
createComponent(Card, {})keeps its own empty props object: a component is allowed to write toprops.