🥮 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))),
    ])
    }

    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.

    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 —— 「哪端可用」那张表是机器校验的(漂了就红)。

    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_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 —— 「哪端可用」那张表是机器校验的(漂了就红)。

    Source Files