diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a3a4dc..e87c62b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/mix/tasks/mob.doctor.ex b/lib/mix/tasks/mob.doctor.ex index a560d43..590a69f 100644 --- a/lib/mix/tasks/mob.doctor.ex +++ b/lib/mix/tasks/mob.doctor.ex @@ -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 [] @@ -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__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 diff --git a/test/mix/tasks/mob_doctor_test.exs b/test/mix/tasks/mob_doctor_test.exs index 6b44fde..2594b31 100644 --- a/test/mix/tasks/mob_doctor_test.exs +++ b/test/mix/tasks/mob_doctor_test.exs @@ -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