marko6
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseMarko 6 language
Marko 6 语言
Distilled from the full Marko 6 docs (reference, explanation, guide, tutorial; Marko 5 / class API intentionally excluded). This file = rules agents get wrong + quick reference. Full detail lives in . When something here doesn't match observed behavior, the live docs win — Marko moves fast: index at https://markojs.com/llms.txt; any page fetches as markdown by appending (e.g. https://markojs.com/docs/reference/core-tag.md).
references/.md从完整的 Marko 6 文档(reference、explanation、guide、tutorial;有意排除 Marko 5 / class API)中提炼而来。此文件 = Agent 容易搞错的规则 + 快速参考。完整细节位于 。如果这里的内容与实际观察到的行为不一致,以最新在线文档为准——Marko 发展很快:索引见 https://markojs.com/llms.txt;给任意页面 URL 追加 即可获取 Markdown(例如 https://markojs.com/docs/reference/core-tag.md)。
references/.mdMarko 6 only — never write Marko 5
仅限 Marko 6——绝不要编写 Marko 5
Reject on sight: blocks, scriptlets, / paren args, string handlers , /, /, discovery, types. Marko 6 equivalents: ///, , , , discovery, .
class { onCreate/onMount... }$ const x = ...<if(cond)><for(x in y)>on-click("handleClick")statethis.state<include><invoke>components/Marko.Component<let><const><script><lifecycle><if=cond><for|x| of=y>onClick() {}tags/export interface Input看到以下内容应立即拒绝: 代码块、 脚本片段、带括号的参数形式 /、字符串型 handler 、/、/、 发现机制、 类型。Marko 6 中的对应写法:///、、、、 发现机制、。
class { onCreate/onMount... }$ const x = ...<if(cond)><for(x in y)>on-click("handleClick")statethis.state<include><invoke>components/Marko.Component<let><const><script><lifecycle><if=cond><for|x| of=y>onClick() {}tags/export interface InputChecklist for any new or edited .marko
file
.marko任何新建或编辑的 .marko
文件检查清单
.marko- Marko 6 syntax only (list above).
- State first: no /
<script>where<lifecycle>/<let>/controllable natives (<const>) do the job;value:=only for genuine browser effects.<script> - Handlers are functions (/
onClick() {}), never strings;onClick=fnargs, no(event, element).currentTarget - declared; typecheck with
export interface Input(mtc) —@marko/type-checkskipstscand reports success..marko - Ship minimal client JS: prefer HTML/CSS (,
<details>,<dialog>,:has) over scripted behavior; only reactive parts ship anyway.popover - Keep components small; split by concern, not by technology (bundle size is unaffected by splitting).
- SSR-safe: nothing unserializable in ;
<let>/element refs only in$signal/<script>/handlers.<lifecycle> - Immutable updates (),
x = x.concat()on reorderableby=s,<for>vs<show>chosen deliberately.<if>
| File | Read when |
|---|---|
| syntax: statements, attributes & shorthands, content, attr tags, tag vars/params, dynamic tags, concise mode, whitespace |
| |
| |
| |
| |
| tag discovery order, |
| controllable components, nested state, serializable/immutable state, streaming, tooling |
Component architecture for this repo (Zag wiring, attr-tag APIs): .
skills/port-to-marko/references/marko-gotchas.md- 仅使用 Marko 6 语法(见上方列表)。
- 状态优先:如果 /
<let>/可控原生标签(<const>)能够完成任务,就不要使用value:=/<script>;只有在真正需要浏览器副作用时才使用<lifecycle>。<script> - Handler 必须是函数(/
onClick() {}),绝不能是字符串;参数为onClick=fn,不要使用(event, element)。currentTarget - 声明 ;使用
export interface Input(mtc)进行类型检查——@marko/type-check会跳过tsc并报告成功。.marko - 尽量减少客户端 JS:优先使用 HTML/CSS(、
<details>、<dialog>、:has),而不是脚本化行为;无论如何只会向浏览器发送具备响应性的部分。popover - 保持组件小巧;按关注点拆分,而不是按技术拆分(拆分不会影响 bundle size)。
- SSR-safe:中不能包含不可序列化的内容;
<let>/元素引用只能出现在$signal/<script>/handler 中。<lifecycle> - 使用不可变更新();可重排的
x = x.concat()使用<for>;有意识地选择by=或<show>。<if>
| 文件 | 何时阅读 |
|---|---|
| 语法:语句、属性与简写、内容、attr tags、tag 变量/参数、动态 tags、concise mode、空白处理 |
| |
| |
| |
| |
| tag 发现顺序、 |
| 可控组件、嵌套状态、可序列化/不可变状态、流式传输、工具链 |
本仓库的组件架构(Zag 接线、attr-tag API):。
skills/port-to-marko/references/marko-gotchas.mdMental model (10 lines)
心智模型(10 行)
- File = component. = attrs.
inputdirs auto-discovertags/; PascalCase variables are tags; strings in<tag-name>are always native elements.<${...}> - Module statements only: ,
import,export,static,server. Everything else is template body (reactive).client - Reactive sources: tag vars (), tag params (
/x),|x|. Graph is compiled, not tracked at runtime. Expressions must be pure.input - = state (assignment-reactive, immutable updates, serialized on SSR).
<let>= derived (lazy, flush-time).<const>= module constant.static const - Updates batch after a microtask. After the
count++is new, every<let>/DOM is stale until flush.<const> - = browser-only effect that re-runs on referenced tag var/param changes; cleanup via
<script>.$signal.onabort=<lifecycle>with persistentonMount/onUpdate/onDestroy.this - Native tag var is a getter function, browser-only, usable only after render.
<div/el/> - Controllable everything: ≡
value:=x; on member exprs ≡value=x valueChange(v){x=v}.value=input.x valueChange=input.xChangemakes a component controllable.<let/x:=input.x> - Server/client boundary: only serializable plain data crosses; no class instances, DOM nodes, or foreign-module closures in state; stays server-side unless in
$global.serializedGlobals - Zero-JS by default: only reactive parts ship to the browser; component granularity doesn't change bundle size.
- 文件 = 组件。= attrs。
input目录会自动发现tags/;PascalCase 变量表示 tags;<tag-name>中的字符串始终表示原生元素。<${...}> - 只允许模块语句:、
import、export、static、server。其他所有内容都是模板正文(具备响应性)。client - 响应式数据源:tag 变量()、tag 参数(
/x)、|x|。依赖图在编译期生成,而不是在运行时追踪。表达式必须保持纯粹。input - = 状态(赋值触发响应式、使用不可变更新、在 SSR 时序列化)。
<let>= 派生值(惰性计算,在 flush 时求值)。<const>= 模块常量。static const - 更新会在 microtask 之后批量处理。执行 后,
count++中保存的是新值;在 flush 之前,所有<let>/DOM 都是陈旧的。<const> - = 仅限浏览器的副作用,会在引用的 tag 变量/参数发生变化时重新运行;通过
<script>清理。$signal.onabort= 提供<lifecycle>的onMount/onUpdate/onDestroy会持续存在。this - 原生 tag 变量 是一个 getter function,仅限浏览器端使用,并且只能在 render 完成后使用。
<div/el/> - 所有内容都可控制:等价于
value:=x;作用于成员表达式时,等价于value=x valueChange(v){x=v}。value=input.x valueChange=input.xChange可让组件变为可控组件。<let/x:=input.x> - Server/client 边界:只有可序列化的纯数据可以跨越边界;状态中不能包含 class 实例、DOM 节点或外部模块闭包;除非包含在 中,否则
serializedGlobals应保留在 server 端。$global - 默认 Zero-JS:只有具备响应性的部分才会发送到浏览器;组件粒度不会改变 bundle size。
Quick reference — the rules that bite
快速参考——最容易踩中的规则
Syntax
语法
- Unenclosed in attr value →
>.value=(a > b)and>=OK.=> - shorthand;
<div#id.cls.cls2/>is<Comp.x/>, not property access →class="x".<${Comp.x}/> - Bare root text in concise mode parses as tags → . Multi-line attrs: leading commas.
-- text - Attribute string is a JS literal; enumerated attrs need strings:
aria-*.aria-pressed=on && "true" - attrs skipped;
null|undefined|false,0,""written. Text:NaNskipped,null|undefined|false|""|NaN|0nrendered.0 - Line break between two inline tags = no space. Put the space on the same line.
- /
<if>/<else if=…>are direct siblings. Docs write<else>;<else if=cond>also compiles on 6.3.34 (verified).<else-if=cond> - forwards content; native tags also accept
<${input.content}/>or justcontent=input.content. Literal body (even a comment) overrides<div ...input/>.content= - A component's attr must be the entirety of its default body — Marko sets
contentfrom a tag's non-attr-tag children automatically. Callers never writeinput.contentfor it; the body goes in directly (params too:<@content>, not<Tag|params|>body</Tag>). If a component needs two-or-more peer body-shaped slots, name them (e.g.<@content|params|>), nevermain—contentimplies "the rest of the body" and a second same-shaped slot breaks that.content - Repeated →
<@item>is first + iterable:input.itemor<for|it| of=input.item>. Type[...input.item || []].Marko.AttrTag<{...}> - Tag params: ; args form
<child|{ n }|>→<${input.content}(1,2)/>. Attrs or args, not both. Attr tags can't read params.|a, b| - Closing tag name optional (); dynamic tags close with
</>.</>
- 属性值中未用括号包住的 →
>。value=(a > b)和>=无需处理。=> - 是简写;
<div#id.cls.cls2/>表示<Comp.x/>,而不是属性访问;后者应写成class="x"。<${Comp.x}/> - 在 concise mode 中,根级纯文本会被解析为 tags → 。多行属性:后续行使用前导逗号。
-- text - 属性中的字符串是 JS literal;枚举属性需要字符串:
aria-*。aria-pressed=on && "true" - 属性会被跳过;
null|undefined|false、0、""会被写入。文本:NaN会被跳过,null|undefined|false|""|NaN|0n会渲染。0 - 两个 inline tags 之间换行 = 没有空格。需要空格时,应将空格放在同一行上。
- /
<if>/<else if=…>必须是直接同级元素。文档写作<else>;<else if=cond>在 6.3.34 中也可以编译(已验证)。<else-if=cond> - 用于转发 content;原生 tags 也接受
<${input.content}/>,或者直接使用content=input.content。字面量正文(即使只是注释)会覆盖<div ...input/>。content= - 组件的 属性必须是其默认正文的全部内容——Marko 会根据 tag 中非 attr-tag 的子元素自动设置
content。调用方绝不需要为它写input.content;正文应直接放入(带参数时也一样:<@content>,而不是<Tag|params|>body</Tag>)。如果组件需要两个或更多并列的正文形态插槽,应为它们命名(例如<@content|params|>),绝不要命名为main——content意味着“正文的其余部分”,第二个同形态插槽会破坏这一规则。content - 重复的 →
<@item>是“第一个 item + iterable”:input.item或<for|it| of=input.item>。类型为[...input.item || []]。Marko.AttrTag<{...}> - Tag 参数:;参数形式
<child|{ n }|>→<${input.content}(1,2)/>。属性和参数不能同时使用。Attr tags 无法读取参数。|a, b| - 闭合 tag 名称可以省略();动态 tags 使用
</>闭合。</>
Reactivity & timing
响应式与时序
- does NOT follow later
<let/x=input.x>changes. Useinput.x(controllable) or read<let/x:=input.x>directly.input.x - Mutation isn't reactive: ❌ →
arr.push();arr = arr.concat()❌ →obj.k = v.obj = { ...obj, k: v } - In handlers recompute from s;
<let>values and DOM are stale until flush.<const> - destroys/recreates state;
<if>keeps it mounted.<show>to preserve state on reorder.<for ... by="id"> - only inside
$signal/<script>/handlers (throws on server elsewhere). Capture it before<lifecycle>.await - : return an object from
<lifecycle>(bare fn cleanup is dropped); don't overwrite attribute props ononMount.this - : one per template/content, top-level; vary its
<return>.value=
- 不会跟踪后续的
<let/x=input.x>变化。使用input.x(可控),或者直接读取<let/x:=input.x>。input.x - 变更不会触发响应式:❌ →
arr.push();arr = arr.concat()❌ →obj.k = v。obj = { ...obj, k: v } - 在 handler 中应基于 重新计算;在 flush 之前,
<let>值和 DOM 都是陈旧的。<const> - 会销毁并重新创建状态;
<if>会保持其挂载状态。使用<show>在重排后保留状态。<for ... by="id"> - 只能出现在
$signal/<script>/handler 中(在其他位置使用会抛出 server 端错误)。必须在<lifecycle>之前捕获它。await - :
<lifecycle>必须返回一个对象(直接返回函数会导致清理逻辑丢失);不要覆盖onMount上的属性 props。this - :每个模板/content 只能有一个,必须位于顶层;可以通过
<return>返回不同值。value=
Native tags & events
原生 tags 与事件
- Handler ; no
(event, element)(delegated) — use 2nd arg or ref. Marko handlers run before native listeners; non-bubbling events only on the target.event.currentTarget - gives strings →
<input value:=n>/value:parseFloat:=n.valueChange(v){ n = +v } - : every
<select value:=x>needs<option>; novalue=.selected= - keys hyphen-case, no unit inference; conditional declarations at array level.
style={} - Spread owns attrs (removed when missing later); write fixed attrs after the spread.
- ,
<details open:=o>;<dialog open:=o>doesn't fireshowModal().openChange
- Handler 参数为 ;不要使用
(event, element)(事件采用委托)——请使用第二个参数或 ref。Marko handler 会先于原生 listener 执行;不冒泡的事件只能在 target 上处理。event.currentTarget - 返回字符串 →
<input value:=n>/value:parseFloat:=n。valueChange(v){ n = +v } - :每个
<select value:=x>都需要<option>;不要使用value=。selected= - 的键使用连字符格式,不推断单位;条件声明应放在数组层级。
style={} - Spread 拥有属性控制权(后续缺失相应属性时会被移除);固定属性应写在 spread 之后。
- 、
<details open:=o>;<dialog open:=o>不会触发showModal()。openChange
Types
类型
- (bare
export interface Input {}= parse error).interface,Marko.Body<[params], { value }>,Marko.AttrTag<T>for extending natives,Marko.HTML.Buttonaugmentation,Marko.Globalfor custom elements. Typecheck withMarko.NativeTags(mtc), not@marko/type-check.tsc
- (裸写
export interface Input {}会产生解析错误)。interface、Marko.Body<[params], { value }>、用于扩展原生标签的Marko.AttrTag<T>、Marko.HTML.Buttonaugmentation、用于自定义元素的Marko.Global。使用Marko.NativeTags(mtc)进行类型检查,而不是@marko/type-check。tsc
Discovery & assets
发现机制与资源
- Only dirs are searched (upward); sibling files need
tags/. Packages export viaimportmarko.json. Adjacentexports/style.cssauto-attached.foo.style.css - global, once;
<style>CSS Modules;<style/styles>inside${}→ custom props, declaration values only, above the styled content.<style> - Lazy: static string; facade tag to centralize.
import X from "<x>" with { load: "visible#id | idle?timeout=2000" }
- 只会向上搜索 目录;同级文件需要使用
tags/。Package 通过import的marko.json导出。相邻的exports/style.css会自动关联。foo.style.css - 是全局样式,只应出现一次;
<style>是 CSS Modules;<style/styles>中的<style>表示自定义属性,只能用在声明值中,并且必须位于被设置样式的内容之前。${} - 延迟加载:,其中必须是静态字符串;可使用 facade tag 集中管理。
import X from "<x>" with { load: "visible#id | idle?timeout=2000" }
Template API
模板 API
- consumed once:
T.render(input),for await,.pipe(res),.toReadable(),await(sync-only)..toString()→T.mount(input, node, pos).update/destroy/value - to expose globals to the client (never secrets);
$global.serializedGlobals;cspNonce/renderIdidentifier-safe.runtimeId
- 只能消费一次:
T.render(input)、for await、.pipe(res)、.toReadable()、await(仅支持同步)。.toString()→T.mount(input, node, pos)。update/destroy/value - 使用 将全局变量暴露给客户端(绝不能暴露 secrets);还有
$global.serializedGlobals;cspNonce/renderId必须符合标识符安全要求。runtimeId
Known agent-error catalog (verified against this repo)
已知 Agent 错误案例目录(已针对本仓库验证)
Verified against real components in and .
packages/shadcn/ui/notes/bug-marko-dynamic-tag-hydration-crash.md-
/
<if>must be direct template siblings —<else>is not valid nested inside a wrapper, and nothing (not even whitespace-only text expressions) may sit between<else>and</if>other than other<else>/<else-if>tags.<else>marko<!-- right --> <if=a>A</if> <else-if=b>B</else-if> <else>C</else> <!-- wrong: wrapping breaks the sibling chain --> <div> <if=a>A</if> </div> <else>C</else> -
Bare top-level/
interface/typegets parsed as a tag attempt, not TypeScript. Every top-level TS declaration needsconst(orexportfor runtimestatic/const) in front of it.functionmarko<!-- wrong: "interface" parses as a custom tag name --> interface Input { name: string } <!-- right --> export interface Input { name: string } <!-- wrong: bare top-level const is a template-body reactive statement, not module scope, and usually not what you meant --> const PAGE_SIZE = 20; <!-- right --> static const PAGE_SIZE = 20; -
is ambiguous — Marko overloads
!/(unescaped-HTML marker,!) and$!{...}(tag-variable marker,/) as adjacent single-character sigils; a hand-written<tag/name/>sequence risks being parsed as the wrong one. Not independently reproduced against the compiler for this skill (no minimal repro found before publishing — treat as lower-confidence pending verification) — the safe rule regardless: always write the explicit!/form for unescaped output, and never place a tag-variable directly after an unescaped-output position without a separating space or reordering the attributes.$!{expr}marko<!-- right: unambiguous --> <div>$!{input.articleHtml}</div> -
Self-recursive tag imports — a tag that imports itself (e.g. a tree node component rendering its own children) must import itself via the relative-file custom-tag path or an explicit PascalCase import of its own compiled file, never by referencing its own bare tag name as if it were already a global custom tag; relative discovery only finds tags under adirectory (see https://markojs.com/docs/reference/custom-tag), so a same-directory recursive component needs an explicit self-import.
tags/marko<!-- tree-node.marko --> import TreeNode from "./tree-node.marko"; <li> ${input.label} <if=input.children?.length> <ul> <for|child| of=input.children> <TreeNode ...child/> </for> </ul> </if> </li> -
SSR→hydration boundary serializability contract (closure-wrap rule). Every value that crosses from server-rendered/reactive state into the client must be plain, serializable data — never a live object with methods (a Zag service, a
<let>result, any class instance holding closures). This is a REQUIREMENT of Marko's resumability model, not a bug to work around: if resume corrupts, the default read is "an unserializable value is still crossing the boundary somewhere," not "Marko is broken." The fix is to serialize only primitives and re-derive anything stateful inside a template-written closure (the module getter onconnect(), or<zag/api=() => mod .../>onprops=/<zag>) that runs fresh on each side, never a raw value passed through<zag-machine>.inputmarko<!-- wrong: service/api is a live object with methods — throws "Unable to serialize" --> <let/api=switchMachine.connect(service, normalizeProps)> <!-- right: <zag> wires the machine and returns a getter, re-evaluated on each side --> <zag/api=() => switchMachine from=input/> -
Walk-orderhazard — fixed upstream in marko 6.3.46, no workaround needed. When a component was mounted through dynamic-tag-content (
<const>) nested inside an ancestor's own portal/dynamic-tag mount, that component's OWN walk-time reads of its OWN<${input.content}/>-derived value — in<const>/<if>branch selectors, attribute spreads, anywhere evaluated during the first mount walk — could fire before the<for>'s lazy/signal-deferred value had ever computed on that fresh scope, throwing<const>. This was a genuine defect inTypeError: ... is not a function, fixed by marko-js/marko#4062, shipped in@marko/runtime-tags. Plain@marko/runtime-tags@6.3.46+<zag/api=...>at every call site — the ordinary pattern — is correct; do not reintroduce aapi()-style static-function mirror.connectFreshmarko<!-- correct, at marko >=6.3.46: plain api() getter, no mirror needed --> <zag/api=() => machine from=input/> <span ...api().getRootProps()> <if=api().open>...</if> </span>Historical note: before the fix landed,/calendar.marko/avatar.markoeach carried a module-levelradio-group.markothat calledstatic function connectFresh(...)directly against the raw service, bypassing themachine.connect()entirely, as a workaround. All three were reverted to plain<const>once the workspace'sapi()pin moved to 6.3.46 (2026-08-26). Full incident writeup, including the mirror shapes that were tried and still crashed:marko.notes/bug-marko-dynamic-tag-hydration-crash.md -
Dead-scope closure hazard: content unrendered at SSR time loses its captured variables — pass entry data via tag parameters, not closures. Closure capture itself WORKS: anbody inside
<@item>reading<for|person|>renders correctly on the server and through the ordinary hydration walk (always-open control proves it). The hazard is resumability-specific: each content body is serialized as a reference to its OWNER SCOPE (the per-iteration${person}scope holding the loop binding); a body that never rendered during SSR (e.g. behind a closed<for>) gets no serialized owner scope, so when it first fires on a CLIENT-ONLY mount, ANY read through the closure —<if>placeholder or a${person}attribute expression alike — hits an undefined scope and throwsvalue=person(minified:TypeError: Cannot read properties of undefined (reading 'person')/(reading '2')). Tag parameters dodge this structurally: the data travels inside the render invocation at fire time ('N'+<${entry.content}(entry.value)/>reading<@item|name| value=person>), not through a serialized scope reference. Menu-like components should therefore always pass entry data to item content via args. Verified as a discriminating pair in the Marko playground (v6.3.45, SSR+hydration). Writeup + playground links:${name}; minimal repro:notes/bug-marko-dynamic-tag-hydration-crash.md.scratch/content-closures-repro/ -
shows only the first attr tag by design — the repeated-attr-tag object is "first item + iterable"; stringify serializes own enumerable props only. Count with
JSON.stringify(input.item), and never branch on[...input.item].length(it is never an array —Array.isArray(input.item)silently discards all but the first tag; spread directly:Array.isArray(x) ? x : [x]).[...(input.item ?? [])]
已使用 中的真实组件以及 进行验证。
packages/shadcn/ui/notes/bug-marko-dynamic-tag-hydration-crash.md-
/
<if>必须是直接的模板同级元素——<else>不能嵌套在 wrapper 内,而且<else>与</if>之间不得放置任何内容(即使是仅含空白的文本表达式),只能放置其他<else>/<else-if>tags。<else>marko<!-- right --> <if=a>A</if> <else-if=b>B</else-if> <else>C</else> <!-- wrong: wrapping breaks the sibling chain --> <div> <if=a>A</if> </div> <else>C</else> -
顶层裸写的/
interface/type会被解析为 tag 尝试,而不是 TypeScript。 每个顶层 TS declaration 前面都必须加const(运行时的export/const则加function)。staticmarko<!-- wrong: "interface" parses as a custom tag name --> interface Input { name: string } <!-- right --> export interface Input { name: string } <!-- wrong: bare top-level const is a template-body reactive statement, not module scope, and usually not what you meant --> const PAGE_SIZE = 20; <!-- right --> static const PAGE_SIZE = 20; -
存在歧义——Marko 将
!/(未转义 HTML 标记,!)和$!{...}(tag 变量标记,/)用作相邻的单字符符号;手写<tag/name/>序列可能被解析成错误的含义。该问题尚未针对此 skill 在编译器中独立复现(发布前未找到最小复现——在得到验证前应视为置信度较低),但无论是否如此,安全规则始终是:始终使用明确的!/形式输出未转义内容;不要在未转义输出位置之后直接放置 tag 变量,除非使用空格分隔或重新排列属性。$!{expr}marko<!-- right: unambiguous --> <div>$!{input.articleHtml}</div> -
自递归 tag 导入——一个导入自身的 tag(例如渲染自身子项的 tree node 组件),必须通过相对文件 custom-tag 路径,或通过显式 PascalCase import 导入自身编译后的文件;绝不能像使用全局 custom tag 一样直接引用自己的裸 tag 名称。相对发现机制只会找到目录下的 tags(参见 https://markojs.com/docs/reference/custom-tag),因此,同目录下的递归组件需要显式自导入。
tags/marko<!-- tree-node.marko --> import TreeNode from "./tree-node.marko"; <li> ${input.label} <if=input.children?.length> <ul> <for|child| of=input.children> <TreeNode ...child/> </for> </ul> </if> </li> -
SSR→hydration 边界的可序列化契约(closure-wrap 规则)。 每个从 server 端渲染的/响应式状态跨越到 client 端的值,都必须是纯且可序列化的数据——绝不能是带方法的活动对象(Zag service、
<let>的结果、任何持有闭包的 class 实例)。这是 Marko resumability 模型的硬性要求,不是需要绕过的 bug;如果 resume 发生损坏,默认判断应该是“仍有不可序列化的值跨越了边界”,而不是“Marko 出了问题”。修复方式是只序列化基础类型,并在模板编写的闭包中重新派生任何有状态内容(connect()上的 module getter,或<zag/api=() => mod .../>/<zag>上的<zag-machine>),该闭包会在 server/client 两侧分别重新运行;绝不能通过props=传递原始值。inputmarko<!-- wrong: service/api is a live object with methods — throws "Unable to serialize" --> <let/api=switchMachine.connect(service, normalizeProps)> <!-- right: <zag> wires the machine and returns a getter, re-evaluated on each side --> <zag/api=() => switchMachine from=input/> -
遍历顺序隐患——已在 marko 6.3.46 中从上游修复,无需 workaround。 当一个组件通过嵌套在祖先组件自身的 portal/dynamic-tag mount 中的 dynamic-tag-content 挂载时,该组件在遍历时读取自身
<const>派生值——包括在<const>/<if>分支选择器、属性 spread,以及首次 mount 遍历期间求值的任何位置——可能在该<for>的惰性/signal-deferred 值尚未于新作用域中计算之前触发,从而抛出<const>。这是TypeError: ... is not a function中真实存在的缺陷,已由 marko-js/marko#4062 修复,并随@marko/runtime-tags发布。普通的@marko/runtime-tags@6.3.46+ 在每个调用点使用<zag/api=...>才是正确模式;不要重新引入api()风格的静态函数镜像。connectFreshmarko<!-- correct, at marko >=6.3.46: plain api() getter, no mirror needed --> <zag/api=() => machine from=input/> <span ...api().getRootProps()> <if=api().open>...</if> </span>历史说明:在修复落地前,/calendar.marko/avatar.marko各自包含一个模块级radio-group.marko,直接针对原始 service 调用static function connectFresh(...),绕过machine.connect(),作为 workaround。workspace 中的<const>固定版本升级到 6.3.46(2026-08-26)后,这三个文件都恢复为普通的marko。完整事件记录,包括尝试过的镜像实现及其仍会崩溃的原因:api()。notes/bug-marko-dynamic-tag-hydration-crash.md -
死作用域闭包隐患:SSR 时未渲染的 content 会丢失捕获的变量——通过 tag 参数传递入口数据,而不是闭包。 闭包捕获本身可以正常工作:中读取
<for|person|>的${person}正文,在 server 端以及普通 hydration 遍历中都能正确渲染(始终打开的对照组可以证明这一点)。隐患与 resumability 特性有关:每个 content 正文都会作为其所属作用域的引用进行序列化,也就是保存循环绑定变量的每次迭代作用域;如果某个正文在 SSR 期间从未渲染过(例如位于关闭的<@item>后面),它就不会获得已序列化的所属作用域。因此,当它首次在仅 client 端挂载时,任何通过闭包进行的读取——无论是<if>占位符,还是${person}属性表达式——都会遇到未定义作用域并抛出value=person(压缩后为TypeError: Cannot read properties of undefined (reading 'person')/(reading '2'))。Tag 参数可以从结构上避开这个问题:数据会在触发时随 render 调用一起传递('N'+<${entry.content}(entry.value)/>读取<@item|name| value=person>),而不是依赖已序列化的作用域引用。因此,menu 类组件应始终通过 args 将条目数据传递给 item content。已在 Marko playground(v6.3.45,SSR+hydration)中通过一组有区分度的对照用例完成验证。记录与 playground 链接:${name};最小复现:notes/bug-marko-dynamic-tag-hydration-crash.md。scratch/content-closures-repro/ -
按设计只显示第一个 attr tag——重复的 attr-tag 对象是“第一个 item + iterable”;
JSON.stringify(input.item)只会序列化其自身 enumerable props。请使用stringify计数,并且绝不能基于[...input.item].length分支处理(它永远不是数组——Array.isArray(input.item)会静默丢弃除第一个之外的所有 tag;应直接使用 spread:Array.isArray(x) ? x : [x])。[...(input.item ?? [])]
4. Concise-mode vs HTML-mode gotchas
4. Concise mode 与 HTML mode 的陷阱
-
Bare root-level text is misparsed as tags in concise mode. Text needs thefence (or must live inside an HTML-mode tag's content).
--marko<!-- wrong: each word/line is parsed as an attempted tag --> Hello World Welcome to Marko <!-- right --> -- Hello World -- Welcome to Marko -
Attribute lists spanning lines in concise mode use leading commas, by convention at the start of each continuation line (not trailing).marko
<!-- right --> div id="hello" class="world", style={ border: "1px solid red" } -
A file starts concise and switches to HTML mode at the first HTML-syntax (bracketed) tag — mixing is allowed at the file level (switch once, don't flip back and forth per tag), but a given tag occurrence must be written fully one way or the other.
-
Unenclosedin a concise-mode attribute value needs parentheses — a bare
>reads as end-of-tag;>(with surrounding whitespace) and>=are exempted as unambiguous.=>marko<!-- wrong: > closes the tag early --> <if=count > 0>...</if> <!-- right --> <if=(count > 0)>...</if>
-
在 concise mode 中,顶层裸文本会被错误解析为 tags。 文本需要使用围栏(或放在 HTML-mode tag 的 content 中)。
--marko<!-- wrong: each word/line is parsed as an attempted tag --> Hello World Welcome to Marko <!-- right --> -- Hello World -- Welcome to Marko -
在 concise mode 中,跨行属性列表按惯例使用前导逗号,即放在每个续行的开头(而不是结尾)。marko
<!-- right --> div id="hello" class="world", style={ border: "1px solid red" } -
文件以 concise mode 开始,并在遇到第一个 HTML-syntax(带方括号的)tag 时切换到 HTML mode——允许在文件层级混用(只切换一次,不要在不同 tag 之间来回切换),但每个具体的 tag 实例必须完整使用其中一种写法。
-
在 concise mode 的属性值中,未用括号包住的需要加括号——裸写
>会被视为 tag 结束符;带空格的>和>=是明确无歧义的例外。=>marko<!-- wrong: > closes the tag early --> <if=count > 0>...</if> <!-- right --> <if=(count > 0)>...</if>
Samples verified
已验证的样例
Every nontrivial sample above that is not an intentionally-broken ("wrong") fragment was dropped into a scratch file under (so 's own dependency tree resolves) and compiled with from , run via (plain cannot resolve the nested self-reference inside bun's store layout — is required, matching the compiler resolution the repo's own tooling relies on). All 17 real samples compiled successfully:
.markoapps/docs/markocompileFilenode_modules/.bun/marko@6.3.34/node_modules/@marko/compiler/dist/index.jsbunnodemarko/translator.bun/bun <script>.mjsOK: 01-class-id-shorthand.marko -> code length 293
OK: 02-dynamic-native-tag.marko -> code length 771
OK: 03-content-forward.marko -> code length 595
OK: 04-attr-tag-collect.marko -> code length 995
OK: 05-tag-params.marko -> code length 575
OK: 05b-tag-params-parent.marko -> code length 865
OK: 06-tag-var-ref.marko -> code length 444
OK: 07-let-script-signal.marko -> code length 492
OK: 08-lifecycle-cleanup.marko -> code length 382
OK: 09-static-function.marko -> code length 651
OK: 10-if-elseif-else.marko -> code length 1459
OK: 11-export-interface-static-const.marko -> code length 635
OK: 12-self-recursive.marko -> code length 2150
OK: 13-if-paren-compare.marko -> code length 1014
OK: 14-root-text-fence.marko -> code length 270
OK: 15-leading-comma-attrs.marko -> code length 310
OK: 16-connect-closure.marko -> code length 1276
OK: 17-connect-fresh-static.marko -> code length 990The four intentionally-wrong fragments (bare top-level , / split by an intervening , unparenthesized , unenclosed root text without ) were compiled too, to confirm each fails for the reason the corresponding rule claims — not an unrelated syntax mistake. The compiler's own diagnostics independently confirm the rule text:
interface<if><else><div><if=input.count > 0>--w1-bare-interface.marko -> "Invalid attribute name." (bare `interface` parsed as a tag)
w2-if-else-wrapped.marko -> "The <else> tag must have a preceding <if=cond> or <else if=cond>."
w3-unparenthesized-if.marko -> "Ambiguous \">\" in attribute... wrap it in parentheses, eg \"=(input.count > 0)\"."
w4-root-text-no-fence.marko -> "Unable to find entry point for custom tag <Hello>... prefix it with -- ... or wrap it in an element"上面每个非平凡且并非故意错误(“wrong”)片段的 样例,都被放入 下的 scratch 文件中(这样 自身的依赖树可以完成解析),并使用 中的 进行编译,再通过 运行(普通 无法在 bun 的 store 布局中解析嵌套的 自引用——因此必须使用 ,这也与仓库自身工具链所依赖的编译器解析方式一致)。全部 17 个真实样例均成功编译:
.markoapps/docs/markonode_modules/.bun/marko@6.3.34/node_modules/@marko/compiler/dist/index.jscompileFilebunnode.bun/marko/translatorbun <script>.mjsOK: 01-class-id-shorthand.marko -> code length 293
OK: 02-dynamic-native-tag.marko -> code length 771
OK: 03-content-forward.marko -> code length 595
OK: 04-attr-tag-collect.marko -> code length 995
OK: 05-tag-params.marko -> code length 575
OK: 05b-tag-params-parent.marko -> code length 865
OK: 06-tag-var-ref.marko -> code length 444
OK: 07-let-script-signal.marko -> code length 492
OK: 08-lifecycle-cleanup.marko -> code length 382
OK: 09-static-function.marko -> code length 651
OK: 10-if-elseif-else.marko -> code length 1459
OK: 11-export-interface-static-const.marko -> code length 635
OK: 12-self-recursive.marko -> code length 2150
OK: 13-if-paren-compare.marko -> code length 1014
OK: 14-root-text-fence.marko -> code length 270
OK: 15-leading-comma-attrs.marko -> code length 310
OK: 16-connect-closure.marko -> code length 1276
OK: 17-connect-fresh-static.marko -> code length 990另外还对四个故意错误的片段进行了编译,以确认每个片段确实是因为对应规则所述的原因而失败,而不是因为其他无关的语法错误。编译器自身的 diagnostics 也独立确认了规则内容:
w1-bare-interface.marko -> "Invalid attribute name." (bare `interface` parsed as a tag)
w2-if-else-wrapped.marko -> "The <else> tag must have a preceding <if=cond> or <else if=cond>."
w3-unparenthesized-if.marko -> "Ambiguous \">\" in attribute... wrap it in parentheses, eg \"=(input.count > 0)\"."
w4-root-text-no-fence.marko -> "Unable to find entry point for custom tag <Hello>... prefix it with -- ... or wrap it in an element"