Skip to content

Let Fuse views keep their @State and @Environment private - #282

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

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

Conversation

@Ceylo

@Ceylo Ceylo commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Paired with skip-fuse-ui: skiptools/skip-fuse-ui#151 (branch Ceylo/skip-fuse-ui:feature/private-state-bridging). That one merges first; this one needs its runtime entry points. A follow-up there then uncomments its private-state fixture and test, which need a skipstone release carrying this PR (its diff).

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 a Fuse view with a private or fileprivate @State, @FocusState, @GestureState, @AppStorage or @Environment property fails to transpile with kotlinBridgeStatePrivate ("Private state property 'x' cannot be bridged to Android. Consider making this property internal"), because the generated bridge file syncs each property by name and cannot see a private one. With this PR such a view builds: its Evaluate asks SkipSwiftUI for the view's dynamic properties, found at runtime by field reflection (the paired skip-fuse-ui PR), and syncs each by index instead of by name.

Before / after

before after
@State private var count = 0 → error: Private state property 'count' cannot be bridged to Android builds; Evaluate loops over Swift_dynamicPropertyKinds(Swift_peer) and syncs each property through Swift_initDynamicState/Swift_syncDynamicState (or the AppStorage/Environment equivalents) by index

The choice is made per view, in addSwiftUIImplementation:

  • No private state or environment property: today's per-name code, unchanged. All 142 existing BridgeToKotlinTests expectations are untouched and pass.
  • Any private/fileprivate one: the whole view's Evaluate switches to the index-based loop. It gets seven fixed-name external functions: the kinds query, plus six index-taking init/sync functions forwarding to SkipSwiftUI's Java_*Dynamic* entry points. A generic view's type-erased peer is then given no per-name closures, since nothing refers to them.

kotlinBridgeStatePrivate is removed, since nothing emits it any more.

Side effects

  • Existing views: none. The output for a view without private state is byte-identical, so saved-state keys, the number of JNI calls and generated symbols are unchanged. The shared Evaluate scaffolding was factored into one helper, and the unchanged expectations show it emits the same text.
  • Making one property private changes the view's generated code. Its rememberSaveable calls move inside key(i) in a loop, so saved state keyed under the old per-name layout is not carried over across that one source change.
  • Needs the paired skip-fuse-ui. The generated Swift calls Java_dynamicPropertyKinds and six sibling functions. Those exist only in skip-fuse-ui with the paired PR, so this skipstone must ship with (or after) it. A project with a newer skipstone and an older skip-fuse-ui gets a "cannot find 'Java_dynamicPropertyKinds'" build error in generated code, where it used to get the kotlinBridgeStatePrivate message. That can only happen to a view that previously failed to build anyway.

Known limits

  • Private types are still out of reach. A private struct view or ViewModifier gets no bridge at all. This 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.
  • The path is chosen at transpile time from the attributes skipstone 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

  • swift build succeeds.

  • swift test --parallel: 949 tests, 948 pass. The one failure, SkipRunnerTests/testSnippets, compares "1 KB" against a French-locale "1 ko" from ByteCountFormatter. It fails the same way on unmodified main on this machine and is unrelated.

  • BridgeToKotlinTests: 148 pass, the 142 existing ones with unchanged expectations plus 6 new ones:

    • testPrivateState
    • testFileprivateEnvironment
    • testMixedInternalAndPrivateState (both properties end up on the dynamic path)
    • testGenericPrivateState (the type-erased peer gets no per-name closures)
    • testPrivateStateViewModifier
    • testPrivateAppStorageFocusStateAndGestureState

    Each expectation was filled from the transpiler's actual output, then read by hand.

  • Mutation. With the private-property check forced off, testPrivateAppStorageFocusStateAndGestureState fails, so the new tests do exercise the switch.

  • Cross-repo, host bridge. skip-fuse-ui's JVM-hosted FuseComposeUITests.testPrivateCounterIncrements (committed commented out in Brdige NSNumber and fix some newly-discovered bridging bugs #151 until this ships, uncommented for this run) exercised this generator's output end to end, built with this branch as SKIPLOCAL: it passed, and it failed on its own assertion when the State conformance was removed. Caveat: since skip-fuse-ui Make all @_cdecl functions public in order to prevent removal in release builds #134, those Compose tests skip on a macOS host unless run against a device. That run re-enabled the host main-actor hook locally, and the change is not part of either PR.

  • 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) of the paired skip-fuse-ui PR's tests, built with this branch as SKIPLOCAL, 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. This CI's Fuse skip init … --bridged emulator job exercises the bridged output on a device.


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 (not applicable: the generated bridge code only compiles for Android)
  • 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: swift build, swift test --parallel (with BridgeToKotlinTests expectations read against the transpiler's output), the mutation run above, the paired skip-fuse-ui PR's Robolectric tests built with this branch as SKIPLOCAL, and the FurAffinityApp build and emulator runs.

Ceylo and others added 5 commits September 27, 2026 13:11
A private or fileprivate @State/@FocusState/@GestureState/@AppStorage/
@Environment property has no name the generated bridge file can reach,
so it errored with kotlinBridgeStatePrivate. SkipSwiftUI can now find a
view's dynamic properties at runtime instead of by name (companion
skip-fuse-ui change), so this switches to that path per view, only when
it has such a property.

addSwiftUIImplementation now emits one of two Evaluate bodies:
unaffected views keep today's per-name rememberSaveable/environment
calls, byte-for-byte, so every existing BridgeToKotlinTests expectation
is untouched; a view with any private/fileprivate property switches its
whole Evaluate to a generic loop over Swift_dynamicPropertyKinds,
syncing each property by index through six new fixed-name external
functions instead of per-name ones. A generic view's type-erased peer
is given no per-name closures in that case, since nothing needs to
close over a name it cannot see.

Adds five tests: a private-state view, a fileprivate-environment view,
one mixing an internal and a private property (both end up bridged
dynamically), a generic private-state view (confirming its type-erased
peer gets no per-name closures), and a private-state ViewModifier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
swiftUIEvaluate and swiftUIEvaluateDynamic each built the same override
declaration: parameters, return type, @composable, and the trailing
super.Evaluate call. Both now pass their body lines to one helper.
Generated output is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The existing tests switch views to the dynamic path through @State and
@Environment only. This one uses the other three recognised attributes,
two private and one fileprivate, and expects the same dynamic Evaluate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The generated Kotlin switches on codes that SkipSwiftUI's
Java_dynamicPropertyKinds produces; say so next to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A private property implies a non-empty list, so the dynamic branch can
come first and the per-name one follow as `else if`, leaving its lines
as they were. The generic peer target is spelled through
peerSwiftTarget, like the other genericvalue reads. Generated output is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cla-bot cla-bot Bot added the cla-signed label Sep 27, 2026
@Ceylo Ceylo changed the title Bridge private SwiftUI state and environment properties Let Fuse views keep their @State and @Environment private Sep 27, 2026
@Ceylo
Ceylo marked this pull request as ready for review September 27, 2026 22:01
@Ceylo Ceylo closed this Sep 28, 2026
@Ceylo Ceylo reopened this Sep 28, 2026
@Ceylo
Ceylo marked this pull request as draft September 28, 2026 00:30
@Ceylo Ceylo mentioned this pull request Sep 28, 2026
5 tasks done
@Ceylo
Ceylo marked this pull request as ready for review September 28, 2026 19:30
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