From 24ccc521fa2fdedbd5bc16a150112399ed75984f Mon Sep 17 00:00:00 2001 From: tifroz Date: Thu, 30 Apr 2026 15:02:20 -0700 Subject: [PATCH] Track state mutation transactions --- Sources/SkipModel/MutableStateBacking.swift | 4 ++ Sources/SkipModel/StateTracking.swift | 37 +++++++++++++++++++ Tests/SkipModelTests/StateTrackingTests.swift | 26 +++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 Tests/SkipModelTests/StateTrackingTests.swift diff --git a/Sources/SkipModel/MutableStateBacking.swift b/Sources/SkipModel/MutableStateBacking.swift index 4f12229..1e521d8 100644 --- a/Sources/SkipModel/MutableStateBacking.swift +++ b/Sources/SkipModel/MutableStateBacking.swift @@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf public final class MutableStateBacking: StateTracker { private var state: MutableList> = mutableListOf() + private var lastMutationTransactions: MutableList = mutableListOf() private var isTracking = false public init() { @@ -15,6 +16,7 @@ public final class MutableStateBacking: StateTracker { public func access(stateAt index: Int) { synchronized(self) { initialize(stateAt: index) + StateTracking.recordMutationRead(lastMutationTransactions[index]) let _ = state[index].value } } @@ -22,6 +24,7 @@ public final class MutableStateBacking: StateTracker { public func update(stateAt index: Int) { synchronized(self) { initialize(stateAt: index) + lastMutationTransactions[index] = StateTracking.currentMutationTransaction // Only update state when tracking. We do, however, read state even when tracking has not begun. // Otherwise post-tracking updates may not cause recomposition if isTracking { @@ -33,6 +36,7 @@ public final class MutableStateBacking: StateTracker { private func initialize(stateAt index: Int) { while state.size <= index { state.add(mutableStateOf(0)) + lastMutationTransactions.add(nil) } } diff --git a/Sources/SkipModel/StateTracking.swift b/Sources/SkipModel/StateTracking.swift index 7de0c4c..5016175 100644 --- a/Sources/SkipModel/StateTracking.swift +++ b/Sources/SkipModel/StateTracking.swift @@ -12,8 +12,22 @@ public protocol StateTracker { func trackState() } +/// A neutral transaction marker for state mutations. +/// +/// Higher-level UI packages can attach their own transaction objects here without +/// making SkipModel depend on those packages. +public protocol StateMutationTransaction: AnyObject { +} + /// Manage observable state tracking. public final class StateTracking { + /// The transaction currently attached to state writes. + public static var currentMutationTransaction: StateMutationTransaction? = nil + + // Render ledger. Nil entries are meaningful: a state value written outside + // a mutation transaction must still line up with the matching animatable. + private static var mutationReadTransactions: [StateMutationTransaction?] = [] + #if SKIP private static var bodyDepth = 0 private static let trackers: MutableList = mutableListOf() @@ -51,6 +65,9 @@ public final class StateTracking { public static func pushBody() { #if SKIP if isMainThread { + if bodyDepth == 0 { + clearMutationReads() + } bodyDepth += 1 activateTrackers() } @@ -67,6 +84,26 @@ public final class StateTracking { #endif } + /// Record the transaction that last wrote a state value as that value is read while building render state. + public static func recordMutationRead(_ transaction: StateMutationTransaction?) { + mutationReadTransactions.append(transaction) + } + + /// Consume the next recorded write transaction for an animatable value. + /// + /// This intentionally models a render ledger, not the current write scope. + public static func consumeMutationRead() -> StateMutationTransaction? { + guard !mutationReadTransactions.isEmpty else { + return nil + } + return mutationReadTransactions.removeFirst() + } + + /// Clear recorded read transactions at a known lifecycle boundary. + public static func clearMutationReads() { + mutationReadTransactions.removeAll() + } + #if SKIP private static func activateTrackers() { guard !trackers.isEmpty() else { diff --git a/Tests/SkipModelTests/StateTrackingTests.swift b/Tests/SkipModelTests/StateTrackingTests.swift new file mode 100644 index 0000000..55c5699 --- /dev/null +++ b/Tests/SkipModelTests/StateTrackingTests.swift @@ -0,0 +1,26 @@ +// Copyright 2026 Skip +// SPDX-License-Identifier: MPL-2.0 +import SkipModel +import XCTest + +final class StateTrackingTests: XCTestCase { + func testMutationReadTransactionsAreConsumedInReadOrder() { + let first = TestStateMutationTransaction() + let second = TestStateMutationTransaction() + + StateTracking.clearMutationReads() + StateTracking.recordMutationRead(nil) + StateTracking.recordMutationRead(first) + StateTracking.recordMutationRead(second) + + XCTAssertNil(StateTracking.consumeMutationRead()) + XCTAssertTrue(StateTracking.consumeMutationRead() === first) + XCTAssertTrue(StateTracking.consumeMutationRead() === second) + XCTAssertNil(StateTracking.consumeMutationRead()) + + StateTracking.clearMutationReads() + } +} + +private final class TestStateMutationTransaction: StateMutationTransaction { +}