diff --git a/__tests__/__prod_snapshots__/base.js.snap b/__tests__/__prod_snapshots__/base.js.snap index 57234af2..cedad8ae 100644 --- a/__tests__/__prod_snapshots__/base.js.snap +++ b/__tests__/__prod_snapshots__/base.js.snap @@ -1,13 +1,27 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > map drafts > revokes map proxies 1`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > map drafts > revokes map proxies 2`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > recipe functions > cannot return a modified child draft 1`] = `[Error: [Immer] minified error nr: 4. Full error at: https://bit.ly/3cXEKWf]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > set drafts > revokes sets 1`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > set drafts > revokes sets 2`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > throws when Object.defineProperty() is used on drafts 1`] = `[Error: [Immer] minified error nr: 11. Full error at: https://bit.ly/3cXEKWf]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > throws when Object.setPrototypeOf() is used on a draft 1`] = `[Error: [Immer] minified error nr: 12. Full error at: https://bit.ly/3cXEKWf]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > throws when the draft is modified and another object is returned 1`] = `[Error: [Immer] minified error nr: 4. Full error at: https://bit.ly/3cXEKWf]`; + exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=false > map drafts > revokes map proxies 1`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=false > map drafts > revokes map proxies 2`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=false > recipe functions > cannot return a modified child draft 1`] = `[Error: [Immer] minified error nr: 4. Full error at: https://bit.ly/3cXEKWf]`; -exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=false > recipe functions > cannot return an object that references itself 1`] = `[RangeError: Maximum call stack size exceeded]`; - exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=false > set drafts > revokes sets 1`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=false > set drafts > revokes sets 2`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; @@ -24,8 +38,6 @@ exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener= exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=true > recipe functions > cannot return a modified child draft 1`] = `[Error: [Immer] minified error nr: 4. Full error at: https://bit.ly/3cXEKWf]`; -exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=true > recipe functions > cannot return an object that references itself 1`] = `[RangeError: Maximum call stack size exceeded]`; - exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=true > set drafts > revokes sets 1`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=true > set drafts > revokes sets 2`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; @@ -42,8 +54,6 @@ exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=f exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=false > recipe functions > cannot return a modified child draft 1`] = `[Error: [Immer] minified error nr: 4. Full error at: https://bit.ly/3cXEKWf]`; -exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=false > recipe functions > cannot return an object that references itself 1`] = `[RangeError: Maximum call stack size exceeded]`; - exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=false > set drafts > revokes sets 1`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=false > set drafts > revokes sets 2`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; @@ -60,8 +70,6 @@ exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=t exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=true > recipe functions > cannot return a modified child draft 1`] = `[Error: [Immer] minified error nr: 4. Full error at: https://bit.ly/3cXEKWf]`; -exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=true > recipe functions > cannot return an object that references itself 1`] = `[RangeError: Maximum call stack size exceeded]`; - exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=true > set drafts > revokes sets 1`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=true > set drafts > revokes sets 2`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; @@ -78,8 +86,6 @@ exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=f exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=false > recipe functions > cannot return a modified child draft 1`] = `[Error: [Immer] minified error nr: 4. Full error at: https://bit.ly/3cXEKWf]`; -exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=false > recipe functions > cannot return an object that references itself 1`] = `[RangeError: Maximum call stack size exceeded]`; - exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=false > set drafts > revokes sets 1`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=false > set drafts > revokes sets 2`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; @@ -96,8 +102,6 @@ exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=t exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=true > recipe functions > cannot return a modified child draft 1`] = `[Error: [Immer] minified error nr: 4. Full error at: https://bit.ly/3cXEKWf]`; -exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=true > recipe functions > cannot return an object that references itself 1`] = `[RangeError: Maximum call stack size exceeded]`; - exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=true > set drafts > revokes sets 1`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=true > set drafts > revokes sets 2`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; @@ -114,8 +118,6 @@ exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=fa exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=false > recipe functions > cannot return a modified child draft 1`] = `[Error: [Immer] minified error nr: 4. Full error at: https://bit.ly/3cXEKWf]`; -exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=false > recipe functions > cannot return an object that references itself 1`] = `[RangeError: Maximum call stack size exceeded]`; - exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=false > set drafts > revokes sets 1`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=false > set drafts > revokes sets 2`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; @@ -132,8 +134,6 @@ exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=tr exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=true > recipe functions > cannot return a modified child draft 1`] = `[Error: [Immer] minified error nr: 4. Full error at: https://bit.ly/3cXEKWf]`; -exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=true > recipe functions > cannot return an object that references itself 1`] = `[RangeError: Maximum call stack size exceeded]`; - exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=true > set drafts > revokes sets 1`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=true > set drafts > revokes sets 2`] = `[Error: [Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf]`; @@ -519,3 +519,50 @@ exports[`complex nesting map / set / object > modify deep object 16`] = ` }, ] `; + +exports[`complex nesting map / set / object > modify deep object 17`] = ` +{ + "map": Map { + "set1" => Set { + { + "a": 2, + }, + { + "b": 2, + }, + }, + "set2" => Set { + { + "c": 3, + }, + }, + }, +} +`; + +exports[`complex nesting map / set / object > modify deep object 18`] = ` +[ + { + "op": "remove", + "path": [ + "map", + "set1", + 0, + ], + "value": { + "a": 1, + }, + }, + { + "op": "add", + "path": [ + "map", + "set1", + 0, + ], + "value": { + "a": 2, + }, + }, +] +`; diff --git a/__tests__/__snapshots__/base.js.snap b/__tests__/__snapshots__/base.js.snap index 6f98a59a..f7278952 100644 --- a/__tests__/__snapshots__/base.js.snap +++ b/__tests__/__snapshots__/base.js.snap @@ -1,5 +1,41 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > array drafts > throws when a non-numeric property is added 1`] = `[Error: [Immer] Immer only supports setting array indices and the 'length' property]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > array drafts > throws when a non-numeric property is deleted 1`] = `[Error: [Immer] Immer only supports deleting array indices]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > map drafts > revokes map proxies 1`] = `[Error: [Immer] Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process? {}]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > map drafts > revokes map proxies 2`] = `[Error: [Immer] Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process? {}]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > recipe functions > cannot return a modified child draft 1`] = `[Error: [Immer] An immer producer returned a new value *and* modified its draft. Either return a new value *or* modify the draft.]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > revokes the draft once produce returns 1`] = `[TypeError: Cannot perform 'get' on a proxy that has been revoked]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > revokes the draft once produce returns 2`] = `[TypeError: Cannot perform 'set' on a proxy that has been revoked]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > revokes the draft once produce returns 3`] = `[TypeError: Cannot perform 'get' on a proxy that has been revoked]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > revokes the draft once produce returns 4`] = `[TypeError: Cannot perform 'set' on a proxy that has been revoked]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > revokes the draft once produce returns 5`] = `[TypeError: Cannot perform 'get' on a proxy that has been revoked]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > revokes the draft once produce returns 6`] = `[TypeError: Cannot perform 'set' on a proxy that has been revoked]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > revokes the draft once produce returns 7`] = `[TypeError: Cannot perform 'get' on a proxy that has been revoked]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > revokes the draft once produce returns 8`] = `[TypeError: Cannot perform 'set' on a proxy that has been revoked]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > set drafts > revokes sets 1`] = `[Error: [Immer] Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process? {}]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > set drafts > revokes sets 2`] = `[Error: [Immer] Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process? {}]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > throws when Object.defineProperty() is used on drafts 1`] = `[Error: [Immer] Object.defineProperty() cannot be used on an Immer draft]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > throws when Object.setPrototypeOf() is used on a draft 1`] = `[Error: [Immer] Object.setPrototypeOf() cannot be used on an Immer draft]`; + +exports[`base functionality - array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false > throws when the draft is modified and another object is returned 1`] = `[Error: [Immer] An immer producer returned a new value *and* modified its draft. Either return a new value *or* modify the draft.]`; + exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=false > array drafts > throws when a non-numeric property is added 1`] = `[Error: [Immer] Immer only supports setting array indices and the 'length' property]`; exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=false > array drafts > throws when a non-numeric property is deleted 1`] = `[Error: [Immer] Immer only supports deleting array indices]`; @@ -663,3 +699,50 @@ exports[`complex nesting map / set / object > modify deep object 16`] = ` }, ] `; + +exports[`complex nesting map / set / object > modify deep object 17`] = ` +{ + "map": Map { + "set1" => Set { + { + "a": 2, + }, + { + "b": 2, + }, + }, + "set2" => Set { + { + "c": 3, + }, + }, + }, +} +`; + +exports[`complex nesting map / set / object > modify deep object 18`] = ` +[ + { + "op": "remove", + "path": [ + "map", + "set1", + 0, + ], + "value": { + "a": 1, + }, + }, + { + "op": "add", + "path": [ + "map", + "set1", + 0, + ], + "value": { + "a": 2, + }, + }, +] +`; diff --git a/__tests__/base.js b/__tests__/base.js index 6e6f7b99..e68234a5 100644 --- a/__tests__/base.js +++ b/__tests__/base.js @@ -7,9 +7,17 @@ import { isDraft, immerable, enablePatches, - enableMapSet + enableMapSet, + enableArrayMethods } from "../src/immer" -import {each, shallowCopy, DRAFT_STATE} from "../src/internal" + +import { + each, + shallowCopy, + DRAFT_STATE, + clearPlugin, + PluginArrayMethods +} from "../src/internal" import deepFreeze from "deep-freeze" import * as lodash from "lodash" @@ -37,10 +45,22 @@ for (const autoFreeze of [true, false]) { } } +// Run one additional test suite with the array methods plugin enabled, +// as that should be a separate concern from the other settings +const testArrayMethodsName = `array-plugin=true:auto-freeze=true:shallow-copy=false:use-listener=false` +runBaseTest(testArrayMethodsName, true, false, false, true) + class Foo {} -function runBaseTest(name, autoFreeze, useStrictShallowCopy, useListener) { +function runBaseTest( + name, + autoFreeze, + useStrictShallowCopy, + useListener, + useArrayMethods = false +) { const listener = useListener ? function() {} : undefined + const {produce, produceWithPatches} = createPatchedImmer({ autoFreeze, useStrictShallowCopy @@ -67,6 +87,13 @@ function runBaseTest(name, autoFreeze, useStrictShallowCopy, useListener) { beforeEach(() => { origBaseState = baseState = createBaseState() + + // Allow running our tests with and without the array method plugin + if (useArrayMethods) { + enableArrayMethods() + } else { + clearPlugin(PluginArrayMethods) + } }) it("returns the original state when no changes are made", () => { @@ -701,6 +728,26 @@ function runBaseTest(name, autoFreeze, useStrictShallowCopy, useListener) { }) expect(result).toBe(base) // No modifications }) + + test("mutating item in filter result updates original value", () => { + const initialState = { + largeArray: Array.from({length: 10}).map((_, i) => ({ + id: i, + value: i * 10 + })) + } + + const result = produce(initialState, draft => { + const filtered = draft.largeArray.filter(item => item.id <= 5) + + filtered[0].value = 999 + draft.filtered = filtered + }) + + expect(result.largeArray[0].value).toBe(999) + expect(result.filtered[0].value).toBe(999) + expect(result.largeArray[0]).toBe(result.filtered[0]) + }) }) describe("map()", () => { @@ -1137,6 +1184,561 @@ function runBaseTest(name, autoFreeze, useStrictShallowCopy, useListener) { }) }) + describe("concat()", () => { + test("returns new array with concatenated items", () => { + const base = {items: [{id: 1}, {id: 2}]} + const result = produce(base, draft => { + const concatenated = draft.items.concat([{id: 3}]) + expect(concatenated).toHaveLength(3) + expect(concatenated[2].id).toBe(3) + }) + expect(result).toBe(base) + }) + + test("concat with no arguments creates shallow copy", () => { + const base = { + items: [ + {id: 1, value: 10}, + {id: 2, value: 20} + ] + } + const result = produce(base, draft => { + const copy = draft.items.concat() + expect(copy).toHaveLength(2) + expect(copy[0].id).toBe(1) + }) + expect(result).toBe(base) + }) + + // Behavior differs based on array methods plugin + if (useArrayMethods) { + test("concat returns base values, not drafts (with plugin)", () => { + const base = {items: [{id: 1, value: 10}]} + const result = produce(base, draft => { + const concatenated = draft.items.concat() + // With plugin: concat returns base values, not drafts + expect(isDraft(concatenated[0])).toBe(false) + }) + expect(result).toBe(base) + }) + } else { + test("concat returns draft proxies (default behavior)", () => { + const base = {items: [{id: 1, value: 10}]} + const result = produce(base, draft => { + const concatenated = draft.items.concat() + // Without plugin: concat returns draft proxies via get trap + expect(isDraft(concatenated[0])).toBe(true) + }) + expect(result).toBe(base) + }) + } + + test("concat with multiple arrays", () => { + const base = {items: [{id: 1}]} + const result = produce(base, draft => { + const concatenated = draft.items.concat([{id: 2}], [{id: 3}]) + expect(concatenated).toHaveLength(3) + expect(concatenated[1].id).toBe(2) + expect(concatenated[2].id).toBe(3) + }) + expect(result).toBe(base) + }) + + test("concat result assigned to draft works", () => { + const base = {items: [{id: 1}], combined: []} + const result = produce(base, draft => { + draft.combined = draft.items.concat([{id: 2}]) + }) + expect(result.combined).toHaveLength(2) + expect(result.combined[0].id).toBe(1) + expect(result.combined[1].id).toBe(2) + }) + + test("concat with primitives", () => { + const base = {numbers: [1, 2, 3]} + const result = produce(base, draft => { + const concatenated = draft.numbers.concat([4, 5]) + expect(concatenated).toEqual([1, 2, 3, 4, 5]) + }) + expect(result).toBe(base) + }) + }) + + describe("flat()", () => { + test("returns flattened array", () => { + const base = { + nested: [ + [1, 2], + [3, 4] + ] + } + const result = produce(base, draft => { + const flattened = draft.nested.flat() + expect(flattened).toEqual([1, 2, 3, 4]) + }) + expect(result).toBe(base) + }) + + test("flat with depth parameter", () => { + const base = {nested: [[[1, 2]], [[3, 4]]]} + const result = produce(base, draft => { + const shallow = draft.nested.flat(1) + expect(shallow).toEqual([ + [1, 2], + [3, 4] + ]) + const deep = draft.nested.flat(2) + expect(deep).toEqual([1, 2, 3, 4]) + }) + expect(result).toBe(base) + }) + + // Behavior differs based on array methods plugin + if (useArrayMethods) { + test("flat returns base values, not drafts (with plugin)", () => { + const base = {nested: [[{id: 1}], [{id: 2}]]} + const result = produce(base, draft => { + const flattened = draft.nested.flat() + expect(flattened).toHaveLength(2) + // With plugin: flat returns base values, not drafts + expect(isDraft(flattened[0])).toBe(false) + }) + expect(result).toBe(base) + }) + } else { + test("flat returns draft proxies (default behavior)", () => { + const base = {nested: [[{id: 1}], [{id: 2}]]} + const result = produce(base, draft => { + const flattened = draft.nested.flat() + expect(flattened).toHaveLength(2) + // Without plugin: flat returns draft proxies via get trap + expect(isDraft(flattened[0])).toBe(true) + }) + expect(result).toBe(base) + }) + } + + test("flat with empty nested arrays", () => { + const base = {nested: [[], [1, 2], []]} + const result = produce(base, draft => { + const flattened = draft.nested.flat() + expect(flattened).toEqual([1, 2]) + }) + expect(result).toBe(base) + }) + + test("flat result assigned to draft works", () => { + const base = {nested: [[1], [2]], result: []} + const result = produce(base, draft => { + draft.result = draft.nested.flat() + }) + expect(result.result).toEqual([1, 2]) + }) + }) + + describe("findIndex()", () => { + test("returns index of found item", () => { + const base = createTestData() + const result = produce(base, draft => { + const index = draft.items.findIndex(item => item.id === 3) + expect(index).toBe(2) + }) + expect(result).toBe(base) + }) + + test("returns -1 when not found", () => { + const base = createTestData() + const result = produce(base, draft => { + const index = draft.items.findIndex(item => item.id === 999) + expect(index).toBe(-1) + }) + expect(result).toBe(base) + }) + + test("predicate receives correct arguments", () => { + const base = createTestData() + const result = produce(base, draft => { + draft.items.findIndex((item, index, array) => { + expect(typeof index).toBe("number") + expect(Array.isArray(array)).toBe(true) + return false + }) + }) + expect(result).toBe(base) + }) + + test("works with primitives", () => { + const base = {numbers: [10, 20, 30, 40, 50]} + const result = produce(base, draft => { + const index = draft.numbers.findIndex(n => n > 25) + expect(index).toBe(2) + }) + expect(result).toBe(base) + }) + }) + + describe("findLastIndex()", () => { + test("returns last matching index", () => { + const base = { + items: [ + {id: 1, type: "A"}, + {id: 2, type: "B"}, + {id: 3, type: "A"} + ] + } + const result = produce(base, draft => { + const index = draft.items.findLastIndex(item => item.type === "A") + expect(index).toBe(2) + }) + expect(result).toBe(base) + }) + + test("returns -1 when not found", () => { + const base = {items: [{id: 1}, {id: 2}]} + const result = produce(base, draft => { + const index = draft.items.findLastIndex(item => item.id === 999) + expect(index).toBe(-1) + }) + expect(result).toBe(base) + }) + + test("predicate receives correct arguments", () => { + const base = createTestData() + const result = produce(base, draft => { + draft.items.findLastIndex((item, index, array) => { + expect(typeof index).toBe("number") + expect(Array.isArray(array)).toBe(true) + return false + }) + }) + expect(result).toBe(base) + }) + }) + + describe("some()", () => { + test("returns true when condition met", () => { + const base = createTestData() + const result = produce(base, draft => { + const hasHighValue = draft.items.some(item => item.value > 40) + expect(hasHighValue).toBe(true) + }) + expect(result).toBe(base) + }) + + test("returns false when no items match", () => { + const base = createTestData() + const result = produce(base, draft => { + const hasHugeValue = draft.items.some(item => item.value > 1000) + expect(hasHugeValue).toBe(false) + }) + expect(result).toBe(base) + }) + + test("short-circuits on first match", () => { + const base = {items: [{id: 1}, {id: 2}, {id: 3}]} + let callCount = 0 + const result = produce(base, draft => { + const found = draft.items.some(item => { + callCount++ + return item.id === 1 + }) + expect(found).toBe(true) + }) + expect(callCount).toBe(1) + expect(result).toBe(base) + }) + + test("predicate receives correct arguments", () => { + const base = createTestData() + const result = produce(base, draft => { + draft.items.some((item, index, array) => { + expect(typeof index).toBe("number") + expect(Array.isArray(array)).toBe(true) + return false + }) + }) + expect(result).toBe(base) + }) + + test("works with primitives", () => { + const base = {numbers: [1, 2, 3, 4, 5]} + const result = produce(base, draft => { + expect(draft.numbers.some(n => n > 3)).toBe(true) + expect(draft.numbers.some(n => n > 10)).toBe(false) + }) + expect(result).toBe(base) + }) + }) + + describe("every()", () => { + test("returns true when all items match", () => { + const base = createTestData() + const result = produce(base, draft => { + const allPositive = draft.items.every(item => item.value > 0) + expect(allPositive).toBe(true) + }) + expect(result).toBe(base) + }) + + test("returns false when any item fails", () => { + const base = createTestData() + const result = produce(base, draft => { + const allHighValue = draft.items.every(item => item.value > 30) + expect(allHighValue).toBe(false) + }) + expect(result).toBe(base) + }) + + test("short-circuits on first failure", () => { + const base = {items: [{id: 1}, {id: 2}, {id: 3}]} + let callCount = 0 + const result = produce(base, draft => { + const allMatch = draft.items.every(item => { + callCount++ + return item.id === 999 + }) + expect(allMatch).toBe(false) + }) + expect(callCount).toBe(1) + expect(result).toBe(base) + }) + + test("predicate receives correct arguments", () => { + const base = createTestData() + const result = produce(base, draft => { + draft.items.every((item, index, array) => { + expect(typeof index).toBe("number") + expect(Array.isArray(array)).toBe(true) + return true + }) + }) + expect(result).toBe(base) + }) + + test("returns true for empty array", () => { + const base = {items: []} + const result = produce(base, draft => { + const allMatch = draft.items.every(() => false) + expect(allMatch).toBe(true) + }) + expect(result).toBe(base) + }) + }) + + describe("lastIndexOf()", () => { + test("returns last index of item", () => { + const base = {items: [1, 2, 3, 2, 1]} + const result = produce(base, draft => { + const index = draft.items.lastIndexOf(2) + expect(index).toBe(3) + }) + expect(result).toBe(base) + }) + + test("returns -1 when not found", () => { + const base = {items: [1, 2, 3]} + const result = produce(base, draft => { + const index = draft.items.lastIndexOf(99) + expect(index).toBe(-1) + }) + expect(result).toBe(base) + }) + + test("works with fromIndex parameter", () => { + const base = {items: [1, 2, 3, 2, 1]} + const result = produce(base, draft => { + const index = draft.items.lastIndexOf(2, 2) + expect(index).toBe(1) + }) + expect(result).toBe(base) + }) + + test("works with negative fromIndex", () => { + const base = {items: [1, 2, 3, 2, 1]} + const result = produce(base, draft => { + const index = draft.items.lastIndexOf(2, -2) + expect(index).toBe(3) + }) + expect(result).toBe(base) + }) + }) + + describe("includes()", () => { + test("returns true when item exists", () => { + const base = {items: [1, 2, 3, 4, 5]} + const result = produce(base, draft => { + expect(draft.items.includes(3)).toBe(true) + }) + expect(result).toBe(base) + }) + + test("returns false when item doesn't exist", () => { + const base = {items: [1, 2, 3, 4, 5]} + const result = produce(base, draft => { + expect(draft.items.includes(99)).toBe(false) + }) + expect(result).toBe(base) + }) + + test("works with fromIndex parameter", () => { + const base = {items: [1, 2, 3, 4, 5]} + const result = produce(base, draft => { + expect(draft.items.includes(2, 2)).toBe(false) + expect(draft.items.includes(3, 2)).toBe(true) + }) + expect(result).toBe(base) + }) + + test("works with NaN", () => { + const base = {items: [1, NaN, 3]} + const result = produce(base, draft => { + expect(draft.items.includes(NaN)).toBe(true) + }) + expect(result).toBe(base) + }) + + test("works with negative fromIndex", () => { + const base = {items: [1, 2, 3, 4, 5]} + const result = produce(base, draft => { + expect(draft.items.includes(4, -2)).toBe(true) + expect(draft.items.includes(2, -2)).toBe(false) + }) + expect(result).toBe(base) + }) + }) + + describe("toString()", () => { + test("returns string representation", () => { + const base = {items: [1, 2, 3]} + const result = produce(base, draft => { + const str = draft.items.toString() + expect(str).toBe("1,2,3") + }) + expect(result).toBe(base) + }) + + test("works with objects", () => { + const base = {items: [{id: 1}, {id: 2}]} + const result = produce(base, draft => { + const str = draft.items.toString() + expect(str).toContain("[object Object]") + }) + expect(result).toBe(base) + }) + + test("works with empty array", () => { + const base = {items: []} + const result = produce(base, draft => { + const str = draft.items.toString() + expect(str).toBe("") + }) + expect(result).toBe(base) + }) + }) + + describe("toLocaleString()", () => { + test("returns locale string representation", () => { + const base = {items: [1, 2, 3]} + const result = produce(base, draft => { + const str = draft.items.toLocaleString() + expect(typeof str).toBe("string") + }) + expect(result).toBe(base) + }) + + test("works with numbers", () => { + const base = {numbers: [1000, 2000]} + const result = produce(base, draft => { + const str = draft.numbers.toLocaleString() + expect(typeof str).toBe("string") + }) + expect(result).toBe(base) + }) + }) + + describe("findLast() additional edge cases", () => { + test("returns undefined when not found", () => { + const base = {items: [{id: 1}, {id: 2}, {id: 3}]} + const result = produce(base, draft => { + const found = draft.items.findLast(item => item.id === 999) + expect(found).toBeUndefined() + }) + expect(result).toBe(base) + }) + + test("nested mutations on findLast result", () => { + const base = { + items: [ + {id: 1, type: "A", nested: {value: 10}}, + {id: 2, type: "B", nested: {value: 20}}, + {id: 3, type: "A", nested: {value: 30}} + ] + } + const result = produce(base, draft => { + const found = draft.items.findLast(item => item.type === "A") + expect(isDraft(found)).toBe(true) + if (found) { + found.nested.value = 999 + } + }) + expect(result.items[2].nested.value).toBe(999) + expect(base.items[2].nested.value).toBe(30) + }) + + test("findLast on empty array", () => { + const base = {items: []} + const result = produce(base, draft => { + const found = draft.items.findLast(() => true) + expect(found).toBeUndefined() + }) + expect(result).toBe(base) + }) + }) + + describe("comparison: filter vs concat behavior", () => { + test("filter returns drafts that can affect original", () => { + const base = { + items: [ + {id: 1, value: 10}, + {id: 2, value: 20} + ] + } + const result = produce(base, draft => { + const filtered = draft.items.filter(item => item.id === 1) + expect(isDraft(filtered[0])).toBe(true) + filtered[0].value = 999 + }) + expect(result.items[0].value).toBe(999) + }) + + // Behavior differs based on array methods plugin + if (useArrayMethods) { + test("concat returns base values that cannot affect original (with plugin)", () => { + const base = {items: [{id: 1, value: 10}]} + const result = produce(base, draft => { + const concatenated = draft.items.concat() + // With plugin: concat returns base values, not drafts + expect(isDraft(concatenated[0])).toBe(false) + // Attempting to mutate won't affect the draft + // (would throw if autoFreeze is on, or just not be tracked) + }) + expect(result).toBe(base) + }) + } else { + test("concat returns drafts that can affect original (default behavior)", () => { + const base = {items: [{id: 1, value: 10}]} + const result = produce(base, draft => { + const concatenated = draft.items.concat() + // Without plugin: concat returns draft proxies via get trap + expect(isDraft(concatenated[0])).toBe(true) + // Mutations to concatenated items ARE tracked + concatenated[0].value = 999 + }) + expect(result.items[0].value).toBe(999) + }) + } + }) + describe("combined operations", () => { test("chain filter then map then mutate", () => { const base = createTestData() @@ -1214,23 +1816,6 @@ function runBaseTest(name, autoFreeze, useStrictShallowCopy, useListener) { expect(result.items[0].value).toBe(999) }) - test("mutation during filter callback", () => { - const base = createTestData() - const result = produce(base, draft => { - const filtered = draft.items.filter(item => { - // Verify items in filter callback are drafts - expect(isDraft(item)).toBe(true) - item.touched = true - return item.value > 25 - }) - expect(filtered).toHaveLength(3) - }) - expect(result.items[0].touched).toBe(true) - expect(result.items[3].touched).toBe(true) - // Verify base state unchanged - expect(base.items[0].touched).toBeUndefined() - }) - test("primitive array filter", () => { const base = {numbers: [1, 2, 3, 4, 5]} const result = produce(base, draft => { diff --git a/package.json b/package.json index fca65414..71a02627 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "build": "tsup", "publish-docs": "cd website && GIT_USER=mweststrate USE_SSH=true yarn docusaurus deploy", "start": "cd website && yarn start", - "test:size": "yarn build && yarn import-size --report . produce enableMapSet enablePatches", + "test:size": "yarn build && yarn import-size --report . produce enableMapSet enablePatches enableArrayMethods", "test:sizequick": "yarn build && yarn import-size . produce" }, "husky": { diff --git a/perf-testing/immutability-benchmarks.mjs b/perf-testing/immutability-benchmarks.mjs index 01d81a44..7ed2c2f1 100644 --- a/perf-testing/immutability-benchmarks.mjs +++ b/perf-testing/immutability-benchmarks.mjs @@ -9,10 +9,8 @@ import {produce as produce9, setAutoFreeze as setAutoFreeze9} from "immer9" import {produce as produce10, setAutoFreeze as setAutoFreeze10} from "immer10" import { produce as produce10Perf, - setAutoFreeze as setAutoFreeze10Perf - // Uncomment when using a build of Immer that exposes this function, - // and enable the corresponding line in the setStrictIteration object below. - // setUseStrictIteration as setUseStrictIteration10Perf + setAutoFreeze as setAutoFreeze10Perf, + enableArrayMethods as enableArrayMethods10Perf } from "immer10Perf" import {create as produceMutative} from "mutative" import { @@ -239,6 +237,21 @@ const setStrictIteration = { limu: noop } +const setEnableArrayMethods = { + vanilla: noop, + immer5: noop, + immer6: noop, + immer7: noop, + immer8: noop, + immer9: noop, + immer10: noop, + immer10Perf: enableArrayMethods10Perf, + mutative: noop, + mutativeCompat: noop, + structura: noop, + limu: noop +} + // RTKQ-style separate reducer functions (simulating separate RTK slices) const updateQueries = (queries, action) => { switch (action.type) { @@ -601,6 +614,7 @@ function createBenchmarks() { function benchMethod() { setAutoFreezes[version](freeze) setStrictIteration[version](false) + setEnableArrayMethods[version]() for (let i = 0; i < MAX; i++) { reducers[version](initialState, actions[action](i)) } @@ -635,6 +649,7 @@ function createBenchmarks() { function benchMethod() { setAutoFreezes[version](freeze) setStrictIteration[version](false) + setEnableArrayMethods[version]() let currentState = createInitialState() @@ -663,6 +678,7 @@ function createBenchmarks() { function benchMethod() { setAutoFreezes[version](freeze) setStrictIteration[version](false) + setEnableArrayMethods[version]() let state = createInitialState() @@ -692,6 +708,7 @@ function createBenchmarks() { function benchMethod() { setAutoFreezes[version](freeze) setStrictIteration[version](false) + setEnableArrayMethods[version]() let state = createInitialState() // Use smaller array size for RTKQ benchmark due to exponential scaling diff --git a/perf-testing/immutability-profiling.mjs b/perf-testing/immutability-profiling.mjs new file mode 100644 index 00000000..eb765683 --- /dev/null +++ b/perf-testing/immutability-profiling.mjs @@ -0,0 +1,562 @@ +/* eslint-disable no-inner-declarations */ +import {produce, setAutoFreeze, enableArrayMethods} from "../dist/immer.mjs" + +enableArrayMethods() +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +const PROFILING_CONFIG = { + // How many times to run each scenario (can be overridden via CLI) + iterations: parseInt(process.argv[2]) || 100, + + // Which scenarios to run + scenarios: [ + // Single operations + "add", + "remove", + "update", + "update-high", + "update-multiple", + "remove-high", + "update-largeObject1", + "update-largeObject2", + "concat", + "mapNested", + "sortById-reverse", + "reverse-array", + + // Reuse scenarios (state evolution) + "update-reuse", + "update-high-reuse", + "remove-reuse", + "remove-high-reuse", + "update-largeObject1-reuse", + "update-largeObject2-reuse", + + // Complex sequences + "mixed-sequence", + "rtkq-sequence" + ] +} + +const BENCHMARK_CONFIG = { + arraySize: 100, + nestedArraySize: 10, + largeObjectSize1: 1000, + largeObjectSize2: 3000, + multiUpdateCount: 5, + reuseStateIterations: 10 +} + +const MAX = 1 + +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ + +function createInitialState(arraySize = BENCHMARK_CONFIG.arraySize) { + const initialState = { + largeArray: Array.from({length: arraySize}, (_, i) => ({ + id: i, + value: Math.random(), + nested: {key: `key-${i}`, data: Math.random()}, + moreNested: { + items: Array.from( + {length: BENCHMARK_CONFIG.nestedArraySize}, + (_, i) => ({id: i, name: String(i)}) + ) + } + })), + otherData: Array.from({length: arraySize}, (_, i) => ({ + id: i, + name: `name-${i}`, + isActive: i % 2 === 0 + })), + largeObject1: createLargeObject(BENCHMARK_CONFIG.largeObjectSize1), + largeObject2: createLargeObject(BENCHMARK_CONFIG.largeObjectSize2), + api: { + queries: {}, + provided: { + keys: {} + }, + subscriptions: {} + } + } + return initialState +} + +function createLargeObject(size = 100) { + const obj = {} + for (let i = 0; i < size; i++) { + obj[`property${i}`] = { + id: i, + value: Math.random(), + name: `item-${i}`, + active: i % 2 === 0 + } + } + return obj +} + +const getValidIndex = (arraySize = BENCHMARK_CONFIG.arraySize) => { + return Math.min(arraySize - 2, Math.max(0, arraySize - 2)) +} + +const getValidId = (arraySize = BENCHMARK_CONFIG.arraySize) => { + return Math.min(arraySize - 2, Math.max(0, arraySize - 2)) +} + +// ============================================================================ +// ACTION CREATORS +// ============================================================================ + +const add = index => ({ + type: "test/addItem", + payload: {id: index, value: index, nested: {data: index}} +}) + +const remove = index => ({type: "test/removeItem", payload: index}) + +const update = index => ({ + type: "test/updateItem", + payload: {id: index, value: index, nestedData: index} +}) + +const updateLargeObject1 = index => ({ + type: "test/updateLargeObject1", + payload: {value: index} +}) + +const updateLargeObject2 = index => ({ + type: "test/updateLargeObject2", + payload: {value: index} +}) + +const concat = index => ({ + type: "test/concatArray", + payload: Array.from({length: 500}, (_, i) => ({id: i, value: index})) +}) + +const mapNested = () => ({ + type: "test/mapNested" +}) + +const updateHigh = index => ({ + type: "test/updateHighIndex", + payload: { + id: + Math.floor(BENCHMARK_CONFIG.arraySize * 0.8) + + (index % Math.floor(BENCHMARK_CONFIG.arraySize * 0.2)), + value: index, + nestedData: index + } +}) + +const updateMultiple = index => ({ + type: "test/updateMultiple", + payload: Array.from({length: BENCHMARK_CONFIG.multiUpdateCount}, (_, i) => ({ + id: (index + i) % BENCHMARK_CONFIG.arraySize, + value: index + i, + nestedData: index + i + })) +}) + +const removeHigh = index => ({ + type: "test/removeHighIndex", + payload: + Math.floor(BENCHMARK_CONFIG.arraySize * 0.8) + + (index % Math.floor(BENCHMARK_CONFIG.arraySize * 0.2)) +}) + +const sortByIdReverse = () => ({ + type: "test/sortByIdReverse" +}) + +const reverseArray = () => ({ + type: "test/reverseArray" +}) + +const rtkqPending = index => ({ + type: "rtkq/pending", + payload: { + cacheKey: `some("test-${index}-")`, + requestId: `req-${index}`, + id: `test-${index}-` + } +}) + +const rtkqResolved = index => ({ + type: "rtkq/resolved", + payload: { + cacheKey: `some("test-${index}-")`, + requestId: `req-${index}`, + id: `test-${index}-`, + data: `test-${index}-1` + } +}) + +const actions = { + add, + remove, + update, + concat, + mapNested, + "update-largeObject1": updateLargeObject1, + "update-largeObject2": updateLargeObject2, + "update-high": updateHigh, + "update-multiple": updateMultiple, + "remove-high": removeHigh, + "sortById-reverse": sortByIdReverse, + "reverse-array": reverseArray +} + +// ============================================================================ +// REDUCER IMPLEMENTATION +// ============================================================================ + +const immerReducer = (state = createInitialState(), action) => + produce(state, draft => { + switch (action.type) { + case "test/addItem": + draft.largeArray.push(action.payload) + break + case "test/removeItem": + draft.largeArray.splice(action.payload, 1) + break + case "test/updateItem": { + const item = draft.largeArray.find( + item => item.id === action.payload.id + ) + item.value = action.payload.value + item.nested.data = action.payload.nestedData + break + } + case "test/updateLargeObject1": { + draft.largeObject1[`propertyAdded${action.payload.value}`] = { + id: action.payload.value + } + break + } + case "test/updateLargeObject2": { + draft.largeObject2[`propertyAdded${action.payload.value}`] = { + id: action.payload.value + } + break + } + case "test/concatArray": { + const length = state.largeArray.length + const newArray = action.payload.concat(state.largeArray) + newArray.length = length + draft.largeArray = newArray + break + } + case "test/mapNested": { + draft.otherData = draft.largeArray.map(item => item.nested) + break + } + case "test/updateHighIndex": { + const item = draft.largeArray.find( + item => item.id === action.payload.id + ) + if (item) { + item.value = action.payload.value + item.nested.data = action.payload.nestedData + } + break + } + case "test/updateMultiple": { + action.payload.forEach(update => { + const item = draft.largeArray.find(item => item.id === update.id) + if (item) { + item.value = update.value + item.nested.data = update.nestedData + } + }) + break + } + case "test/removeHighIndex": { + const indexToRemove = draft.largeArray.findIndex( + item => item.id === action.payload + ) + if (indexToRemove !== -1) { + draft.largeArray.splice(indexToRemove, 1) + } + break + } + case "test/sortByIdReverse": { + draft.largeArray.sort((a, b) => b.id - a.id) + break + } + case "test/reverseArray": { + draft.largeArray.reverse() + break + } + case "rtkq/pending": { + const cacheKey = action.payload.cacheKey + draft.api.queries[cacheKey] = { + id: action.payload.id, + status: "pending", + data: undefined + } + draft.api.provided.keys[cacheKey] = {} + draft.api.subscriptions[cacheKey] = { + [action.payload.requestId]: { + pollingInterval: 0, + skipPollingIfUnfocused: false + } + } + break + } + case "rtkq/resolved": { + const cacheKey = action.payload.cacheKey + draft.api.queries[cacheKey].status = "fulfilled" + draft.api.queries[cacheKey].data = action.payload.data + break + } + } + }) + +// ============================================================================ +// SCENARIO FUNCTIONS +// ============================================================================ + +// Single operation scenarios - execute once +function scenario_add() { + const initialState = createInitialState() + for (let j = 0; j < MAX; j++) { + immerReducer(initialState, actions.add(j)) + } +} + +function scenario_remove() { + const initialState = createInitialState() + for (let j = 0; j < MAX; j++) { + immerReducer(initialState, actions.remove(j)) + } +} + +function scenario_update() { + const initialState = createInitialState() + for (let j = 0; j < MAX; j++) { + immerReducer(initialState, actions.update(j)) + } +} + +function scenario_update_high() { + const initialState = createInitialState() + for (let j = 0; j < MAX; j++) { + immerReducer(initialState, actions["update-high"](j)) + } +} + +function scenario_update_multiple() { + const initialState = createInitialState() + for (let j = 0; j < MAX; j++) { + immerReducer(initialState, actions["update-multiple"](j)) + } +} + +function scenario_remove_high() { + const initialState = createInitialState() + for (let j = 0; j < MAX; j++) { + immerReducer(initialState, actions["remove-high"](j)) + } +} + +function scenario_update_largeObject1() { + const initialState = createInitialState() + for (let j = 0; j < MAX; j++) { + immerReducer(initialState, actions["update-largeObject1"](j)) + } +} + +function scenario_update_largeObject2() { + const initialState = createInitialState() + for (let j = 0; j < MAX; j++) { + immerReducer(initialState, actions["update-largeObject2"](j)) + } +} + +function scenario_concat() { + const initialState = createInitialState() + for (let j = 0; j < MAX; j++) { + immerReducer(initialState, actions.concat(j)) + } +} + +function scenario_mapNested() { + const initialState = createInitialState() + for (let j = 0; j < MAX; j++) { + immerReducer(initialState, actions.mapNested()) + } +} + +function scenario_sortById_reverse() { + const initialState = createInitialState() + for (let j = 0; j < MAX; j++) { + immerReducer(initialState, actions["sortById-reverse"]()) + } +} + +function scenario_reverse_array() { + const initialState = createInitialState() + for (let j = 0; j < MAX; j++) { + immerReducer(initialState, actions["reverse-array"]()) + } +} + +// Reuse scenarios - execute full state evolution sequence once +function scenario_update_reuse() { + let currentState = createInitialState() + + for (let j = 0; j < BENCHMARK_CONFIG.reuseStateIterations; j++) { + currentState = immerReducer(currentState, actions.update(j)) + } +} + +function scenario_update_high_reuse() { + let currentState = createInitialState() + + for (let j = 0; j < BENCHMARK_CONFIG.reuseStateIterations; j++) { + currentState = immerReducer(currentState, actions["update-high"](j)) + } +} + +function scenario_remove_reuse() { + let currentState = createInitialState() + + for (let j = 0; j < BENCHMARK_CONFIG.reuseStateIterations; j++) { + currentState = immerReducer(currentState, actions.remove(j)) + } +} + +function scenario_remove_high_reuse() { + let currentState = createInitialState() + + for (let j = 0; j < BENCHMARK_CONFIG.reuseStateIterations; j++) { + currentState = immerReducer(currentState, actions["remove-high"](j)) + } +} + +function scenario_update_largeObject1_reuse() { + let currentState = createInitialState() + + for (let j = 0; j < BENCHMARK_CONFIG.reuseStateIterations; j++) { + currentState = immerReducer(currentState, actions["update-largeObject1"](j)) + } +} + +function scenario_update_largeObject2_reuse() { + let currentState = createInitialState() + + for (let j = 0; j < BENCHMARK_CONFIG.reuseStateIterations; j++) { + currentState = immerReducer(currentState, actions["update-largeObject2"](j)) + } +} + +// Complex sequence scenarios - execute full sequence once +function scenario_mixed_sequence() { + let state = createInitialState() + state = immerReducer(state, actions.add(1)) + state = immerReducer(state, actions.update(getValidId())) + state = immerReducer(state, actions["update-high"](2)) + state = immerReducer(state, actions["update-multiple"](3)) + state = immerReducer(state, actions.remove(getValidIndex())) +} + +function scenario_rtkq_sequence() { + let state = createInitialState() + const arraySize = 100 + + // Phase 1: Execute all pending actions + for (let j = 0; j < arraySize; j++) { + state = immerReducer(state, rtkqPending(j)) + } + + // Phase 2: Execute all resolved actions + for (let j = 0; j < arraySize; j++) { + state = immerReducer(state, rtkqResolved(j)) + } +} + +// ============================================================================ +// SCENARIO REGISTRY +// ============================================================================ + +const scenarios = { + // Single operations + add: scenario_add, + remove: scenario_remove, + update: scenario_update, + "update-high": scenario_update_high, + "update-multiple": scenario_update_multiple, + "remove-high": scenario_remove_high, + "update-largeObject1": scenario_update_largeObject1, + "update-largeObject2": scenario_update_largeObject2, + concat: scenario_concat, + mapNested: scenario_mapNested, + "sortById-reverse": scenario_sortById_reverse, + "reverse-array": scenario_reverse_array, + + // Reuse scenarios + "update-reuse": scenario_update_reuse, + "update-high-reuse": scenario_update_high_reuse, + "remove-reuse": scenario_remove_reuse, + "remove-high-reuse": scenario_remove_high_reuse, + "update-largeObject1-reuse": scenario_update_largeObject1_reuse, + "update-largeObject2-reuse": scenario_update_largeObject2_reuse, + + // Complex sequences + "mixed-sequence": scenario_mixed_sequence, + "rtkq-sequence": scenario_rtkq_sequence +} + +// ============================================================================ +// MAIN EXECUTION +// ============================================================================ + +function main() { + // Set freeze to true (default Immer behavior) + setAutoFreeze(true) + + console.log("=".repeat(80)) + console.log("IMMER PROFILING SCRIPT (freeze: true)") + console.log("=".repeat(80)) + console.log(`Iterations per scenario: ${PROFILING_CONFIG.iterations}`) + console.log(`Total scenarios: ${PROFILING_CONFIG.scenarios.length}`) + console.log("=".repeat(80)) + console.log() + + let completed = 0 + for (const scenarioName of PROFILING_CONFIG.scenarios) { + const scenarioFn = scenarios[scenarioName] + if (!scenarioFn) { + console.log(`⚠ Skipping unknown scenario: ${scenarioName}`) + continue + } + + const start = performance.now() + + // Driver loop handles iterations + for (let i = 0; i < PROFILING_CONFIG.iterations; i++) { + scenarioFn() + } + + const duration = performance.now() - start + + completed++ + console.log( + `[${completed}/${ + PROFILING_CONFIG.scenarios.length + }] ✓ ${scenarioName} - ${duration.toFixed(2)}ms` + ) + } + + console.log() + console.log("=".repeat(80)) + console.log("Profiling complete!") + console.log("=".repeat(80)) +} + +main() diff --git a/src/core/finalize.ts b/src/core/finalize.ts index ee266945..9cb9177d 100644 --- a/src/core/finalize.ts +++ b/src/core/finalize.ts @@ -20,7 +20,8 @@ import { latest, prepareCopy, getFinalValue, - getValue + getValue, + ProxyArrayState } from "../internal" export function processResult(result: any, scope: ImmerScope) { @@ -194,7 +195,10 @@ function generatePatchesAndFinalize(state: ImmerState, rootScope: ImmerScope) { const shouldFinalize = state.modified_ && !state.finalized_ && - (state.type_ === ArchType.Set || (state.assigned_?.size ?? 0) > 0) + (state.type_ === ArchType.Set || + (state.type_ === ArchType.Array && + (state as ProxyArrayState).allIndicesReassigned_) || + (state.assigned_?.size ?? 0) > 0) if (shouldFinalize) { const {patchPlugin_} = rootScope diff --git a/src/core/proxy.ts b/src/core/proxy.ts index 57b8f689..a3bc4d4d 100644 --- a/src/core/proxy.ts +++ b/src/core/proxy.ts @@ -23,7 +23,8 @@ import { CONFIGURABLE, ENUMERABLE, VALUE, - isArray + isArray, + isArrayIndex } from "../internal" interface ProxyBaseState extends ImmerBaseState { @@ -43,6 +44,8 @@ export interface ProxyArrayState extends ProxyBaseState { base_: AnyArray copy_: AnyArray | null draft_: Drafted + operationMethod?: string + allIndicesReassigned_?: boolean } type ProxyState = ProxyObjectState | ProxyArrayState @@ -109,6 +112,17 @@ export const objectTraps: ProxyHandler = { get(state, prop) { if (prop === DRAFT_STATE) return state + let arrayPlugin = state.scope_.arrayMethodsPlugin_ + const isArrayWithStringProp = + state.type_ === ArchType.Array && typeof prop === "string" + // Intercept array methods so that we can override + // behavior and skip proxy creation for perf + if (isArrayWithStringProp) { + if (arrayPlugin?.isArrayOperationMethod(prop)) { + return arrayPlugin.createMethodInterceptor(state, prop) + } + } + const source = latest(state) if (!has(source, prop, state.type_)) { // non-existing or non-own property... @@ -118,6 +132,20 @@ export const objectTraps: ProxyHandler = { if (state.finalized_ || !isDraftable(value)) { return value } + + // During mutating array operations, defer proxy creation for array elements + // This optimization avoids creating unnecessary proxies during sort/reverse + if ( + isArrayWithStringProp && + (state as ProxyArrayState).operationMethod && + arrayPlugin?.isMutatingArrayMethod( + (state as ProxyArrayState).operationMethod! + ) && + isArrayIndex(prop) + ) { + // Return raw value during mutating operations, create proxy only if modified + return value + } // Check for existing draft in modified state. // Assigned values are never drafted. This catches any drafts we created, too. if (value === peek(state.base_, prop)) { diff --git a/src/core/scope.ts b/src/core/scope.ts index d1c26e4b..72987a3b 100644 --- a/src/core/scope.ts +++ b/src/core/scope.ts @@ -11,7 +11,9 @@ import { MapSetPlugin, isPluginLoaded, PluginMapSet, - PluginPatches + PluginPatches, + ArrayMethodsPlugin, + PluginArrayMethods } from "../internal" /** Each scope represents a `produce` call. */ @@ -21,6 +23,7 @@ export interface ImmerScope { inversePatches_?: Patch[] patchPlugin_?: PatchesPlugin mapSetPlugin_?: MapSetPlugin + arrayMethodsPlugin_?: ArrayMethodsPlugin canAutoFreeze_: boolean drafts_: any[] parent_?: ImmerScope @@ -50,6 +53,9 @@ let createScope = ( processedForPatches_: new Set(), mapSetPlugin_: isPluginLoaded(PluginMapSet) ? getPlugin(PluginMapSet) + : undefined, + arrayMethodsPlugin_: isPluginLoaded(PluginArrayMethods) + ? getPlugin(PluginArrayMethods) : undefined }) diff --git a/src/immer.ts b/src/immer.ts index 615dff71..2ad2e56d 100644 --- a/src/immer.ts +++ b/src/immer.ts @@ -123,3 +123,4 @@ export {Immer} export {enablePatches} from "./plugins/patches" export {enableMapSet} from "./plugins/mapset" +export {enableArrayMethods} from "./plugins/arrayMethods" diff --git a/src/plugins/arrayMethods.ts b/src/plugins/arrayMethods.ts new file mode 100644 index 00000000..9770cf3d --- /dev/null +++ b/src/plugins/arrayMethods.ts @@ -0,0 +1,395 @@ +import { + PluginArrayMethods, + latest, + loadPlugin, + markChanged, + prepareCopy, + ProxyArrayState +} from "../internal" + +/** + * Methods that directly modify the array in place. + * These operate on the copy without creating per-element proxies: + * - `push`, `pop`: Add/remove from end + * - `shift`, `unshift`: Add/remove from start (marks all indices reassigned) + * - `splice`: Add/remove at arbitrary position (marks all indices reassigned) + * - `reverse`, `sort`: Reorder elements (marks all indices reassigned) + */ +type MutatingArrayMethod = + | "push" + | "pop" + | "shift" + | "unshift" + | "splice" + | "reverse" + | "sort" + +/** + * Methods that read from the array without modifying it. + * These fall into distinct categories based on return semantics: + * + * **Subset operations** (return drafts - mutations propagate): + * - `filter`, `slice`: Return array of draft proxies + * - `find`, `findLast`: Return single draft proxy or undefined + * + * **Transform operations** (return base values - mutations don't track): + * - `concat`, `flat`: Create new structures, not subsets of original + * + * **Primitive-returning** (no draft needed): + * - `findIndex`, `findLastIndex`, `indexOf`, `lastIndexOf`: Return numbers + * - `some`, `every`, `includes`: Return booleans + * - `join`, `toString`, `toLocaleString`: Return strings + */ +type NonMutatingArrayMethod = + | "filter" + | "slice" + | "concat" + | "flat" + | "find" + | "findIndex" + | "findLast" + | "findLastIndex" + | "some" + | "every" + | "indexOf" + | "lastIndexOf" + | "includes" + | "join" + | "toString" + | "toLocaleString" + +/** Union of all array operation methods handled by the plugin. */ +export type ArrayOperationMethod = MutatingArrayMethod | NonMutatingArrayMethod + +/** + * Enables optimized array method handling for Immer drafts. + * + * This plugin overrides array methods to avoid unnecessary Proxy creation during iteration, + * significantly improving performance for array-heavy operations. + * + * **Mutating methods** (push, pop, shift, unshift, splice, sort, reverse): + * Operate directly on the copy without creating per-element proxies. + * + * **Non-mutating methods** fall into categories: + * - **Subset operations** (filter, slice, find, findLast): Return draft proxies - mutations track + * - **Transform operations** (concat, flat): Return base values - mutations don't track + * - **Primitive-returning** (indexOf, includes, some, every, etc.): Return primitives + * + * **Important**: Callbacks for overridden methods receive base values, not drafts. + * This is the core performance optimization. + * + * @example + * ```ts + * import { enableArrayMethods, produce } from "immer" + * + * enableArrayMethods() + * + * const next = produce(state, draft => { + * // Optimized - no proxy creation per element + * draft.items.sort((a, b) => a.value - b.value) + * + * // filter returns drafts - mutations propagate + * const filtered = draft.items.filter(x => x.value > 5) + * filtered[0].value = 999 // Affects draft.items[originalIndex] + * }) + * ``` + * + * @see https://immerjs.github.io/immer/array-methods + */ +export function enableArrayMethods() { + const SHIFTING_METHODS = new Set(["shift", "unshift"]) + + const QUEUE_METHODS = new Set(["push", "pop"]) + + const RESULT_RETURNING_METHODS = new Set([ + ...QUEUE_METHODS, + ...SHIFTING_METHODS + ]) + + const REORDERING_METHODS = new Set(["reverse", "sort"]) + + // Optimized method detection using array-based lookup + const MUTATING_METHODS = new Set([ + ...RESULT_RETURNING_METHODS, + ...REORDERING_METHODS, + "splice" + ]) + + const FIND_METHODS = new Set(["find", "findLast"]) + + const NON_MUTATING_METHODS = new Set([ + "filter", + "slice", + "concat", + "flat", + ...FIND_METHODS, + "findIndex", + "findLastIndex", + "some", + "every", + "indexOf", + "lastIndexOf", + "includes", + "join", + "toString", + "toLocaleString" + ]) + + // Type guard for method detection + function isMutatingArrayMethod( + method: string + ): method is MutatingArrayMethod { + return MUTATING_METHODS.has(method as any) + } + + function isNonMutatingArrayMethod( + method: string + ): method is NonMutatingArrayMethod { + return NON_MUTATING_METHODS.has(method as any) + } + + function isArrayOperationMethod( + method: string + ): method is ArrayOperationMethod { + return isMutatingArrayMethod(method) || isNonMutatingArrayMethod(method) + } + + function enterOperation( + state: ProxyArrayState, + method: ArrayOperationMethod + ) { + state.operationMethod = method + } + + function exitOperation(state: ProxyArrayState) { + state.operationMethod = undefined + } + + // Shared utility functions for array method handlers + function executeArrayMethod( + state: ProxyArrayState, + operation: () => T, + markLength = true + ): T { + prepareCopy(state) + const result = operation() + markChanged(state) + if (markLength) state.assigned_!.set("length", true) + return result + } + + function markAllIndicesReassigned(state: ProxyArrayState) { + state.allIndicesReassigned_ = true + } + + function normalizeSliceIndex(index: number, length: number): number { + if (index < 0) { + return Math.max(length + index, 0) + } + return Math.min(index, length) + } + + /** + * Handles mutating operations that add/remove elements (push, pop, shift, unshift, splice). + * + * Operates directly on `state.copy_` without creating per-element proxies. + * For shifting methods (shift, unshift), marks all indices as reassigned since + * indices shift. + * + * @returns For push/pop/shift/unshift: the native method result. For others: the draft. + */ + function handleSimpleOperation( + state: ProxyArrayState, + method: string, + args: any[] + ) { + return executeArrayMethod(state, () => { + const result = (state.copy_! as any)[method](...args) + + // Handle index reassignment for shifting methods + if (SHIFTING_METHODS.has(method as MutatingArrayMethod)) { + markAllIndicesReassigned(state) + } + + // Return appropriate value based on method + return RESULT_RETURNING_METHODS.has(method as MutatingArrayMethod) + ? result + : state.draft_ + }) + } + + /** + * Handles reordering operations (reverse, sort) that change element order. + * + * Operates directly on `state.copy_` and marks all indices as reassigned + * since element positions change. Does not mark length as changed since + * these operations preserve array length. + * + * @returns The draft proxy for method chaining. + */ + function handleReorderingOperation( + state: ProxyArrayState, + method: string, + args: any[] + ) { + return executeArrayMethod( + state, + () => { + ;(state.copy_! as any)[method](...args) + markAllIndicesReassigned(state) + return state.draft_ + }, + false + ) // Don't mark length as changed + } + + /** + * Creates an interceptor function for a specific array method. + * + * The interceptor wraps array method calls to: + * 1. Set `state.operationMethod` flag during execution (allows proxy `get` trap + * to detect we're inside an optimized method and skip proxy creation) + * 2. Route to appropriate handler based on method type + * 3. Clean up the operation flag in `finally` block + * + * The `operationMethod` flag is the key mechanism that enables the proxy's `get` + * trap to return base values instead of creating nested proxies during iteration. + * + * @param state - The proxy array state + * @param originalMethod - Name of the array method being intercepted + * @returns Interceptor function that handles the method call + */ + function createMethodInterceptor( + state: ProxyArrayState, + originalMethod: string + ) { + return function interceptedMethod(...args: any[]) { + // Enter operation mode - this flag tells the proxy's get trap to return + // base values instead of creating nested proxies during iteration + const method = originalMethod as ArrayOperationMethod + enterOperation(state, method) + + try { + // Check if this is a mutating method + if (isMutatingArrayMethod(method)) { + // Direct method dispatch - no configuration lookup needed + if (RESULT_RETURNING_METHODS.has(method)) { + return handleSimpleOperation(state, method, args) + } + if (REORDERING_METHODS.has(method)) { + return handleReorderingOperation(state, method, args) + } + + if (method === "splice") { + const res = executeArrayMethod(state, () => + state.copy_!.splice(...(args as [number, number, ...any[]])) + ) + markAllIndicesReassigned(state) + return res + } + } else { + // Handle non-mutating methods + return handleNonMutatingOperation(state, method, args) + } + } finally { + // Always exit operation mode - must be in finally to handle exceptions + exitOperation(state) + } + } + } + + /** + * Handles non-mutating array methods with different return semantics. + * + * **Subset operations** return draft proxies for mutation tracking: + * - `filter`, `slice`: Return `state.draft_[i]` for each selected element + * - `find`, `findLast`: Return `state.draft_[i]` for the found element + * + * This allows mutations on returned elements to propagate back to the draft: + * ```ts + * const filtered = draft.items.filter(x => x.value > 5) + * filtered[0].value = 999 // Mutates draft.items[originalIndex] + * ``` + * + * **Transform operations** return base values (no draft tracking): + * - `concat`, `flat`: These create NEW arrays rather than selecting subsets. + * Since the result structure differs from the original, tracking mutations + * back to specific draft indices would be impractical/impossible. + * + * **Primitive operations** return the native result directly: + * - `indexOf`, `includes`, `some`, `every`, `join`, etc. + * + * @param state - The proxy array state + * @param method - The non-mutating method name + * @param args - Arguments passed to the method + * @returns Drafts for subset operations, base values for transforms, primitives otherwise + */ + function handleNonMutatingOperation( + state: ProxyArrayState, + method: NonMutatingArrayMethod, + args: any[] + ) { + const source = latest(state) + + // Methods that return arrays with selected items - need to return drafts + if (method === "filter") { + const predicate = args[0] + const result: any[] = [] + + // First pass: call predicate on base values to determine which items pass + for (let i = 0; i < source.length; i++) { + if (predicate(source[i], i, source)) { + // Only create draft for items that passed the predicate + result.push(state.draft_[i]) + } + } + + return result + } + + if (FIND_METHODS.has(method)) { + const predicate = args[0] + const isForward = method === "find" + const step = isForward ? 1 : -1 + const start = isForward ? 0 : source.length - 1 + + for (let i = start; i >= 0 && i < source.length; i += step) { + if (predicate(source[i], i, source)) { + return state.draft_[i] + } + } + return undefined + } + + if (method === "slice") { + const rawStart = args[0] ?? 0 + const rawEnd = args[1] ?? source.length + + // Normalize negative indices + const start = normalizeSliceIndex(rawStart, source.length) + const end = normalizeSliceIndex(rawEnd, source.length) + + const result: any[] = [] + + // Return drafts for items in the slice range + for (let i = start; i < end; i++) { + result.push(state.draft_[i]) + } + + return result + } + + // For other methods, call on base array directly: + // - indexOf, includes, join, toString: Return primitives, no draft needed + // - concat, flat: Return NEW arrays (not subsets). Elements are base values. + // This is intentional - concat/flat create new data structures rather than + // selecting subsets of the original, making draft tracking impractical. + return source[method as keyof typeof Array.prototype](...args) + } + + loadPlugin(PluginArrayMethods, { + createMethodInterceptor, + isArrayOperationMethod, + isMutatingArrayMethod + }) +} diff --git a/src/plugins/patches.ts b/src/plugins/patches.ts index df556d79..a1b5fa87 100644 --- a/src/plugins/patches.ts +++ b/src/plugins/patches.ts @@ -184,11 +184,15 @@ export function enablePatches() { ;[patches, inversePatches] = [inversePatches, patches] } + const allReassigned = state.allIndicesReassigned_ === true + // Process replaced indices. for (let i = 0; i < base_.length; i++) { const copiedItem = copy_[i] const baseItem = base_[i] - if (assigned_?.get(i.toString()) && copiedItem !== baseItem) { + + const isAssigned = allReassigned || assigned_?.get(i.toString()) + if (isAssigned && copiedItem !== baseItem) { const childState = copiedItem?.[DRAFT_STATE] if (childState && childState.modified_) { // Skip - let the child generate its own patches diff --git a/src/types/index.js.flow b/src/types/index.js.flow index 6f14c85a..5b3d94fa 100644 --- a/src/types/index.js.flow +++ b/src/types/index.js.flow @@ -107,5 +107,6 @@ declare export function finishDraft(base: T, listener?: PatchListener): T declare export function enableMapSet(): void declare export function enablePatches(): void +declare export function enableArrayMethods(): void declare export function freeze(obj: T, freeze?: boolean): T diff --git a/src/utils/common.ts b/src/utils/common.ts index 46277880..2ff01b6b 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -171,6 +171,11 @@ export let isFunction = (target: any): target is Function => export let isBoolean = (target: any): target is boolean => typeof target === "boolean" +export function isArrayIndex(value: string | number): value is number | string { + const n = +value + return Number.isInteger(n) && String(n) === value +} + export let getProxyDraft = (value: T): ImmerState | null => { if (!isObjectish(value)) return null return (value as {[DRAFT_STATE]: any})?.[DRAFT_STATE] diff --git a/src/utils/plugins.ts b/src/utils/plugins.ts index 91656347..3cc89200 100644 --- a/src/utils/plugins.ts +++ b/src/utils/plugins.ts @@ -7,11 +7,13 @@ import { AnySet, ArchType, die, - ImmerScope + ImmerScope, + ProxyArrayState } from "../internal" export const PluginMapSet = "MapSet" export const PluginPatches = "Patches" +export const PluginArrayMethods = "ArrayMethods" export type PatchesPlugin = { generatePatches_( @@ -34,10 +36,17 @@ export type MapSetPlugin = { fixSetContents: (state: ImmerState) => void } +export type ArrayMethodsPlugin = { + createMethodInterceptor: (state: ProxyArrayState, method: string) => Function + isArrayOperationMethod: (method: string) => boolean + isMutatingArrayMethod: (method: string) => boolean +} + /** Plugin utilities */ const plugins: { Patches?: PatchesPlugin MapSet?: MapSetPlugin + ArrayMethods?: ArrayMethodsPlugin } = {} type Plugins = typeof plugins @@ -56,6 +65,10 @@ export function getPlugin( export let isPluginLoaded = (pluginKey: K): boolean => !!plugins[pluginKey] +export let clearPlugin = (pluginKey: K): void => { + delete plugins[pluginKey] +} + export function loadPlugin( pluginKey: K, implementation: Plugins[K] diff --git a/website/docs/api.md b/website/docs/api.md index 0fa2b801..3397d435 100644 --- a/website/docs/api.md +++ b/website/docs/api.md @@ -16,6 +16,7 @@ title: API overview | `createDraft` | Given a base state, creates a mutable draft for which any modifications will be recorded | [Async](./async.mdx) | | `current` | Given a draft object (doesn't have to be a tree root), takes a snapshot of the current state of the draft | [Current](./current.md) | | `Draft` | Exposed TypeScript type to convert an immutable type to a mutable type | [TypeScript](./typescript.mdx) | +| `enableArrayMethods()` | Enables optimized array method handling for improved performance with array-heavy operations. | [Array Methods](./array-methods.md) | | `enableMapSet()` | Enables support for `Map` and `Set` collections. | [Installation](./installation.mdx#pick-your-immer-version) | | `enablePatches()` | Enables support for JSON patches. | [Installation](./installation#pick-your-immer-version) | | `finishDraft` | Given an draft created using `createDraft`, seals the draft and produces and returns the next immutable state that captures all the changes | [Async](./async.mdx) | diff --git a/website/docs/array-methods.md b/website/docs/array-methods.md new file mode 100644 index 00000000..e2c0d948 --- /dev/null +++ b/website/docs/array-methods.md @@ -0,0 +1,277 @@ +--- +id: array-methods +title: Array Methods Plugin +--- + +
+
+
+ +## Overview + +The Array Methods Plugin (`enableArrayMethods()`) optimizes array operations within Immer producers by avoiding unnecessary Proxy creation during iteration. This provides significant performance improvements for array-heavy operations. + +**Why does this matter?** Without the plugin, every array element access during iteration (e.g., in `filter`, `find`, `slice`) creates a Proxy object. For a 1000-element array, this means 1000+ proxy trap invocations just to iterate. With the plugin enabled, callbacks receive base (non-proxied) values, and proxies are only created as needed for mutation tracking. + +## Installation + +Enable the plugin once at your application's entry point: + +```javascript +import {enableArrayMethods} from "immer" + +enableArrayMethods() +``` + +This adds approximately **2KB** to your bundle size. + +## Mutating Methods + +These methods modify the array in-place and operate directly on the draft's internal copy without creating per-element proxies: + +| Method | Returns | Description | +| ----------- | ---------------- | ------------------------------------- | +| `push()` | New length | Adds elements to the end | +| `pop()` | Removed element | Removes and returns the last element | +| `shift()` | Removed element | Removes and returns the first element | +| `unshift()` | New length | Adds elements to the beginning | +| `splice()` | Removed elements | Adds/removes elements at any position | +| `sort()` | The draft array | Sorts elements in place | +| `reverse()` | The draft array | Reverses the array in place | + +```javascript +import {produce, enableArrayMethods} from "immer" + +enableArrayMethods() + +const base = {items: [3, 1, 4, 1, 5]} + +const result = produce(base, draft => { + draft.items.push(9) // Adds 9 to end + draft.items.sort() // Sorts: [1, 1, 3, 4, 5, 9] + draft.items.reverse() // Reverses: [9, 5, 4, 3, 1, 1] +}) +``` + +## Non-Mutating Methods + +Non-mutating methods are categorized based on what they return: + +### Subset Operations (Return Drafts) + +These methods select items that exist in the original array and **create draft proxies** for the returned items. The callbacks receive **base values** (the optimization), but the **returned array** contains newly created draft proxies that point back to the original positions. **Mutations to returned items WILL affect the draft state.** + +| Method | Returns | Drafts? | +| ------------ | ---------------------------------- | ------- | +| `filter()` | Array of matching items | ✅ Yes | +| `slice()` | Array of items in range | ✅ Yes | +| `find()` | First matching item or `undefined` | ✅ Yes | +| `findLast()` | Last matching item or `undefined` | ✅ Yes | + +```javascript +const base = { + items: [ + {id: 1, value: 10}, + {id: 2, value: 20}, + {id: 3, value: 30} + ] +} + +const result = produce(base, draft => { + // filter returns drafts - mutations track back to original + const filtered = draft.items.filter(item => item.value > 15) + filtered[0].value = 999 // This WILL affect draft.items[1] + + // find returns a draft - mutations track back + const found = draft.items.find(item => item.id === 3) + if (found) { + found.value = 888 // This WILL affect draft.items[2] + } + + // slice returns drafts + const sliced = draft.items.slice(0, 2) + sliced[0].value = 777 // This WILL affect draft.items[0] +}) + +console.log(result.items[0].value) // 777 +console.log(result.items[1].value) // 999 +console.log(result.items[2].value) // 888 +``` + +### Transform Operations (Return Base Values) + +These methods create **new arrays** that may include external items or restructured data. They return **base values**, NOT drafts. **Mutations to returned items will NOT track back to the draft state.** + +| Method | Returns | Drafts? | +| ---------- | ------------------- | ------- | +| `concat()` | New combined array | ❌ No | +| `flat()` | New flattened array | ❌ No | + +```javascript +const base = {items: [{id: 1, value: 10}]} + +const result = produce(base, draft => { + // concat returns base values - mutations DON'T track + const concatenated = draft.items.concat([{id: 2, value: 20}]) + concatenated[0].value = 999 // This will NOT affect draft.items[0] + + // To actually use concat results, assign them: + draft.items = draft.items.concat([{id: 2, value: 20}]) +}) + +// Original unchanged because concat result wasn't assigned +console.log(result.items[0].value) // 10 (unchanged) +``` + +**Why the distinction?** + +- **Subset operations** (`filter`, `slice`, `find`) select items that exist in the original array. Returning drafts allows mutations to propagate back to the source. +- **Transform operations** (`concat`, `flat`) create new data structures that may include external items or restructured data, making draft tracking impractical. + +### Primitive-Returning Methods + +These methods return primitive values (numbers, booleans, strings). No tracking issues since primitives aren't draftable: + +| Method | Returns | +| ------------------ | -------------------- | +| `indexOf()` | Number (index or -1) | +| `lastIndexOf()` | Number (index or -1) | +| `includes()` | Boolean | +| `some()` | Boolean | +| `every()` | Boolean | +| `findIndex()` | Number (index or -1) | +| `findLastIndex()` | Number (index or -1) | +| `join()` | String | +| `toString()` | String | +| `toLocaleString()` | String | + +```javascript +const base = { + items: [ + {id: 1, active: true}, + {id: 2, active: false} + ] +} + +const result = produce(base, draft => { + const index = draft.items.findIndex(item => item.id === 2) + const hasActive = draft.items.some(item => item.active) + const allActive = draft.items.every(item => item.active) + + console.log(index) // 1 + console.log(hasActive) // true + console.log(allActive) // false +}) +``` + +## Methods NOT Overridden + +The following methods are **not** intercepted by the plugin and work through standard Proxy behavior. Callbacks receive drafts, and mutations track normally: + +| Method | Description | +| --------------- | --------------------------------- | +| `map()` | Transform each element | +| `flatMap()` | Map then flatten | +| `forEach()` | Execute callback for each element | +| `reduce()` | Reduce to single value | +| `reduceRight()` | Reduce from right to left | + +```javascript +const base = { + items: [ + {id: 1, value: 10, nested: {count: 0}}, + {id: 2, value: 20, nested: {count: 0}} + ] +} + +const result = produce(base, draft => { + // forEach receives drafts - mutations work normally + draft.items.forEach(item => { + item.value *= 2 + }) + + // map is NOT overridden - callbacks receive drafts + // The returned array items are also drafts (extracted from draft.items) + const mapped = draft.items.map(item => item.nested) + // Mutations to the result array propagate back + mapped[0].count = 999 // ✅ This affects draft.items[0].nested.count +}) + +console.log(result.items[0].nested.count) // 999 +``` + +## Callback Behavior + +For overridden methods, callbacks receive **base values** (not drafts). This is the core optimization - it avoids creating proxies for every element during iteration. + +```javascript +const base = { + items: [ + {id: 1, value: 10}, + {id: 2, value: 20} + ] +} + +produce(base, draft => { + draft.items.filter(item => { + // `item` is a base value here, NOT a draft + // Reading properties works fine + return item.value > 15 + + // But direct mutation here won't be tracked: + // item.value = 999 // ❌ Won't affect draft + }) + + // Instead, use the returned draft: + const filtered = draft.items.filter(item => item.value > 15) + filtered[0].value = 999 // ✅ This works because filtered[0] is a draft +}) +``` + +## Method Return Behavior Summary + +| Category | Methods | Returns | Mutations Track? | +| --- | --- | --- | --- | +| **Subset** | `filter`, `slice`, `find`, `findLast` | Draft proxies | ✅ Yes | +| **Transform** | `concat`, `flat` | Base values | ❌ No | +| **Primitive** | `indexOf`, `includes`, `some`, `every`, `findIndex`, `findLastIndex`, `lastIndexOf`, `join`, `toString`, `toLocaleString` | Primitives | N/A | +| **Mutating** | `push`, `pop`, `shift`, `unshift`, `splice`, `sort`, `reverse` | Various | ✅ Yes (modifies draft) | +| **Not Overridden** | `map`, `flatMap`, `forEach`, `reduce`, `reduceRight` | Standard behavior | ✅ Yes (callbacks get drafts) | + +## When to Use + +Enable the Array Methods Plugin when: + +- Your application has significant array iteration within producers +- You frequently use methods like `filter`, `find`, `some`, `every` on large arrays +- Performance profiling shows array operations as a bottleneck + +The plugin is most beneficial for: + +- Large arrays (100+ elements) +- Frequent producer calls with array operations +- Read-heavy operations (filtering, searching) where most elements aren't modified + +## Performance Benefit + +**Without the plugin:** + +- Every array element access during iteration creates a Proxy +- A `filter()` on 1000 elements = 1000+ proxy creations + +**With the plugin:** + +- Callbacks receive base values directly +- Proxies only created for the specific elements you actually mutate, or that match filtering predicates + +```javascript +// Without plugin: ~3000+ proxy trap invocations +// With plugin: ~10-20 proxy trap invocations +const result = produce(largeState, draft => { + const filtered = draft.items.filter(x => x.value > threshold) + // Only items you mutate get proxied + filtered.forEach(item => { + item.processed = true + }) +}) +``` diff --git a/website/docs/installation.mdx b/website/docs/installation.mdx index 8a6896ff..2474327c 100644 --- a/website/docs/installation.mdx +++ b/website/docs/installation.mdx @@ -30,6 +30,7 @@ The following features can be opt-in to: | Feature | Description | Method to call | | --- | --- | --- | +| [Array Methods optimization](./array-methods.md) | Optimizes array method handling for improved performance with array-heavy operations | `enableArrayMethods()` | | [ES2015 Map and Set support](./complex-objects.md) | To enable Immer to operate on the native `Map` and `Set` collections, enable this feature | `enableMapSet()` | | [JSON Patch support](./patches.mdx) | Immer can keep track of all the changes you make to draft objects. This can be useful for communicating changes using JSON patches | `enablePatches()` | @@ -56,17 +57,18 @@ expect(usersById_v1.get("michel").country).toBe("NL") expect(usersById_v2.get("michel").country).toBe("UK") ``` -Vanilla Immer kicks in at ~3KB gzipped. Every plugin that is enabled adds < 1 KB to that. The breakdown is as follows: +Vanilla Immer kicks in at ~3KB gzipped. Every plugin that is enabled adds ~1-2 KB to that. The breakdown is as follows: ``` Import size report for immer: ┌───────────────────────┬───────────┬────────────┬───────────┐ -│ (index) │ just this │ cumulative │ increment │ +│ (index) │ just this │ cumulative │ increment │ ├───────────────────────┼───────────┼────────────┼───────────┤ -│ import * from 'immer' │ 5033 │ 0 │ 0 │ -│ produce │ 3324 │ 3324 │ 0 │ -│ enableMapSet │ 4030 │ 4039 │ 715 │ -│ enablePatches │ 4112 │ 4826 │ 787 │ +│ import * from 'immer' │ 6908 │ 0 │ 0 │ +│ produce │ 4183 │ 4183 │ 0 │ +│ enableMapSet │ 4971 │ 4980 │ 797 │ +│ enablePatches │ 5335 │ 6097 │ 1117 │ +│ enableArrayMethods │ 4768 │ 6659 │ 562 │ └───────────────────────┴───────────┴────────────┴───────────┘ (this report was generated by npmjs.com/package/import-size) ``` diff --git a/website/docs/performance.mdx b/website/docs/performance.mdx index 37f54afd..a5162f88 100644 --- a/website/docs/performance.mdx +++ b/website/docs/performance.mdx @@ -69,6 +69,17 @@ Most important observation: ## Performance tips +### Enable the Array Methods Plugin + +For applications with significant array iteration within producers, enable the [Array Methods Plugin](./array-methods.md): + +```javascript +import {enableArrayMethods} from "immer" +enableArrayMethods() +``` + +This plugin optimizes array operations like `filter`, `find`, `some`, `every`, and `slice` by avoiding proxy creation for every element during iteration. Without the plugin, iterating a 1000-element array creates 1000+ proxies. With the plugin, callbacks receive base values, and proxies are only created for elements you actually mutate. + ### Pre-freeze data When adding a large data set to the state tree in an Immer producer (for example data received from a JSON endpoint), it is worth to call `freeze(json)` on the root of the data that is being added first. To _shallowly_ freeze it. This will allow Immer to add the new data to the tree faster, as it will avoid the need to _recursively_ scan and freeze the new data. diff --git a/website/docs/pitfalls.md b/website/docs/pitfalls.md index afa58c2b..999df378 100644 --- a/website/docs/pitfalls.md +++ b/website/docs/pitfalls.md @@ -87,3 +87,29 @@ remove(values, a) ``` If possible, it's recommended to perform the comparison outside the `produce` function, or to use a unique identifier property like `.id` instead, to avoid needing to use `original`. + +### Array Methods Plugin: Callbacks receive base values + +When using the [Array Methods Plugin](./array-methods.md) (`enableArrayMethods()`), callbacks for overridden methods like `filter`, `find`, `some`, `every`, and `slice` receive **base values** (not drafts). This is the core performance optimization - it avoids creating proxies for every element during iteration. + +```javascript +import {enableArrayMethods, produce} from "immer" +enableArrayMethods() + +produce(state, draft => { + draft.items.filter(item => { + // `item` is a base value here, NOT a draft + // Reading works fine: + return item.value > 10 + + // But direct mutation here won't be tracked: + // item.value = 999 // ❌ Won't affect the draft! + }) + + // Instead, use the returned result (which contains drafts): + const filtered = draft.items.filter(item => item.value > 10) + filtered[0].value = 999 // ✅ This works - filtered[0] is a draft +}) +``` + +This only applies to methods intercepted by the plugin. Methods like `map`, `forEach`, `reduce` are NOT overridden and work normally - their callbacks receive drafts.