Skip to content

Commit 1bc21c6

Browse files
authored
Topological order update (#122)
* Implement topological order update algorithm * Refactoring * Refactoring * Track skipping atoms accurately * Add test cases * Refactoring * Support for complex cases of when transitive update is skipped * Refactoring * Add more strict testing * Show all diffs when validation fails * Fix dev tool cache * Refactoring * Update test case to be more strict * Refactoring * Add test for topological sort function
1 parent 5de971d commit 1bc21c6

17 files changed

+562
-74
lines changed

.github/workflows/test.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,14 @@ jobs:
5151
- uses: actions/cache@v4
5252
with:
5353
path: Tools/bin
54-
key: spm-${{ runner.os }}-${{env.DEVELOPER_DIR}}-${{ hashFiles('Package.swift') }}
54+
key: spm-${{ runner.os }}-${{env.DEVELOPER_DIR}}-${{ hashFiles('Tools/Package.swift') }}
5555
- name: Validate lint
5656
run: make lint
5757
- name: Validate format
5858
run: |
5959
make format
60-
if [ -n "$(git status --porcelain)" ]; then git diff --name-only && echo "Make sure that the code is formated by 'make format'."; exit 1; fi
60+
if [ -n "$(git status --porcelain)" ]; then git diff && echo "Make sure that the code is formated by 'make format'."; exit 1; fi
6161
- name: Validate example project
6262
run: |
6363
make proj
64-
if [ -n "$(git status --porcelain)" ]; then git diff --name-only && echo "Make sure that 'Examples/App.xcodeproj' is formated by 'make proj'."; exit 1; fi
64+
if [ -n "$(git status --porcelain)" ]; then git diff && echo "Make sure that 'Examples/App.xcodeproj' is formated by 'make proj'."; exit 1; fi

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ SWIFT_FILE_PATHS = Package.swift Tools/Package.swift Sources Tests Examples
44

55
.PHONY: proj
66
proj:
7-
SWIFT_PACKAGE_RESOURCES=.build/checkouts/XcodeGen/SettingPresets $(TOOL) xcodegen -s Examples/project.yml
7+
SWIFT_PACKAGE_RESOURCES=Tools/.build/checkouts/XcodeGen/SettingPresets $(TOOL) xcodegen -s Examples/project.yml
88

99
.PHONY: format
1010
format:

Sources/Atoms/Core/Loader/AtomLoader.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,18 @@ public protocol AtomLoader {
1818

1919
/// Returns a boolean value indicating whether it should notify updates downstream
2020
/// by checking the equivalence of the given old value and new value.
21-
func shouldUpdate(newValue: Value, oldValue: Value) -> Bool
21+
func shouldUpdateTransitively(newValue: Value, oldValue: Value) -> Bool
2222

23-
/// Performs atom update.
24-
func performUpdate(_ body: () -> Void)
23+
/// Performs transitive update for dependent atoms.
24+
func performTransitiveUpdate(_ body: () -> Void)
2525
}
2626

2727
public extension AtomLoader {
28-
func shouldUpdate(newValue: Value, oldValue: Value) -> Bool {
28+
func shouldUpdateTransitively(newValue: Value, oldValue: Value) -> Bool {
2929
true
3030
}
3131

32-
func performUpdate(_ body: () -> Void) {
32+
func performTransitiveUpdate(_ body: () -> Void) {
3333
body()
3434
}
3535
}

Sources/Atoms/Core/Loader/ModifiedAtomLoader.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,13 @@ public struct ModifiedAtomLoader<Node: Atom, Modifier: AtomModifier>: AtomLoader
2727

2828
/// Returns a boolean value indicating whether it should notify updates downstream
2929
/// by checking the equivalence of the given old value and new value.
30-
public func shouldUpdate(newValue: Value, oldValue: Value) -> Bool {
31-
modifier.shouldUpdate(newValue: newValue, oldValue: oldValue)
30+
public func shouldUpdateTransitively(newValue: Value, oldValue: Value) -> Bool {
31+
modifier.shouldUpdateTransitively(newValue: newValue, oldValue: oldValue)
3232
}
3333

34-
/// Performs atom update.
35-
public func performUpdate(_ body: () -> Void) {
36-
modifier.performUpdate(body)
34+
/// Performs transitive update for dependent atoms.
35+
public func performTransitiveUpdate(_ body: () -> Void) {
36+
modifier.performTransitiveUpdate(body)
3737
}
3838
}
3939

Sources/Atoms/Core/StoreContext.swift

Lines changed: 94 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ internal struct StoreContext {
8383
let key = AtomKey(atom, scopeKey: scopeKey)
8484

8585
if let cache = lookupCache(of: atom, for: key) {
86-
update(atom: atom, for: key, newValue: value, cache: cache)
86+
update(atom: atom, for: key, newValue: value, oldValue: cache.value)
8787
}
8888
}
8989

@@ -95,7 +95,7 @@ internal struct StoreContext {
9595

9696
if let cache = lookupCache(of: atom, for: key) {
9797
let newValue = mutating(cache.value, body)
98-
update(atom: atom, for: key, newValue: newValue, cache: cache)
98+
update(atom: atom, for: key, newValue: newValue, oldValue: cache.value)
9999
}
100100
}
101101

@@ -127,9 +127,9 @@ internal struct StoreContext {
127127
let scopeKey = lookupScopeKey(of: atom, isScopedOverriden: override?.isScoped ?? false)
128128
let key = AtomKey(atom, scopeKey: scopeKey)
129129
let cache = getCache(of: atom, for: key, override: override)
130-
let isNewSubscription = subscriber.subscribingKeys.insert(key).inserted
130+
let isNewSubscription = subscriber.subscribing.insert(key).inserted
131131

132-
store.state.subscriptions[key, default: [:]].updateValue(subscription, forKey: subscriber.key)
132+
store.state.subscriptions[key, default: [:]][subscriber.key] = subscription
133133
subscriber.unsubscribe = { keys in
134134
unsubscribe(keys, for: subscriber.key)
135135
}
@@ -164,7 +164,7 @@ internal struct StoreContext {
164164

165165
// Notify update unless it's cancelled or terminated by other operations.
166166
if !Task.isCancelled && !context.isTerminated {
167-
update(atom: atom, for: key, newValue: value, cache: cache)
167+
update(atom: atom, for: key, newValue: value, oldValue: cache.value)
168168
}
169169

170170
return value
@@ -186,7 +186,7 @@ internal struct StoreContext {
186186

187187
// Notify update unless it's cancelled or terminated by other operations.
188188
if !Task.isCancelled && !transaction.isTerminated {
189-
update(atom: atom, for: key, newValue: value, cache: cache)
189+
update(atom: atom, for: key, newValue: value, oldValue: cache.value)
190190
}
191191

192192
return value
@@ -201,7 +201,7 @@ internal struct StoreContext {
201201

202202
if let cache = lookupCache(of: atom, for: key) {
203203
let newCache = makeCache(of: atom, for: key, override: override)
204-
update(atom: atom, for: key, newValue: newCache.value, cache: cache)
204+
update(atom: atom, for: key, newValue: newCache.value, oldValue: cache.value)
205205
}
206206
}
207207

@@ -233,7 +233,7 @@ internal struct StoreContext {
233233
let scopeKey = lookupScopeKey(of: atom, isScopedOverriden: override?.isScoped ?? false)
234234
let key = AtomKey(atom, scopeKey: scopeKey)
235235

236-
subscriber.subscribingKeys.remove(key)
236+
subscriber.subscribing.remove(key)
237237
unsubscribe([key], for: subscriber.key)
238238
}
239239

@@ -327,7 +327,7 @@ private extension StoreContext {
327327
coordinator: state.coordinator
328328
) { newValue in
329329
if let cache = lookupCache(of: atom, for: key) {
330-
update(atom: atom, for: key, newValue: newValue, cache: cache)
330+
update(atom: atom, for: key, newValue: newValue, oldValue: cache.value)
331331
}
332332
}
333333
}
@@ -336,43 +336,103 @@ private extension StoreContext {
336336
atom: Node,
337337
for key: AtomKey,
338338
newValue: Node.Loader.Value,
339-
cache: AtomCache<Node>
339+
oldValue: Node.Loader.Value
340340
) {
341-
let oldValue = cache.value
341+
store.state.caches[key] = AtomCache(atom: atom, value: newValue)
342342

343-
store.state.caches[key] = mutating(cache) {
344-
$0.value = newValue
343+
// Check whether if the dependent atoms should be updated transitively.
344+
guard atom._loader.shouldUpdateTransitively(newValue: newValue, oldValue: oldValue) else {
345+
return
345346
}
346347

347-
guard atom._loader.shouldUpdate(newValue: newValue, oldValue: oldValue) else {
348-
return
348+
// Perform side effects first.
349+
let state = getState(of: atom, for: key)
350+
let context = AtomCurrentContext(store: self, coordinator: state.coordinator)
351+
atom.updated(newValue: newValue, oldValue: oldValue, context: context)
352+
353+
// Calculate topological order for updating downstream efficiently.
354+
let (edges, redundants) = topologicalSort(key: key, store: store)
355+
var skippedDependencies = Set<AtomKey>()
356+
357+
// Updates the given atom.
358+
func update(for key: AtomKey, cache: some AtomCacheProtocol) {
359+
let override = lookupOverride(of: cache.atom)
360+
let newCache = makeCache(of: cache.atom, for: key, override: override)
361+
362+
// Check whether if the dependent atoms should be updated transitively.
363+
guard cache.atom._loader.shouldUpdateTransitively(newValue: newCache.value, oldValue: cache.value) else {
364+
// Record the atom to avoid downstream from being update.
365+
skippedDependencies.insert(key)
366+
return
367+
}
368+
369+
// Perform side effects before updating downstream.
370+
let state = getState(of: cache.atom, for: key)
371+
let context = AtomCurrentContext(store: self, coordinator: state.coordinator)
372+
cache.atom.updated(newValue: newCache.value, oldValue: cache.value, context: context)
349373
}
350374

351-
atom._loader.performUpdate {
352-
// Notifies update to view subscriptions first.
353-
if let subscriptions = store.state.subscriptions[key] {
354-
for subscription in ContiguousArray(subscriptions.values) {
355-
subscription.update()
356-
}
375+
// Performs update of the given atom with the dependency's context.
376+
func performUpdate(for key: AtomKey, cache: some AtomCacheProtocol, dependency: some Atom) {
377+
dependency._loader.performTransitiveUpdate {
378+
update(for: key, cache: cache)
357379
}
380+
}
358381

359-
// Notifies update to downstream atoms.
360-
if let children = store.graph.children[key] {
361-
for child in ContiguousArray(children) {
362-
// Reset the atom value and then notifies downstream atoms.
363-
if let cache = store.state.caches[child] {
364-
reset(cache.atom)
365-
}
366-
}
382+
// Performs update of the given subscription with the dependency's context.
383+
func performUpdate(subscription: Subscription, dependency: some Atom) {
384+
dependency._loader.performTransitiveUpdate(subscription.update)
385+
}
386+
387+
func validEdge(_ edge: Edge) -> Edge? {
388+
// Do not transitively update atoms that have dependency recorded not to update downstream.
389+
guard skippedDependencies.contains(edge.from) else {
390+
return edge
367391
}
368392

369-
// Notify value update to observers.
370-
notifyUpdateToObservers()
393+
// If the topological sorting has marked the vertex as a redundant, the update still performed.
394+
guard let fromKey = redundants[edge.to]?.first(where: { !skippedDependencies.contains($0) }) else {
395+
return nil
396+
}
371397

372-
let state = getState(of: atom, for: key)
373-
let context = AtomCurrentContext(store: self, coordinator: state.coordinator)
374-
atom.updated(newValue: newValue, oldValue: oldValue, context: context)
398+
// Convert edge's `from`, which represents a dependency atom, to a non-skipped one to
399+
// change the update transaction context (e.g. animation).
400+
return Edge(from: fromKey, to: edge.to)
401+
}
402+
403+
// Perform transitive update for dependent atoms ahead of notifying updates to subscriptions.
404+
for edge in edges {
405+
switch edge.to {
406+
case .atom(let key):
407+
guard let edge = validEdge(edge) else {
408+
// Record the atom to avoid downstream from being update.
409+
skippedDependencies.insert(key)
410+
continue
411+
}
412+
413+
let cache = store.state.caches[key]
414+
let dependencyCache = store.state.caches[edge.from]
415+
416+
if let cache, let dependencyCache {
417+
performUpdate(for: key, cache: cache, dependency: dependencyCache.atom)
418+
}
419+
420+
case .subscriber(let key):
421+
guard let edge = validEdge(edge) else {
422+
continue
423+
}
424+
425+
let subscription = store.state.subscriptions[edge.from]?[key]
426+
let dependencyCache = store.state.caches[edge.from]
427+
428+
if let subscription, let dependencyCache {
429+
performUpdate(subscription: subscription, dependency: dependencyCache.atom)
430+
}
431+
}
375432
}
433+
434+
// Notify the observers after all updates are completed.
435+
notifyUpdateToObservers()
376436
}
377437

378438
func unsubscribe<Keys: Sequence<AtomKey>>(_ keys: Keys, for subscriberKey: SubscriberKey) {

Sources/Atoms/Core/Subscriber.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ internal struct Subscriber {
1010
self.key = SubscriberKey(token: state.token)
1111
}
1212

13-
var subscribingKeys: Set<AtomKey> {
14-
get { state?.subscribingKeys ?? [] }
15-
nonmutating set { state?.subscribingKeys = newValue }
13+
var subscribing: Set<AtomKey> {
14+
get { state?.subscribing ?? [] }
15+
nonmutating set { state?.subscribing = newValue }
1616
}
1717

1818
var unsubscribe: ((Set<AtomKey>) -> Void)? {
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
@MainActor
22
final class SubscriberState {
33
let token = SubscriberKey.Token()
4-
var subscribingKeys = Set<AtomKey>()
4+
var subscribing = Set<AtomKey>()
55
var unsubscribe: ((Set<AtomKey>) -> Void)?
66

77
init() {}
88

99
deinit {
10-
unsubscribe?(subscribingKeys)
10+
unsubscribe?(subscribing)
1111
}
1212
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
internal enum Vertex: Hashable {
2+
case atom(key: AtomKey)
3+
case subscriber(key: SubscriberKey)
4+
}
5+
6+
internal struct Edge: Hashable {
7+
let from: AtomKey
8+
let to: Vertex
9+
}
10+
11+
/// DFS topological sorting.
12+
@MainActor
13+
internal func topologicalSort(key: AtomKey, store: AtomStore) -> (
14+
edges: ReversedCollection<[Edge]>,
15+
redundants: [Vertex: [AtomKey]] // key = vertex, value = dependencies
16+
) {
17+
var trace = Set<Vertex>()
18+
var edges = [Edge]()
19+
var redundants = [Vertex: [AtomKey]]()
20+
21+
func traverse(key: AtomKey, isRedundant: Bool) {
22+
if let children = store.graph.children[key] {
23+
for child in ContiguousArray(children) {
24+
traverse(key: child, from: key, isRedundant: isRedundant)
25+
}
26+
}
27+
28+
if let subscriptions = store.state.subscriptions[key] {
29+
for subscriberKey in ContiguousArray(subscriptions.keys) {
30+
traverse(key: subscriberKey, from: key, isRedundant: isRedundant)
31+
}
32+
}
33+
}
34+
35+
func traverse(key: AtomKey, from fromKey: AtomKey, isRedundant: Bool) {
36+
let vertex = Vertex.atom(key: key)
37+
let isRedundant = isRedundant || trace.contains(vertex)
38+
39+
trace.insert(vertex)
40+
41+
// Do not stop traversing downstream even when edges are already traced
42+
// to analyze the redundant edges later.
43+
traverse(key: key, isRedundant: isRedundant)
44+
45+
if isRedundant {
46+
redundants[vertex, default: []].append(fromKey)
47+
}
48+
else {
49+
let edge = Edge(from: fromKey, to: vertex)
50+
edges.append(edge)
51+
}
52+
}
53+
54+
func traverse(key: SubscriberKey, from fromKey: AtomKey, isRedundant: Bool) {
55+
let vertex = Vertex.subscriber(key: key)
56+
let isRedundant = isRedundant || trace.contains(vertex)
57+
58+
trace.insert(vertex)
59+
60+
if isRedundant {
61+
redundants[vertex, default: []].append(fromKey)
62+
}
63+
else {
64+
let edge = Edge(from: fromKey, to: vertex)
65+
edges.append(edge)
66+
}
67+
}
68+
69+
traverse(key: key, isRedundant: false)
70+
71+
return (edges: edges.reversed(), redundants: redundants)
72+
}

Sources/Atoms/Modifier/AnimationModifier.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ public struct AnimationModifier<T>: AtomModifier {
7070
value
7171
}
7272

73-
/// Performs atom update.
74-
public func performUpdate(_ body: () -> Void) {
73+
/// Performs transitive update for dependent atoms.
74+
public func performTransitiveUpdate(_ body: () -> Void) {
7575
withAnimation(animation, body)
7676
}
7777
}

0 commit comments

Comments
 (0)