From 75d878949714f779ebc35c4c08e158a34e273743 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 27 Aug 2026 09:29:17 -0600 Subject: [PATCH 1/2] MOB-104: mix mob.doctor detects the stale sheet-dismissal wire shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decisions/2026-08-25-detect-dont-autopatch-native-source.md:39 says any future "template fixed, existing apps still broken" situation should follow the same shape as the MOB-98 check: a pure detection kernel plus a mix mob.doctor warning, because MobBridge.kt is app-owned and never re-rendered. MOB-104 is exactly that situation. An app carrying the pre-fix sheet renderer routes dismissal through MobBridge.nativeSendTap and delivers {:tap, tag}. Mob.UI.sheet/2 documents :on_dismiss as {:dismiss, tag} and iOS has always sent that, so a screen written to the contract never matches: Mob.Screen forwards the unmatched message to handle_info, raising FunctionClauseError and killing the screen — or it is silently dropped by a catch-all, and the BEAM never learns the sheet closed so it can't be re-presented. Harder to diagnose than MOB-98, which at least failed loudly with UnsatisfiedLinkError. Gated on the sheet renderer being present (`fun MobSheet(`), so an app generated before Mob.UI.sheet/2 existed is not warned about — it has no sheet support to be wrong about. Worth noting the blast radius is narrower than it first looks: no PUBLISHED mob_new ships MobSheet yet (it is unreleased on master), so this can only affect apps generated from mob_new master — dev boxes and agent-generated test apps — not anyone installing from Hex. The check earns its place going forward, once 0.4.24 puts the renderer in real projects. Detection deliberately matches `nativeSendDismiss` as a bare substring rather than a declaration shape: the MOB-98 kernel already had to be rewritten once because a one-line String.contains? reported a correctly hand-ported fix as broken forever (doctor only warns and never re-checks itself), and the same trap is covered here by a split-annotation test. Verified against real template output, not just fixtures: origin/master's MobBridge.kt.eex flags true, the MOB-104 branch's flags false, and a 0.4.23-generated app's rendered MobBridge.kt flags false. 13 doctor tests, format and credo --strict clean. Co-Authored-By: Claude Opus 5 (1M context) --- lib/mix/tasks/mob.doctor.ex | 71 ++++++++++++++++++++++++++++- test/mix/tasks/mob_doctor_test.exs | 73 ++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) 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 From 39ec9397fa95c83a6c1441c09437c6b174f83381 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 27 Aug 2026 09:55:32 -0600 Subject: [PATCH 2/2] MOB-104: document the doctor check under [Unreleased] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check would otherwise land on master undocumented and ship in whatever release comes next with no note — the same changelog/release seam that left mob 0.7.30 with empty GitHub release notes. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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