-
Notifications
You must be signed in to change notification settings - Fork 1.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Cache store scoping when possible #2527
Conversation
@@ -24,10 +24,8 @@ final class EnumTests: BaseIntegrationTests { | |||
StoreOf<BasicsView.Feature?>.init | |||
StoreOf<BasicsView.Feature?>.init | |||
StoreOf<BasicsView.Feature?>.init | |||
StoreOf<BasicsView.Feature?>.init |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These deletions in our integration suite represent scoped stores that are able to be reused rather than discarded and recreated each render.
@@ -697,116 +813,6 @@ public final class Store<State, Action> { | |||
/// ``` | |||
public typealias StoreOf<R: Reducer> = Store<R.State, R.Action> | |||
|
|||
extension Reducer { | |||
fileprivate func rescope<ChildState, ChildAction>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since stores are now being cached, we think this rescope
machinery is no longer necessary in most cases. I also did not measure much of a performance difference in its removal in our very limited suite.
@iampatbrown Let me know your thoughts on this, especially if you have apprehensions. I think we could potentially keep rescope
for stores that are not cached, though I'm not 100% sure what that would look like.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No apprehensions :) I'm a fan of the cached approach.
Scoping via a key path is probably preferable. Computed properties might be an alternative for people using a closure as well.
let childStore = Store<ChildState, ChildAction>( | ||
initialState: initialChildState | ||
) { | ||
Reduce(internal: { [weak self] childState, childAction in |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should probably explore using a dedicated reducer conformance for scoping (as was done before with rescope
), though it'd be nice to measure the difference.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It could potentially contain some of the strong/weak dance too.
Could you explain how this affects the lifetimes of child stores? Will the parent retain them for as long as it lives, or if not then when will a child be removed from the |
@pyrtsa Child stores live as long as they are "valid". Optional/presented children are removed when the associated state goes away. While this means some stores may live longer than previously, where they'd only live as long as a view is in the hierarchy, we think it won't ever be an inordinate number. |
Many of the custom let footerRegistration = UICollectionView.SupplementaryRegistration<SupplementaryView>(
elementKind: UICollectionView.elementKindSectionFooter
) { [weak store] footerView, elementKind, indexPath in
guard let store else { return }
store.scope(
state: {
let item = $0.sections[indexPath.section].footer
return (
item: item,
elementKind: elementKind
)
},
action: { $0 }
).ifLet { [weak footerView] store in
footerView?.update(with: store)
}
.store(in: &footerView.cancellables)
} Would there be a way I could benefit from cached scoping if I am using closures to transform a parent state to a tuple child state? I imagine the answer is no because I think 99% of the time the scoped state is a subset of original state, but that can't be said for what I'm doing in the example above (elementKind value does not come from store). |
@acosmicflamingo You can always define computed properties and subscripts to make new key paths. In this case I think you could add a custom subscript that takes the element kind. Something like: extension Feature.State {
subscript(
section section: Int, elementKind elementKind: ElementKind
) -> (item: Item, elementKind: ElementKind) {
(item: self.sections[section].footer, elementKind: elementKind)
}
} And then: store.scope(
state: \.[section: indexPath.section, elementKind: elementKind],
action: { $0 }
).ifLet { [weak footerView] store in
footerView?.update(with: store)
}
.store(in: &footerView.cancellables) |
@stephencelis BRILLIANT! Thank you for the reply AND example too! |
@mbrandonw that is correct. The commit where the regression occurs only includes changes where Store.action is now being passed a casePath instead of a closure. I also verified that the scope where the issue occurs only exists in store-tree and not 1.4.2. I'll try to create a reproducer soon. |
@mbrandonw this might be quite a corner case that only impacts me because I am doing something a little different in order to work around an issue that has to do with how UICollectionView itself handles applying snapshots when the snapshot sectionIdentifiers are dynamic. There was a workaround that an Apple engineer in the UICollectionView team recommended years ago where to work around this issue I should apply an empty snapshot first and then the actual snapshot to a data source...so it might not be a coincidence that the instances where I'm getting these issues are the same ones that involve this workaround. Any time a UICollectionReusableView instance is brought into view, it contains the right data as well. I'll have to do further investigation but I do not think that this is a blocker for the store-tree release. In all other cases in my app where I utilize diffable data source snapshot API, everything works perfectly. |
@acosmicflamingo Thanks for following up. Please do let us know if you uncover anything fishy happening. |
Hmm...how does a if
let item = store.withState(\.snapshotData.sections[safeIndex: indexPath.section])
{
print("Store.withState:", item)
}
if let scopedState = store.scope(
state: \.snapshotData.sections[safeIndex: indexPath.section],
action: \.self
).withState({ $0 }) {
print("ScopedStore.withState", scopedState)
}
Prints:
Store.withState: string("A")
ScopedStore.withState string("Next Week")
Store.withState: string("B")
ScopedStore.withState string("Today") When I asked the question earlier about the SupplementaryView displaying the wrong text, I thought that there was some race condition going on (and maybe there still is), but now I can definitely say that a |
I'm pretty confident I have found the root of the problem and it looks to be a race condition. In my scenario, I'm extracting some scoped state within a cell registration closure and it appears that by getting the cached I can confirm this with some trusty print statements: ChildState has updated in scoped subscription closure with: Optional(MediaLibraryFeature.MediaLibrarySection.string("A"))
Store.withState: string("A")
Returning childStore
ScopedStore.withState string("A")
MediaLibrarySectionCell updated with A
Store.withState: string("B")
Returning childStore
ScopedStore.withState string("Today")
MediaLibrarySectionCell updated with Today
ChildState has updated in scoped subscription closure with: Optional(MediaLibraryFeature.MediaLibrarySection.string("B"))
ChildState has updated in scoped subscription closure with: Optional(MediaLibraryFeature.MediaLibrarySection.string("A"))
ChildState has updated in scoped subscription closure with: Optional(MediaLibraryFeature.MediaLibrarySection.string("B")) Notice that the first header view is correct, and the logs show that the subscription for the letter "A" is executed before any of the |
Thanks for the details, but without a minimal reproducing project there really isn't much help we can offer. I think that would be the best path forward. |
@mbrandonw Yep, I understand. Since I've narrowed the problem down, it'll be easier for me to create a reproducer. In addition, there are things I can do on my end to work around this (like slapping on a |
Here's a reproducer: https://github.com/acosmicflamingo/StoreScopingCacheReproducer.git. To reproduce, simply tap on the second segmented control button and you'll see that sometimes the header does not reflect what should be shown. These logs that I have also confirm what I'm seeing: /*
Initial:
Store.withState: first("9")
ScopedStore.withState first("9")
MusicLibrarySectionCell updated with 9
Store.withState: first("A")
ScopedStore.withState first("A")
MusicLibrarySectionCell updated with A
Store.withState: first("B")
ScopedStore.withState first("B")
MusicLibrarySectionCell updated with B
Store.withState: first("C")
ScopedStore.withState first("C")
MusicLibrarySectionCell updated with C
Store.withState: first("D")
ScopedStore.withState first("D")
MusicLibrarySectionCell updated with D
*/ /*
After tapping second segment:
Store.withState: first("E")
ScopedStore.withState first("E")
MusicLibrarySectionCell updated with E
Store.withState: first("W")
ScopedStore.withState first("9")
MusicLibrarySectionCell updated with 9
Store.withState: first("S")
ScopedStore.withState first("S")
MusicLibrarySectionCell updated with S
Store.withState: first("H")
ScopedStore.withState first("H")
MusicLibrarySectionCell updated with H
Store.withState: first("D")
ScopedStore.withState first("D")
MusicLibrarySectionCell updated with D
Store.withState: first("E")
ScopedStore.withState first("E")
MusicLibrarySectionCell updated with E
Store.withState: first("S")
ScopedStore.withState first("S")
MusicLibrarySectionCell updated with S
Store.withState: first("H")
ScopedStore.withState first("H")
MusicLibrarySectionCell updated with H
Store.withState: first("W")
ScopedStore.withState first("9")
MusicLibrarySectionCell updated with 9
Store.withState: first("D")
ScopedStore.withState first("D")
MusicLibrarySectionCell updated with D
Store.withState: first("W")
ScopedStore.withState first("9")
MusicLibrarySectionCell updated with 9
Store.withState: first("H")
ScopedStore.withState first("H")
MusicLibrarySectionCell updated with H
Store.withState: first("S")
ScopedStore.withState first("S")
MusicLibrarySectionCell updated with S
Store.withState: first("E")
ScopedStore.withState first("E")
MusicLibrarySectionCell updated with E
*/ It is a race condition so sometimes it will work, in which case you just have to rebuild and try tapping again. Any of the headers could have the issue too; it's just a matter of looking in the log and seeing that |
8a97b2f
to
5065584
Compare
It looks like the reproducer I created demonstrating this race condition still has the problem where the cached store is not guaranteed to have its sink function executed by the time it's returned, resulting in incorrect data sometimes being used to populate a UICollectionView's section headers. Is this a bug or a feature? |
@acosmicflamingo It's hard to say. In order for us to best look into the issue we think we need a smaller reproducing example. The current one is quite complex and it's hard to tell if it's an issue with the library or in UIKit. Ideally the problem could be expressed as a simple unit test, but if that's not possible then an integration test with fewer moving parts would be best. |
@mbrandonw how is this latest commit acosmicflamingo/StoreScopingCacheReproducer@07930c8? If you check it out, you'll notice that I've removed all possible models, extensions, and view/controllers that makes the app more complex than it needs to be (and hopefully makes it easier to debug this issue). I've also added an integration test that will fail about half the time:
|
…ure to from: "1.5.0" (#773) This PR contains the following updates: | Package | Update | Change | |---|---|---| | [pointfreeco/swift-composable-architecture](https://togithub.com/pointfreeco/swift-composable-architecture) | minor | `from: "1.4.2"` -> `from: "1.5.0"` | --- ### Release Notes <details> <summary>pointfreeco/swift-composable-architecture (pointfreeco/swift-composable-architecture)</summary> ### [`v1.5.0`](https://togithub.com/pointfreeco/swift-composable-architecture/releases/tag/1.5.0) [Compare Source](https://togithub.com/pointfreeco/swift-composable-architecture/compare/1.4.2...1.5.0) #### What's Changed - Added: A new `Store.scope` method that takes a state key path and action case key path ([https://github.com/pointfreeco/swift-composable-architecture/pull/2527](https://togithub.com/pointfreeco/swift-composable-architecture/pull/2527)). This method uses the hashability of key paths to cache scoped child stores in the parent, improving the performance of store scoping, especially when SwiftUI views are recomputed. See [the migration guide](https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5) for more info. - Added: DependenciesMacros is now automatically exported, making `@DependencyClient` available by default when importing the Composable Architecture (thanks [@​tgrapperon](https://togithub.com/tgrapperon), [https://github.com/pointfreeco/swift-composable-architecture/pull/2586](https://togithub.com/pointfreeco/swift-composable-architecture/pull/2586)). - Infrastructure: Fixed links in the migration guide (thanks [@​woxtu](https://togithub.com/woxtu), [https://github.com/pointfreeco/swift-composable-architecture/pull/2578](https://togithub.com/pointfreeco/swift-composable-architecture/pull/2578)). **Full Changelog**: pointfreeco/swift-composable-architecture@1.4.2...1.5.0 </details> --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://togithub.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Renovate Bot](https://togithub.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNi4xMDAuMCIsInVwZGF0ZWRJblZlciI6IjM2LjEwMC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiJ9--> Co-authored-by: Self-hosted Renovate Bot <361546+cgrindel-self-hosted-renovate[bot]@users.noreply.github.enterprise.com>
Currently, store scoping can happen quite often: whenever a view that scopes a store is recomputed, the old scoped store is discarded and a brand new scoped store is created. This involves creating a new object and parent/child subscription every single time a store is scoped. While stores are generally lightweight objects, this is still unnecessary work.
This PR introduces the ability to cache child stores directly in the parent via new
scope
methods. Thesescope
methods take a state key path instead of state transform function. The parent store uses this key path as a hashable identifier to store the child store in a dictionary. This means that after the first time a store is scoped, scoping to it again is a simple dictionary lookup.Anyone using key path expression syntax for their scopes will automatically benefit from this change, while the older style of scoping (soft deprecated for now and probably soft deprecated for some time) will mostly go through the older code path.
Adapting an existing benchmark to scope a store X number of times before sending an action to the scoped store shows a pretty significant improvement:
Before:
After: