///|
using @common {type Keyboard, type Mouse, type Scroll, type Viewport}
///|
using @cmd {trait Scheduler}
///|
using @cmd {type Cmd, type Emit}
///|
/// 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` / `.history` | `window` 存在(`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`)。
enum Sub {
Custom(String, Scope, Error, SubLoader)
Batch(Array[Sub])
}
///|
/// Custom subscriptions loader.
pub(all) struct SubLoader((Error, &Scheduler) -> RunningSub?)
///|
pub(all) struct RunningSub {
unload : (&Scheduler) -> Unit
update_tagger : (Error) -> Unit
}
///|
/// Create a custom subscription
pub fn custom_sub(
key : String,
scope : Scope,
payload : Error,
loader : SubLoader,
) -> Sub {
Custom(key, scope, payload, loader)
}
///|
priv suberror BuiltinSub {
Every(Int, Cmd)
AnimationFrame(Emit[Double])
Keydown(Emit[Keyboard])
Keyup(Emit[Keyboard])
MouseMove(Emit[Mouse])
Resize(Emit[Viewport])
UrlChanged(Emit[@url.Url])
UrlRequest(Emit[@url.UrlRequest])
Scroll(Emit[Scroll])
VisibilityChange(Emit[Bool])
}
///|
/// 宿主给 resize 用的载荷形状(`native.geometry` 的 `subscribe` 推过来的 JSON)。
///
/// 为什么在这里定义而不是给 `@common.Viewport` 加 `derive(FromJson)`:
/// **这个形状是宿主契约的一部分**,归用它的人管;`Viewport` 是应用可见的类型,
/// 不该为了内部通道挂上一个序列化派生。
priv struct HostViewport {
width : Int
height : Int
} derive(FromJson)
///|
/// **严格**解 `{"width":Int,"height":Int}`;解不出来给 `None`。
///
/// 调用方拿到 `None` 时**必须报错,不能取默认值** —— 取默认值(0×0)恰好复现了
/// 本通道要消灭的那个毛病:**静默给错值**(见 `cmd/host_native.mbt` 的
/// `HostCapability::subscribe_json` 说明)。
fn viewport_of_payload(payload : String) -> Viewport? {
try @json.from_json(@json.parse(payload)) catch {
_ => None
} noraise {
(v : HostViewport) => Some({ width: v.width, height: v.height })
}
}
///|
/// 宿主给的 resize 载荷不合形状时的报错 —— 带上"收到的是什么",别只说"解析失败"。
fn abort_bad_viewport(payload : String) -> Unit {
abort(
"moobile/sub: 宿主能力 `geometry` 推来的载荷不是 {width, height} 形状。\n" +
" 收到:\{payload}\n" +
" 期望:如 {\"width\":320,\"height\":640}(键名必须正好是 width / height)\n" +
" 参考实现:npm/moobile-host/native-rn.js 的 dimensions.subscribe\n" +
" 为什么直接报错而不取 0:取 0 就是「静默给错值」,那正是这条通道要消灭的东西。",
)
}
///|
#cfg(target="js")
fn builtin_sub_loader(payload : Error, scheduler : &Scheduler) -> RunningSub? {
match payload {
Every(ms, cmd) => {
let tagger = @ref.Ref(cmd)
let id = @dom.window().set_interval(() => scheduler.add(tagger.val), ms)
let unload = _ => @dom.window().clear_interval(id)
let update_tagger = self => {
guard self is BuiltinSub::Every(_, new_cmd) else { return }
tagger.val = new_cmd
}
Some({ unload, update_tagger })
}
AnimationFrame(cmd) => {
let tagger = @ref.Ref(cmd)
let mut active = true
let mut request_id : Double? = None
fn queue_next() {
request_id = Some(
@dom.window().request_animation_frame(timestamp => {
guard active else { return }
scheduler.add((tagger.val)(timestamp))
queue_next()
}),
)
}
queue_next()
let unload = _ => {
active = false
guard request_id is Some(id) else { return }
@dom.window().cancel_animation_frame(id)
}
let update_tagger = self => {
guard self is BuiltinSub::AnimationFrame(new_cmd) else { return }
tagger.val = new_cmd
}
Some({ unload, update_tagger })
}
Keydown(cmd) => {
let tagger = @ref.Ref(cmd)
let listener : @dom.Listener = event => {
scheduler.add(
(tagger.val)(keyboard_from_dom(event.to_keyboard_event().unwrap())),
)
}
let unload = _ => {
@dom.document()
.as_event_target()
.remove_event_listener("keydown", listener)
}
let update_tagger = self => {
guard self is BuiltinSub::Keydown(new_cmd) else { return }
tagger.val = new_cmd
}
@dom.document().as_event_target().add_event_listener("keydown", listener)
Some({ unload, update_tagger })
}
Keyup(cmd) => {
let tagger = @ref.Ref(cmd)
let listener : @dom.Listener = event => {
scheduler.add(
(tagger.val)(keyboard_from_dom(event.to_keyboard_event().unwrap())),
)
}
let unload = _ => {
@dom.document()
.as_event_target()
.remove_event_listener("keyup", listener)
}
let update_tagger = self => {
guard self is BuiltinSub::Keyup(new_cmd) else { return }
tagger.val = new_cmd
}
@dom.document().as_event_target().add_event_listener("keyup", listener)
Some({ unload, update_tagger })
}
MouseMove(cmd) => {
let tagger = @ref.Ref(cmd)
let listener : @dom.Listener = event => {
scheduler.add(
(tagger.val)(mouse_from_dom(event.to_mouse_event().unwrap())),
)
}
let unload = _ => {
@dom.document()
.as_event_target()
.remove_event_listener("mousemove", listener)
}
let update_tagger = self => {
guard self is BuiltinSub::MouseMove(new_cmd) else { return }
tagger.val = new_cmd
}
@dom.document()
.as_event_target()
.add_event_listener("mousemove", listener)
Some({ unload, update_tagger })
}
Resize(cmd) => {
let mut tagger = cmd
let update_tagger = self => {
guard self is BuiltinSub::Resize(new_cmd) else { return }
tagger = new_cmd
}
// 宿主能力优先(RN:`Dimensions`);问不到 / 形状不对 → 回退 DOM(Web)。
//
// ⚠️ 这一条是**先修的那一个**:它原来在 RN 上是**静默失效**(`window` 存在,
// 但 `innerWidth/innerHeight` 是 `undefined` → 按 `Int` 接住就是 0×0,
// **不抛**),比 `on_key_down` 那类"会抛"的危险得多。判定依据见
// `npm/moobile-host/native-rn.js` 与 `tools/cap_platform.mjs` 的 `rnFails`。
let host_sub = match @cmd.host_capability("geometry") {
Some(cap) =>
cap.subscribe_json(payload => {
match viewport_of_payload(payload) {
Some(v) => scheduler.add(tagger(v))
// 契约不匹配 = 作者错误(宿主字段名写错了)→ 立刻报错。
// **绝不退化成 0×0**:那正是这条通道要消灭的"静默给错值"。
None => abort_bad_viewport(payload)
}
})
None => None
}
match host_sub {
Some(sub) => {
let unload = _ => sub.cancel()
Some({ unload, update_tagger })
}
None => {
let listener : @dom.Listener = event => {
ignore(event)
scheduler.add(
tagger({
width: @dom.window().inner_width(),
height: @dom.window().inner_height(),
}),
)
}
let unload = _ => {
@dom.window()
.as_event_target()
.remove_event_listener("resize", listener)
}
@dom.window().as_event_target().add_event_listener("resize", listener)
Some({ unload, update_tagger })
}
}
}
Scroll(cmd) => {
// ⚠️ 这条在 RN 上**不是"待接宿主能力",是语义不成立**:
// Web 的 `on_scroll` 报的是**文档级**滚动;RN 没有文档级滚动,
// 滚动发生在每个 `ScrollView` **内部**,事件是那个组件的 `onScroll` prop。
// 也就是说它的替代物在**组件通道**(I 轨道),不在宿主能力通道。
//
// 老行为是**静默给 0**(`window` 存在但 `scrollY` 是 `undefined`)—— 最难发现的一类。
// 现在改成**当场报错**:与 `on_key_down` 那类"会抛"的行为对齐,
// 宁可在开发期炸一次,也不要上线后滚动位置永远是 0。
if !@cmd.host_has_dom() {
abort(
"moobile/sub: `on_scroll` 在当前运行时不可用。\n" +
" Web 上它报的是**文档级**滚动;这个运行时没有文档级滚动(RN 的滚动在 ScrollView 内部)。\n" +
" 替代做法:给 `scroll_view` 挂组件级回调取载荷,再喂回 Model —— 走的是**组件通道**,\n" +
" 不是宿主能力通道(宿主能力换不掉一个不存在的语义)。\n" +
" 为什么直接报错:老行为是静默给 0(`window.scrollY` 是 undefined 却不抛),\n" +
" 那种失效要上线很久才会被发现。",
)
}
let mut tagger = cmd
let listener : @dom.Listener = event => {
ignore(event)
scheduler.add(tagger(window_scroll()))
}
let unload = _ => {
@dom.window()
.as_event_target()
.remove_event_listener("scroll", listener)
}
let update_tagger = self => {
guard self is BuiltinSub::Scroll(new_cmd) else { return }
tagger = new_cmd
}
@dom.window().as_event_target().add_event_listener("scroll", listener)
Some({ unload, update_tagger })
}
UrlChanged(cmd) => {
let mut tagger = cmd
scheduler.set_url_changed_injector(url => {
try @url.parse(url) catch {
e => {
println("failed to parse \{url}: \{e}")
@cmd.none
}
} noraise {
url => tagger(url)
}
})
let update_tagger = self => {
guard self is BuiltinSub::UrlChanged(new_cmd) else { return }
tagger = new_cmd
}
// 两条驱动方式,按**运行时有没有 DOM** 分(不是按宿主有没有登记能力):
// · 有 DOM(Web)→ 自己挂 `popstate`,变化时读地址栏并注入(老行为,一字不变);
// · 没有 DOM(RN / Node)→ **一个 DOM API 都不碰**,只把注入器留在宿主手里,
// 由宿主在它自己的"URL 变化"时机调 `inject_url_changed`。
//
// ⚠️ 修的是"直接抛":老代码无条件 `@dom.window().add_event_listener("popstate", …)`,
// 而 RN 上 `window` 存在、`addEventListener` 不存在 → TypeError。
// 注意这里**不是**"宿主没登记能力就回退 DOM" —— 有没有 DOM 是运行时事实,
// 不需要谁声明(见 `cmd/host_native.mbt` 的 `host_has_dom`)。
if @cmd.host_has_dom() {
let listener : @dom.Listener = _ => {
let cmd = scheduler.inject_url_changed(@dom.window().current_url())
scheduler.add(cmd)
}
let unload = _ => {
scheduler.set_url_changed_injector(_ => @cmd.none)
@dom.window()
.to_event_target()
.remove_event_listener("popstate", listener)
}
@dom.window().to_event_target().add_event_listener("popstate", listener)
Some({ unload, update_tagger })
} else {
// 宿主接管:它调 `inject_url_changed` 时注入器就会跑。
let unload = _ => scheduler.set_url_changed_injector(_ => @cmd.none)
Some({ unload, update_tagger })
}
}
UrlRequest(cmd) => {
let mut tagger = cmd
scheduler.set_url_request_injector(href => {
// 判断同源需要一个"当前地址"。
// 有 DOM → 读地址栏(老行为);没有 DOM(RN)→ 问宿主(`Context::get_origin`,
// `ReactHost` 已实现)。**不读 `window.location`** —— RN 上它不存在,会抛。
let base = if @cmd.host_has_dom() {
@dom.window().current_url()
} else {
scheduler.get_origin()
}
let curr = try! @url.parse(base)
let next = try! @url.parse(href)
let request = if curr.protocol == next.protocol &&
curr.host == next.host &&
curr.port == next.port {
@url.Internal(next)
} else {
External(href)
}
tagger(request)
})
fn unload(_) {
scheduler.set_url_request_injector(_ => @cmd.none)
}
fn update_tagger(self) {
guard self is BuiltinSub::UrlRequest(new_cmd) else { return }
tagger = new_cmd
}
Some({ unload, update_tagger })
}
VisibilityChange(cmd) => {
let mut tagger = cmd
let update_tagger = self => {
guard self is BuiltinSub::VisibilityChange(new_cmd) else { return }
tagger = new_cmd
}
// 宿主能力优先:RN 上用 AppState,Web 上宿主不登记 → 落到下面的 DOM 回退。
// 为什么要两条路:`#cfg(target="js")` 区分不了 Web 与 RN(同一个 target),
// 而 `document` 在 RN 上**不存在** —— 老实现在原生端是直接抛,不是静默失效。
// 契约见 `cmd/host_native.mbt`;宿主登记的 `visibility.subscribe(cb)` 里,
// `cb` 收到的是"是否隐藏",与 DOM 的 `document.hidden()` 同义。
let host_sub = match @cmd.host_capability("visibility") {
Some(cap) =>
cap.subscribe_bool(hidden => scheduler.queue_command(tagger(hidden)))
None => None
}
match host_sub {
Some(sub) => {
let unload = _ => sub.cancel()
Some({ unload, update_tagger })
}
None => {
let listener : @dom.Listener = _ => {
scheduler.queue_command(tagger(@dom.document().hidden()))
}
let unload = _ => {
@dom.document()
.as_event_target()
.remove_event_listener("visibilitychange", listener)
}
@dom.document()
.as_event_target()
.add_event_listener("visibilitychange", listener)
Some({ unload, update_tagger })
}
}
}
_ => None
}
}
///|
#cfg(not(target="js"))
fn builtin_sub_loader(payload : Error, scheduler : &Scheduler) -> RunningSub? {
match payload {
UrlChanged(cmd) => {
let mut tagger = cmd
scheduler.set_url_changed_injector(url => {
try @url.parse(url) catch {
e => {
println("failed to parse \{url}: \{e}")
@cmd.none
}
} noraise {
url => tagger(url)
}
})
let unload = _ => scheduler.set_url_changed_injector(_ => @cmd.none)
let update_tagger = self => {
guard self is BuiltinSub::UrlChanged(new_cmd) else { return }
tagger = new_cmd
}
Some({ unload, update_tagger })
}
_ => None
}
}
///|
pub(all) enum Scope {
Local
Global
}
///|
#doc(hidden)
pub fn Sub::to_map(
self : Self,
_ : @key.Key,
filter_global~ : Bool,
) -> Map[String, (Error, SubLoader)] {
let map = Map([])
fn go(s) {
match s {
Custom(_, Global, _, _) if filter_global => ()
Custom(k, _, h, v) => map[k] = (h, v)
Batch(xs) => xs.each(go)
}
}
go(self)
map
}
///|
#cfg(target="js")
fn keyboard_from_dom(event : @dom.KeyboardEvent) -> Keyboard {
Keyboard::new(
key=event.key(),
code=event.code(),
alt_key=event.alt_key(),
ctrl_key=event.ctrl_key(),
shift_key=event.shift_key(),
meta_key=event.meta_key(),
is_composing=event.is_composing(),
repeat=event.repeat(),
location=event.location(),
)
}
///|
#cfg(target="js")
fn mouse_from_dom(event : @dom.MouseEvent) -> Mouse {
Mouse::new(
screen={ x: event.get_screen_x(), y: event.get_screen_y() },
offset={ x: event.get_offset_x(), y: event.get_offset_y() },
client={ x: event.get_client_x(), y: event.get_client_y() },
)
}
///|
#cfg(target="js")
fn window_scroll() -> Scroll {
let root = @dom.document().query_selector("html").unwrap()
Scroll::new(
offset={ x: @dom.window().scroll_x(), y: @dom.window().scroll_y() },
width=root.get_scroll_width().to_int(),
height=root.get_scroll_height().to_int(),
)
}
///|
/// Repeatedly enqueue `cmd` every `ms` milliseconds.
///
/// **平台**:Web ✅ · RN ✅ —— `setInterval` 是**标准 JS 全局**,不是什么 DOM 能力。
///
/// 见 `tools/cap_platform.mjs` —— 「哪端可用」那张表是**机器校验**的(漂了就红)。
pub fn every(ms : Int, cmd : @cmd.Cmd) -> Sub {
custom_sub("every(\{ms})", Local, Every(ms, cmd), builtin_sub_loader)
}
///|
/// 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` —— 「哪端可用」那张表是**机器校验**的(漂了就红)。
pub fn on_resize(msg : @cmd.Emit[Viewport]) -> Sub {
custom_sub("on_resize", Local, Resize(msg), builtin_sub_loader)
}
///|
/// 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` —— 「哪端可用」那张表是**机器校验**的(漂了就红)。
pub fn on_animation_frame(msg : @cmd.Emit[Double]) -> Sub {
custom_sub(
"on_animation_frame",
Local,
AnimationFrame(msg),
builtin_sub_loader,
)
}
///|
/// 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` —— 「哪端可用」那张表是**机器校验**的(漂了就红)。
pub fn on_key_down(msg : @cmd.Emit[Keyboard]) -> Sub {
custom_sub("on_key_down", Local, Keydown(msg), builtin_sub_loader)
}
///|
/// 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` —— 「哪端可用」那张表是**机器校验**的(漂了就红)。
pub fn on_key_up(msg : @cmd.Emit[Keyboard]) -> Sub {
custom_sub("on_key_up", Local, Keyup(msg), builtin_sub_loader)
}
///|
/// 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` —— 「哪端可用」那张表是**机器校验**的(漂了就红)。
pub fn on_visibility_change(msg : @cmd.Emit[Bool]) -> Sub {
custom_sub(
"on_visibility_change",
Local,
VisibilityChange(msg),
builtin_sub_loader,
)
}
///|
/// 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` —— 「哪端可用」那张表是**机器校验**的(漂了就红)。
pub fn on_mouse_move(msg : @cmd.Emit[Mouse]) -> Sub {
custom_sub("on_mouse_move", Local, MouseMove(msg), builtin_sub_loader)
}
///|
/// 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` —— 「哪端可用」那张表是**机器校验**的(漂了就红)。
pub fn on_scroll(msg : @cmd.Emit[Scroll]) -> Sub {
custom_sub("on_scroll", Local, Scroll(msg), builtin_sub_loader)
}
///|
/// 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` —— 「哪端可用」那张表是**机器校验**的(漂了就红)。
pub fn on_url_changed(msg : @cmd.Emit[@url.Url]) -> Sub {
custom_sub("on_url_changed", Global, UrlChanged(msg), builtin_sub_loader)
}
///|
/// 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` —— 「哪端可用」那张表是**机器校验**的(漂了就红)。
pub fn on_url_request(msg : @cmd.Emit[@url.UrlRequest]) -> Sub {
custom_sub("on_url_request", Global, UrlRequest(msg), builtin_sub_loader)
}
///|
/// A subscription that does nothing.
pub let none : Sub = Batch([])
///|
/// Combine multiple subscriptions into one.
///
/// If multiple subscriptions use the same internal key, the later one wins.
pub fn batch(xs : Array[Sub]) -> Sub {
Batch(xs)
}