🥮 mooncakes.io

    #Subscriptions

    The sub package models long-lived external signals that should feed back into your TEA update loop.

    Unlike Cmd, which runs once, a Sub stays installed until your app stops returning it.

    #Basic shape

    In Rabbita, subscriptions are usually returned from the subscriptions callback of create_state.

    ///|
    fn subscriptions(model : Model, emit : Emit[Msg]) -> @sub.Sub {
    if model.running {
    @sub.every(1000, emit(Tick))
    } else {
    @sub.none
    }
    }

    #Building subscriptions

    Use none when nothing should be active:

    ///|
    test "sub none" {
    let _ : Sub = @sub.none
    }

    Use batch to combine multiple subscriptions:

    ///|
    test "sub batch" {
    let _ : Sub = @sub.batch([none, every(1000, @cmd.none)])
    }

    #Emitting messages

    Event-based subscriptions usually work with emit.map(...), just like HTML event handlers.

    ///|
    enum Msg {
    Resized(ViewPort)
    MouseMoved(Mouse)
    }

    ///|
    fn subscriptions(_model : Model, emit : Emit[Msg]) -> @sub.Sub {
    @sub.batch([
    @sub.on_resize(v => emit(Resized(v))),
    @sub.on_mouse_move(m => emit(MouseMoved(m))),
    ])
    }

    NodeRect

    pub struct NodeRect {
    x : Double
    y : Double
    width : Double
    height : Double
    } derive(
    FromJson
    )

    节点的一次测量 —— 相对视口(与 DOM 的 getBoundingClientRect() 同一口径)。

    ⚠️ 要算「某一节在滚动容器里的位置」,调用方得再减一次容器的矩形 (容器自己也可以是个具名节点:量两次相减)。这一层不替调用方猜你要哪个坐标系 —— 猜错的表现是「高亮总是偏一节」,而且两端还偏得不一样。

    RunningSub

    pub(all) struct RunningSub {
    unload : (&
    Scheduler
    ) -> Unit
    update_tagger : (Error) -> Unit
    }

    Scope

    pub(all) enum Scope {
    Local
    Global
    }

    Sub

    type Sub

    A long-lived subscription managed by the Rabbita runtime.

    Use subscriptions to listen to external signals such as timers, window resize, scrolling, keyboard input, visibility changes, or WebSocket events.

    平台可用性(别信"inert"那句话)

    上游原文是 "Browser event sources are inert on native targets"。 在 React Native 上这句不成立,而且两种失效方式差别很大(2026-09-21 实测):

    依赖RN 上表现
    document.*document 未定义抛 ReferenceError
    window.location / .historywindow 存在(global.window = global)但没有这些属性抛 TypeError
    window.innerWidth / scrollY属性是 undefined不抛,静默给 0 ← 最难发现

    所以最该先修的不是"无替代物"那几个,而是"静默给错值"的那两个 (on_resize / on_scroll)。完整的「哪端可用 + 替代物 + 失效形态」表在 tools/cap_platform.mjs,那张表是机器校验的(代码变了表没改 → 门红)。

    走通了的路子:on_visibility_change —— 先问宿主能力(MOBILE_HOST.native), 问不到再回退 DOM(见 cmd/host_native.mbt)。

    SubLoader

    Custom subscriptions loader.

    batch

    fn batch(xs : Array[Sub]) -> Sub

    Combine multiple subscriptions into one.

    If multiple subscriptions use the same internal key, the later one wins.

    copy_text

    fn copy_text(text : String) ->
    Cmd

    把 text 写进剪贴板。

    平台:Web ✅(navigator.clipboard,需要安全上下文 —— http://127.0.0.1 与 file:// 在本仓的判据里都算)· RN 未实现 ⇒ abort(要接得让宿主登记 clipboard 能力的 invoke)。

    见 tools/cap_platform.mjs —— 「哪端可用」那张表是机器校验的(漂了就红)。

    current_url

    fn current_url() -> String?

    读一次当前 URL(同步)。

    与 on_url_changed 互补 —— 而这一条是实测逼出来的(2026-10-07,真 Chrome + CDP, examples/apps/route-spike/drive.mjs):on_url_changed 只在 popstate 那一档推 (改 hash / 后退 / 前进 / 点同文档 <a> 都推),而 pushState / replaceState 不推、首屏也不推。 ⇒ 只有订阅没有同步读的话,"进来的时候我在哪"永远拿不到(首屏是瞎的)。

    取值顺序与 current_viewport() 同一套: ① 宿主能力优先(url 的 read,RN 侧要宿主登记); ② 问不到 → 回退 DOM(window.location.href); ③ 连 DOM 都没有 → None(不猜、不给空串,理由同 current_viewport)。

    ⚠️ 回退那一步先问 host_has_dom():RN 上 window 存在而 location 不存在, 直接读会抛(这正是 on_url_changed 那条订阅当年在原生端装载就炸的原因)。

    平台:Web ✅(DOM)· RN 待查实(宿主需登记 url.read;Linking.getInitialURL 是异步的, 要拿它当同步读得宿主先缓存一份)。

    见 tools/cap_platform.mjs —— 「哪端可用」那张表是机器校验的(漂了就红)。

    current_viewport

    读一次当前视口(同步)。与 on_resize 互补:它给的是"现在多大", 而订阅给的是"变了多少" —— 而两端都不会在挂载时补发一次。

    为什么这条 API 是必需的(第十一轮,从真实应用的痛处来的)

    on_resize 只在真的发生 resize 时推值:浏览器不拖窗口、手机不转屏, 它就一次都不推。于是"应用想按窗口宽度排版 / 算尺寸"这件事做不到 —— 真实后果:zhouyi-reader 的罗盘边长按"窗口宽度的 94%(上限 720)"算, 而窗口宽度从来没读到过 ⇒ 一直画在回落值 360 上 —— 在 1400px 的浏览器里 罗盘小得离谱,而且没有任何报错。当时的绕法是"往小里取"的常数(保证不溢出)—— 那不是响应式,是猜。

    取值顺序(与 on_resize 同一套)

    1. 宿主能力优先:native.geometry.read()(RN → Dimensions.get('window'));
    2. 问不到(没登记 read 的宿主 / SSR)→ 回退 DOM:window.innerWidth/innerHeight;
    3. 运行时连 DOM 都没有(RN 且宿主没实现)→ None(不猜)。

    拿到 None 时应用该怎么做:用一个明确写下理由的回落值, 而不是把 None 当 0 混进计算(0 会算出 0 宽的画布,那种 bug 更难查)。

    平台:Web ✅(DOM)· RN ✅(宿主能力 native.geometry.read())。

    见 tools/cap_platform.mjs —— 「哪端可用」那张表是机器校验的(漂了就红)。

    custom_sub

    fn custom_sub(key : String, scope : Scope, payload : Error, loader : SubLoader) -> Sub

    Create a custom subscription

    every

    fn every(ms : Int, cmd :
    Cmd
    ) -> Sub

    Repeatedly enqueue cmd every ms milliseconds.

    平台:Web ✅ · RN ✅ —— setInterval 是标准 JS 全局,不是什么 DOM 能力。

    见 tools/cap_platform.mjs —— 「哪端可用」那张表是机器校验的(漂了就红)。

    node_exists

    fn node_exists(node : String) -> Bool

    问一下这个具名节点现在在不在(不报错 —— 这正是它与 node_rect 的区别)。

    为什么需要它(站点跨页跳锚点时逼出来的)

    "切页"与"新页面渲染出来"之间隔着一次 React 提交 ⇒ 紧接着发滚动命令会滚到旧内容上。 应用要自己等,就得有一个不会炸的探针:node_rect 找不到节点会 abort(那是对的 —— 让 id 写错当场响),但"还没渲染出来"是正常过程,不能用报错来表达。

    ⚠️ 判词很关键:它回答的是「能确认它现在就在吗」—— · Web:getElementById(...) != null ✓; · RN(宿主没登记 node 能力):恒 false ⇒ 依赖它的重试会走到"超时后报错"那条路。 这是刻意的:恒假会让"等一个节点"的调用方明着失败,而不是静默地永远不滚。

    平台:Web ✅ · RN 未实现(见上一条的取舍)。

    node_rect

    fn node_rect(node : String) -> NodeRect?

    量一个具名节点(同步读一次,视口坐标系)。

    取值顺序同 scroll_to_node:① 宿主动作(invoke("measure", {"node":"…"}), 回包 {"x":…,"y":…,"width":…,"height":…})→ ② DOM 回退 → ③ 两头都没有 ⇒ None (不猜:不给全 0 —— 全 0 会算出一个「在第 0 像素」的高亮,那种 bug 更难查)。

    ⚠️ 节点在 DOM 里找不到时会 abort(那是 bug,不是「这个平台没有」)⇒ 不要在 initial() / 首帧之前调,调用点应当在挂载之后(例如某个 Msg 的处理里)。

    node_scroll_top

    fn node_scroll_top(node : String) -> Double?

    读一个具名节点的滚动位置(scrollTop,一次性)。

    用途:站点正文栏那种「自己滚的容器」——「现在滚到哪了」要问容器,不能问 window (@sub.on_scroll 给的是文档级滚动,语义不同,见 tools/cap_platform.mjs 里那条 window.scroll_y 的判词)。

    ⚠️ 这一版只有一次性读;「滚到哪高亮到哪」要的是订阅,还没做(见文件头 §未做 1)。 今天应用侧要跟读只能自己轮询(@sub.every),代价与 hosttheme 那条 2 秒轮询同类。

    平台:Web ✅ · RN 未实现 ⇒ None(容器滚动在 RN 是组件 prop,不是元素属性)。

    none

    let none : Sub

    A subscription that does nothing.

    on_animation_frame

    fn on_animation_frame(msg :
    Emit
    [Double]) -> Sub

    Subscribe to requestAnimationFrame ticks.

    The handler receives the browser timestamp for each frame while the subscription is active.

    平台:Web ✅ · RN ✅ —— requestAnimationFrame 也是标准 JS 全局。

    见 tools/cap_platform.mjs —— 「哪端可用」那张表是机器校验的(漂了就红)。

    on_key_down

    Subscribe to document keydown events.

    The handler receives a normalized Keyboard value from @common.

    平台:Web ✅ · RN ❌ Web-only —— 移动端没有全局键盘。 RN 上会抛 ReferenceError: document is not defined(会抛,不是静默, 所以它比 on_resize 那类好查)。要键盘/手势请走 RN 的 responder 或手势通道。

    见 tools/cap_platform.mjs —— 「哪端可用」那张表是机器校验的(漂了就红)。

    on_key_up

    Subscribe to document keyup events.

    The handler receives a normalized Keyboard value from @common.

    平台:Web ✅ · RN ❌ Web-only(同 on_key_down,会抛)。

    见 tools/cap_platform.mjs —— 「哪端可用」那张表是机器校验的(漂了就红)。

    on_mouse_move

    Subscribe to document mousemove events.

    The handler receives a normalized Mouse value from @common.

    平台:Web ✅ · RN ❌ Web-only —— 触屏没有"指针移动"这回事 (onHoverIn/Out 只在 Web/桌面有意义)。RN 上会抛(document 不存在)。 拖拽类交互请走 RN 的手势通道,不要复用这条。

    见 tools/cap_platform.mjs —— 「哪端可用」那张表是机器校验的(漂了就红)。

    on_node_scroll

    fn on_node_scroll(node : String, msg :
    Emit
    [Double]) -> Sub

    订阅某个具名节点的滚动(载荷 = 它当前的 scrollTop)。

    为什么需要它(on_scroll 换不掉它)

    on_scroll 报的是文档级滚动。而站点那种"三栏各自滚"的版面里,正文栏是自己的滚动容器 —— 它的滚动位置不会出现在 window.scrollY 上。所以"滚到哪高亮到哪"这类跟读, 必须能订阅某一个节点的滚动。

    契约(三条,都是实测逼出来的)

    1. 装载时先补一次当前值 —— 订阅只报"变化",而 DOM 与 RN 的滚动事件都不会在挂载时补发一次 (本仓在 current_viewport / current_url 上各栽过一次)⇒ 这里装载完成后立刻推一次 scrollTop。应用因此首帧就有值,不必自己再读一遍。
    2. 按 Attrs::id(…) 找节点 —— 与 scroll_to_node / node_rect 同一套寻址; 挂载之前读不到,所以用有界重试(约 1 秒),额度用完仍找不到 ⇒ abort(带节点名)。
    3. 只认那个节点的滚动 —— 实现是 document 捕获阶段的 scroll 监听(滚动事件不冒泡, 但能在捕获阶段收到),按 event.target 的 id 过滤;挂载前装载也不受影响。

    ⚠️ 今天不支持"条件渲染、以后才出现"的节点:重试额度用完就报错(见第 2 条)。

    平台:Web ✅ · RN ❌ 未实现(RN 的容器滚动在组件层 ScrollView.onScroll; 要走宿主能力得先给 node 能力定一个订阅形状)⇒ 未实现时装载即 abort,不静默。

    见 tools/cap_platform.mjs —— 「哪端可用」那张表是机器校验的(漂了就红)。

    on_resize

    Subscribe to window resize events.

    The handler receives the latest viewport width and height.

    平台:Web ✅(走 DOM 回退)· RN ✅(走宿主能力 native.geometry → Dimensions)。

    ⚠️ 它原来是静默失效那一类:window 存在(RN 做了 global.window = global), 但 window.innerWidth 是 undefined → 按 Int 接住就是 0×0 且不抛 —— 所以先修的是它(tools/cap_platform.mjs 会把"静默给错值"这类单独报出来)。 载荷经 subscribe_json 严格解:宿主字段名写错会当场报错,不会退化成 0。 ⚠️ 真机未验(visibility 那条验过了,这条只到逻辑层测试)。

    见 tools/cap_platform.mjs —— 「哪端可用」那张表是机器校验的(漂了就红)。

    on_scroll

    Subscribe to window scroll events.

    This reports page-level scrolling on window, not scrolling of an inner element. The handler receives the current scroll offset and document scroll size as a Scroll value.

    平台:Web ✅ · RN ❌ 语义不成立(注意:不是「待接」)。

    Web 上它报的是文档级滚动;RN 没有文档级滚动 —— 滚动发生在每个 ScrollView 内部,事件是那个组件的 onScroll prop。所以它的替代物在组件通道(I 轨道), 不是在宿主能力通道:宿主能力换的是"某个能力的平台实现", 而这里换不掉的是"一个不存在的语义"。

    ⚠️ 老行为是静默给 0(window 存在但 scrollY 是 undefined,不抛)—— 最难发现的一类。现在装载时明确 abort 并给出替代做法,与 on_key_down 那类 "会抛"的行为对齐:宁可在开发期炸一次。

    见 tools/cap_platform.mjs —— 「哪端可用」那张表是机器校验的(漂了就红)。

    on_url_changed

    Subscribe to browser location changes.

    This is an app-scoped subscription. It is only active when returned from the root cell's subscriptions callback; if a non-root cell returns it, the subscription is ignored.

    平台:Web ✅ · RN ✅ 机制已通(宿主驱动)。

    驱动方式是既有的 Scheduler 注入器:sub 把注入器交给宿主 (Scheduler::set_url_changed_injector),宿主在它自己的"URL 变化"时机调 Context::inject_url_changed(ReactHost 已实现)。

    ⚠️ 原来 RN 上装载就抛:老代码无条件又去挂 popstate (@dom.window().to_event_target().add_event_listener(...)), 而 RN 上 window 存在、addEventListener 不存在 → TypeError。 现在按 @cmd.host_has_dom() 分两条路:有 DOM 照旧自己挂 popstate(Web 一字不变), 没有 DOM 就一个 DOM API 都不碰,只把注入器留给宿主。

    见 tools/cap_platform.mjs —— 「哪端可用」那张表是机器校验的(漂了就红)。

    on_url_request

    Subscribe to captured navigation requests from @html.a(...).

    This is an app-scoped subscription. It is only active when returned from the root cell's subscriptions callback; if a non-root cell returns it, the subscription is ignored.

    平台:Web ✅ · RN ✅ 机制已通(宿主驱动,同 on_url_changed)。

    同源判断需要一个"当前地址"当基准:有 DOM 时读地址栏(老行为), 没有 DOM 时问宿主(Context::get_origin —— ReactHost 已实现)。 原来那一句无条件读 @dom.window().current_url()(= window.location.href), 而 RN 上没有 location,注入一发生就会抛。

    见 tools/cap_platform.mjs —— 「哪端可用」那张表是机器校验的(漂了就红)。

    on_visibility_change

    fn on_visibility_change(msg :
    Emit
    [Bool]) -> Sub

    Subscribe to document visibility changes.

    The handler receives true when the document is hidden, and false when it becomes visible again.

    平台:Web ✅(走 DOM 回退)· RN ✅(走宿主能力 native.visibility → AppState)。 这是第一条走宿主能力通道的订阅:两端同一份应用代码,真机验过(PLAN.md §3.6 的 N2)。 语义近似处(RN 的 inactive 算作不可见、只在变化时推送不补发当前值)见 npm/moobile-host/native-rn.js 的文件头。

    见 tools/cap_platform.mjs —— 「哪端可用」那张表是机器校验的(漂了就红)。

    open_url

    fn open_url(url : String) ->
    Cmd

    在新窗口/新标签里打开一条外部地址(站点正文里的外链用它)。

    取值顺序与 push_url 同一套: ① 宿主动作优先:url 能力的 invoke("open", url)(RN 侧将来登记成 Linking.openURL); ② 有 DOM 就自己来:window.open(url, "_blank", "noopener"); ③ 两头都没有 ⇒ abort。

    ⚠️ 与 copy_text 同一条取舍:不承诺成功 —— window.open 可能被弹窗拦截 (不是用户手势触发时最常见),而那既不是"通道不通"也不该炸掉应用 ⇒ 失败打到控制台。 ⚠️ 站内链接不该走这一条(那会把 SPA 甩出去):站内跳页请用 Msg::Go(站点侧由 find_page 判"这条链接是不是站内的")。

    push_url

    fn push_url(url : String) ->
    Cmd

    推一条新地址进历史(history.pushState 语义):调用方点"前进到某页"时用它。

    与 popstate 同形的回声由本函数补(见文件头)⇒ 调用方不必再问"我刚推的地址生效了吗"。

    平台:Web ✅(DOM)· RN 待宿主登记(未登记且无 DOM ⇒ abort,不静默)。

    replace_url

    fn replace_url(url : String) ->
    Cmd

    换掉当前那条历史(history.replaceState 语义):不新增历史条目时用它 (例如"同一条路由只换装饰参数")。

    平台:同 push_url。

    scroll_to_node

    fn scroll_to_node(node : String, block? : String, behavior? : String) ->
    Cmd

    滚到具名节点(scrollIntoView 语义)。

    参数是语义,不是 CSS 值: · block ∈ "start" / "center" / "end" / "nearest"(默认 "start" —— 与「点目录让那一节顶到容器上沿」同一个意思); · behavior ∈ "auto" / "smooth"(默认 "auto";⚠️ "smooth" 是动画, 判据读到的会是中间态 —— 见 spike 的读数)。

    平台:Web ✅(DOM)· RN 未实现 ⇒ abort(等宿主登记 node 能力的 invoke("scrollTo", {"node":"…","block":"…"}),回包 {"ok":true,"why":""})。