• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mweststrate / immer / #1139

29 Oct 2026 05:49PM UTC coverage: 46.914% (-1.9%) from 48.808%
#1139

push

702 of 756 branches covered (92.86%)

Branch coverage included in aggregate %.

1799 of 4575 relevant lines covered (39.32%)

2332.76 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.73
/src/plugins/arrayMethods.ts
1
import {
×
2
        PluginArrayMethods,
3
        latest,
4
        loadPlugin,
5
        markChanged,
6
        prepareCopy,
7
        handleCrossReference,
8
        ProxyArrayState
9
} from "../internal"
10

11
/**
12
 * Methods that directly modify the array in place.
13
 * These operate on the copy without creating per-element proxies:
14
 * - `push`, `pop`: Add/remove from end
15
 * - `shift`, `unshift`: Add/remove from start (marks all indices reassigned)
16
 * - `splice`: Add/remove at arbitrary position (marks all indices reassigned)
17
 * - `reverse`, `sort`: Reorder elements (marks all indices reassigned)
18
 */
19
type MutatingArrayMethod =
20
        | "push"
21
        | "pop"
22
        | "shift"
23
        | "unshift"
24
        | "splice"
25
        | "reverse"
26
        | "sort"
27

28
/**
29
 * Methods that read from the array without modifying it.
30
 * These fall into distinct categories based on return semantics:
31
 *
32
 * **Subset operations** (return drafts - mutations propagate):
33
 * - `filter`, `slice`: Return array of draft proxies
34
 * - `find`, `findLast`: Return single draft proxy or undefined
35
 *
36
 * **Transform operations** (return base values - mutations don't track):
37
 * - `concat`, `flat`: Create new structures, not subsets of original
38
 *
39
 * **Primitive-returning** (no draft needed):
40
 * - `findIndex`, `findLastIndex`, `indexOf`, `lastIndexOf`: Return numbers
41
 * - `some`, `every`, `includes`: Return booleans
42
 * - `join`, `toString`, `toLocaleString`: Return strings
43
 */
44
type NonMutatingArrayMethod =
45
        | "filter"
46
        | "slice"
47
        | "concat"
48
        | "flat"
49
        | "find"
50
        | "findIndex"
51
        | "findLast"
52
        | "findLastIndex"
53
        | "some"
54
        | "every"
55
        | "indexOf"
56
        | "lastIndexOf"
57
        | "includes"
58
        | "join"
59
        | "toString"
60
        | "toLocaleString"
61

62
/** Union of all array operation methods handled by the plugin. */
63
export type ArrayOperationMethod = MutatingArrayMethod | NonMutatingArrayMethod
64

65
/**
66
 * Enables optimized array method handling for Immer drafts.
67
 *
68
 * This plugin overrides array methods to avoid unnecessary Proxy creation during iteration,
69
 * significantly improving performance for array-heavy operations.
70
 *
71
 * **Mutating methods** (push, pop, shift, unshift, splice, sort, reverse):
72
 * Operate directly on the copy without creating per-element proxies.
73
 *
74
 * **Non-mutating methods** fall into categories:
75
 * - **Subset operations** (filter, slice, find, findLast): Return draft proxies - mutations track
76
 * - **Transform operations** (concat, flat): Return base values - mutations don't track
77
 * - **Primitive-returning** (indexOf, includes, some, every, etc.): Return primitives
78
 *
79
 * **Important**: Callbacks for overridden methods receive base values, not drafts.
80
 * This is the core performance optimization.
81
 *
82
 * @example
83
 * ```ts
84
 * import { enableArrayMethods, produce } from "immer"
85
 *
86
 * enableArrayMethods()
87
 *
88
 * const next = produce(state, draft => {
89
 *   // Optimized - no proxy creation per element
90
 *   draft.items.sort((a, b) => a.value - b.value)
91
 *
92
 *   // filter returns drafts - mutations propagate
93
 *   const filtered = draft.items.filter(x => x.value > 5)
94
 *   filtered[0].value = 999 // Affects draft.items[originalIndex]
95
 * })
96
 * ```
97
 *
98
 * @see https://immerjs.github.io/immer/array-methods
99
 */
100
export function enableArrayMethods() {
1 ✔
101
        const SHIFTING_METHODS = new Set<MutatingArrayMethod>(["shift", "unshift"])
1 ✔
102

103
        const QUEUE_METHODS = new Set<MutatingArrayMethod>(["push", "pop"])
1 ✔
104

105
        const RESULT_RETURNING_METHODS = new Set<MutatingArrayMethod>([
1 ✔
106
                ...QUEUE_METHODS,
1 ✔
107
                ...SHIFTING_METHODS
1 ✔
108
        ])
1 ✔
109

110
        const REORDERING_METHODS = new Set<MutatingArrayMethod>(["reverse", "sort"])
1 ✔
111

112
        // Optimized method detection using array-based lookup
113
        const MUTATING_METHODS = new Set<MutatingArrayMethod>([
1 ✔
114
                ...RESULT_RETURNING_METHODS,
1 ✔
115
                ...REORDERING_METHODS,
1 ✔
116
                "splice"
1 ✔
117
        ])
1 ✔
118

119
        const FIND_METHODS = new Set<NonMutatingArrayMethod>(["find", "findLast"])
1 ✔
120

121
        const NON_MUTATING_METHODS = new Set<NonMutatingArrayMethod>([
1 ✔
122
                "filter",
1 ✔
123
                "slice",
1 ✔
124
                "concat",
1 ✔
125
                "flat",
1 ✔
126
                ...FIND_METHODS,
1 ✔
127
                "findIndex",
1 ✔
128
                "findLastIndex",
1 ✔
129
                "some",
1 ✔
130
                "every",
1 ✔
131
                "indexOf",
1 ✔
132
                "lastIndexOf",
1 ✔
133
                "includes",
1 ✔
134
                "join",
1 ✔
135
                "toString",
1 ✔
136
                "toLocaleString"
1 ✔
137
        ])
1 ✔
138

139
        // Type guard for method detection
140
        function isMutatingArrayMethod(
1 ✔
141
                method: string
1,722 ✔
142
        ): method is MutatingArrayMethod {
1,722 ✔
143
                return MUTATING_METHODS.has(method as any)
1,722 ✔
144
        }
1,722 ✔
145

146
        function isNonMutatingArrayMethod(
1,722 ✔
147
                method: string
916 ✔
148
        ): method is NonMutatingArrayMethod {
916 ✔
149
                return NON_MUTATING_METHODS.has(method as any)
916 ✔
150
        }
916 ✔
151

152
        function isArrayOperationMethod(
916 ✔
153
                method: string
981 ✔
154
        ): method is ArrayOperationMethod {
981 ✔
155
                return isMutatingArrayMethod(method) || isNonMutatingArrayMethod(method)
981 ✔
156
        }
916 ✔
157

158
        function enterOperation(
916 ✔
159
                state: ProxyArrayState,
1 ✔
160
                method: ArrayOperationMethod
165 ✔
161
        ) {
165 ✔
162
                state.operationMethod = method
165 ✔
163
        }
165 ✔
164

165
        function exitOperation(state: ProxyArrayState) {
1 ✔
166
                state.operationMethod = undefined
165 ✔
167
        }
165 ✔
168

169
        // Shared utility functions for array method handlers
170
        function executeArrayMethod<T>(
165 ✔
171
                state: ProxyArrayState,
12 ✔
172
                operation: () => T,
12 ✔
173
                markLength = true
12 ✔
174
        ): T {
12 ✔
175
                prepareCopy(state)
12 ✔
176
                const result = operation()
12 ✔
177
                markArrayChanged(state, markLength)
12 ✔
178
                return result
12 ✔
179
        }
12 ✔
180

181
        /**
182
         * Marks the array (and its ancestors) as modified after a mutating operation.
183
         *
184
         * Kept separate from `executeArrayMethod` so that operations which turn out to
185
         * be no-ops (e.g. `push()` with no arguments, `splice(i, 0)`, `pop()`/`shift()`
186
         * on an empty array) can prepare a copy but skip marking, preserving Immer's
187
         * structural-sharing guarantee that a change-free producer returns the base.
188
         */
189
        function markArrayChanged(state: ProxyArrayState, markLength = true) {
1 ✔
190
                markChanged(state)
53 ✔
191
                if (markLength) state.assigned_!.set("length", true)
53 ✔
192
        }
41 ✔
193

194
        function markAllIndicesReassigned(state: ProxyArrayState) {
1 ✔
195
                state.allIndicesReassigned_ = true
33 ✔
196
                state.baseRefs_ = new Set(state.base_)
33 ✔
197
        }
33 ✔
198

199
        function normalizeSliceIndex(index: number, length: number): number {
1 ✔
200
                if (index < 0) {
19 ✔
201
                        return Math.max(length + index, 0)
19 ✔
202
                }
2 ✔
203
                return Math.min(index, length)
19 ✔
204
        }
17 ✔
205

206
        /**
207
         * Calls handleCrossReference for each value being inserted into the array,
208
         * and marks the corresponding indices as assigned in `assigned_`.
209
         *
210
         * This ensures nested drafts inside inserted values (e.g. from spreading
211
         * a draft object) are properly finalized, matching the behavior of the
212
         * proxy set trap which calls handleCrossReference on every assignment.
213
         *
214
         * Without this, values containing draft proxies (like `{...state[0]}`)
215
         * pushed via the array methods plugin would have their nested drafts
216
         * revoked during finalization without being replaced by final values.
217
         *
218
         * The index is stringified because the proxy traps only ever see property
219
         * names, so `assigned_` is keyed by string everywhere else. A numeric key
220
         * would be invisible to the readers that look indices up by name, such as
221
         * patch generation.
222
         */
223
        function handleInsertedValues(
17 ✔
224
                state: ProxyArrayState,
28 ✔
225
                startIndex: number,
28 ✔
226
                values: any[]
28 ✔
227
        ) {
28 ✔
228
                for (let i = 0; i < values.length; i++) {
28 ✔
229
                        const index = "" + (startIndex + i)
28 ✔
230
                        state.assigned_!.set(index, true)
35 ✔
231
                        handleCrossReference(state, index, values[i])
35 ✔
232
                }
35 ✔
233
        }
35 ✔
234

235
        /**
236
         * Handles mutating operations that add/remove elements (push, pop, shift, unshift, splice).
237
         *
238
         * Operates directly on `state.copy_` without creating per-element proxies.
239
         * For shifting methods (shift, unshift), marks all indices as reassigned since
240
         * indices shift.
241
         *
242
         * @returns For push/pop/shift/unshift: the native method result. For others: the draft.
243
         */
244
        function handleSimpleOperation(
35 ✔
245
                state: ProxyArrayState,
38 ✔
246
                method: string,
38 ✔
247
                args: any[]
38 ✔
248
        ) {
38 ✔
249
                // Detect no-op calls before creating a copy so structural sharing is
250
                // preserved: push/unshift with no arguments add nothing, and pop/shift
251
                // on an empty array remove nothing. The check must happen before
252
                // prepareCopy - a stray copy_ on an unmodified state would be picked
253
                // up by finalization of a parent Set and break identity there.
254
                const isInsert = method === "push" || method === "unshift"
38 ✔
255
                if (isInsert ? args.length === 0 : latest(state).length === 0) {
38 ✔
256
                        // Match the native return values: push/unshift return the
257
                        // (unchanged) length, pop/shift on an empty array return undefined
258
                        return isInsert ? latest(state).length : undefined
38 ✔
259
                }
3 ✔
260

261
                prepareCopy(state)
38 ✔
262

263
                // For push/unshift, capture the length before the operation
264
                // so we can compute insertion indices for handleCrossReference
265
                const lengthBefore = state.copy_!.length
32 ✔
266

267
                const result = (state.copy_! as any)[method](...args)
32 ✔
268

269
                markArrayChanged(state)
32 ✔
270

271
                // Handle index reassignment for shifting methods
272
                if (SHIFTING_METHODS.has(method as MutatingArrayMethod)) {
32 ✔
273
                        markAllIndicesReassigned(state)
38 ✔
274
                }
12 ✔
275

276
                // Handle cross-references for newly inserted values.
277
                // push appends at the end, unshift inserts at the beginning.
278
                if (method === "push") {
38 ✔
279
                        handleInsertedValues(state, lengthBefore, args)
38 ✔
280
                } else if (method === "unshift") {
38 ✔
281
                        handleInsertedValues(state, 0, args)
18 ✔
282
                }
7 ✔
283

284
                // Return appropriate value based on method
285
                return RESULT_RETURNING_METHODS.has(method as MutatingArrayMethod)
38 ✔
286
                        ? result
32 ✔
287
                        : state.draft_
32 ✔
288
        }
32 ✔
289

290
        /**
291
         * Handles reordering operations (reverse, sort) that change element order.
292
         *
293
         * Operates directly on `state.copy_` and marks all indices as reassigned
294
         * since element positions change. Does not mark length as changed since
295
         * these operations preserve array length.
296
         *
297
         * @returns The draft proxy for method chaining.
298
         */
299
        function handleReorderingOperation(
38 !
300
                state: ProxyArrayState,
16 ✔
301
                method: string,
16 ✔
302
                args: any[]
16 ✔
303
        ) {
16 ✔
304
                // Reordering an array with fewer than two elements cannot change it,
305
                // so preserve structural sharing and skip copying (see
306
                // handleSimpleOperation for why this must run before prepareCopy).
307
                if (latest(state).length <= 1) return state.draft_
16 ✔
308

309
                return executeArrayMethod(
16 ✔
310
                        state,
12 ✔
311
                        () => {
12 ✔
312
                                ;(state.copy_! as any)[method](...args)
12 ✔
313
                                markAllIndicesReassigned(state)
12 ✔
314
                                return state.draft_
12 ✔
315
                        },
12 ✔
316
                        false
12 ✔
317
                ) // Don't mark length as changed
12 ✔
318
        }
12 ✔
319

320
        /**
321
         * Creates an interceptor function for a specific array method.
322
         *
323
         * The interceptor wraps array method calls to:
324
         * 1. Set `state.operationMethod` flag during execution (allows proxy `get` trap
325
         *    to detect we're inside an optimized method and skip proxy creation)
326
         * 2. Route to appropriate handler based on method type
327
         * 3. Clean up the operation flag in `finally` block
328
         *
329
         * The `operationMethod` flag is the key mechanism that enables the proxy's `get`
330
         * trap to return base values instead of creating nested proxies during iteration.
331
         *
332
         * @param state - The proxy array state
333
         * @param originalMethod - Name of the array method being intercepted
334
         * @returns Interceptor function that handles the method call
335
         */
336
        function createMethodInterceptor(
12 ✔
337
                state: ProxyArrayState,
165 ✔
338
                originalMethod: string
165 ✔
339
        ) {
165 ✔
340
                return function interceptedMethod(...args: any[]) {
165 ✔
341
                        // Enter operation mode - this flag tells the proxy's get trap to return
342
                        // base values instead of creating nested proxies during iteration
343
                        const method = originalMethod as ArrayOperationMethod
165 ✔
344
                        enterOperation(state, method)
165 ✔
345

346
                        try {
165 ✔
347
                                // Check if this is a mutating method
348
                                if (isMutatingArrayMethod(method)) {
165 ✔
349
                                        // Direct method dispatch - no configuration lookup needed
350
                                        if (RESULT_RETURNING_METHODS.has(method)) {
165 ✔
351
                                                return handleSimpleOperation(state, method, args)
65 ✔
352
                                        }
38 ✔
353
                                        if (REORDERING_METHODS.has(method)) {
65 ✔
354
                                                return handleReorderingOperation(state, method, args)
65 ✔
355
                                        }
16 ✔
356

357
                                        if (method === "splice") {
65 ✔
358
                                                const insertCount = args.length > 2 ? args.length - 2 : 0
11 ✔
359

360
                                                // Detect no-op calls before creating a copy so structural
361
                                                // sharing is preserved (see handleSimpleOperation). A splice
362
                                                // removes nothing when called with no arguments, when the
363
                                                // normalized start is at/past the end, or when the delete
364
                                                // count coerces to less than 1. splice(start) with a single
365
                                                // argument removes everything from start onwards, so it must
366
                                                // not match. Exotic arguments that slip through are caught
367
                                                // by the post-copy check below.
368
                                                if (insertCount === 0) {
11 ✔
369
                                                        const length = latest(state).length
11 ✔
370
                                                        if (
4 ✔
371
                                                                args.length === 0 ||
4 ✔
372
                                                                normalizeSliceIndex(args[0] ?? 0, length) === length ||
4 !
373
                                                                (args.length > 1 && !(args[1] >= 1))
4 ✔
374
                                                        ) {
4 ✔
375
                                                                return []
4 ✔
376
                                                        }
2 ✔
377
                                                }
2 ✔
378

379
                                                prepareCopy(state)
11 ✔
380
                                                // The start index must be resolved against the length the array
9 ✔
381
                                                // had before the splice, like the native method does
9 ✔
382
                                                const lengthBefore = state.copy_!.length
9 ✔
383
                                                const res = state.copy_!.splice(
384
                                                        ...(args as [number, number, ...any[]])
385
                                                )
386

387
                                                // Backstop for exotic arguments the pre-check above missed:
9 ✔
388
                                                // if nothing was removed and nothing inserted, the array is
11 !
389
                                                // unchanged and must not be marked as modified.
×
390
                                                if (res.length === 0 && insertCount === 0) {
391
                                                        return res
11 ✔
392
                                                }
9 ✔
393

394
                                                markArrayChanged(state)
9 ✔
395
                                                markAllIndicesReassigned(state)
11 ✔
396
                                                // Handle cross-references for inserted values (args from index 2+)
7 ✔
397
                                                if (insertCount > 0) {
7 !
398
                                                        const startIndex = normalizeSliceIndex(args[0] ?? 0, lengthBefore)
7 ✔
399
                                                        handleInsertedValues(state, startIndex, args.slice(2))
7 ✔
400
                                                }
7 ✔
401
                                                return res
7 ✔
402
                                        }
9 ✔
403
                                } else {
9 ✔
404
                                        // Handle non-mutating methods
405
                                        return handleNonMutatingOperation(state, method, args)
165 ✔
406
                                }
100 ✔
407
                        } finally {
100 ✔
408
                                // Always exit operation mode - must be in finally to handle exceptions
409
                                exitOperation(state)
165 ✔
410
                        }
165 ✔
411
                }
165 ✔
412
        }
165 ✔
413

414
        /**
415
         * Handles non-mutating array methods with different return semantics.
416
         *
417
         * **Subset operations** return draft proxies for mutation tracking:
418
         * - `filter`, `slice`: Return `state.draft_[i]` for each selected element
419
         * - `find`, `findLast`: Return `state.draft_[i]` for the found element
420
         *
421
         * This allows mutations on returned elements to propagate back to the draft:
422
         * ```ts
423
         * const filtered = draft.items.filter(x => x.value > 5)
424
         * filtered[0].value = 999 // Mutates draft.items[originalIndex]
425
         * ```
426
         *
427
         * **Transform operations** return base values (no draft tracking):
428
         * - `concat`, `flat`: These create NEW arrays rather than selecting subsets.
429
         *   Since the result structure differs from the original, tracking mutations
430
         *   back to specific draft indices would be impractical/impossible.
431
         *
432
         * **Primitive operations** return the native result directly:
433
         * - `indexOf`, `includes`, `some`, `every`, `join`, etc.
434
         *
435
         * @param state - The proxy array state
436
         * @param method - The non-mutating method name
437
         * @param args - Arguments passed to the method
438
         * @returns Drafts for subset operations, base values for transforms, primitives otherwise
439
         */
440
        function handleNonMutatingOperation(
165 ✔
441
                state: ProxyArrayState,
100 ✔
442
                method: NonMutatingArrayMethod,
100 ✔
443
                args: any[]
100 ✔
444
        ) {
100 ✔
445
                const source = latest(state)
100 ✔
446

447
                // Methods that return arrays with selected items - need to return drafts
448
                if (method === "filter") {
100 ✔
449
                        const predicate = args[0]
100 ✔
450
                        const thisArg = args[1]
23 ✔
451
                        const result: any[] = []
23 ✔
452

453
                        // First pass: call predicate on base values to determine which items pass
454
                        const length = source.length
23 ✔
455
                        for (let i = 0; i < length; i++) {
23 ✔
456
                                if (predicate.call(thisArg, source[i], i, source)) {
457
                                        // Only create draft for items that passed the predicate
1,112 ✔
458
                                        result.push(state.draft_[i])
564 ✔
459
                                }
564 ✔
460
                        }
461

564 ✔
462
                        return result
23 ✔
463
                }
464

100 ✔
465
                if (FIND_METHODS.has(method)) {
100 ✔
466
                        const predicate = args[0]
13 ✔
467
                        const thisArg = args[1]
13 ✔
468
                        const length = source.length
13 ✔
469
                        const isForward = method === "find"
13 ✔
470
                        const step = isForward ? 1 : -1
471
                        const start = isForward ? 0 : length - 1
13 ✔
472

13 ✔
473
                        for (let i = start; i >= 0 && i < length; i += step) {
33 ✔
474
                                if (predicate.call(thisArg, source[i], i, source)) {
8 ✔
475
                                        return state.draft_[i]
8 ✔
476
                                }
8 ✔
477
                        }
5 ✔
478
                        return undefined
479
                }
100 ✔
480

100 ✔
481
                if (method === "slice") {
4 ✔
482
                        const rawStart = args[0] ?? 0
483
                        const rawEnd = args[1] ?? source.length
484

4 ✔
485
                        // Normalize negative indices
4 ✔
486
                        const start = normalizeSliceIndex(rawStart, source.length)
487
                        const end = normalizeSliceIndex(rawEnd, source.length)
4 ✔
488

489
                        const result: any[] = []
490

4 ✔
491
                        // Return drafts for items in the slice range
4 ✔
492
                        for (let i = start; i < end; i++) {
11 ✔
493
                                result.push(state.draft_[i])
494
                        }
11 ✔
495

4 ✔
496
                        return result
497
                }
498

499
                // For other methods, call on base array directly:
500
                // - indexOf, includes, join, toString: Return primitives, no draft needed
501
                // - concat, flat: Return NEW arrays (not subsets). Elements are base values.
502
                //   This is intentional - concat/flat create new data structures rather than
100 ✔
503
                //   selecting subsets of the original, making draft tracking impractical.
60 ✔
504
                return source[method as keyof typeof Array.prototype](...args)
505
        }
1 ✔
506

1 ✔
507
        loadPlugin(PluginArrayMethods, {
1 ✔
508
                createMethodInterceptor,
1 ✔
509
                isArrayOperationMethod,
1 ✔
510
                isMutatingArrayMethod
1 ✔
511
        })
512
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc