Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Sources/SkipModel/MutableStateBacking.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf

public final class MutableStateBacking: StateTracker {
private var state: MutableList<MutableState<Int>> = mutableListOf()
private var lastMutationTransactions: MutableList<StateMutationTransaction?> = mutableListOf()
private var isTracking = false

public init() {
Expand All @@ -15,13 +16,15 @@ public final class MutableStateBacking: StateTracker {
public func access(stateAt index: Int) {
synchronized(self) {
initialize(stateAt: index)
StateTracking.recordMutationRead(lastMutationTransactions[index])
let _ = state[index].value
}
}

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 {
Expand All @@ -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)
}
}

Expand Down
37 changes: 37 additions & 0 deletions Sources/SkipModel/StateTracking.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<StateTracker> = mutableListOf()
Expand Down Expand Up @@ -51,6 +65,9 @@ public final class StateTracking {
public static func pushBody() {
#if SKIP
if isMainThread {
if bodyDepth == 0 {
clearMutationReads()
}
bodyDepth += 1
activateTrackers()
}
Expand All @@ -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 {
Expand Down
26 changes: 26 additions & 0 deletions Tests/SkipModelTests/StateTrackingTests.swift
Original file line number Diff line number Diff line change
@@ -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 {
}