Skip to content

Commit d0b3ba5

Browse files
committed
Ensure we have a mostly-sorted items array to speed up sorting
1 parent 90cc14b commit d0b3ba5

File tree

2 files changed

+128
-6
lines changed

2 files changed

+128
-6
lines changed

packages/toolkit/src/entities/sorted_state_adapter.ts

+41-4
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export function createSortedStateAdapter<T, Id extends EntityId>(
7676
state: R,
7777
): void {
7878
let appliedUpdates = false
79+
let replacedIds = false
7980

8081
for (let update of updates) {
8182
const entity: T | undefined = (state.entities as Record<Id, T>)[update.id]
@@ -88,13 +89,16 @@ export function createSortedStateAdapter<T, Id extends EntityId>(
8889
Object.assign(entity, update.changes)
8990
const newId = selectId(entity)
9091
if (update.id !== newId) {
92+
replacedIds = true
9193
delete (state.entities as Record<Id, T>)[update.id]
94+
const oldIndex = (state.ids as Id[]).indexOf(update.id)
95+
state.ids[oldIndex] = newId
9296
;(state.entities as Record<Id, T>)[newId] = entity
9397
}
9498
}
9599

96100
if (appliedUpdates) {
97-
resortEntities(state)
101+
resortEntities(state, [], replacedIds)
98102
}
99103
}
100104

@@ -136,11 +140,44 @@ export function createSortedStateAdapter<T, Id extends EntityId>(
136140
;(state.entities as Record<Id, T>)[selectId(model)] = model
137141
})
138142

139-
resortEntities(state)
143+
resortEntities(state, models)
140144
}
141145

142-
function resortEntities(state: R) {
143-
const allEntities = Object.values(state.entities) as T[]
146+
function resortEntities(
147+
state: R,
148+
addedItems: readonly T[] = [],
149+
replacedIds = false,
150+
) {
151+
let allEntities: T[]
152+
153+
allEntities = Object.values(state.entities) as T[]
154+
if (replacedIds) {
155+
// This is a really annoying edge case. Just figure this out from scratch
156+
// rather than try to be clever. This will be more expensive since it isn't sorted right.
157+
allEntities = Object.values(state.entities) as T[]
158+
} else {
159+
// We're starting with an already-sorted list.
160+
let existingIds = state.ids
161+
162+
if (addedItems.length) {
163+
// There's a couple edge cases where we can have duplicate item IDs.
164+
// Ensure we don't have duplicates.
165+
const uniqueIds = new Set(existingIds as Id[])
166+
167+
addedItems.forEach((item) => {
168+
uniqueIds.add(selectId(item))
169+
})
170+
existingIds = Array.from(uniqueIds)
171+
}
172+
173+
// By this point `ids` and `entities` should be 1:1, but not necessarily sorted.
174+
// Make this a sorta-mostly-sorted array.
175+
allEntities = existingIds.map(
176+
(id) => (state.entities as Record<Id, T>)[id as Id],
177+
)
178+
}
179+
180+
// Now when we sort, things should be _close_ already, and fewer comparisons are needed.
144181
allEntities.sort(sort)
145182

146183
const newSortedIds = allEntities.map(selectId)

packages/toolkit/src/entities/tests/sorted_state_adapter.test.ts

+87-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import type { EntityAdapter, EntityState } from '../models'
22
import { createEntityAdapter } from '../create_adapter'
3-
import { createAction, createSlice, configureStore } from '@reduxjs/toolkit'
3+
import {
4+
createAction,
5+
createSlice,
6+
configureStore,
7+
nanoid,
8+
} from '@reduxjs/toolkit'
49
import type { BookModel } from './fixtures/book'
510
import {
611
TheGreatGatsby,
@@ -247,7 +252,7 @@ describe('Sorted State Adapter', () => {
247252

248253
const { ids, entities } = withUpdated
249254

250-
expect(ids.length).toBe(2)
255+
expect(ids).toEqual(['a', 'c'])
251256
expect(entities.a).toBeTruthy()
252257
expect(entities.b).not.toBeTruthy()
253258
expect(entities.c).toBeTruthy()
@@ -584,6 +589,86 @@ describe('Sorted State Adapter', () => {
584589
expect(withUpdate.entities['b']!.title).toBe(book1.title)
585590
})
586591

592+
it('should minimize the amount of sorting work needed', () => {
593+
const PARAMETERS = {
594+
NUM_ITEMS: 10_000,
595+
}
596+
597+
type Entity = { id: string; name: string; position: number }
598+
599+
let numSorts = 0
600+
601+
const adaptor = createEntityAdapter({
602+
selectId: (entity: Entity) => entity.id,
603+
sortComparer: (a, b) => {
604+
numSorts++
605+
if (a.position < b.position) return -1
606+
else if (a.position > b.position) return 1
607+
return 0
608+
},
609+
})
610+
611+
const initialState: Entity[] = new Array(PARAMETERS.NUM_ITEMS)
612+
.fill(undefined)
613+
.map((x, i) => ({
614+
name: `${i}`,
615+
position: Math.random(),
616+
id: nanoid(),
617+
}))
618+
619+
const entitySlice = createSlice({
620+
name: 'entity',
621+
initialState: adaptor.getInitialState(undefined, initialState),
622+
reducers: {
623+
updateOne: adaptor.updateOne,
624+
upsertOne: adaptor.upsertOne,
625+
upsertMany: adaptor.upsertMany,
626+
},
627+
})
628+
629+
const store = configureStore({
630+
reducer: {
631+
entity: entitySlice.reducer,
632+
},
633+
middleware: (getDefaultMiddleware) => {
634+
return getDefaultMiddleware({
635+
serializableCheck: false,
636+
immutableCheck: false,
637+
})
638+
},
639+
})
640+
641+
store.dispatch(
642+
entitySlice.actions.upsertOne({
643+
id: nanoid(),
644+
position: Math.random(),
645+
name: 'test',
646+
}),
647+
)
648+
649+
// These numbers will vary because of the randomness, but generally
650+
// with 10K items the old code had 200K+ sort calls, while the new code
651+
// is around 130K sort calls.
652+
expect(numSorts).toBeLessThan(200_000)
653+
654+
const { ids } = store.getState().entity
655+
const middleItemId = ids[(ids.length / 2) | 0]
656+
657+
numSorts = 0
658+
659+
store.dispatch(
660+
// Move this middle item near the end
661+
entitySlice.actions.updateOne({
662+
id: middleItemId,
663+
changes: {
664+
position: 0.99999,
665+
},
666+
}),
667+
)
668+
// The old code was around 120K, the new code is around 10K.
669+
expect(numSorts).toBeLessThan(25_000)
670+
})
671+
587672
describe('can be used mutably when wrapped in createNextState', () => {
588673
test('removeAll', () => {
589674
const withTwo = adapter.addMany(state, [TheGreatGatsby, AnimalFarm])

0 commit comments

Comments
 (0)