From 4d311fd68f273af1236ba8942fc727561fbab059 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Fri, 12 Jun 2026 13:28:40 -0400 Subject: [PATCH 1/2] Add animation provenance hooks for bridged observable property accesses --- Sources/SkipAndroidBridge/Observation.swift | 50 +++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/Sources/SkipAndroidBridge/Observation.swift b/Sources/SkipAndroidBridge/Observation.swift index d7314b2..9a13bb8 100644 --- a/Sources/SkipAndroidBridge/Observation.swift +++ b/Sources/SkipAndroidBridge/Observation.swift @@ -61,20 +61,70 @@ public struct Observation { } } +/// Hooks for an upper layer (SkipFuseUI) to thread per-slot animation provenance through +/// bridged observable property accesses: `willSet` stamps the property's slot with the +/// current scope's token and `access` reports a stamped slot's token back. Both default to +/// nil so the bookkeeping is skipped entirely unless a UI layer installs them. +public enum BridgedObservationProvenance { + /// Returns the provenance token of the innermost active animation scope, or nil. + nonisolated(unsafe) public static var currentToken: (() -> Any?)? + /// Records that a property whose last write was stamped with `token` was just read. + nonisolated(unsafe) public static var recordRead: ((Any) -> Void)? +} + private final class BridgeObservationSupport: @unchecked Sendable { init() { } public func access(_ subject: Subject, keyPath: KeyPath) { let index = Java_init(forKeyPath: keyPath) + recordProvenanceRead(at: index) Java_access(index) } public func willSet(_ subject: Subject, keyPath: KeyPath) { let index = Java_init(forKeyPath: keyPath) + stampProvenance(at: index) Java_update(index) } + // Per-keyPath-index animation provenance stamps, mirroring the per-slot design of the + // Kotlin-side `MutableStateBacking` ledger and SkipFuseUI's `Box.lastWriteAnimation`: + // a write inside an animation scope stamps the slot, a plain write clears the stale + // stamp, and a read reports the stamp so the UI layer's read cursor can pair it with + // the consuming modifier. Lazily allocated so observables that are never mutated inside + // an animation scope pay nothing. + private var provenanceStamps: [Int: Any]? = nil + + private func stampProvenance(at index: Int) { + guard let currentToken = BridgedObservationProvenance.currentToken else { + return + } + let token = currentToken() + lock.wait() + defer { lock.signal() } + if token != nil || provenanceStamps != nil { + if provenanceStamps == nil { + provenanceStamps = [:] + } + // A nil token removes the entry, clearing a stale stamp from a prior + // animation-scoped write so a later plain write correctly snaps. + provenanceStamps![index] = token + } + } + + private func recordProvenanceRead(at index: Int) { + guard let recordRead = BridgedObservationProvenance.recordRead else { + return + } + lock.wait() + let token = provenanceStamps?[index] + lock.signal() + if let token { + recordRead(token) + } + } + private static let Java_stateClass = try? JClass(name: "skip/model/MutableStateBacking") private static let Java_state_init_methodID = Java_stateClass?.getMethodID(name: "", sig: "()V") private static let Java_state_access_methodID = Java_stateClass?.getMethodID(name: "access", sig: "(I)V") From 214469911ecdfcc46d076f52d042ac90f530426d Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Fri, 12 Jun 2026 14:37:01 -0400 Subject: [PATCH 2/2] Key animation provenance stamps by keyPath instead of Java_init index --- Sources/SkipAndroidBridge/Observation.swift | 22 +- .../ObservationSamples.swift | 232 ++++++++++++++++++ .../ObservationSamplesTests.swift | 87 +++++++ 3 files changed, 331 insertions(+), 10 deletions(-) create mode 100644 Sources/SkipAndroidBridgeSamples/ObservationSamples.swift create mode 100644 Tests/SkipAndroidBridgeSamplesTests/ObservationSamplesTests.swift diff --git a/Sources/SkipAndroidBridge/Observation.swift b/Sources/SkipAndroidBridge/Observation.swift index 9a13bb8..2d4039c 100644 --- a/Sources/SkipAndroidBridge/Observation.swift +++ b/Sources/SkipAndroidBridge/Observation.swift @@ -77,26 +77,28 @@ private final class BridgeObservationSupport: @unchecked Sendable { } public func access(_ subject: Subject, keyPath: KeyPath) { + recordProvenanceRead(at: keyPath) let index = Java_init(forKeyPath: keyPath) - recordProvenanceRead(at: index) Java_access(index) } public func willSet(_ subject: Subject, keyPath: KeyPath) { + stampProvenance(at: keyPath) let index = Java_init(forKeyPath: keyPath) - stampProvenance(at: index) Java_update(index) } - // Per-keyPath-index animation provenance stamps, mirroring the per-slot design of the + // Per-keyPath animation provenance stamps, mirroring the per-slot design of the // Kotlin-side `MutableStateBacking` ledger and SkipFuseUI's `Box.lastWriteAnimation`: // a write inside an animation scope stamps the slot, a plain write clears the stale // stamp, and a read reports the stamp so the UI layer's read cursor can pair it with - // the consuming modifier. Lazily allocated so observables that are never mutated inside - // an animation scope pay nothing. - private var provenanceStamps: [Int: Any]? = nil + // the consuming modifier. Keyed directly by the keyPath — pure native bookkeeping, + // deliberately independent of the Kotlin peer (whose `Java_init` index degrades to a + // single shared slot when `skip.model` is not on the classpath). Lazily allocated so + // observables that are never mutated inside an animation scope pay nothing. + private var provenanceStamps: [AnyKeyPath: Any]? = nil - private func stampProvenance(at index: Int) { + private func stampProvenance(at keyPath: AnyKeyPath) { guard let currentToken = BridgedObservationProvenance.currentToken else { return } @@ -109,16 +111,16 @@ private final class BridgeObservationSupport: @unchecked Sendable { } // A nil token removes the entry, clearing a stale stamp from a prior // animation-scoped write so a later plain write correctly snaps. - provenanceStamps![index] = token + provenanceStamps![keyPath] = token } } - private func recordProvenanceRead(at index: Int) { + private func recordProvenanceRead(at keyPath: AnyKeyPath) { guard let recordRead = BridgedObservationProvenance.recordRead else { return } lock.wait() - let token = provenanceStamps?[index] + let token = provenanceStamps?[keyPath] lock.signal() if let token { recordRead(token) diff --git a/Sources/SkipAndroidBridgeSamples/ObservationSamples.swift b/Sources/SkipAndroidBridgeSamples/ObservationSamples.swift new file mode 100644 index 0000000..d4fd179 --- /dev/null +++ b/Sources/SkipAndroidBridgeSamples/ObservationSamples.swift @@ -0,0 +1,232 @@ +// Copyright 2026 Skip +// SPDX-License-Identifier: MPL-2.0 +import Foundation +import SkipAndroidBridge +#if canImport(Observation) +import Observation +#endif + +// Native test support for the bridged Observation machinery, exercised by the transpiled +// `ObservationSamplesTests` shim. The interesting behavior — the `@Observable` macro binding +// to the bridged `Observation.ObservationRegistrar`, the per-keyPath provenance stamp ledger +// in `BridgeObservationSupport`, and `withObservationTracking` — is all natively-compiled +// code, so it runs here and each public function returns a failure description ("" = pass) +// for the shim to assert on. +// +// The model is fileprivate so it is not itself bridged (its generated facade would otherwise +// trip the bridge generator's lack of `@available` propagation at this package's macOS 13 +// deployment target); only these primitive-returning functions are bridged. + +#if SKIP_BRIDGE +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) +@Observable +fileprivate final class ObservedSampleModel { + var first = 0.0 + var second = 0.0 +} + +/// Mutable counter that `@Sendable` onChange closures may capture. +fileprivate final class ChangeCounter: @unchecked Sendable { + var count = 0 +} + +/// Runs `body` with the provenance hooks installed to use the settable token as the current +/// scope token and to append reads into the recorded list, restoring the uninstalled state +/// afterwards. +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) +private func withProvenanceHooks(_ body: (_ setToken: (String?) -> Void, _ recorded: () -> [String]) -> String) -> String { + var currentToken: String? = nil + var recorded: [String] = [] + BridgedObservationProvenance.currentToken = { currentToken } + BridgedObservationProvenance.recordRead = { token in + recorded.append((token as? String) ?? "") + } + defer { + BridgedObservationProvenance.currentToken = nil + BridgedObservationProvenance.recordRead = nil + } + return body({ currentToken = $0 }, { recorded }) +} +#endif + +// MARK: - Provenance stamp ledger + +/// A read of a property whose last write happened inside a token scope must report that +/// token — once per read. +public func testSupport_observationProvenanceRecordsStampedRead() -> String { + #if SKIP_BRIDGE + guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) else { return "unavailable" } + return withProvenanceHooks { setToken, recorded in + let model = ObservedSampleModel() + setToken("tx1") + model.first = 1.0 + let _ = model.first + let _ = model.first + guard recorded() == ["tx1", "tx1"] else { + return "expected two stamped reads of tx1, got \(recorded())" + } + return "" + } + #else + return "not built for bridging" + #endif +} + +/// A plain write (no active scope) must clear a stale stamp from a prior scoped write. +public func testSupport_observationProvenancePlainWriteClearsStamp() -> String { + #if SKIP_BRIDGE + guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) else { return "unavailable" } + return withProvenanceHooks { setToken, recorded in + let model = ObservedSampleModel() + setToken("tx1") + model.first = 1.0 + setToken(nil) + model.first = 2.0 + let _ = model.first + guard recorded().isEmpty else { + return "expected no records after a plain write cleared the stamp, got \(recorded())" + } + return "" + } + #else + return "not built for bridging" + #endif +} + +/// Stamps are per property: an unstamped sibling read must not report, and distinct +/// properties keep distinct tokens. +public func testSupport_observationProvenancePerSlotIsolation() -> String { + #if SKIP_BRIDGE + guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) else { return "unavailable" } + return withProvenanceHooks { setToken, recorded in + let model = ObservedSampleModel() + setToken("A") + model.first = 1.0 + setToken(nil) + model.second = 2.0 + let _ = model.second + guard recorded().isEmpty else { + return "unstamped sibling read should not record, got \(recorded())" + } + setToken("B") + model.second = 3.0 + let _ = model.first + let _ = model.second + guard recorded() == ["A", "B"] else { + return "expected per-slot tokens [A, B], got \(recorded())" + } + return "" + } + #else + return "not built for bridging" + #endif +} + +/// Stamps are per instance: the same property on a different instance must not report. +public func testSupport_observationProvenancePerInstanceIsolation() -> String { + #if SKIP_BRIDGE + guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) else { return "unavailable" } + return withProvenanceHooks { setToken, recorded in + let stamped = ObservedSampleModel() + let plain = ObservedSampleModel() + setToken("tx1") + stamped.first = 1.0 + setToken(nil) + let _ = plain.first + guard recorded().isEmpty else { + return "other instance's read should not record, got \(recorded())" + } + let _ = stamped.first + guard recorded() == ["tx1"] else { + return "stamped instance's read should record tx1, got \(recorded())" + } + return "" + } + #else + return "not built for bridging" + #endif +} + +/// The latest write wins: re-stamping a property inside a different scope replaces the token. +public func testSupport_observationProvenanceLatestWriteWins() -> String { + #if SKIP_BRIDGE + guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) else { return "unavailable" } + return withProvenanceHooks { setToken, recorded in + let model = ObservedSampleModel() + setToken("A") + model.first = 1.0 + setToken("B") + model.first = 2.0 + let _ = model.first + guard recorded() == ["B"] else { + return "expected the latest stamp B, got \(recorded())" + } + return "" + } + #else + return "not built for bridging" + #endif +} + +/// With no hooks installed the ledger must stay inert: no stamping, no recording, no crash — +/// and installing only the read hook later must not surface phantom stamps. +public func testSupport_observationProvenanceInertWithoutHooks() -> String { + #if SKIP_BRIDGE + guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) else { return "unavailable" } + let model = ObservedSampleModel() + model.first = 1.0 + let _ = model.first + var recorded: [String] = [] + BridgedObservationProvenance.recordRead = { token in + recorded.append((token as? String) ?? "") + } + defer { BridgedObservationProvenance.recordRead = nil } + let _ = model.first + guard recorded.isEmpty else { + return "read recorded \(recorded) despite no stamping hook at write time" + } + return "" + #else + return "not built for bridging" + #endif +} + +// MARK: - General bridged Observation behavior + +/// `withObservationTracking` must fire `onChange` when a tracked property is mutated — +/// exactly once (tracking is one-shot). +public func testSupport_observationTrackingFiresOnChangeOnce() -> String { + #if SKIP_BRIDGE + guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) else { return "unavailable" } + let model = ObservedSampleModel() + let changes = ChangeCounter() + let initial = ObservationModule.withObservationTrackingFunc({ model.first }, onChange: { changes.count += 1 }) + guard initial == 0.0 else { return "tracking apply should return the current value" } + guard changes.count == 0 else { return "onChange fired before any mutation" } + model.first = 1.0 + guard changes.count == 1 else { return "expected onChange once after first mutation, got \(changes.count)" } + model.first = 2.0 + guard changes.count == 1 else { return "tracking is one-shot; expected no re-fire, got \(changes.count)" } + return "" + #else + return "not built for bridging" + #endif +} + +/// `withObservationTracking` only tracks the properties actually accessed in `apply`: +/// mutating an un-accessed sibling must not fire `onChange`. +public func testSupport_observationTrackingIgnoresUntrackedProperty() -> String { + #if SKIP_BRIDGE + guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) else { return "unavailable" } + let model = ObservedSampleModel() + let changes = ChangeCounter() + let _ = ObservationModule.withObservationTrackingFunc({ model.first }, onChange: { changes.count += 1 }) + model.second = 1.0 + guard changes.count == 0 else { return "mutating an untracked property fired onChange" } + model.first = 1.0 + guard changes.count == 1 else { return "mutating the tracked property should fire onChange, got \(changes.count)" } + return "" + #else + return "not built for bridging" + #endif +} diff --git a/Tests/SkipAndroidBridgeSamplesTests/ObservationSamplesTests.swift b/Tests/SkipAndroidBridgeSamplesTests/ObservationSamplesTests.swift new file mode 100644 index 0000000..ed653b3 --- /dev/null +++ b/Tests/SkipAndroidBridgeSamplesTests/ObservationSamplesTests.swift @@ -0,0 +1,87 @@ +// Copyright 2026 Skip +// SPDX-License-Identifier: MPL-2.0 +import Foundation +import SkipBridge +import SkipAndroidBridge +import SkipAndroidBridgeSamples +import XCTest + +/// Transpiled shims for the natively-compiled Observation test support in +/// `SkipAndroidBridgeSamples/ObservationSamples.swift`. +/// +/// The behavior under test — the `@Observable` macro binding to the bridged +/// `Observation.ObservationRegistrar`, the per-keyPath animation-provenance stamp ledger in +/// `BridgeObservationSupport` (`BridgedObservationProvenance` hooks), and bridged +/// `withObservationTracking` — is native Swift, so each test just calls a bridged +/// `testSupport_` function and asserts its failure description is empty. +final class ObservationSamplesTests: XCTestCase { + override func setUp() { + #if SKIP + loadPeerLibrary(packageName: "skip-android-bridge", moduleName: "SkipAndroidBridgeSamples") + #endif + } + + func testObservationProvenanceRecordsStampedRead() throws { + #if !SKIP + throw XCTSkip("bridged Observation only runs on Android/Robolectric") + #else + XCTAssertEqual("", testSupport_observationProvenanceRecordsStampedRead()) + #endif + } + + func testObservationProvenancePlainWriteClearsStamp() throws { + #if !SKIP + throw XCTSkip("bridged Observation only runs on Android/Robolectric") + #else + XCTAssertEqual("", testSupport_observationProvenancePlainWriteClearsStamp()) + #endif + } + + func testObservationProvenancePerSlotIsolation() throws { + #if !SKIP + throw XCTSkip("bridged Observation only runs on Android/Robolectric") + #else + XCTAssertEqual("", testSupport_observationProvenancePerSlotIsolation()) + #endif + } + + func testObservationProvenancePerInstanceIsolation() throws { + #if !SKIP + throw XCTSkip("bridged Observation only runs on Android/Robolectric") + #else + XCTAssertEqual("", testSupport_observationProvenancePerInstanceIsolation()) + #endif + } + + func testObservationProvenanceLatestWriteWins() throws { + #if !SKIP + throw XCTSkip("bridged Observation only runs on Android/Robolectric") + #else + XCTAssertEqual("", testSupport_observationProvenanceLatestWriteWins()) + #endif + } + + func testObservationProvenanceInertWithoutHooks() throws { + #if !SKIP + throw XCTSkip("bridged Observation only runs on Android/Robolectric") + #else + XCTAssertEqual("", testSupport_observationProvenanceInertWithoutHooks()) + #endif + } + + func testObservationTrackingFiresOnChangeOnce() throws { + #if !SKIP + throw XCTSkip("bridged Observation only runs on Android/Robolectric") + #else + XCTAssertEqual("", testSupport_observationTrackingFiresOnChangeOnce()) + #endif + } + + func testObservationTrackingIgnoresUntrackedProperty() throws { + #if !SKIP + throw XCTSkip("bridged Observation only runs on Android/Robolectric") + #else + XCTAssertEqual("", testSupport_observationTrackingIgnoresUntrackedProperty()) + #endif + } +}