Skip to content

Let Fuse views keep their @State and @Environment private (runtime half) - #151

Open
Ceylo wants to merge 5 commits into
skiptools:mainfrom
Ceylo:feature/private-state-bridging
Open

Ceylo wants to merge 5 commits into
skiptools:mainfrom
Ceylo:feature/private-state-bridging

Conversation

@Ceylo

@Ceylo Ceylo commented Sep 27, 2026 •

Copy link
Copy Markdown

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 uncomments PrivateCounterTestFixture and testPrivateCounterIncrements once 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, @AppStorage and @Environment properties private, 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 a private or fileprivate @State, @FocusState, @GestureState, @AppStorage or @Environment property ("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.swift finds a view's wrappers by field reflection and exposes them by index to generated code (Java_dynamicPropertyKinds plus six Java_*Dynamic* entry points).

Before / after

The new PrivateCounterTestFixture is CounterTestFixture with its @State and an @Environment read made private. It is exercised by testPrivateCounterIncrements, which mirrors testCounterIncrements. Both are committed commented out, since the released skipstone cannot transpile the fixture; the table and the runs below are with them uncommented.

before (released skipstone) after (with the skipstone PR)
Fixtures.swift:85:1: error: Private state property 'count' cannot be bridged to Android. Consider making this property internal (and the same for layoutDirection) builds; testPrivateCounterIncrements passes

Side effects

  • iOS / macOS apps: no behaviour change. Everything that reflects is inside #if SKIP_BRIDGE. Apple-platform app builds get only the three protocols and their conformances. An iOS-simulator build of SkipSwiftUI has no swift_reflectionMirror_* or _forEachField reference (nm -u).
  • Reflection, per platform.
    • On Android it goes through the stdlib's own @_spi(Reflection) _forEachField.
    • The macOS host bridge build (-DSKIP_BRIDGE -DROBOLECTRIC, Robolectric tests only) links an Apple SDK whose stdlib interface strips that SPI. There, it calls the same runtime entry points Mirror uses, through @_silgen_name. Those references exist only in that test build, never in an app binary.
  • Existing views are unaffected. The paired skipstone change emits the dynamic path only for views that have a private property. Every other view keeps today's per-name generated code, byte for byte, so its saved-state keys and performance are unchanged. testCounterIncrements still passes.
  • Green CI on its own. Nothing here needs the skipstone PR to build: the runtime only adds protocols, conformances and entry points that nothing calls until the new skipstone generates calls to them. The fixture and test that do need it stay commented out until the follow-up.

Known limits

  • Private types are still out of reach. A private struct view or ViewModifier gets no bridge at all. This PR covers private properties of a type the bridge can name.
  • There is no recursion into custom DynamicProperty structs. A State nested inside a user-defined DynamicProperty is not discovered. That's a candidate for a follow-up.
  • skipstone decides at transpile time which path a view takes, from the attributes it already recognises (@State, @FocusState, @GestureState, @AppStorage, @Environment). A view switches to runtime discovery only when one of those attributes, written literally, marks a private/fileprivate property. Once a view is on that path, every conforming wrapper in it is found by type.

Verified

  • Robolectric, JVM-hosted FuseComposeUITests, run against the paired skipstone branch (SKIPLOCAL). testPrivateCounterIncrements and testCounterIncrements pass. 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 own testAnimationLifetime* tests fail the same way with or without this PR.
  • Mutation. With the State : BridgedStateProperty conformance removed, testPrivateCounterIncrements fails on its own assertion, Failed to assert the following: (Text + EditableText = [count: 1 dir: ltr]). The initial count: 0 assertion still passes and testCounterIncrements stays green. With the conformance restored, it passes again.
  • Before. With the released skipstone, swift build fails on the uncommented fixture with Private state property 'count'/'layoutDirection' cannot be bridged to Android. As committed, with the fixture and test commented out, the same swift build succeeds.
  • Builds. Darwin swift build (against the paired skipstone) succeeds. The Android _forEachField path type-checks for aarch64-unknown-linux-android28 (Swift 6.3.3 Android SDK, Swift 6 language mode). An iOS-simulator xcodebuild build -scheme SkipSwiftUI succeeds, with the nm -u result above.
  • Android emulator, in a real app. Also built into the Android port of FurAffinityApp (Skip Fuse; this patch on a local branch) and run on an API 37 emulator: 32 views with private state 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.
  • FuseComposeUITests on a device: blocked. An attempt against the same API 37 (Android 17) emulator (ANDROID_SERIAL) could not run any FuseComposeUITests case, the existing testCounterIncrements included: each fails before composing with Espresso's NoSuchMethodException: android.hardware.input.InputManager.getInstance, a method API 37 no longer has.

Skip Pull Request Checklist:

  • REQUIRED: I have signed the Contributor Agreement
  • REQUIRED: I have tested my change locally with swift test
  • OPTIONAL: I have tested my change on an iOS simulator or device
  • OPTIONAL: I have tested my change on an Android emulator or device

  • AI was used to generate or assist with generating this PR. Please specify below how you used AI to help you, and what steps you have taken to manually verify the changes.

Written with Claude Code: the patch and its tests. Every claim above comes from a run: the Robolectric FuseComposeUITests against the paired skipstone branch, with the mutation run above, the swift build that fails with the released skipstone, the Darwin, Android-SDK and iOS-simulator builds with the nm -u check, the emulator attempt, and the FurAffinityApp build and emulator runs.

Ceylo and others added 4 commits September 27, 2026 12:53
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>
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 Ceylo changed the title Bridge private SwiftUI state and environment properties Let Fuse views keep their @State and @Environment private (runtime half) Sep 27, 2026
@Ceylo
Ceylo marked this pull request as ready for review September 27, 2026 22:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant