Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/format.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Format

on:
pull_request:
push:
branch: 'master'

jobs:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,15 @@ let lazyListNavigationReducer = Reducer<

case .setNavigation(selection: .none):
if let selection = state.selection {
state.rows[selection.id]?.count = selection.count
state.rows[id: selection.id]?.count = selection.count
state.selection = nil
}
return .none

case let .setNavigationSelectionDelayCompleted(id):
state.rows[id]?.isActivityIndicatorVisible = false
state.rows[id: id]?.isActivityIndicatorVisible = false
state.selection = Identified(
CounterState(count: state.rows[id]?.count ?? 0),
CounterState(count: state.rows[id: id]?.count ?? 0),
id: id
)
return .none
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,14 @@ let eagerListNavigationReducer = Reducer<

case .setNavigation(selection: .none):
if let selection = state.selection, let count = selection.value?.count {
state.rows[selection.id]?.count = count
state.rows[id: selection.id]?.count = count
state.selection = nil
}
return .cancel(id: CancelId())

case .setNavigationSelectionDelayCompleted:
guard let id = state.selection?.id else { return .none }
state.selection?.value = CounterState(count: state.rows[id]?.count ?? 0)
state.selection?.value = CounterState(count: state.rows[id: id]?.count ?? 0)
return .none
}
},
Expand Down
13 changes: 12 additions & 1 deletion Sources/ComposableArchitecture/Reducer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,17 @@ public struct Reducer<State, Action, Environment> {
guard let (index, localAction) = toLocalAction.extract(from: globalAction) else {
return .none
}
// NB: This does not need to be a fatal error because of the index subscript that follows it.
assert(
index < globalState[keyPath: toLocalState].endIndex,
"""
Index out of range. This can happen when a reducer that can remove the last element from \
an array is then combined with a "forEach" from that array. To avoid this and other \
index-related gotchas, consider using an "IdentifiedArray" of state instead. Or, combine \
your reducers so that the "forEach" comes before any reducer that can remove elements from \
its array.
"""
)
Comment on lines +197 to +207
Copy link
Member Author

@stephencelis stephencelis May 6, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error message should help fix #21. We can finesse the wording if any of it could be improved.

return self.reducer(
&globalState[keyPath: toLocalState][index],
localAction,
Expand Down Expand Up @@ -238,7 +249,7 @@ public struct Reducer<State, Action, Environment> {
guard let (id, localAction) = toLocalAction.extract(from: globalAction) else { return .none }
return self.optional
.reducer(
&globalState[keyPath: toLocalState][id],
&globalState[keyPath: toLocalState][id: id],
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated the interface to take an explicit id parameter to avoid any ambiguity for Int-based ids.

localAction,
toLocalEnvironment(globalEnvironment)
)
Expand Down
2 changes: 1 addition & 1 deletion Sources/ComposableArchitecture/SwiftUI/ForEachStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ where Data: Collection, ID: Hashable, Content: View {
ForEach(viewStore.state, id: viewStore.id) { element in
content(
store.scope(
state: { $0[element[keyPath: viewStore.id]] ?? element },
state: { $0[id: element[keyPath: viewStore.id]] ?? element },
action: { (element[keyPath: viewStore.id], $0) }
)
)
Expand Down
8 changes: 6 additions & 2 deletions Sources/ComposableArchitecture/SwiftUI/IdentifiedArray.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,15 @@ where ID: Hashable {
}
}

public subscript(id: ID) -> Element? {
public subscript(id id: ID) -> Element? {
get {
self.dictionary[id]
}
_modify {
yield &self.dictionary[id]
if self.dictionary[id] == nil {
self.ids.removeAll(where: { $0 == id })
}
}
Comment on lines 99 to 104
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We weren't properly cleaning up the ids array when nil-ing out elements.

}

Expand All @@ -115,6 +118,7 @@ where ID: Hashable {
}
}

@discardableResult
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stumbled upon this warning when writing tests.

public mutating func remove(at position: Int) -> Element {
let id = self.ids.remove(at: position)
let element = self.dictionary[id]!
Expand All @@ -138,7 +142,7 @@ where ID: Hashable {
}

public mutating func remove(atOffsets offsets: IndexSet) {
for offset in offsets {
for offset in offsets.reversed() {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests caught a bug here, as well!

_ = self.remove(at: offset)
}
}
Expand Down
113 changes: 113 additions & 0 deletions Tests/ComposableArchitectureTests/IdentifiedArrayTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import XCTest

@testable import ComposableArchitecture

final class IdentifiedArrayTests: XCTestCase {
func testIdSubscript() {
struct User: Equatable, Identifiable {
let id: Int
var name: String
}

var array: IdentifiedArray = [User(id: 1, name: "Blob")]

XCTAssertEqual(array[id: 1], .some(User(id: 1, name: "Blob")))

array[id: 1] = nil
XCTAssertEqual(array, [])
}

func testInsert() {
struct User: Equatable, Identifiable {
let id: Int
var name: String
}

var array: IdentifiedArray = [User(id: 1, name: "Blob")]

array.insert(User(id: 2, name: "Blob Jr."), at: 0)
XCTAssertEqual(array, [User(id: 2, name: "Blob Jr."), User(id: 1, name: "Blob")])
}

func testInsertContentsOf() {
struct User: Equatable, Identifiable {
let id: Int
var name: String
}

var array: IdentifiedArray = [User(id: 1, name: "Blob")]

array.insert(contentsOf: [User(id: 3, name: "Blob Sr."), User(id: 2, name: "Blob Jr.")], at: 0)
XCTAssertEqual(
array,
[User(id: 3, name: "Blob Sr."), User(id: 2, name: "Blob Jr."), User(id: 1, name: "Blob")]
)
}

func testRemoveAt() {
struct User: Equatable, Identifiable {
let id: Int
var name: String
}

var array: IdentifiedArray = [
User(id: 3, name: "Blob Sr."),
User(id: 2, name: "Blob Jr."),
User(id: 1, name: "Blob"),
]

array.remove(at: 1)
XCTAssertEqual(array, [User(id: 3, name: "Blob Sr."), User(id: 1, name: "Blob")])
}

func testRemoveAllWhere() {
struct User: Equatable, Identifiable {
let id: Int
var name: String
}

var array: IdentifiedArray = [
User(id: 3, name: "Blob Sr."),
User(id: 2, name: "Blob Jr."),
User(id: 1, name: "Blob"),
]

array.removeAll(where: { $0.name.starts(with: "Blob ") })
XCTAssertEqual(array, [User(id: 1, name: "Blob")])
}

func testRemoveAtOffsets() {
struct User: Equatable, Identifiable {
let id: Int
var name: String
}

var array: IdentifiedArray = [
User(id: 3, name: "Blob Sr."),
User(id: 2, name: "Blob Jr."),
User(id: 1, name: "Blob"),
]

array.remove(atOffsets: [0, 2])
XCTAssertEqual(array, [User(id: 2, name: "Blob Jr.")])
}

func testMoveFromOffsets() {
struct User: Equatable, Identifiable {
let id: Int
var name: String
}

var array: IdentifiedArray = [
User(id: 3, name: "Blob Sr."),
User(id: 2, name: "Blob Jr."),
User(id: 1, name: "Blob"),
]

array.move(fromOffsets: [0], toOffset: 2)
XCTAssertEqual(
array,
[User(id: 2, name: "Blob Jr."), User(id: 3, name: "Blob Sr."), User(id: 1, name: "Blob")]
)
}
}