MOB-98: fix native component events arriving as charlists, not binaries - #83
Merged
Merged
Conversation
Both native bridges (android/jni/mob_nif.zig, ios/mob_nif.m) built
mob_send_component_event's event/payload_json via enif_make_string,
producing charlists. Mob.ComponentServer decodes payload_json with
:json.decode/1, which requires a binary — the component process
crashed before handle_event/3 ever ran.
Fixed both bridges to emit UTF-8 binaries: Android reuses the
existing cstrToBin/3 helper, iOS allocates an ErlNifBinary and copies
the bytes. Mob.ComponentServer also now normalizes event/payload_json
at the boundary (binary passthrough, charlist -> binary) so a
hot-deployed newer BEAM doesn't crash against an older native shell
still emitting charlists — the native contract is the real fix, this
is compatibility only. Malformed or non-map JSON keeps falling back
to %{} instead of crashing the component (previously true only for
valid-but-non-map JSON; a genuinely malformed payload raised).
Device-verified end-to-end on both platforms: a real tier-2 native
component (Compose Button / SwiftUI Button) with a tagged event,
traced via :erlang.trace to confirm {:component_event, "tapped", "{}"}
arrives as binaries, handle_event/3 fires, and the component/screen
stay alive.
From code review on PR #83: - Mob.ComponentServer.to_binary/1 raised FunctionClauseError for any input that wasn't a binary or list, with no rescue in the calling handle_info clause — a malformed event/payload shape from native code crashed the component process. Added a catch-all fallback (logs + returns "") and wrapped the list branch's IO.iodata_to_binary call (which itself can raise ArgumentError on malformed content). - Switched List.to_string/1 to IO.iodata_to_binary/1 for the legacy charlist conversion: ERL_NIF_LATIN1 maps codepoint N to byte N, the same as raw-byte iodata — List.to_string/1 would UTF-8-encode any byte > 127 into two bytes, corrupting non-ASCII legacy payloads instead of reproducing them. - ios/mob_nif.m: mob_send_component_event ignored enif_alloc_binary's return value (1=success/0=failure) and computed strlen twice per buffer. Now checks both allocations, releases the first binary if the second fails, and computes each length once. - android/jni/mob_nif.zig: cstrToBin (pre-existing, reused by this PR's new call sites) had the same discarded-return pattern. Falls back to the :nil sentinel this file's other call sites already use for "absent" on allocation failure. - test/mob/component_server_test.exs: setup raced Mob.ComponentRegistry (a fixed-name GenServer) against component_test.exs under async: true — start_supervised! raises on {:already_started, _}. Now tolerates either order. Added tests for the crash-safety fallback and the byte-preservation fix. Device-verified: real native builds (Android emulator + iOS simulator) compile and boot cleanly with these changes.
GenericJam
added a commit
that referenced
this pull request
Aug 26, 2026
Ships MOB-98 (native component events arriving as charlists, not binaries) and MOB-99 (iOS accessibility-tree hit-testing race in Mob.Test.tap_id/2 and friends), both merged from PR #83 and #84 with their review-fix follow-ups. Also fixes an intermittent test flake in component_test.exs surfaced by this release's preflight — the other half of a Mob.ComponentRegistry named-GenServer race MOB-98 already covered on the component_server_test.exs side. See CHANGELOG.md for the full breakdown.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
android/jni/mob_nif.zigandios/mob_nif.m'smob_send_component_eventbuiltevent/payload_jsonviaenif_make_string, producing charlists.Mob.ComponentServerdecodespayload_jsonwith:json.decode/1, which requires a binary — the component process crashed beforehandle_event/3ever ran.cstrToBin/3; iOS allocates anErlNifBinary+enif_make_binary).Mob.ComponentServernow also normalizesevent/payload_jsonat the boundary (binary passthrough, charlist → binary) — compatibility only, for a hot-deployed newer BEAM landing on an older native shell. The native contract itself is the real fix.%{}instead of crashing the component (previously only true for valid-but-non-map JSON).Test plan
mix test— 1026 passed, newtest/mob/component_server_test.exs(11 tests): binary + legacy charlist event/payload, malformed/non-map JSON fallback, component stays alivemix format/mix credo --strict/mix erlfmt --check src//clang-format/swiftlint— all clean:erlang.traceto confirm{:component_event, "tapped", "{}"}arrives as binaries;handle_event/3fires; component + screen stay alive; screen state visibly updatesLinear: MOB-98 (companion PRs: mob_new fix/mob-98-native-component-events, mob_dev fix/mob-98-native-component-events)
🤖 Generated with Claude Code