Skip to content

MOB-98: fix native component events arriving as charlists, not binaries - #83

Merged
GenericJam merged 2 commits into
masterfrom
fix/mob-98-native-component-events
Aug 26, 2026
Merged

MOB-98: fix native component events arriving as charlists, not binaries#83
GenericJam merged 2 commits into
masterfrom
fix/mob-98-native-component-events

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

Summary

  • android/jni/mob_nif.zig and ios/mob_nif.m's mob_send_component_event built 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 native bridges to emit UTF-8 binaries (Android reuses cstrToBin/3; iOS allocates an ErlNifBinary + enif_make_binary).
  • Mob.ComponentServer now also normalizes event/payload_json at 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.
  • Malformed JSON now falls back to %{} instead of crashing the component (previously only true for valid-but-non-map JSON).

Test plan

  • mix test — 1026 passed, new test/mob/component_server_test.exs (11 tests): binary + legacy charlist event/payload, malformed/non-map JSON fallback, component stays alive
  • mix format / mix credo --strict / mix erlfmt --check src/ / clang-format / swiftlint — all clean
  • Device-verified end-to-end on Android emulator + iOS simulator: a real tier-2 native component (Compose Button / SwiftUI Button) fires a tagged event; traced via :erlang.trace to confirm {:component_event, "tapped", "{}"} arrives as binaries; handle_event/3 fires; component + screen stay alive; screen state visibly updates

Linear: MOB-98 (companion PRs: mob_new fix/mob-98-native-component-events, mob_dev fix/mob-98-native-component-events)

🤖 Generated with Claude Code

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
GenericJam merged commit a98f5ff into master Aug 26, 2026
4 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant