Conversation
BridgedStateProperty, BridgedAppStorageProperty and BridgedEnvironmentProperty expose the Compose sync entry points on State, FocusState, GestureState, AppStorage and Environment themselves, so generated bridge code can reach a wrapper it cannot name, such as a private view property. Unguarded, like the rest of this public Java_* bridging API: on a Darwin build this is three protocols and their conformances, with no behaviour. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Field reflection lists, once per view type, the fields that are bridged state, app storage or environment wrappers. Generated bridge code then syncs each by index through the Java_*Dynamic* entry points instead of naming it, so a private property can be bridged. Gated behind SKIP_BRIDGE, per platform: the Android cross-compile uses the stdlib's own @_spi(Reflection) _forEachField; the host bridge build (Robolectric tests, -DSKIP_BRIDGE -DROBOLECTRIC) uses the same @_silgen_name shim Mirror itself is built on, since that SPI is stripped from the Apple SDKs it links against. A plain Darwin build carries none of this file. Out of scope for now: recursing into a custom DynamicProperty struct that itself stores state/environment wrappers — only the view's own fields are walked. Adds PrivateCounterTestFixture and testPrivateCounterIncrements, mirroring CounterTestFixture/testCounterIncrements with a private @State and a private @Environment read. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`_forEachField` visits nothing when `.classType` does not match whether the type is a class, so a class view's state and environment were never found on Android, while the host bridge build's reflection path listed them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`pointer.load(as:)` opens the stored existential metatype itself, so the three per-protocol `load` helpers are unnecessary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 of 5 tasks
The released skipstone rejects a private @State, so the fixture and its test would keep this repository's CI red until the paired skipstone change ships. A follow-up uncomments them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ceylo
marked this pull request as ready for review
September 27, 2026 22:01
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.
Paired with skipstone: skiptools/skipstone#282 (branch
Ceylo/skipstone:feature/private-state-bridging). Merge order: this PR first (runtime only, green on its own), then skiptools/skipstone#282, then a small follow-up here that uncommentsPrivateCounterTestFixtureandtestPrivateCounterIncrementsonce a skipstone release carries #282 (its diff, on top of this branch).What
With this pair, a Fuse view can keep its
@State,@FocusState,@GestureState,@AppStorageand@Environmentpropertiesprivate, as SwiftUI code normally does, instead of widening them to internal just so the bridge can reach them. Today, skipstone refuses to bridge a view with aprivateorfileprivate@State,@FocusState,@GestureState,@AppStorageor@Environmentproperty ("Private state property 'x' cannot be bridged to Android. Consider making this property internal"). That's because the generated bridge file has to name each property, and it can't see a private one. This PR adds the runtime half of the fix. Each of those wrappers conforms to a small protocol (BridgedStateProperty,BridgedAppStorageProperty,BridgedEnvironmentProperty) that syncs Compose storage through the wrapper itself.DynamicPropertyDiscovery.swiftfinds a view's wrappers by field reflection and exposes them by index to generated code (Java_dynamicPropertyKindsplus sixJava_*Dynamic*entry points).Before / after
The new
PrivateCounterTestFixtureisCounterTestFixturewith its@Stateand an@Environmentread madeprivate. It is exercised bytestPrivateCounterIncrements, which mirrorstestCounterIncrements. Both are committed commented out, since the released skipstone cannot transpile the fixture; the table and the runs below are with them uncommented.Fixtures.swift:85:1: error: Private state property 'count' cannot be bridged to Android. Consider making this property internal(and the same forlayoutDirection)testPrivateCounterIncrementspassesSide effects
#if SKIP_BRIDGE. Apple-platform app builds get only the three protocols and their conformances. An iOS-simulator build ofSkipSwiftUIhas noswift_reflectionMirror_*or_forEachFieldreference (nm -u).@_spi(Reflection) _forEachField.-DSKIP_BRIDGE -DROBOLECTRIC, Robolectric tests only) links an Apple SDK whose stdlib interface strips that SPI. There, it calls the same runtime entry pointsMirroruses, through@_silgen_name. Those references exist only in that test build, never in an app binary.testCounterIncrementsstill passes.Known limits
private structview orViewModifiergets no bridge at all. This PR covers private properties of a type the bridge can name.DynamicPropertystructs. AStatenested inside a user-definedDynamicPropertyis not discovered. That's a candidate for a follow-up.@State,@FocusState,@GestureState,@AppStorage,@Environment). A view switches to runtime discovery only when one of those attributes, written literally, marks aprivate/fileprivateproperty. Once a view is on that path, every conforming wrapper in it is found by type.Verified
FuseComposeUITests, run against the paired skipstone branch (SKIPLOCAL).testPrivateCounterIncrementsandtestCounterIncrementspass. Caveat: since Animation Performance Fix (Android) #134 these Compose tests skip on a macOS host unless run against a device (ANDROID_SERIAL); this run re-enabled the host main-actor hook locally, a change that is not part of this PR. Under that hook, Animation Performance Fix (Android) #134's owntestAnimationLifetime*tests fail the same way with or without this PR.State : BridgedStatePropertyconformance removed,testPrivateCounterIncrementsfails on its own assertion,Failed to assert the following: (Text + EditableText = [count: 1 dir: ltr]). The initialcount: 0assertion still passes andtestCounterIncrementsstays green. With the conformance restored, it passes again.swift buildfails on the uncommented fixture withPrivate state property 'count'/'layoutDirection' cannot be bridged to Android. As committed, with the fixture and test commented out, the sameswift buildsucceeds.swift build(against the paired skipstone) succeeds. The Android_forEachFieldpath type-checks foraarch64-unknown-linux-android28(Swift 6.3.3 Android SDK, Swift 6 language mode). An iOS-simulatorxcodebuild build -scheme SkipSwiftUIsucceeds, with thenm -uresult above.privatestate or environment properties took the new path. Their state interactions (toggles persisting across a restart, text-field focus, sheets, a deep-link highlight) behaved as before, and frame times over a scripted scroll stayed within stock skipstone's run-to-run spread, with the dynamic path at ≤ 0.035 % CPU.FuseComposeUITestson a device: blocked. An attempt against the same API 37 (Android 17) emulator (ANDROID_SERIAL) could not run anyFuseComposeUITestscase, the existingtestCounterIncrementsincluded: each fails before composing with Espresso'sNoSuchMethodException: android.hardware.input.InputManager.getInstance, a method API 37 no longer has.Skip Pull Request Checklist:
swift testWritten with Claude Code: the patch and its tests. Every claim above comes from a run: the Robolectric
FuseComposeUITestsagainst the paired skipstone branch, with the mutation run above, theswift buildthat fails with the released skipstone, the Darwin, Android-SDK and iOS-simulator builds with thenm -ucheck, the emulator attempt, and the FurAffinityApp build and emulator runs.