diff --git a/perf-testing/rtkq-testing.mjs b/perf-testing/rtkq-testing.mjs new file mode 100644 index 00000000..040a8940 --- /dev/null +++ b/perf-testing/rtkq-testing.mjs @@ -0,0 +1,127 @@ +import {produce} from "../dist/immer.mjs" + +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 + })), + api: { + queries: {}, + provided: { + keys: {} + }, + subscriptions: {} + } + } + return initialState +} + +const MAX = 1 + +const BENCHMARK_CONFIG = { + iterations: 1, + arraySize: 100, + nestedArraySize: 10, + multiUpdateCount: 5, + reuseStateIterations: 10 +} + +// RTKQ-style action creators +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 createImmerReducer = produce => { + const immerReducer = (state = createInitialState(), action) => + produce(state, draft => { + switch (action.type) { + case "rtkq/pending": { + // Simulate separate RTK slice reducers with combined reducer pattern + 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 + // provided and subscriptions don't change on resolved + break + } + } + }) + + return immerReducer +} + +const immerReducer = createImmerReducer(produce) +const initialState = createInitialState() + +const arraySizes = [10, 100, 250, 500, 1000, 1021, 1500, 2000, 3000] + +for (const arraySize of arraySizes) { + console.log(`Running benchmark with array size: ${arraySize}`) + + const start = performance.now() + + let state = initialState + + // Phase 1: Execute all pending actions + for (let i = 0; i < arraySize; i++) { + state = immerReducer(state, rtkqPending(i)) + } + + // Phase 2: Execute all resolved actions + for (let i = 0; i < arraySize; i++) { + state = immerReducer(state, rtkqResolved(i)) + } + + const end = performance.now() + const total = end - start + const avg = total / arraySize + + console.log( + `Done in ${total.toFixed(1)} ms (items: ${arraySize}, avg: ${avg.toFixed( + 3 + )} ms / item)` + ) +} diff --git a/src/core/immerClass.ts b/src/core/immerClass.ts index 64c221f1..43df8c82 100644 --- a/src/core/immerClass.ts +++ b/src/core/immerClass.ts @@ -31,7 +31,12 @@ interface ProducersFns { produceWithPatches: IProduceWithPatches } -export type StrictMode = boolean | "class_only" +export type StrictMode = + | boolean + | "class_only" + | "strings_only" + | "with_symbols" + | "full" export class Immer implements ProducersFns { autoFreeze_: boolean = true @@ -45,7 +50,7 @@ export class Immer implements ProducersFns { }) { if (typeof config?.autoFreeze === "boolean") this.setAutoFreeze(config!.autoFreeze) - if (typeof config?.useStrictShallowCopy === "boolean") + if (typeof config?.useStrictShallowCopy !== "undefined") this.setUseStrictShallowCopy(config!.useStrictShallowCopy) if (typeof config?.useStrictIteration === "boolean") this.setUseStrictIteration(config!.useStrictIteration) diff --git a/src/utils/common.ts b/src/utils/common.ts index 7bb5f93d..658ecfcd 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -164,7 +164,11 @@ export function shallowCopy(base: any, strict: StrictMode) { const isPlain = isPlainObject(base) - if (strict === true || (strict === "class_only" && !isPlain)) { + if ( + strict === true || + strict === "full" || + (strict === "class_only" && !isPlain) + ) { // Perform a strict copy const descriptors = Object.getOwnPropertyDescriptors(base) delete descriptors[DRAFT_STATE as any] @@ -192,6 +196,25 @@ export function shallowCopy(base: any, strict: StrictMode) { // perform a sloppy copy const proto = getPrototypeOf(base) if (proto !== null && isPlain) { + // v8 has a perf cliff at 1020 properties where it + // switches from "fast properties" to "dictionary mode" at 1020 keys: + // - https://github.com/v8/v8/blob/754e7ba956b06231c487e09178aab9baba1f46fe/src/objects/property-details.h#L242-L247 + // - https://github.com/v8/v8/blob/754e7ba956b06231c487e09178aab9baba1f46fe/test/mjsunit/dictionary-prototypes.js + // At that size, object spread gets drastically slower, + // and an `Object.keys()` loop becomes _faster_. + // Immer currently expects that we also copy symbols. That would require either a `Reflect.ownKeys()`, + // or `.keys()` + `.getOwnPropertySymbols()`. + // For v10.x, we can keep object spread the default, + // and offer an option to switch to just strings to enable better perf + // with larger objects. For v11, we can flip those defaults. + if (strict === "strings_only") { + const copy: Record = {} + Object.keys(base).forEach(key => { + copy[key] = base[key] + }) + return copy + } + return {...base} // assumption: better inner class optimization than the assign below } const obj = Object.create(proto)