Docs/Page Lifecycle

Page Lifecycle

This page describes the step-by-step process in the build, which takes a page.tsx and turns it into a index.html & bundle.js.

We'll go through the server-client code split, HTML generation, JSX transform, component ids, regions expression and more.

At a very high level, every page goes through two esbuild passes, a transform which forks the code into two parts, an SSR run that decides what the client needs and one DCE pass, that decides which code actually reaches the bundle.

The Start

Here is the page we will be working with for this article:

tsx
// pages/index.tsx
import Link from "elegance-js/link"; // this is a simple extension of an Anchor element component
import { format } from "./utils"; // this is a plain local file, it'll get inlined here.
import { readFileSync } from "node:fs"; // this is server-code, we want to keep it OUT of the client bundle.

const onSave = (id: string) => console.log("saved", id); // this is a simple example event-handler.

export default function Page() {
    const buildTime = readFileSync("x.txt"); // this is a server-side value generated during the page render.

    return <main>
        <Link href="/">Home</Link>
        <Counter onSave={onSave} />
        <button onClick={() => console.log(format(buildTime))}>Click</button>
    </main>;
}

Here counter is something like this:

tsx
// components/Counter.tsx
import { div } from "...";

const Counter = component<{ 
    onSave?: (id: string) => void 
}>({
    view({ self }) {
        return div({ class: "counter" }, "0");
    }
});

export default Counter;

JSX lowering

This only applies to .tsx extension files, and essentially just translates <a> into function style: a()

There's two key details at this step:

  1. Lowercase tags stay as bare identifiers, <main> becomes

main({ ... }, [...]). These are not real globals; the next transform stage rewrites them to __tags.main.

  1. Capitalized names become plain function calls, <Link href="/">

becomes Link({ href: "/" }, [...]).

Our page after this step:

js
// pages/index.tsx (in memory representation)

import Link from "elegance-js/link";
import { format } from "./utils";
import { readFileSync } from "node:fs";

const onSave = (id: string) => console.log("saved", id);

export default function Page() {
    const buildTime = readFileSync("x.txt");
    return main({},
        Link({ href: "/" }, ["Home"]),
        Counter({ onSave: onSave }),
        button({ onClick: () => console.log(format(buildTime)) }, ["Click"]),
    );
}

ESBuild Step 1

Now, all our pages are given to ESBuild, which will lower them to JS, inline any imports that it should (see bundle generation for more information)

Our ESBuild options look something like this:

ts
export const ROUTE_ESBUILD_BASE = {
    bundle:        true, // this inlines all our imported files into one
    write:         false,
    format:        "esm",
    target:        `node${process.versions.node}`,
    legalComments: "inline",
    packages:      "external", // this keeps package imports like Chart.JS as "import x from y" statements.
    loader:        { ".ts": "ts", ".tsx": "ts" } as Record<string, "ts">,
    plugins:       [eleganceTsxPlugin, eleganceBundlePlugin],
    minify:        false, // very importantly we don't want minification or tree-shaking, as that hurts later steps.
    treeShaking:   false,
};

Imporant to bundling; esbuild emits a // path/to/source.ts line comment before each module's code in the bundle. These become source-path markers — parsed by Elegance, and used to attribute any byte offset in the bundle back to the file that declared it. That is how per-file atom IDs and server-only-file enforcement work later.

Because we have splitting: true, esbuild hoists any module imported by 2+ entries into a chunk-XXXX.js file. Whether a component ends up inlined in the page's bundle or hoisted into a chunk depends purely on sharing:

  • Link (imported by many pages) becomes import { Link_default } from "./chunk-ADESIFDG.js"
  • Counter (only this page) has its content's inline directly into the file.

This distinction is invisible at authoring time, but it decides which of two resolution paths the region emitter takes later (Step 7).

We also re-write the ./ prefixed chunk urls to / so our server can import them properly.

The server-client split

Every page and layout we build goes through transformBundle:

ts
export function transformBundle(
    source: string,
    filePath: string,
): { serverCode: string; preClientCode: string } {
    const ast = parseSync(filePath, source, { sourceType: "module" });
    const { sharedEdits, serverOnlyEdits, clientOnlyEdits } = collectTransformEdits(source, ast, filePath);

    const serverCode    = applyEdits(source, [...sharedEdits, ...serverOnlyEdits]);
    const preClientCode = applyEdits(source, [...sharedEdits, ...clientOnlyEdits]);

    return { serverCode, preClientCode };
}

This function collects two sets of edits. One that modify the file to be suitable for our server runtime, and another set of edits so the file is safe to run in the browser.

`component({...})` -> identity + init-stripping

This is a shared edit, we give component() definitions __id params that are stable, so that we can reference them later during SSR and hydration.

ts
function generateAtomId(filePath: string, index: number): string {
    const normalized = filePath.replace(/\\/g, "/");
    const str = `${normalized}::atom${index}`;
    return createHash("sha256").update(str).digest("base64url").slice(0, 7);
}

const sourcePath = sourcePathAt(node.start as number);
const id       = generateAtomId(sourcePath, nextCallIndex(sourcePath));
const obj      = node.arguments[0];
const insertAt = obj.start + 1;
sharedEdits.push({ start: insertAt, end: insertAt, replacement: ` __id: "${id}",` });

It's important to note that the cid (component-id) is deterministic per source location. The same component gets the same id in the server build, the client build, and any chunk. This id is the string both bundles share; everything else about naming (esbuild renames like Link_default) differs between the two bundles.

Then, for the client, the init callback is deleted to ensure it never runs on the client.

ts
if (prop.key?.type === "Identifier" && prop.key.name === "init") {
    clientOnlyEdits.push({ start: prop.start, end, replacement: "" }); // begone!
}

atom() Rewrites

Just like for components, we also want static id's for atom definitions.

So, on the server we inject another stable id into the call, like this: atom("ID-123", "initial value");. For the client bundle, we don't want to declare an atom, we want to read it's value from what the server last determined it to be. Thusly, we rename atom calls to __getAtom(atomId)`.

ts
serverOnlyEdits.push({ start: argStart, end: argStart, replacement: `"${atomId}", ` });
clientOnlyEdits.push({ start: node.start, end: node.end, replacement: `_getAtom("${atomId}")` });

Important, atom should only get called within the build context as it requires import-time code to be executed within an AsyncLocalstorage run context.

Tag Rewrites

Because Elegance uses function style a(), div() element creation calls for convenience, but understands that this is a bad idea in practice; we re-write all calls to have a __tags. prefix.

ts
sharedEdits.push({
    start: node.callee.start, end: node.callee.end,
    replacement: `__tags.${node.callee.name}`,
});

This is also the step where element handlers are born for top-level elements (elements outside of a component). If any element call has an on* prop, we record and increment an element-id counter, and we record what the handler is.

This is used to extract the dynamic (code) parts of the handler into separate areas, so that if multiple elements (something like a mapped list) uses the same event handler, they don't have to create multiple functions in memory.

Basically, speed & bundle size optimization.

ts
if (
    componentDepth === 0 &&
    node.arguments.length >= 1 &&
    node.arguments[0].type === "ObjectExpression"
) {
    const optionsObj = node.arguments[0];
    const hasHandler = optionsObj.properties.some((prop: any) => { /* key starts with "on" */ });
    if (hasHandler) {
        const eid = eidCounter++;
        sharedEdits.push({
            start: optionsObj.start + 1, end: optionsObj.start + 1,
            replacement: ` __eid: ${eid},`,
        });
    }
}

It's key to note here, that the "eid" is a positional identity. The counter increments in AST traversal order. The server and client walk in the same order, so both agree on which eid belongs to which handler. Be sure to not break this order somehow! It will cause weird issues.

It's also important to remember that this ONLY applies to top level elements, so anything inside the Page() function, but not inside a component()'s view function. (they are just shipped as-is)

serverAction Calls

Server action calls are also written to __action("MY-ID-123"); automatically for both server and client.

Our Resulting Code

Our code split looks something like this for now: serverCode (bundled into .server.mjs, run by Node):

js
const Counter = component({ __id: "2rY4KOi", init(self) { /* kept */ }, view(...) { ... } });
function Page() {
    const buildTime = readFileSync("x.txt");
    return __tags.main({},
        Link_default({ href: "/" }, ["Home"]),
        Counter_default({ onSave: onSave }),
        __tags.button({ __eid: 0, onClick: () => console.log(format(buildTime)) }, ["Click"]),
    );
}

preClientCode (the client starting point):

js
import { Link_default } from "./chunk-ADESIFDG.js";       // later /chunks/...
const Counter = component({ __id: "2rY4KOi", /* init DELETED */ view(...) { ... } });
function Page() {
    const buildTime = readFileSync("x.txt");              // still here! removed later by DCE
    return __tags.main({},
        Link_default({ href: "/" }, ["Home"]),
        Counter_default({ onSave: onSave }),
        __tags.button({ __eid: 0, onClick: () => console.log(format(buildTime)) }, ["Click"]),
    );
}

Note what preClientCode still contains at this point: node:fs, the page function, everything. Removing it is DCE's job (comes slightly later), not the transform's.

NOTE: If you're ever running into weird bundling / code issues, take a look at .elegance/dist/** and .elegance/cache/**. They hold all these files, and could be of use!

Layout Client Bundling

Next, layouts get their client bundle once at build time.

ts
const { handlerSets } = extractElementHandlersFromAst(bundleSource, bundleAst);
const { declarations: handlerDecls, expression: handlersExpr } =
    generateHandlersExpression(handlerSets);

// ... find the ExportDefaultDeclaration span ...
const withoutDefault =
    defaultStart >= 0
        ? replaced.slice(0, defaultStart) + replaced.slice(defaultEnd)
        : replaced;

const syntheticFn = `
export default function __constructor() {
${handlerDecls}
    const regions  = [];
    const handlers = ${handlersExpr};
    return { regions, handlers };
}
`;

const finalSource = withoutDefault + syntheticFn;
// ... parse, then:
return applyReachabilityDCECore(finalSource, dceAst, filePath, bannedGlobs, { keepAllChunks: true });

What happened here is basically:

  1. `serverAction` call sites were rewritten
  2. The layout's default export (its render function) is CUT OUT. Layout render functions are server-side; the client only needs their { regions, handlers } contract.
  3. The synthetic __constructor carries the layout's extracted element handlers.
  4. DCE runs with `keepAllChunks: true` — a layout bundle keeps all of its /chunks/ imports, because it is the registry provider: it is loaded before any page and its module evaluation is what registers its component in the client's componentConfigs.

The layout bundle is written twice: as the layout's .client.mjs and as a /chunks/<layoutCacheKey>.client.mjs file that pages can import.

Layouts keep blanket seeding: in the DCE core step, every top-level component() declaration is reachable by rule, plus every top-level _getAtom binding. This is intentional for layouts. They have no region input at build time, so they cannot know which of their components any given page will need.

Server-Side Rendering

Our files sit forever as server.mjs and pre_client.mjs / client.mjs files forever; until they're actually rendered. For static pages in prod mode, this happens immediately, for dynamic pages it happens per-request, and in development mode this happens during the first request of a static page.

When a page is rendered, the server bundle is imported, which in turns evaluates all the element, and component calls. Evaluating a component() call returns what we call a live descriptor.

Live meaning the element is "alive" (dynamic).

A live descriptor looks somethingl ike this.

ts
const descriptor = {
    __type: "live",
    __componentId: cid,        // the cid string generated during our previous steps.
    __definition: definition,  // server-side closures (render, serverInit)
    props: props ?? {},
    children,
};

Then, our renderer render.ts walks the VirtualNode tree recursively turning every element into HTML strings.

The element types are handled like this:

  • Elements like div() become HTML. If it's a top-level element call, and the element

has __eid, the eid is stamped as an attribute:

ts
  if (ctx.insideComponentDepth === 0) {
      const eid = element.options.__eid;
      if (typeof eid === "number") open += ` e-id="${eid}"`;
  }

So basically, we stamp on the previously generated element-id into the HTML, so that during hydration, the handlers expression in the final generated bundle can attach the right function to the right element.

All on* props are skipped during HTML serialization, they exist only as a singular eid tag per "interesting" element.

  • Live components get their init's awaited, their view executed with

insideComponentDepth++, and, only at the top level of the page tree, they're collected into regions:

It's a bit weird at first, but we do this for performance reasons. The "regions" we keep speaking of are essentially parts of the page marked as boundaries of the static-live gap. Everything inside a region is always a descendant of a Component() and thusly non-static

ts
  // Only components rendered at the top level of the page tree become
  // regions. Live components nested inside a component's rendered subtree
  // are hydrated client-side when their parent's view re-runs
  // (hydrateTree -> bootstrapComponent), which recreates them with the
  // view scope's real closures. Their props never need to travel
  // through region data. Capturing them here would both duplicate that
  // hydration and force view-scope functions into serialized props.
  if (isLive && ctx.insideComponentDepth === 0) {
      const regionIdx = ctx.regionCounter++;
      
      ctx.regions[regionIdx] = [];
      into.append(`<template data-region="${regionIdx}"></template>`);
      
      while (i < flat.length) {
          const c = flat[i]!;
          /* still live? */ if (!stillLive) break;
      
          ctx.regions[regionIdx]!.push(c);
          await renderLiveComponent(c as any, into, ctx);
      
          i++;
      }
  }

The regions array we generate, ends up a parallel structure to the HTML. For each <template data-region="N"> marker, ctx.regions[N] lists the descriptors of the live components that rendered there.

If you modify atom values during rendering (like in the init of a component), their values are preserved. This is excellent if you for example want to read from a file or fetch from an API for some result.

You must however ensure the data is serializable, as it's turned into what we call the atom snapshot. The atom snapshopt is essentially the "hydration payload" containing the iav's (initial atom values) that the client will receieve via __getAtom.

You can find the IAV in the HTML head easily, it looks something like this: <script data-tag="iav" type="application/json">.

The client receiving a Page

In the client.js, here's how we import and run a page.

First we find and import the page's corresponding bundle.js, which in turn runs it's constructor, which gives us the regions and handlers we made above.

js
const result = (page.mod.default ?? (() => null))();
// result contains: result.regions, result.handlers

That is all the client needs: default() returns { regions, handlers }. Everything else, componentConfigs, __tags, _getAtom, _action is provided by the runtime globals like this:

js
Object.assign(globalThis, { view, component, onPageLoad, track, untrack,
    navigate, fetchPage, rawHTML, _getAtom, _action });

Hydration then runs, which in turn walks template[data-region] markers in document order. For each entry it builds a synthetic descriptor and queues bootstrapComponent (queued so we don't block rendering), which looks the config up by cid:

js
const cfg = componentConfigs.get(cid);

The only reason we have these configs in the client's global map is because we imported the bundle.js before client.js; which made component() calls run (which contain the cids)

Which module code is loaded is entirely the bundle's job. This is why imports are load-bearing side effects for blob-hydrated components.

Then, handlers are reattached by delegation: entries { eid, h } find [e-id="N"] elements and bind the handler functions.

The client Bundle Generation.

This is the most interesting part, and it's a little complex, but is part of the beauty of Elegance.

For static pages it's called at build time, for dynamic per request.

The Static Parts

In our processor, computeSyntheticBundleStaticParts parses preClientCode once and precomputes:

  • `withoutDefault`. Here, the page's default export (its default export render function) is

cut out by byte range. This is why page render functions can contain readFileSync. Because the function never ships, so DCE drops it and everything only it referenced.

  • `inlineBindings` We also build a map cid -> local binding name for components inlined in

this bundle:

ts
  function extractComponentBindings(source: string, ast: any): Map<string, string> {
      // walks top-level component({ __id: "..." }) calls:
      //   cid "2rY4KOi" -> binding "Link_default()"
  }
  • `pageChunkImports`, gets every /chunks/... import statement and its

specifiers, like: ({ Link_default: "Link_default" } from /chunks/chunk-ADESIFDG.js).

  • `topLevelNames`, this retrieves every bindable name in the bundle (imports, vars,

functions, classes): the candidate pool for function-prop references.

  • `extractableFns`, these are named functions anywhere in the module (including

inside the cut page function), mapped to their exact source text:

ts
  function collectExtractableFns(source: string, ast: any): Map<string, { source: string; node: any }> {
      // FunctionDeclaration -> its full source
      // const X = () => ... -> "const X = () => ...;"
  }
  • `fnLocations`, these are normalized function text -> byte offset listings, so an error

about a runtime function object can print the exact file:line:col of its source in the client text.

  • handler extraction, here extractElementHandlersFromAst walks the pre-client

AST mirroring the server's eid logic: same traversal order, same insideComponentDepth rule, finds __tags.X({ on* }) calls at top level and slices the handler function's source text out of the client bundle:

ts
  onProps.push({
      event: keyName.slice(2).toLowerCase(),
      fnSource: src(val),      // quite literally source.slice(node.start, node.end)
  });

Because identification is positional (eid parity), anonymous arrows work perfectly well. generateHandlersExpression dedupes identical handler sets into const _h0 = [{ event: "click", fn: () => ... }] declarations, which saves tremendous memory for common components.

Region resolution.

Imagine a page like this:

tsx
export default function Page() {
    if (Math.random() > 0.5) {
        return <div>a</div>
    } else {
        return <div>b</div>
    }
}

In a traditional system, we'd ship the entire function, but since our server-render already decided what is true, shipping the entire function woul be wasteful.

The key insight to understanding this step is that the SSR run provided runtime truth, and the pre_client source code text provides static truth (which identifier is what).

We need both of these, in order to create a synthetic page function, which contains only the code that is absolutely necessary for hydration of the page.

We do it like this:

ts
return (cid, usedChunkSources): ResolvedRegionComponent => {
    // 1. inlined into the page bundle
    const inline = inlineBindings.get(cid);
    if (inline) return { kind: "call", name: inline };

    // 2. hoisted into a chunk the page imports directly
    for (const pageImport of pageChunkImports) {
        // ... if info.cidToExport.has(cid):
        const local = pageImport.specifiers.get(exported);
        if (local) return { kind: "call", name: local };
        // defined in this chunk but not imported by name: the import side
        // effect registers it, so hydration works via a blob, but the
        // import must be kept.
        usedChunkSources.add(pageImport.source);
        return { kind: "blob", chunkSource: pageImport.source };
    }

    // 3. reachable only through a chunk's own imports: registration happens
    // transitively, so hydration works via a blob, but the page's direct
    // import chain must stay alive.
    // ... breadth first search over chunkDeps here ...

    // 4. owned by a layout chunk: the always-imported layout constructor
    // registers it, exactly like today.
    loadLayoutCids();
    if (layoutCids.has(cid)) return { kind: "blob" };

    return { kind: "missing" };
};

The chunk side of this reads the actual chunk files from disk (unminified!! important), extracts cid -> exported name, which then in turn resolves esbuild's alias bindings (var Link_default = Link;) and re-exports, plus allCids (side-effect registrations) and inter-chunk deps, all cached by mtime.

The Regions Expression

We finally get to create our aforementioned regions expression, which contains every top-level component.

For every region entry that we recorded earlier, we emit:

  • `call`, a real component call with the resolved binding and

SSR-evaluated arguments:

ts
  function emitComponentCall(name, desc, ctx): string {
      const props = serializePropsRecord(desc.props, ctx);
      const children = desc.children ?? [];
      const args = children.length > 0
          ? `, ${children.map((c, i) => serializeRegionValue(c, ctx, `children[${i}]`)).join(", ")}`
          : "";
      return `${name}(${props}${args})`;
  }

So, we essentially take the SSR result's { __cid: "abc", children: [...] } and translate it back into the original element call, eg. Link().

This might be a little complex, but the runtime has no way to know which function name generated which component, it only knows the cid, and the AST only knows which function names match which cid.

So we need both to get back to "source-code" like text.

  • `blob` (layout-owned or transitively-chunked) -> `{ __cid: "...", props:

{...}, children: [...] } data, deduped into _p0`-style consts.

  • `missing` → build error (Unhydratable Component) printing the server

side's view closure to identify the component.

serializeRegionValue is the props serializer. For valid values it emits the same output the old serializePropValue did. Literals, arrays, atoms as _getAtom(id, value). The function branch is the interesting one:

ts
if (typeof value === "function") {
    const name = (value as Function).name;
    if (!name) {
        throw richError({ title: "Unserializable Region Prop", /* ... */ });
    }
    if (!ctx.topLevelNames.has(name)) {
        if (!ctx.extractableFns.has(name)) {
            throw richError({ title: "Unserializable Region Prop", /* ... */ });
        }
        // page-local named function: spliced into the bundle by
        // generateSyntheticBundle, so the emitted name resolves there.
        ctx.usedExtractions.add(name);
    }
    return name;
}

And nested live descriptors (slot pattern) are recursed into as calls, __definition never crosses into the bundle like so:

ts
if (obj.__type === "live" && typeof obj.__componentId === "string") {
    const resolved = ctx.resolveCid(obj.__componentId, ctx.usedChunkSources);
    if (resolved.kind === "call") return emitComponentCall(resolved.name, obj, ctx);
}

Function Extraction

Functions referenced by region props but defined inside the page function (extracted from server scope) get their source spliced into the bundle (generateSyntheticBundle):

ts
const splicedNames = new Set<string>();
const splicedDecls: string[] = [];
const spliceQueue: string[] = [...usedExtractions];

while (spliceQueue.length > 0) {
    const name = spliceQueue.pop()!;
    if (splicedNames.has(name)) continue;
    splicedNames.add(name);

    const fn = extractableFns.get(name)!;
    splicedDecls.push(fn.source.endsWith(";") ? fn.source : fn.source + ";");

    for (const free of collectFreeIdentifiers(fn.node)) {
        if (topLevelNames.has(free) || splicedNames.has(free) || KNOWN_CLIENT_GLOBALS.has(free)) continue;
        if (extractableFns.has(free)) { spliceQueue.push(free); continue; }
        throw richError({ title: "Unshippable Function Closure", /* ... */ });
    }
}

The closure check is a kind of safety net. A spliced function referencing node:fs's import becomes reachable code, so the `//!allow-bundling` guard fires; referencing a //!no-bundle decl fires Server Only Error. The guard web polices the splice for free because splices are real code inside the DCE pass. What no guard can see is a fn closing over a page-local non-function value (const x = readFileSync(...) inside Page; fn uses x), the free identifier check catches that with Unshippable Function Closure.

Final Assembly & DCE

Here's what the actual __constructor we've been working towards looks like:

ts
const syntheticFn = `
export default function __constructor() {
${propsDecls}
${handlerDecls}
${layoutCalls}
    const regions  = ${regionsExpr};
    const handlers = ${handlersExpr};

    return { regions: ${mergedRegions}, handlers: ${mergedHandlers} };
}
`;

You can see it's quite different from our original Page() function!

It's constructed something like this:

ts
const importSection = layoutImports ? layoutImports + "\n" : "";
const finalSource   = importSection + withoutDefault + extractedDecls + syntheticFn;
// ... parse, then DCE:
const result = applyReachabilityDCECore(finalSource, dceAst, filePath, bannedGlobs, {
    keepChunkSources: usedChunkSources,
});

The layout wiring is auto-generated and injected:

js
//!allow-bundling
import { default as __l0 } from "/chunks/layout_layout.client.mjs";
// ...
const _l0 = __l0();
// return { regions: [..._l0.regions, ...regions], handlers: [..._l0.handlers, ...handlers] };

This then results in a final bundle (before DCE), of this:

js
import { Link_default } from "/chunks/chunk-ADESIFDG.js";

const Counter = component({ __id: "2rY4KOi", view(...) {...} });
var Counter_default = Counter;

const onSave = (id) => console.log("saved", id);          // ← spliced (was in Page)

export default function __constructor() {
    const regions  = [[
        Link_default({ "href": "/" }, ["Home"]),
        Counter_default({ "onSave": onSave }),
    ]];
    const handlers = [{ eid: 0, h: [{ event: "click", fn: () => console.log(format(buildTime)) }] }];
    return { regions, handlers };
}

You'll see that node:fs is still there, don't worrry, he'll die soon.

DCE aka Dead Code Elimination

Here's the fun part! At this stage, we want to kill and strip away everything that is not required. We do this by applying a reachability check, which essentially goes through a section of code, sees what variables it references, finds those variables, and sees what they reference, recursively, until there are no more references.

For our instance, this would leave most of the code there, dropping only the node:fs import, but for some pages that do a lot of server-side generation, this may save drastic space, as well as eliminates key secrets (like secrets in code, etc.)

We start our seed point (the starting piont) at the synthetic __constructor, as well as some other key points, like:

  1. view / onMount / onUnmount / onNavigate / atoms prop values of

every component() call (CLIENT_ENTRY_PROPS)

  1. every top-level `component()` declaration (blanket, ensures they get registered in the client;

layouts rely on this, pages mostly get calls anyway)

  1. <export default>, the synthetic constructor
  2. every top-level _getAtom() binding
  3. onPageLoad() argument identifiers

There's something important about chunks:

ts
// /chunks/ imports are registration side effects for blob-hydrated
// components. They are kept when provably needed: layout bundles keep all
// of theirs (registry providers, built without region knowledge), page
// bundles keep only those whose resolved cids appear in the current
// request's regions. Everything else dies with normal reachability.
function keepChunkImport(node: any): boolean {
    const src = node.source.value;
    
    if (!src.startsWith("/chunks/")) return false;
    if (opts?.keepAllChunks) return true;
    
    return opts?.keepChunkSources?.has(src) === true;
}

usedChunkSources is filled by the earlier resolver. Only chunks that actually register a blob-hydrated cid for this request are kept. A chunk import whose components are all emitted as calls survives by normal reachability instead.

We recognise DCE isn't perfect (trust me I've run into at least 300 issues in it whilst making it), and developers aren't perfect (and AI is worse!), so we have a list of guards you can use to make sure that when you run into issues, they're loud and crash hard, so you can find and fix them fast.

Guards:

  • //!no-bundle -> Anything referencing this that ends up in the bundle.js throws a ServerOnlyError
  • //!no-bundle-file -> If any file imported the file that contains this, it throws Server Only File
  • //!allow-bundling -> This file can stay in the bundle.js as an import X from Y. Note: this is required if you want to use a client-side package like Chart.js.

Note: /chunks/ imports are exempt, they are framework-generated. Note: You can also define path-specific allow-bundling directives in your elegance.config.ts. Note: As a package author, you can define path-specific bundling directives in your package.json's elegance section.

After DCE, our page's final client bundle:

js
import { Link_default } from "/chunks/chunk-ADESIFDG.js";

const Counter = component({ __id: "2rY4KOi", view(...) {...} });
var Counter_default = Counter;
const onSave = (id) => console.log("saved", id);

export default function __constructor() {
    const regions  = [[
        Link_default({ "href": "/" }, ["Home"]),
        Counter_default({ "onSave": onSave }),
    ]];
    const handlers = [{ eid: 0, h: [{ event: "click", fn: () => console.log(format("...")) }] }];
    return { regions, handlers };
}

HTML Assembly

We're almost there to serve a page to the user. We have our server-rendered html, and our bundle, now we just need some boilerplate:

html
<script type="module" src="/client.js"></script>
<script data-pathname="..." type="module" data-bundle="true" src="bundle.js"></script>
...
<template data-region="0"></template>
<a href="/" e-id="0">Home</a>          <!-- e-id stamped where __eid existed -->
...
<script data-tag="iav" type="application/json">{"atoms":{...}}</script>

Elegance comes with a bunch of default security features that are opt-out, in the config, they're omitted here for brevity.

It's also useful to note, that because dynamic pages are rendered per request, their bundles cannot be written to disk. Thusly, their bundles are embedded into the page html with a tag that looks like this: <script id="__dyn_bundle" type="text/plain"> We then create a simple inline script which creates a Blob URL for this tag, so that client.js can import it.

There's no difference to the client whether or not a target page is dynamic.

Congratulations! You have served a page to a user. Obviously we skipped some major things like optimization, minification, security checks, how the server works, prod vs dev; but this is the general build path of a page.

Quirks

Top-level Elements are non-reactive Because of the static nature of the Page() constructor, static elements won't reactively update their values, only component's and their subtrees will.