Skip to content

Commit 9e59598

Browse files
authored
Bug fix: Atom effect receives context with unintended scope (#179)
* Add test case * Call atom effect with the scope at which they were initialized * Write more tests * Switch context when mutating operation * Refactoring * Disable test case for Swift 5 compiler
1 parent 74ed60c commit 9e59598

File tree

3 files changed

+196
-46
lines changed

3 files changed

+196
-46
lines changed

Sources/Atoms/AtomScope.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ public struct AtomScope<Content: View>: View {
8080
/// Observes the state changes with a snapshot that captures the whole atom states and
8181
/// their dependency graph at the point in time for debugging purposes.
8282
///
83-
/// Note that unlike ``AtomRoot/observe(_:)``, this observes only the state changes caused by atoms
84-
/// used in this scope.
83+
/// Note that unlike ``AtomRoot/observe(_:)``, this observes only the state changes of atoms
84+
/// initialized in this scope.
8585
///
8686
/// - Note: It ignores the observers if this scope inherits the parent scope.
8787
///

Sources/Atoms/Core/StoreContext.swift

+54-43
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ internal struct StoreContext {
7171
let (key, _) = lookupAtomKeyAndOverride(of: atom)
7272

7373
if let cache = lookupCache(of: atom, for: key) {
74-
update(atom: atom, for: key, cache: cache, newValue: value)
74+
switchContext(scope: cache.initializedScope)
75+
.update(atom: atom, for: key, cache: cache, newValue: value)
7576
}
7677
}
7778

@@ -81,7 +82,8 @@ internal struct StoreContext {
8182

8283
if let cache = lookupCache(of: atom, for: key) {
8384
let newValue = mutating(cache.value, body)
84-
update(atom: atom, for: key, cache: cache, newValue: newValue)
85+
switchContext(scope: cache.initializedScope)
86+
.update(atom: atom, for: key, cache: cache, newValue: newValue)
8587
}
8688
}
8789

@@ -128,7 +130,10 @@ internal struct StoreContext {
128130
@_disfavoredOverload
129131
func refresh<Node: AsyncAtom>(_ atom: Node) async -> Node.Produced {
130132
let (key, override) = lookupAtomKeyAndOverride(of: atom)
131-
let context = prepareForTransaction(of: atom, for: key)
133+
let cache = lookupCache(of: atom, for: key)
134+
let localContext = switchContext(scope: cache?.initializedScope)
135+
let context = localContext.prepareForTransaction(of: atom, for: key)
136+
132137
let value: Node.Produced
133138

134139
if let override {
@@ -140,14 +145,14 @@ internal struct StoreContext {
140145

141146
await atom.refreshProducer.refreshValue(value, context)
142147

143-
guard let cache = lookupCache(of: atom, for: key) else {
148+
guard let cache else {
144149
checkAndRelease(for: key)
145150
return value
146151
}
147152

148153
// Notify update unless it's cancelled or terminated by other operations.
149154
if !Task.isCancelled && !context.isTerminated {
150-
update(atom: atom, for: key, cache: cache, newValue: value)
155+
localContext.update(atom: atom, for: key, cache: cache, newValue: value)
151156
}
152157

153158
return value
@@ -156,8 +161,10 @@ internal struct StoreContext {
156161
@usableFromInline
157162
func refresh<Node: Refreshable>(_ atom: Node) async -> Node.Produced {
158163
let (key, _) = lookupAtomKeyAndOverride(of: atom)
159-
let state = getState(of: atom, for: key)
160-
let currentContext = AtomCurrentContext(store: self)
164+
let cache = lookupCache(of: atom, for: key)
165+
let localContext = switchContext(scope: cache?.initializedScope)
166+
let state = localContext.getState(of: atom, for: key)
167+
let currentContext = AtomCurrentContext(store: localContext)
161168

162169
// Detach the dependencies once to delay updating the downstream until
163170
// this atom's value refresh is complete.
@@ -167,14 +174,14 @@ internal struct StoreContext {
167174
// Restore dependencies when the refresh is completed.
168175
attachDependencies(dependencies, for: key)
169176

170-
guard let transactionState = state.transactionState, let cache = lookupCache(of: atom, for: key) else {
177+
guard let transactionState = state.transactionState, let cache else {
171178
checkAndRelease(for: key)
172179
return value
173180
}
174181

175182
// Notify update unless it's cancelled or terminated by other operations.
176183
if !Task.isCancelled && !transactionState.isTerminated {
177-
update(atom: atom, for: key, cache: cache, newValue: value)
184+
localContext.update(atom: atom, for: key, cache: cache, newValue: value)
178185
}
179186

180187
return value
@@ -186,14 +193,18 @@ internal struct StoreContext {
186193
let (key, override) = lookupAtomKeyAndOverride(of: atom)
187194

188195
if let cache = lookupCache(of: atom, for: key) {
189-
let newValue = getValue(of: atom, for: key, override: override)
190-
update(atom: atom, for: key, cache: cache, newValue: newValue)
196+
let localContext = switchContext(scope: cache.initializedScope)
197+
let newValue = localContext.getValue(of: atom, for: key, override: override)
198+
localContext.update(atom: atom, for: key, cache: cache, newValue: newValue)
191199
}
192200
}
193201

194202
@usableFromInline
195203
func reset(_ atom: some Resettable) {
196-
let currentContext = AtomCurrentContext(store: self)
204+
let (key, _) = lookupAtomKeyAndOverride(of: atom)
205+
let cache = lookupCache(of: atom, for: key)
206+
let localContext = switchContext(scope: cache?.initializedScope)
207+
let currentContext = AtomCurrentContext(store: localContext)
197208
atom.reset(context: currentContext)
198209
}
199210

@@ -306,22 +317,27 @@ private extension StoreContext {
306317
updatedScopes[currentScope.key] = currentScope
307318
}
308319

309-
func transitiveUpdate(for key: AtomKey, cache: some AtomCacheProtocol) {
320+
func updatePropagation(for key: AtomKey, cache: some AtomCacheProtocol) {
310321
// Dependents must be updated with the scope at which they were initialised.
311-
let localContext = StoreContext(
312-
store: store,
313-
rootScope: rootScope,
314-
currentScope: cache.initializedScope
315-
)
322+
let localContext = switchContext(scope: cache.initializedScope)
316323

317-
let didUpdate = localContext.transitiveUpdate(for: key, cache: cache)
324+
// Overridden atoms don't get updated transitively.
325+
let newValue = localContext.getValue(of: cache.atom, for: key, override: nil)
318326

319-
guard didUpdate else {
327+
store.state.caches[key] = cache.updated(value: newValue)
328+
329+
// Check whether if the dependent atoms should be updated transitively.
330+
guard cache.atom.producer.shouldUpdate(cache.value, newValue) else {
320331
// Record the atom to avoid downstream from being update.
321332
skippedDependencies.insert(key)
322333
return
323334
}
324335

336+
// Perform side effects before updating downstream.
337+
let state = localContext.getState(of: cache.atom, for: key)
338+
let currentContext = AtomCurrentContext(store: localContext)
339+
state.effect.updated(context: currentContext)
340+
325341
if let scope = cache.initializedScope {
326342
updatedScopes[scope.key] = scope
327343
}
@@ -362,7 +378,7 @@ private extension StoreContext {
362378

363379
if let cache, let dependencyCache {
364380
performUpdate(dependency: dependencyCache.atom) {
365-
transitiveUpdate(for: key, cache: cache)
381+
updatePropagation(for: key, cache: cache)
366382
}
367383
}
368384

@@ -384,30 +400,12 @@ private extension StoreContext {
384400
notifyUpdateToObservers(scopes: updatedScopes.values)
385401
}
386402

387-
func transitiveUpdate(for key: AtomKey, cache: some AtomCacheProtocol) -> Bool {
388-
// Overridden atoms don't get updated transitively.
389-
let newValue = getValue(of: cache.atom, for: key, override: nil)
390-
391-
store.state.caches[key] = cache.updated(value: newValue)
392-
393-
// Check whether if the dependent atoms should be updated transitively.
394-
guard cache.atom.producer.shouldUpdate(cache.value, newValue) else {
395-
return false
396-
}
397-
398-
// Perform side effects before updating downstream.
399-
let state = getState(of: cache.atom, for: key)
400-
let currentContext = AtomCurrentContext(store: self)
401-
state.effect.updated(context: currentContext)
402-
return true
403-
}
404-
405403
func release(for key: AtomKey) {
406404
let dependencies = store.graph.dependencies.removeValue(forKey: key)
407405
let state = store.state.states.removeValue(forKey: key)
406+
let cache = store.state.caches.removeValue(forKey: key)
408407

409408
store.graph.children.removeValue(forKey: key)
410-
store.state.caches.removeValue(forKey: key)
411409
store.state.subscriptions.removeValue(forKey: key)
412410

413411
if let dependencies {
@@ -419,8 +417,12 @@ private extension StoreContext {
419417

420418
state?.transactionState?.terminate()
421419

422-
let currentContext = AtomCurrentContext(store: self)
423-
state?.effect.released(context: currentContext)
420+
if let state, let cache {
421+
// It must call release effect with the scope at which they were initialised.
422+
let localContext = switchContext(scope: cache.initializedScope)
423+
let currentContext = AtomCurrentContext(store: localContext)
424+
state.effect.released(context: currentContext)
425+
}
424426
}
425427

426428
func checkAndRelease(for key: AtomKey) {
@@ -505,7 +507,8 @@ private extension StoreContext {
505507

506508
return AtomProducerContext(store: self, transactionState: transactionState) { newValue in
507509
if let cache = lookupCache(of: atom, for: key) {
508-
update(atom: atom, for: key, cache: cache, newValue: newValue)
510+
switchContext(scope: cache.initializedScope)
511+
.update(atom: atom, for: key, cache: cache, newValue: newValue)
509512
}
510513
}
511514
}
@@ -640,4 +643,12 @@ private extension StoreContext {
640643
observer.onUpdate(snapshot)
641644
}
642645
}
646+
647+
func switchContext(scope: Scope?) -> StoreContext {
648+
StoreContext(
649+
store: store,
650+
rootScope: rootScope,
651+
currentScope: scope
652+
)
653+
}
643654
}

Tests/AtomsTests/Core/StoreContextTests.swift

+140-1
Original file line numberDiff line numberDiff line change
@@ -1199,6 +1199,145 @@ final class StoreContextTests: XCTestCase {
11991199
XCTAssertEqual(effect.releasedCount, 1)
12001200
}
12011201

1202+
#if compiler(>=6)
1203+
@MainActor
1204+
func testEffectCrossScopeBoundary() async {
1205+
struct TestDependencyAtom: ValueAtom, Hashable {
1206+
func value(context: Context) -> Int {
1207+
1
1208+
}
1209+
}
1210+
1211+
struct TestAtom: StateAtom, Refreshable, Resettable {
1212+
let testEffect: TestEffect
1213+
1214+
var key: UniqueKey {
1215+
UniqueKey()
1216+
}
1217+
1218+
func defaultValue(context: Context) -> Int {
1219+
context.read(TestDependencyAtom())
1220+
}
1221+
1222+
func refresh(context: CurrentContext) async -> Int {
1223+
context.read(TestDependencyAtom())
1224+
}
1225+
1226+
func reset(context: CurrentContext) {
1227+
context.reset(TestDependencyAtom())
1228+
}
1229+
1230+
func effect(context: CurrentContext) -> some AtomEffect {
1231+
testEffect.initContext = context
1232+
return testEffect
1233+
}
1234+
}
1235+
1236+
struct TestAsyncAtom: AsyncPhaseAtom {
1237+
let testEffect: TestEffect
1238+
1239+
var key: UniqueKey {
1240+
UniqueKey()
1241+
}
1242+
1243+
func value(context: Context) async -> Int {
1244+
context.read(TestDependencyAtom())
1245+
}
1246+
1247+
func effect(context: CurrentContext) -> some AtomEffect {
1248+
testEffect.initContext = context
1249+
return testEffect
1250+
}
1251+
}
1252+
1253+
final class TestEffect: AtomEffect {
1254+
var initContext: AtomCurrentContext?
1255+
var value1: Int?
1256+
var value2: Int?
1257+
1258+
func updateValues(context: Context) {
1259+
value1 = context.read(TestDependencyAtom())
1260+
value2 = initContext?.read(TestDependencyAtom())
1261+
}
1262+
1263+
func initialized(context: Context) {
1264+
updateValues(context: context)
1265+
}
1266+
1267+
func updated(context: Context) {
1268+
updateValues(context: context)
1269+
}
1270+
1271+
func released(context: Context) {
1272+
updateValues(context: context)
1273+
}
1274+
}
1275+
1276+
let subscriberState = SubscriberState()
1277+
let subscriber = Subscriber(subscriberState)
1278+
let store = AtomStore()
1279+
let rootScopeToken = ScopeKey.Token()
1280+
let scopeToken = ScopeKey.Token()
1281+
let context = StoreContext.root(store: store, scopeKey: rootScopeToken.key)
1282+
let scopedContext = context.scoped(
1283+
scopeID: ScopeID(DefaultScopeID()),
1284+
scopeKey: scopeToken.key,
1285+
observers: [],
1286+
overrideContainer: OverrideContainer()
1287+
.addingOverride(for: TestDependencyAtom()) { _ in
1288+
2
1289+
}
1290+
)
1291+
let testEffect = TestEffect()
1292+
let testAtom = TestAtom(testEffect: testEffect)
1293+
let testAsyncAtom = TestAsyncAtom(testEffect: testEffect)
1294+
1295+
func assert<Node: Atom>(
1296+
_ atom: Node,
1297+
_ expected: Node.Produced,
1298+
file: StaticString = #filePath,
1299+
line: UInt = #line
1300+
) where Node.Produced: Equatable {
1301+
XCTAssertEqual(testEffect.value1, 1, file: file, line: line)
1302+
XCTAssertEqual(testEffect.value2, 1, file: file, line: line)
1303+
XCTAssertEqual(context.read(atom), expected, file: file, line: line)
1304+
}
1305+
1306+
_ = context.read(testAtom)
1307+
assert(testAtom, 1)
1308+
1309+
_ = context.watch(testAtom, subscriber: subscriber, subscription: Subscription())
1310+
assert(testAtom, 1)
1311+
1312+
_ = context.watch(testAsyncAtom, subscriber: subscriber, subscription: Subscription())
1313+
assert(testAsyncAtom, .suspending)
1314+
1315+
context.modify(testAtom) { $0 = 1 }
1316+
assert(testAtom, 1)
1317+
1318+
scopedContext.set(context.read(testAtom), for: testAtom)
1319+
assert(testAtom, 1)
1320+
1321+
scopedContext.modify(testAtom) { $0 = 1 }
1322+
assert(testAtom, 1)
1323+
1324+
_ = await scopedContext.refresh(testAtom)
1325+
assert(testAtom, 1)
1326+
1327+
_ = await scopedContext.refresh(testAsyncAtom)
1328+
assert(testAsyncAtom, .success(1))
1329+
1330+
scopedContext.reset(testAtom)
1331+
assert(testAtom, 1)
1332+
1333+
scopedContext.reset(testAsyncAtom)
1334+
assert(testAsyncAtom, .suspending)
1335+
1336+
scopedContext.unwatch(testAtom, subscriber: subscriber)
1337+
assert(testAtom, 1)
1338+
}
1339+
#endif
1340+
12021341
@MainActor
12031342
func testUpdateInTopologicalOrder() {
12041343
struct TestAtom1: StateAtom, Hashable {
@@ -1254,7 +1393,7 @@ final class StoreContextTests: XCTestCase {
12541393
}
12551394

12561395
@MainActor
1257-
func testTransitiveUpdate() {
1396+
func testUpdatePropagation() {
12581397
struct TestAtom1: StateAtom, Hashable {
12591398
func defaultValue(context: Context) -> Int {
12601399
0

0 commit comments

Comments
 (0)