Skip to content

fix: string-in, string-out result contract on both platforms, Android message ports - #13

Open
predatorx7 wants to merge 4 commits into
mainfrom
fix/issue-11-string-result-contract
Open

predatorx7 wants to merge 4 commits into
mainfrom
fix/issue-11-string-result-contract

Conversation

@predatorx7

@predatorx7 predatorx7 commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

Closes #11

Root cause

On Android the plugin hands evaluateJavaScriptAsync the user's script and forwards whatever string comes back. androidx.javascriptengine only returns the completion value when it is a JS string; every other type (numbers, booleans, objects, undefined) is coerced to "". The Dart side then ran a parseValue heuristic that guessed a type from the string content, so a JS string "42" became int 42 and '{"a":1}' became a Map, while a real JS number 42 arrived as "". A genuine empty string was indistinguishable from a coerced one.

Darwin (JavaScriptCore over FFI) had its own, different problems: NaN, Infinity, 1e21 and 2 ** 63 threw FormatException out of the typed converter; Promise results were JSON-encoded twice (Promise.resolve("hi") returned "\"hi\""); undefined, functions, symbols, bigints and circular objects silently became null; a rejected promise lost its reason ({}); and a plain-string channel message failed to decode.

So the two platforms disagreed with each other and with the README.

The contract (now identical on both platforms)

runJavaScriptReturningResult / runJavaScriptFromFileReturningResult:

  1. The value the script evaluates to (its completion value), or the value a returned Promise resolves to, is returned to Dart as a String exactly as-is. No JSON decoding, no number/bool parsing, no type detection. "" is "".
  2. If that value is not a JS string, the future resolves to '' on every platform. Callers who want structured data end their script with JSON.stringify(...) and jsonDecode it in Dart. The plugin deliberately does not parse the user's script to recover a typed last expression; scripts can be megabytes and that is the caller's job if they want it.
  3. Promises are awaited (Android needs a WebView with JS_FEATURE_PROMISE_RETURN; a warning is logged otherwise). A rejection, a thrown error or a parse failure throws a JavaScriptExecutionException carrying the JS message.
  4. Channels: sendMessage(name, string) is delivered verbatim to JavaScriptMessage.message; JavaScriptReply.message reaches JS verbatim; a null reply resolves the JS promise with null; a non-string message or an unregistered channel name throws in JS.
  5. The declared return type stays Future<Object?> (the public API does not change); it always resolves to a String and may be narrowed in a future major.

⚠️ Behavior change (deliberate, documented, minor bump)

Android no longer auto-JSON-decodes results. A JS string "42" is now the Dart String '42', not int 42; '{"a":1}' is the JSON text, not a Map. Darwin no longer returns typed numbers/bools/objects. Migration: jsonDecode(result as String) where you relied on auto-decoding; end scripts with JSON.stringify(...) where you relied on typed results. Called out at the top of the javascript_flutter 1.3.0 and javascript_android 2.2.0 changelogs and in the README.

Commits (in order)

  1. build: modernize toolchain, CI and the example app (tooling only, no behavior change). Plugin Gradle moved to Kotlin DSL on AGP 9.1 / Kotlin 2.4 / Java 17 / compileSdk 36 matching the Flutter 3.47 plugin template; minSdk declared as 26 (the engine already required it); pigeon 26 → 29 with @asyncCallback so the Kotlin surface is unchanged; example platform folders regenerated with flutter create (Gradle 9.3.1, SwiftPM) keeping lib/; the old Gradle 8.12 wrapper could not run on JDK 25. CI: the eight old workflows had paths: filters without the packages/ prefix (they never triggered) and referenced packages and fluttium flows that do not exist; replaced by one workflow that formats, analyzes and tests every package, builds the example APK, and runs the device integration tests on an Android emulator, an iOS simulator and macOS desktop. Dependabot now points at real directories. Requires Flutter ≥ 3.44 (flutter_tools applies the Kotlin Gradle plugin to plugins automatically from that version); the pubspec constraints say so.
  2. fix(darwin): the contract, every Darwin bug above, and the shared device test packages/javascript/example/integration_test/javascript_test.dart that pins the contract and must pass on all three targets. javascript_platform_interface 2.1.0 documents the contract. One small, commented patch to the vendored runtime: isError now comes from a real exception ref rather than result.startsWith('ERROR:'), so a script returning "ERROR: x" is no longer reported as a failure and throw "str" is.
  3. fix(android): results returned verbatim; androidx.javascriptengine 1.0.0 → 1.1.1 and channel messaging over a MessagePort (createMessageChannel / android.getNamedPort) when JS_FEATURE_MESSAGE_PORTS is supported, falling back to the console-messaging hack when JS_FEATURE_CONSOLE_MESSAGING is supported, then to timer polling. The fallback transports are kept intact and are exercised on-device by integration_test/android_transports_test.dart, which forces each transport via @visibleForTesting hooks and skips the ones a device lacks. Also fixed while there: start-up failures leaked the isolate/port/polling timer, message ids collided when a channel was re-added, and in-flight sends on a removed channel never settled.
  4. docs: README, dartdoc, example (JSON.stringify + jsonDecode pattern), changelogs, 15 unit tests pinning the JavaScript facade API, example widget test, cspell words.

Verification

Check Result
dart format --set-exit-if-changed, flutter analyze, flutter test in all 4 packages + example clean; 12 / 15 / 39 / 81 / 5 tests pass
javascript_darwin tests run the real JavaScriptCore on the macOS host 81 pass, pin every contract row by name
integration_test/javascript_test.dart on Android emulator (API 37, WebView 153) 78 pass
integration_test/android_transports_test.dart on the same emulator 21 pass (7 per transport); logcat shows Message port transport ...: enabled by default
integration_test/javascript_test.dart on iPhone 17 Pro simulator 78 pass
integration_test/javascript_test.dart on macOS desktop 78 pass
flutter build apk --debug, flutter build ios --simulator, flutter build macos, flutter run -d emulator of the example all succeed
cspell over **/*.md 0 issues

Not verified / caveats

  • First CI run: every job passed except example-ios, which hung for 6 hours after the Xcode build. A diagnostic run (ci: temporary iOS simulator diagnostics (not for merge) #14, throwaway) showed the app launches but a simulator booted only via simctl never surfaces the "Dart VM Service is listening" log line on the hosted macOS 26 / Xcode 26.6 runner, so flutter test -d waits forever; with Simulator.app opened on the booted device the same job passes all 78 tests. The workflow now opens Simulator.app after boot and every job has a timeout-minutes. Even with that, one run on an overloaded macOS fleet (GitHub annotated the sibling macOS job with "not acquired by Runner of type hosted") hung again while the identical code passed minutes earlier on ci: temporary iOS simulator diagnostics (not for merge) #14, so both Apple test steps run flutter test under a 10-minute watchdog and retry only on a hang (SIGALRM, exit 142); a real test failure still fails immediately. On the final green run the first simulator attempt hung and the retry passed, so expect the iOS job to take ~17 minutes when that happens and ~8 when it does not. The job also quits Simulator.app itself, otherwise the runner's post-job cleanup stalls for minutes.
  • The CI emulator (API 36 google_apis) ships a WebView without JS_FEATURE_MESSAGE_PORTS: the transport test skips its 7 message-port cases there (visible as "⏭️ Skipped tests" in the job log) and the contract suite runs over the console-messaging fallback, which is the intended degradation. I tried to get message ports covered in CI with API 37.0 google_apis and 37.1/37.2 google_apis_ps16k x86_64 images; all three time out booting on the hosted runner, so the message-port transport is verified only locally (API 37.1 emulator, WebView 153, 21/21 transport tests, logcat Message port transport ...: enabled).
  • The JS_FEATURE_PROMISE_RETURN-unsupported path and the Kotlin createMessageChannel exception fallback are covered by Dart fakes only; every available emulator supports both features.
  • Running both integration files in one flutter test integration_test -d macos invocation is flaky in the Flutter tool (second desktop app launch fails with "Error waiting for a debug connection"), so the Apple CI jobs run the contract file only; the transports file is Android-only anyway.
  • Pigeon regen and the vendored-file reformat in commit 1 are mechanical.

🤖 Generated with Claude Code

@predatorx7
predatorx7 force-pushed the fix/issue-11-string-result-contract branch 2 times, most recently from da7dfc1 to 8b67e7c Compare October 26, 2026 17:21
predatorx7 and others added 4 commits October 26, 2026 23:06
Tooling-only changes so the repository builds and tests on current
Flutter stable (3.47) with a current JDK. No behavior changes.

- javascript_android: convert android/build.gradle to Kotlin DSL on
  AGP 9.1.0 / Kotlin 2.4.0 / Java 17 / compileSdk 36, matching the
  Flutter 3.47 plugin template. minSdk is now declared as 26, which
  androidx.javascriptengine 1.0.0 already required. Drop the stale
  standalone settings.gradle (AGP 7.3 / Kotlin 1.7).
- javascript_android: bump pigeon ^26 -> ^29 and regenerate. The
  host API methods now use `@asyncCallback` so the generated Kotlin
  keeps the existing callback signatures instead of suspend functions.
  `make gen_schema` formats the generated Dart.
- example: regenerate the platform folders with `flutter create` on
  Flutter 3.47 (Gradle 9.3.1, AGP 9.1.0, Kotlin 2.4.0, SwiftPM), keeping
  lib/, README, pubspec and the path overrides. The previous Gradle 8.12
  wrapper could not run on JDK 25. Keep minSdk 26 and the macOS network
  client entitlement the example needs to load scripts from URLs. Remove
  the empty widget_test.dart which failed to load.
- CI: replace the eight per-package workflows, whose `paths:` filters
  lacked the `packages/` prefix and so never triggered, and which
  referenced javascript_ios/linux/macos/web/windows packages and
  fluttium flows that do not exist, with a single workflow that
  formats, analyzes and tests every package on current stable, builds
  the example for Android, and runs the example integration tests on
  an Android emulator, an iOS simulator and macOS desktop.
- dependabot: point at the real package directories and the two Gradle
  projects.
- javascript_darwin: exclude the vendored flutter_js sources from
  analysis; accept the analyzer excludes Flutter writes into
  analysis_options.yaml.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Define the one result contract for `runJavaScriptReturningResult` and
`runJavaScriptFromFileReturningResult`, document it in
`javascript_platform_interface` (2.1.0), implement it on Darwin
(2.1.0), and pin it with a device integration test that must pass on
Android, iOS and macOS.

Contract: the script's completion value, or the value a returned
Promise resolves to, is returned to Dart as a String verbatim. No JSON
decoding, no type detection. A non-string value resolves to the empty
string on every platform. A rejected promise or a thrown error
completes with a JavaScriptExecutionException carrying the JS message.
Callers who want structured data end their script with JSON.stringify
and jsonDecode it in Dart. The plugin deliberately does not parse the
script to recover a typed last expression.

Darwin fixes, each pinned by a real-JavaScriptCore test:
- NaN, Infinity, 1e21 and 2 ** 63 threw FormatException out of the
  typed converter; they now resolve to ''.
- Promise results were double-encoded (Promise.resolve("hi") gave
  "\"hi\""); the resolved JSValue is now inspected directly.
- undefined, functions, symbols, bigint and circular objects silently
  became null; they now resolve to ''.
- Promise rejection reasons were lost as "{}"; the Error message and
  stack, or String(reason), are now carried by the exception.
- A string result starting with "ERROR:" was reported as a failure and
  non-object throws were not; the vendored runtime now tracks whether an
  exception was actually raised.
- Channel messages reach onMessageReceived as the exact string passed to
  sendMessage, and sendMessage to an unregistered channel throws, which
  is what Android already did.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…gine 1.1.1

Fixes the Android half of #11 and migrates channel messaging to the
proper message-port API.

Results: `runJavaScriptReturningResult` and
`runJavaScriptFromFileReturningResult` now return the engine's string
untouched. The old `parseValue` heuristic, which guessed types from the
string content (a JS string "42" became int 42, '{"a":1}' became a
Map, while an actual JS number 42 arrived as "" because
androidx.javascriptengine coerces every non-string result to ""), is
removed. This is the documented behavior change of this release:
callers jsonDecode themselves. A warning is logged at isolate start if
the WebView lacks JS_FEATURE_PROMISE_RETURN, in which case promises
cannot be awaited by the engine and resolve to "".

Messaging: bump androidx.javascriptengine 1.0.0 -> 1.1.1 and, when
JS_FEATURE_MESSAGE_PORTS is supported, open one MessagePort per isolate
with createMessageChannel; JS obtains it with android.getNamedPort and
posts {channelName, id, message} as a string, replies travel back as
{channelName, id, ok, message | error}. Messages posted before the port
promise resolves are queued. Selection order is message port, then the
console-messaging hack (JS_FEATURE_CONSOLE_MESSAGING), then timer
polling, gated by isFeatureSupported, so older WebViews keep working.
Pigeon gains a JavaScriptAndroidMessagePortHandler proxy API and two
host methods; the public Dart API is unchanged apart from
@VisibleForTesting hooks to force a transport.

removeJavaScriptChannel now also deletes the channel on the JS side so
sendMessage on a removed channel throws, as it does on Darwin.

Tests: 35 Dart unit tests against a fake pigeon host (verbatim results,
transport selection, port/console/polling routing, SetTimeout,
exception mapping) and a device test that forces each of the three
transports in turn. Verified on an API 37 emulator (WebView 153), where
logcat confirms "Message port transport ...: enabled" by default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- javascript_flutter 1.3.0: rewrite the runJavaScriptReturningResult
  dartdoc and the README around the string-in, string-out contract,
  with the JSON.stringify + jsonDecode pattern as the way to get
  structured data back, and a loud behavior-change note with migration
  steps. Depend on javascript_platform_interface ^2.1.0,
  javascript_android ^2.2.0 and javascript_darwin ^2.1.0 so the fixed
  implementations are guaranteed.
- javascript_flutter tests: 15 unit tests pin the public JavaScript API
  against a mock platform (ids, pass-through of results, forwarding,
  dispose semantics, latching of unavailable-platform errors).
- example: README snippet and app updated to the contract; the console
  pretty-prints a result only when it parses as JSON, purely as a
  convenience in the example. Add a widget smoke test and model tests
  so `flutter test` finds tests.
- cspell: add the words the new docs use.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@predatorx7
predatorx7 force-pushed the fix/issue-11-string-result-contract branch from 8b67e7c to 08a3378 Compare October 26, 2026 17:36
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.

unJavaScriptReturningResult passes raw evaluateJavaScriptAsync result to Flutter without empty-string validation

1 participant