Skip to content

Dynamic tag referencing a Marko.Template imported from a separate module crashes client hydration in production builds — reproduces with @marko/run, not with plain @marko/vite #266

Description

@svallory

Note

this issue and the reproduction repo were written by Claude, but I did personally verify that it's real.

Reproduction repo: https://github.com/svallory/marko-run-dynamic-tag-repro — the crashing @marko/run variant at the root, the non-crashing plain @marko/vite counterpart under plain/, README has the one-line failing/working switch.

Environment

  • marko: 6.3.34 and 6.3.35 (latest at time of writing) — both confirmed
  • @marko/run: 0.11.8 and 0.11.9 (latest) — both confirmed (crashing variant; re-verified after bun update --latest)
  • @marko/vite: 6.1.9 (non-crashing plain variant, same trigger shape)
  • vite: 8.2.1
  • Node: 22.15.1
  • bun: 1.3.11
  • OS: macOS (Darwin), also unrelated to platform as far as I can tell

Summary

Rendering a component through a dynamic tag (<${Component}/>) throws a TypeError during client hydration, but only in a production build (marko-run build) served statically — the Vite dev server never reproduces it.

The trigger is narrow: the Component reference must come from an object literal (a Record<string, Marko.Template>-shaped manifest) that is declared in a separate module and imported, then indexed by a runtime-determined string key (in my repro, a route param). The identical lookup against the identical object, when the object is instead declared as an inline <const> literal directly in the .marko file doing the rendering, does not crash.

I built two repros with the identical trigger shape — same manifest pattern, same nested-layout structure, same dynamic tag — one routed through @marko/run, one using plain @marko/vite with a hand-rolled Node HTTP server and no router. The @marko/run variant crashes. The plain @marko/vite variant does not (verified with real, non-scripted clicks incrementing a hydrated counter 0 → 1 → 2 → 3, zero console errors). That's why this is filed here rather than at marko-js/marko: the evidence points at something in @marko/run's generated route/client entries, not the marko compiler or @marko/vite's bundling in isolation. (I have not ruled out that marko-js/marko is still the right owner if the root cause turns out to be a compiler edge case that @marko/run's route codegen happens to trigger and @marko/vite's linked-mode codegen happens not to — filing here first since it's the narrower, better-evidenced claim, and I'm happy to move it or cross-link if a maintainer determines otherwise.)

Minimal reproduction — crashing (@marko/run)

src/demos/tiny-manifest.ts:

import Demo_button_button_demo from "./button/button-demo.marko";

export const DEMOS: Record<string, Marko.Template> = {
  button: Demo_button_button_demo,
};

src/demos/button/button-demo.marko:

<let/count=0>

<button onClick() { count++ }>Button (clicked ${count} times)</button>

src/routes/docs/components/$name/+page.marko (a @marko/run route file,
param $name):

import { DEMOS } from "../../../../demos/tiny-manifest.ts";

<const/Selected=DEMOS[$global.params.name]/>

<div>
  <if=Selected>
    <${Selected}/>
  </if>
  <else>
    <div>not found</div>
  </else>
</div>

Two layered layouts (src/routes/+layout.marko and src/routes/docs/+layout.marko) each thread content through with their own <${input.content}/> dynamic tag, matching the route nesting depth of the app this was bisected from.

Steps:

  1. bun install
  2. bun run build (i.e. marko-run build)
  3. bun run start (i.e. marko-run preview) and open
    /docs/components/button
  4. Open the browser console

Observed: on page load,

TypeError: e[(t++)] is not a function

(minified). With an unminified/sourcemapped build the same throw unminifies to effects[(i++)] is not a function, originating in runEffects, called from runResumeEffectsinit in Marko's compiled DOM runtime. The SSR HTML itself is correct (view-source shows the rendered button), but no client-side event listeners attach — the counter button is dead after the crash.

Expected: the page hydrates cleanly, the same as when the dev server serves it, and the same as the plain-@marko/vite variant below.

Non-crashing counterpart — plain @marko/vite, no @marko/run

Same manifest, same demo component, same nested-dynamic-tag-content layout structure, composed by hand instead of by a router:

src/page.marko (SSR entry, composes the equivalent of the two layouts above plus the component-page lookup — see the attached repro for full
file contents):

import RootLayout from "./root-layout.marko";
import DocsLayout from "./docs-layout.marko";
import ComponentPage from "./component-page.marko";

export interface Input {
  name: string;
}

<RootLayout>
  <@content>
    <DocsLayout>
      <@content>
        <ComponentPage name=input.name/>
      </@content>
    </DocsLayout>
  </@content>
</RootLayout>

ComponentPage (component-page.marko) does the identical DEMOS[input.name] lookup against the identical tiny-manifest.ts, rendered via the identical <${Selected}/>.

Built with two sequential vite build() calls (SSR then client — the same sequence @marko/run's own CLI uses internally, see node_modules/@marko/run/dist/cli/index.mjs), served by a ~40-line plain Node http server calling template.render(input).

Observed: no console error. template.render()'s output hydrates correctly; clicking the button (real mouse clicks, not scripted) increments the counter 0 → 1 → 2 → 3 reliably across repeated clicks.

What changes the outcome (within the @marko/run variant)

Replacing only the manifest's declaration site — nothing else in the file changes — removes the crash:

import Demo_button_button_demo from "../../../../demos/button/button-demo.marko";

<const/DEMOS={ button: Demo_button_button_demo }/>
<const/Selected=DEMOS[$global.params.name]/>

This was bisected from a larger app (a ~76-component docs site) where the same crash appeared on every /docs/components/<name> page, in two independent locations: once with the dynamic tag written directly in a route file, and once with it one level removed inside a custom tag that the route used. In both cases the common factor was the same: a dynamic tag whose component reference was resolved from a runtime lookup (a route param, or a manifest keyed by string) against an object imported from a separate module, as opposed to a statically-known tag reference or an inline object literal.

During minimization, several things were ruled out as necessary conditions: manifest size (one entry crashes the same as hundreds), a <for> loop or multiple call sites on the page (a single call site crashes), a custom-tag boundary around the dynamic tag (crashes with it written directly in the route file), and any interactive chrome (<lifecycle onMount>, <id>-generated DOM ids, tabs) around the dynamic tag (a bare <div><${Selected}/></div> is enough). The one variable that consistently flipped the outcome across every trial (within the @marko/run variant) was the manifest object's declaration site: imported from another module vs. an inline <const> literal in the same file. The plain-@marko/vite variant above adds a second axis: the same "imported from another module" shape, routed without @marko/run, does not crash at all — so whatever @marko/run generates for its route/client entries appears to be a necessary ingredient too, on top of the import-vs-inline distinction.

Suspected cause

Not confirmed, but the shape of the bug (works in dev, breaks only in a minified/bundled production build, only for a very specific rendering pattern, and only through @marko/run's route codegen) strongly suggests a scope-id allocation mismatch between what the compiler assigns to the dynamic-tag branch at compile time and what the SSR-emitted resume payload actually encodes for the client to walk during hydration — i.e. the compiler and the SSR runtime disagree about how many scope slots this branch occupies, and that disagreement only surfaces once the effects array gets walked by the compiled runEffects walker. Given the plain @marko/vite variant doesn't reproduce it, whatever @marko/run's route-file compilation or client-entry generation does differently from @marko/vite's own linked-mode entry generation is likely the other half of the trigger — I did not dig into that diff, since it's @marko/run's own codegen and better understood by that project's maintainers.

Additional notes

  • Confirmed present on both marko 6.3.34 and 6.3.35 (latest).
  • Both repros (crashing @marko/run variant and non-crashing plain
    @marko/vite variant) live in
    https://github.com/svallory/marko-run-dynamic-tag-repro, structured to make the diff between them as small as possible (same manifest file content, same demo component, same nested dynamic-tag-content layout shape) specifically so the only real variable is the routing layer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions