Repository navigation
fix: string-in, string-out result contract on both platforms, Android message ports - #13
Open
predatorx7 wants to merge 4 commits into
Open
predatorx7 wants to merge 4 commits into
predatorx7 wants to merge 4 commits into
Conversation
predatorx7
force-pushed
the
fix/issue-11-string-result-contract
branch
2 times, most recently
from
October 26, 2026 17:21
da7dfc1 to
8b67e7c
Compare
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
force-pushed
the
fix/issue-11-string-result-contract
branch
from
October 26, 2026 17:36
8b67e7c to
08a3378
Compare
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.
Closes #11
Root cause
On Android the plugin hands
evaluateJavaScriptAsyncthe user's script and forwards whatever string comes back.androidx.javascriptengineonly 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 aparseValueheuristic that guessed a type from the string content, so a JS string"42"becameint 42and'{"a":1}'became aMap, while a real JS number42arrived as"". A genuine empty string was indistinguishable from a coerced one.Darwin (JavaScriptCore over FFI) had its own, different problems:
NaN,Infinity,1e21and2 ** 63threwFormatExceptionout of the typed converter; Promise results were JSON-encoded twice (Promise.resolve("hi")returned"\"hi\"");undefined, functions, symbols, bigints and circular objects silently becamenull; 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:Promiseresolves to, is returned to Dart as aStringexactly as-is. No JSON decoding, no number/bool parsing, no type detection.""is"".''on every platform. Callers who want structured data end their script withJSON.stringify(...)andjsonDecodeit 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.JS_FEATURE_PROMISE_RETURN; a warning is logged otherwise). A rejection, a thrown error or a parse failure throws aJavaScriptExecutionExceptioncarrying the JS message.sendMessage(name, string)is delivered verbatim toJavaScriptMessage.message;JavaScriptReply.messagereaches JS verbatim; anullreply resolves the JS promise withnull; a non-string message or an unregistered channel name throws in JS.Future<Object?>(the public API does not change); it always resolves to aStringand may be narrowed in a future major.Android no longer auto-JSON-decodes results. A JS string
"42"is now the DartString'42', notint 42;'{"a":1}'is the JSON text, not aMap. Darwin no longer returns typed numbers/bools/objects. Migration:jsonDecode(result as String)where you relied on auto-decoding; end scripts withJSON.stringify(...)where you relied on typed results. Called out at the top of thejavascript_flutter1.3.0 andjavascript_android2.2.0 changelogs and in the README.Commits (in order)
@asyncCallbackso the Kotlin surface is unchanged; example platform folders regenerated withflutter create(Gradle 9.3.1, SwiftPM) keepinglib/; the old Gradle 8.12 wrapper could not run on JDK 25. CI: the eight old workflows hadpaths:filters without thepackages/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.packages/javascript/example/integration_test/javascript_test.dartthat pins the contract and must pass on all three targets.javascript_platform_interface2.1.0 documents the contract. One small, commented patch to the vendored runtime:isErrornow comes from a real exception ref rather thanresult.startsWith('ERROR:'), so a script returning"ERROR: x"is no longer reported as a failure andthrow "str"is.androidx.javascriptengine1.0.0 → 1.1.1 and channel messaging over aMessagePort(createMessageChannel/android.getNamedPort) whenJS_FEATURE_MESSAGE_PORTSis supported, falling back to the console-messaging hack whenJS_FEATURE_CONSOLE_MESSAGINGis supported, then to timer polling. The fallback transports are kept intact and are exercised on-device byintegration_test/android_transports_test.dart, which forces each transport via@visibleForTestinghooks 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.JavaScriptfacade API, example widget test, cspell words.Verification
dart format --set-exit-if-changed,flutter analyze,flutter testin all 4 packages + examplejavascript_darwintests run the real JavaScriptCore on the macOS hostintegration_test/javascript_test.darton Android emulator (API 37, WebView 153)integration_test/android_transports_test.darton the same emulatorMessage port transport ...: enabledby defaultintegration_test/javascript_test.darton iPhone 17 Pro simulatorintegration_test/javascript_test.darton macOS desktopflutter build apk --debug,flutter build ios --simulator,flutter build macos,flutter run -d emulatorof the example**/*.mdNot verified / caveats
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 viasimctlnever surfaces the "Dart VM Service is listening" log line on the hosted macOS 26 / Xcode 26.6 runner, soflutter test -dwaits 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 atimeout-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 runflutter testunder 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.google_apis) ships a WebView withoutJS_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.0google_apisand 37.1/37.2google_apis_ps16kx86_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, logcatMessage port transport ...: enabled).JS_FEATURE_PROMISE_RETURN-unsupported path and the KotlincreateMessageChannelexception fallback are covered by Dart fakes only; every available emulator supports both features.flutter test integration_test -d macosinvocation 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.🤖 Generated with Claude Code