Skip to content
Merged
2 changes: 1 addition & 1 deletion Examples/Packages/iOS/Sources/iOSApp/iOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public struct iOSApp: App {
.navigationViewStyle(.stack)
}
.observe { snapshot in
print(snapshot)
print(snapshot.graphDescription())
}
}
}
Expand Down
97 changes: 57 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
- [KeepAlive](#keepalive)
- [Suspense](#suspense)
- [Testing](#testing)
- [Debugging](#debugging)
- [Preview](#preview)
- [Observability](#observability)
- [Advanced Usage](#advanced-usage)
- [Dealing with Known SwiftUI Bugs](#dealing-with-known-swiftui-bugs)
- [Contributing](#contributing)
Expand Down Expand Up @@ -1212,57 +1212,74 @@ class FetchBookTests: XCTestCase {

---

### Preview
### Debugging

Even in SwiftUI previews, the view must have an `AtomRoot` somewhere in the ancestor. However, since This library offers the new solution for dependency injection, you don't need to do painful DI each time you create previews anymore. You can to override the atoms that you really want to inject substitutions.
This library defines a Directed Acyclic Graph (DAG) internally to centrally manage atom states, making it easy to analyze its dependencies and where they are (or are not) being used.
There are the following two ways to get a [Snapshot](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/snapshot) of the dependency graph at a given point in time.

The first is to get `Snapshot` through [@ViewContext](#atomviewcontext). This API is suitable for obtaining and analyzing debugging information on demand.

```swift
struct NewsList_Preview: PreviewProvider {
static var previews: some View {
AtomRoot {
NewsList()
}
.override(APIClientAtom()) { _ in
StubAPIClient()
}
@ViewContext
var context

var debugButton: some View {
Button("Dump dependency graph") {
let snapshot = context.snapshot()
print(snapshot.graphDescription())
}
}
```

---
Or, you can observe all updates of atoms and always continue to receive `Snapshots` at that point in time through `observe(_:)` modifier of [AtomRoot](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/atomroot) or [AtomRelay](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/atomrelay).
Note that observing in `AtomRoot` will receive all atom updates that happened in the whole app, but observing in `AtomRelay` will only receive atoms used in the descendant views.

### Observability
```swift
AtomRoot {
HomeScreen()
}
.observe { snapshot in
print(snapshot.graphDescription())
}
```

For debugging, you can observe updates with a snapshot that captures a specific set of values of atoms through the `observe(_:)` function in [AtomRoot](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/atomroot) or [AtomRelay](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/atomrelay).
Observing in `AtomRoot` will receive all atom updates that happened in the whole app, but observing in `AtomRelay` will only receive atoms used in the descendant views.
Calling the [restore()](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/snapshot/restore()) method of the obtained `Snapshot` will roll back to the states and dependency graph at that point in time to see what happened.
The debugging technique is called [time travel debugging](https://en.wikipedia.org/wiki/Time_travel_debugging), and the example application [here](Examples/Packages/iOS/Sources/ExampleTimeTravel) demonstrates how it works.

The [Snapshot](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/snapshot) passed to `observe(:_)` has a [restore()](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/snapshot/restore()) function that can be executed to restore a specific set of atom values. `Snapshot` can also be obtained on-demand through [AtomViewContext](#atomviewcontext).
This observability API can be applied to do [time travel debugging](https://en.wikipedia.org/wiki/Time_travel_debugging) and is demonstrated in one of the [examples](Examples).
In addition, [graphDescription()](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/snapshot/graphdescription()) method returns a string, that represents the dependencies graph and where they are used, as a String in [graph description language DOT](https://graphviz.org/doc/info/lang.html).
This can be converted to an image using [Graphviz](https://graphviz.org), a graph visualization tool, to visually analyze information about the state of the application, as shown below.

```swift
@main
struct ExampleApp: App {
var body: some Scene {
WindowGroup {
AtomRoot {
VStack {
NavigationLink("Home") {
Home()
}
<img src="assets/dependency_graph.png" alt="Dependency Graph" width="50%" align="right">

NavigationLink("Setting") {
AtomRelay {
Setting()
}
.observe { snapshot in // Observes setting related atoms only.
print(snapshot)
}
}
}
}
.observe { snapshot in // Observes all atoms used in the app.
print(snapshot)
}
```dot
digraph {
node [shape=box]
"FilterAtom"
"FilterAtom" -> "TodoApp/FilterPicker.swift" [label="line:3"]
"FilterAtom" -> "FilteredTodosAtom"
"TodosAtom"
"TodosAtom" -> "FilteredTodosAtom"
"FilteredTodosAtom"
"FilteredTodosAtom" -> "TodoApp/TodoList.swift" [label="line:5"]
"TodoApp/TodoList.swift" [style=filled]
"TodoApp/FilterPicker.swift" [style=filled]
}
```

---

### Preview

Even in SwiftUI previews, the view must have an `AtomRoot` somewhere in the ancestor. However, since This library offers the new solution for dependency injection, you don't need to do painful DI each time you create previews anymore. You can to override the atoms that you really want to inject substitutions.

```swift
struct NewsList_Preview: PreviewProvider {
static var previews: some View {
AtomRoot {
NewsList()
}
.override(APIClientAtom()) { _ in
StubAPIClient()
}
}
}
Expand Down
14 changes: 10 additions & 4 deletions Sources/Atoms/Context/AtomTestContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ public struct AtomTestContext: AtomWatchableContext {
private let state: State

/// Creates a new test context instance with fresh internal state.
public init() {
state = State()
public init(fileID: String = #fileID, line: UInt = #line) {
let location = SourceLocation(fileID: fileID, line: line)
state = State(location: location)
}

/// A callback to perform when any of atoms watched by this context is updated.
Expand Down Expand Up @@ -264,16 +265,21 @@ private extension AtomTestContext {
private let _store = Store()
private let _container = SubscriptionContainer()

var overrides = Overrides()
let location: SourceLocation
let notifier = PassthroughSubject<Void, Never>()
var overrides = Overrides()
var onUpdate: (() -> Void)?

init(location: SourceLocation) {
self.location = location
}

var store: StoreContext {
StoreContext(_store, overrides: overrides)
}

var container: SubscriptionContainer.Wrapper {
_container.wrapper
_container.wrapper(location: location)
}

func notifyUpdate() {
Expand Down
2 changes: 1 addition & 1 deletion Sources/Atoms/Core/AtomCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ internal struct AtomCache<Node: Atom>: AtomCacheBase, CustomStringConvertible {
}

var description: String {
String(describing: Node.self) + "(\(value.map { "\($0)" } ?? "nil"))"
value.map { "\($0)" } ?? "nil"
}

func reset(with store: StoreContext) {
Expand Down
2 changes: 1 addition & 1 deletion Sources/Atoms/Core/AtomKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ internal struct AtomKey: Hashable, CustomStringConvertible {
}

var description: String {
"\(typeKey.description)(\(identifier.hashValue))"
typeKey.description
}
}
8 changes: 4 additions & 4 deletions Sources/Atoms/Core/AtomTypeKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ internal struct AtomTypeKey: Hashable, CustomStringConvertible {
getName = { String(describing: Node.self) }
}

static func == (lhs: Self, rhs: Self) -> Bool {
lhs.identifier == rhs.identifier
}

var description: String {
getName()
}

func hash(into hasher: inout Hasher) {
hasher.combine(identifier)
}

static func == (lhs: Self, rhs: Self) -> Bool {
lhs.identifier == rhs.identifier
}
}
4 changes: 4 additions & 0 deletions Sources/Atoms/Core/SourceLocation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
internal struct SourceLocation: Equatable {
let fileID: String
let line: UInt
}
46 changes: 31 additions & 15 deletions Sources/Atoms/Core/StoreContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ internal struct StoreContext {
let key = AtomKey(atom)
defer { checkRelease(for: key) }

return getValue(of: atom, for: key)
let (isNew, value) = getValue(of: atom, for: key)

if isNew {
notifyUpdateToObservers()
}

return value
}

@usableFromInline
Expand All @@ -47,9 +53,15 @@ internal struct StoreContext {

// Add an `Edge` from the upstream to downstream.
store.graph.dependencies[transaction.key, default: []].insert(key)
store.graph.children[key, default: []].insert(transaction.key)

return getValue(of: atom, for: key)
let isInserted = store.graph.children[key, default: []].insert(transaction.key).inserted
let (isNew, value) = getValue(of: atom, for: key)

if isInserted || isNew {
notifyUpdateToObservers()
}

return value
}

@usableFromInline
Expand All @@ -72,9 +84,15 @@ internal struct StoreContext {

// Register the subscription to both the store and the container.
container.subscriptions[key] = subscription
store.state.subscriptions[key, default: [:]].updateValue(subscription, forKey: container.key)

return getValue(of: atom, for: key)
let isInserted = store.state.subscriptions[key, default: [:]].updateValue(subscription, forKey: container.key) == nil
let (isNew, value) = getValue(of: atom, for: key)

if isInserted || isNew {
notifyUpdateToObservers()
}

return value
}

@usableFromInline
Expand Down Expand Up @@ -111,8 +129,9 @@ internal struct StoreContext {
let store = getStore()
let graph = store.graph
let caches = store.state.caches
let subscriptions = store.state.subscriptions

return Snapshot(graph: graph, caches: caches) {
return Snapshot(graph: graph, caches: caches, subscriptions: subscriptions) {
let store = getStore()
let keys = ContiguousArray(caches.keys)
var obsoletedDependencies = [AtomKey: Set<AtomKey>]()
Expand Down Expand Up @@ -203,23 +222,20 @@ private extension StoreContext {
return value
}

func getValue<Node: Atom>(of atom: Node, for key: AtomKey) -> Node.Loader.Value {
func getValue<Node: Atom>(of atom: Node, for key: AtomKey) -> (isNew: Bool, value: Node.Loader.Value) {
let store = getStore()
var cache = getCache(of: atom, for: key)

if let value = cache.value {
return value
return (isNew: false, value: value)
}
else {
let value = getNewValue(of: atom, for: key)

cache.value = value
store.state.caches[key] = cache

// Notify new value.
notifyUpdateToObservers()

return value
return (isNew: true, value: value)
}
}

Expand Down Expand Up @@ -393,11 +409,11 @@ private extension StoreContext {
store.state.states.removeValue(forKey: key)
store.state.subscriptions.removeValue(forKey: key)

// Notify release.
notifyUpdateToObservers()

// Check if the dependencies are releasable.
checkReleaseDependencies(dependencies, for: key)

// Notify release.
notifyUpdateToObservers()
}

func checkRelease(for key: AtomKey) {
Expand Down
12 changes: 6 additions & 6 deletions Sources/Atoms/Core/SubscriptionContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@
internal final class SubscriptionContainer {
private var subscriptions = [AtomKey: Subscription]()

var wrapper: Wrapper {
Wrapper(self)
}

nonisolated init() {}

deinit {
for subscription in ContiguousArray(subscriptions.values) {
subscription.unsubscribe()
}
}

func wrapper(location: SourceLocation) -> Wrapper {
Wrapper(self, location: location)
}
}

internal extension SubscriptionContainer {
Expand All @@ -29,9 +29,9 @@ internal extension SubscriptionContainer {
nonmutating set { container?.subscriptions = newValue }
}

init(_ container: SubscriptionContainer) {
init(_ container: SubscriptionContainer, location: SourceLocation) {
self.container = container
self.key = SubscriptionKey(container)
self.key = SubscriptionKey(container, location: location)
}
}
}
16 changes: 14 additions & 2 deletions Sources/Atoms/Core/SubscriptionKey.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
internal struct SubscriptionKey: Hashable {
private let identifier: ObjectIdentifier

init(_ container: SubscriptionContainer) {
identifier = ObjectIdentifier(container)
let location: SourceLocation

init(_ container: SubscriptionContainer, location: SourceLocation) {
self.identifier = ObjectIdentifier(container)
self.location = location
}

// Ignores `location` because it is a debugging metadata.
func hash(into hasher: inout Hasher) {
hasher.combine(identifier)
}

static func == (lhs: Self, rhs: Self) -> Bool {
lhs.identifier == rhs.identifier
}
}
7 changes: 5 additions & 2 deletions Sources/Atoms/PropertyWrapper/ViewContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,12 @@ public struct ViewContext: DynamicProperty {
@Environment(\.store)
private var _store

private let location: SourceLocation

/// Creates a view context.
public init() {
public init(fileID: String = #fileID, line: UInt = #line) {
_state = StateObject(wrappedValue: State())
location = SourceLocation(fileID: fileID, line: line)
}

/// The underlying view context to interact with atoms.
Expand All @@ -52,7 +55,7 @@ public struct ViewContext: DynamicProperty {
public var wrappedValue: AtomViewContext {
AtomViewContext(
store: _store,
container: state.container.wrapper,
container: state.container.wrapper(location: location),
notifyUpdate: state.objectWillChange.send
)
}
Expand Down
Loading