Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ Full module documentation: [hexdocs.pm/mob_dev](https://hexdocs.pm/mob_dev).

---

## [Unreleased]

### Added
- **`mix mob.doctor` warns when a project still carries the pre-MOB-104 sheet
dismissal wiring.** An app generated before the fix routes
`Mob.UI.sheet/2`'s `:on_dismiss` through `MobBridge.nativeSendTap` and
delivers `{:tap, tag}`, but the documented contract — and iOS — deliver
`{:dismiss, tag}`. A screen written to the contract never matches it and
dies with `FunctionClauseError`, or the dismissal is silently swallowed and
the sheet can never be re-presented. `MobBridge.kt` is app-owned and never
re-rendered, so mob_new's template fix doesn't reach existing projects (see
`decisions/2026-08-25-detect-dont-autopatch-native-source.md` for why this
repo detects rather than auto-patches). The check names the two-line port and
notes it needs mob >= 0.7.31. Only fires on projects that actually render
sheets, so apps predating `Mob.UI.sheet/2` stay quiet (MOB-104).

## [0.6.27] - 2026-08-26

### Added
Expand Down
71 changes: 70 additions & 1 deletion lib/mix/tasks/mob.doctor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,8 @@ defmodule Mix.Tasks.Mob.Doctor do
check_compiled(),
check_driver_tab(),
check_plugin_build_options(),
check_component_event_jni()
check_component_event_jni(),
check_sheet_dismiss_wire_shape()
])
else
[]
Expand Down Expand Up @@ -642,6 +643,74 @@ defmodule Mix.Tasks.Mob.Doctor do
not Regex.match?(fixed, content)
end

# ── Sheet dismissal wire shape (MOB-104) ──────────────────────────────────
#
# An app generated before the MOB-104 fix routes sheet dismissal through
# `MobBridge.nativeSendTap`, delivering `{:tap, tag}`. `Mob.UI.sheet/2`
# documents `:on_dismiss` as `{:dismiss, tag}` and iOS has always sent that,
# so a screen written to the documented contract never matches: Mob.Screen
# forwards the unmatched message to handle_info, which raises
# FunctionClauseError and kills the screen — or silently drops it with a
# catch-all, in which case the BEAM never learns the sheet closed and can't
# re-present it.
#
# Worse to diagnose than MOB-98: that one failed loudly with an
# UnsatisfiedLinkError, while this delivers a plausible-but-wrong message.
# MobBridge.kt is app-owned and never re-rendered, so mob_new's template fix
# doesn't reach existing projects (see
# decisions/2026-08-25-detect-dont-autopatch-native-source.md for why this
# repo detects rather than auto-patches hand-editable native source).
defp check_sheet_dismiss_wire_shape do
"android/app/src/main/java/**/MobBridge.kt"
|> Path.wildcard()
|> Enum.flat_map(fn path ->
case File.read(path) do
{:ok, content} ->
if __sheet_dismiss_wire_shape_stale__(content) do
[
{:warn, "sheet dismissal wire shape (#{path})",
"renders Mob.UI.sheet/2 but has no nativeSendDismiss, so dismissal " <>
"goes through nativeSendTap and delivers {:tap, tag}. Screens " <>
"written to the documented {:dismiss, tag} contract won't match " <>
"it — the screen process dies with FunctionClauseError, or the " <>
"dismissal is silently dropped and the sheet can't be re-presented",
"Port from a freshly generated app (mix mob.new) or mob_new's " <>
"templates: add `@JvmStatic external fun nativeSendDismiss(handle: " <>
"Int)` to object MobBridge, add the matching " <>
"Java_<pkg>_MobBridge_nativeSendDismiss thunk to " <>
"android/app/src/main/jni/beam_jni.c calling mob_send_dismiss, and " <>
"switch sendDismissOnce to MobBridge.nativeSendDismiss. Requires " <>
"mob >= 0.7.31, which exports mob_send_dismiss."}
]
else
[]
end

{:error, _} ->
[]
end
end)
end

@doc false
# Pure kernel: true when this MobBridge.kt renders sheets but predates the
# MOB-104 dismissal fix. Public for tests.
#
# Gated on the sheet renderer being present at all — an app generated before
# Mob.UI.sheet/2 existed has no MobSheet and nothing to be wrong about, so
# flagging it would be noise. `nativeSendDismiss` is matched as a bare
# substring rather than a declaration shape on purpose: the fix's declaration
# can be split across lines, and the MOB-98 kernel above already had to be
# rewritten once because a one-line String.contains? reported a correctly
# hand-ported fix as broken forever (mix mob.doctor only warns, and never
# re-checks itself).
@spec __sheet_dismiss_wire_shape_stale__(String.t()) :: boolean()
def __sheet_dismiss_wire_shape_stale__(content) do
renders_sheets = Regex.match?(Regex.compile!("fun\\s+MobSheet\\s*\\("), content)

renders_sheets and not String.contains?(content, "nativeSendDismiss")
end

# ── Plugin build options ──────────────────────────────────────────────────────
#
# When activated plugins contribute native code, the native build passes
Expand Down
73 changes: 73 additions & 0 deletions test/mix/tasks/mob_doctor_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,77 @@ defmodule Mix.Tasks.Mob.DoctorTest do
refute Mix.Tasks.Mob.Doctor.__component_event_jni_mismatched__(fixed)
end
end

describe "__sheet_dismiss_wire_shape_stale__/1 (MOB-104 dismissal wire shape)" do
test "flags a sheet renderer that still dismisses through nativeSendTap" do
pre_fix = """
object MobBridge {
@JvmStatic external fun nativeSendTap(handle: Int)
}

@Composable
private fun MobSheet(node: MobNode) {
fun sendDismissOnce() {
dismissHandle?.let { MobBridge.nativeSendTap(it) }
}
}
"""

assert Mix.Tasks.Mob.Doctor.__sheet_dismiss_wire_shape_stale__(pre_fix)
end

test "does not flag once nativeSendDismiss has been ported in" do
fixed = """
object MobBridge {
@JvmStatic external fun nativeSendTap(handle: Int)
@JvmStatic external fun nativeSendDismiss(handle: Int)
}

@Composable
private fun MobSheet(node: MobNode) {
fun sendDismissOnce() {
dismissHandle?.let { MobBridge.nativeSendDismiss(it) }
}
}
"""

refute Mix.Tasks.Mob.Doctor.__sheet_dismiss_wire_shape_stale__(fixed)
end

test "does not flag @JvmStatic split across lines (idiomatic Kotlin)" do
# Same trap the MOB-98 kernel fell into: doctor only warns and never
# re-checks, so a false positive tells a dev "still broken" forever.
fixed = """
object MobBridge {
@JvmStatic
external fun nativeSendDismiss(handle: Int)
}

private fun MobSheet(node: MobNode) {}
"""

refute Mix.Tasks.Mob.Doctor.__sheet_dismiss_wire_shape_stale__(fixed)
end

test "does not flag an app generated before Mob.UI.sheet/2 existed" do
# No MobSheet means no sheet support to be wrong about — warning here
# would be pure noise on every pre-0.4.24 project.
no_sheets = """
object MobBridge {
@JvmStatic external fun nativeSendTap(handle: Int)
}
"""

refute Mix.Tasks.Mob.Doctor.__sheet_dismiss_wire_shape_stale__(no_sheets)
end

test "tolerates whitespace variation in the MobSheet signature" do
pre_fix = """
object MobBridge { @JvmStatic external fun nativeSendTap(handle: Int) }
private fun MobSheet (node: MobNode) {}
"""

assert Mix.Tasks.Mob.Doctor.__sheet_dismiss_wire_shape_stale__(pre_fix)
end
end
end
Loading