Repository navigation
fix: bound the search for repeated moved array elements and type maybe-async recipes of primitive states - #202
Merged
Conversation
…erformance summary
…and the performance summary
|
Coverage after merging fix/repeated-elements-and-primitive-types into main will be
Coverage Report
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
…peated moved element
…performance summary
…ne for the doubling search
|
Coverage after merging fix/repeated-elements-and-primitive-types into main will be
Coverage Report
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
This was referenced Oct 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #168.
Summary
This PR fixes a performance regression that #200 introduced and a typing gap that #201 left, documents two limits, and refreshes the published bundle sizes, the API reference, the size limit of all ESM exports and the size baseline.
mainshift(),unshift()orsplice()shift()and eight reads of 10,000 rows of two objects in turn take 581 µs, against 10.3 µs before #200create<number>(1, (n): number | Promise<number> => …)number, or as the patches tuple, although the result can be a Promise; also in curried producers and creators frommakeCreator()number | Promise<number>, as object state types have since #201; synchronous and async recipes keep their typesenablePatchesis typedtruebut may be missing at run time: an optional options parameter of a wrapper, options that may beundefined, or{ enablePatches?: true }create()returns the state aloneproduceWithPatches()booleanas the type that gives either shape, also as the type argument next to an explicit state typeapply()with a prototype that other code has already changed__proto__andconstructorof objects and arrays and a function'sprototypeare read as propertiesChanges, one commit per item
perf: bound the forward search for later copies of a moved array element(item 1): the first eight lookups of an array check the index that the offset of the last move gives, and perf: find a moved array element's original index from the offset of its move #200 then searched forward from it for each later copy, to find the last index of a repeated element, which the map of original indices holds for the later lookups. An element repeated throughout the array took oneindexOf()call per later copy, 39,984 for eight reads aftershift()of 10,000 rows of two objects in turn, where the backward search before perf: find a moved array element's original index from the offset of its move #200 found its last copy near the end at once. This commit follows at most 16 later copies forward and otherwise searches once from the end; commit 9 replaces the search from the end. It returns the same index as before. A test counts the searches on the original array (3,984 onmainfor 1,000 rows), and the moved-element tests also run on an array of two objects in turn, frozen or not, with 1, 8, 9 and 40 edits.fix: type a recipe that may return a Promise for a primitive state(item 2): the first explicit-state overload only gives the async recipes of a primitive state their context, so fix: harden apply() for Set positions and mutable root replacement, and fix development checks and recipe types #201 kept the maybe-async overloads away from primitive state types, whose synchronous recipes would otherwise have matched them. A primitive state now has an overload that takes only synchronous recipes, for direct and curried calls, before the maybe-async ones, which apply to every state type again. The migration guide in the README and on the website no longer lists primitive state types as an exception.docs: describe how the type of the options decides the result type(item 3): underenablePatchesin the README and on thecreate()page.docs: state that apply() does not guard against prototypes changed elsewhere(item 4): the README and theapply()page.docs: measure the bounded search for repeated moved elements in the performance summary(item 1): a paragraph and a table in "Moved array elements", the case that still searches from the end under "Tradeoffs and limits", and an entry in the history; commit 12 replaces them.docs: refresh the bundle sizes in the README, the installation guide and the performance summary: the README still gave the sizes measured for perf: rerun the full benchmark on main and refresh the published results #199, 7.8 and 8.4 kB; with perf: find a moved array element's original index from the offset of its move #200, fix: harden apply() for Set positions and mutable root replacement, and fix development checks and recipe types #201 and this PR they are 7.9 and 8.5 kB (7,912 and 8,524 B Brotli at the head, measured as the README describes). Thesize-limitfigures of the summary dated from perf: read the type of a draft from its state when applying patches #196.docs: regenerate the API reference: the overloads of commit 2 and the line numbers that changed after fix: harden apply() for Set positions and mutable root replacement, and fix development checks and recipe types #201 generated it; the links point to the commit before it.build: refresh the size baseline for the bounded search of moved array elements: commit 1 grows the development builds by 96 B raw, over the allowance of 64 B.perf: search backward from the first probe past the last copy of a repeated moved element(item 1): after commit 1, an element with more than 16 later copies was searched for from the end of the array, as before perf: find a moved array element's original index from the offset of its move #200, which V8 runs about 15 times as slowly on frozen arrays. When the first 100 of 10,000 rows hold two objects in turn,shift()and eight reads of a frozen base took 1,895 µs, against 62 µs onmain. After 16 copies, each forward search now starts twice as far ahead as the one before; the last copy then lies before the start of the first search that finds none, and the backward search starts there. It returns the same index as before. The counting test now allows 17 forward searches one copy after another and about one per doubling of the step (at most 8 × 28 for 1,000 rows; 208 now), with one backward search per read; a new test checks that the backward searches for an array whose first 100 of 1,000 rows repeat two objects start before index 200, which fails on commit 8; and the moved-element tests also run on 60 rows whose first 40 hold two objects in turn.docs: limit the prototype note of apply() to objects and arrays(item 4):apply()checks__proto__andconstructoron the paths of objects and arrays; Map keys of these names are ordinary keys, which it reads and writes.docs: show the patches type argument to pass with an explicit state type(item 3): with an explicit state type, TypeScript infers no other type argument, so options typed{ enablePatches: boolean }needcreate<State, false, boolean>(…), orcreate<State, [], false, boolean>(…)for a curried producer; without it, the call does not compile.docs: measure the doubling search for repeated moved elements in the performance summary(item 1): the paragraph, the table, the "Tradeoffs and limits" bullet, thesize-limitfigures and the history entry of commits 5 and 6, measured again with the lookup of commit 9.build: raise the size limit of all ESM exports and refresh the baseline for the doubling search: commit 9 brings all ESM exports to 8,515 B, over their limit of 8.5 kB, which is now 8.6 kB, and grows the development builds by 53 B raw.Behavior changes to review
main. 20,000 random recipes gave the same states, return values, patches, inverse patches and replays asmainwith the head's production build, and 100,000 with commit 1.Not covered
shift()and eight reads take 655 µs on a frozen base, against 330 µs onmainand 985 µs before perf: find a moved array element's original index from the offset of its move #200. Arrays of objects repeated throughout take 3–4 µs more than before perf: find a moved array element's original index from the offset of its move #200, whose single backward search found their last copies at once (13.6 against 10.3 µs at 10,000 rows). Other lookups were measured:lastIndexOf()(+6 B over this PR) was fast in a process that sees one kind of array, but became up to 7.5 times slower once its lookups had seen frozen, sealed, non-extensible and holey arrays, then slower thanlastIndexOf().undefinedfrom the inferred type of an optional parameter, so only overloads with a required options parameter, for every form ofcreate(), could do it.objectorunknown, cannot tell an async recipe from one that returns a Promise as the new state, since TypeScript infers no recipe type once the call gives the state type. With patches, the result is then typed as the tuple.create<T>(base, async (draft) => { … })in a generic function, still givesT, as in 1.3.0; leaving out the type argument givesPromise<T>. docs: name the state types that keep async results typed as the state, fix the JSON Patch note and state the TypeScript versions #203 documents it. Two changes to the overloads were tried: async overloads with a plain base type before the overloads that infer the state type fix those calls, but also changed 10 of the other 152 types that the probes check in TypeScript 5.0.4–5.9.3, such ascreate(state, (draft) => { draft.count++; }), typedDraftedObject<State>without strict mode, andcreate(0, (n) => n + 1), rejected in strict mode; giving the inferring overloads aneverreturn by default broke five existing type checks.Size
The production CJS artifact grows from 27,375 B to 27,437 B raw and from 8,449 B to 8,472 B Brotli; the UMD and ESM production artifacts grow by 28 B and 31 B Brotli.
size-limitmeasures 8,640 B for it (8,615 B onmain), 7,575 B for an ESM bundle ofcreate(7,546 B) and 8,515 B for all ESM exports (8,485 B), within the limits of 8.7, 7.7 and 8.6 kB. Consumer bundles built with esbuild grow by 27–73 B Brotli, and the development builds by 149–153 B raw. Commit 2 changes types only: the declaration ofmakeCreatorgrows from 4,064 B to 4,554 B.Performance
Production CJS builds of
9f93f16, before #200, ofmainand of this PR, outside the suite, each in processes of its own, three rounds in rotated order, µs per update ofshift()and eight reads near the start; a frozen input is a frozen base state, and a frozen result adds auto-freeze:9f93f16mainAt 10,000 rows with auto-freeze off, in Chromium 148, Firefox 150 and WebKit 26.4, this PR took at most about 1 µs more than commit 1 with no repeated object, two objects in turn, each object twice, and two objects in turn in the first 100 rows or the first half, frozen or not, and 44 µs instead of 1,553 µs in Chromium for the first 100 rows on a frozen base. The suite has no scenario with repeated objects; its moved-row cells, such as
shift-and-update, take the code path of unique elements.The paired performance budgets of CI on the head compared 146 latency cells in five groups: geometric mean 0.999, the slowest cells 1.055 (
return-replacewith patches and without freezing, the slowest cell on commit 8 too, whose code this PR does not change) and 1.046 (search-current-shiftedwith freezing and patches, 0.968–1.003 in its other modes),shift-and-update0.983–1.015, the array move cells 0.972–1.018, and theapply-*cells 0.978–1.009.Type-checking the built declarations with TypeScript 5.8, 150 calls with distinct state types per shape: direct and curried calls with an object state type take the same number of instantiations as on
main, and inferred calls 8 more. A recipe that may return a Promise takes 3% more with an object state type (30,126 instead of 29,223); with a primitive state type, synchronous recipes take 13% more (20,455 instead of 18,064), curried ones 22% more (16,552 instead of 13,548), and recipes that may return a Promise, now typed correctly, 29,154 instead of 18,064. Recipes that return a literal, as increate<'a' | 'b'>('a', () => 'b'), take 58% more (28,577 instead of 18,054), and curried ones 73% more (23,461 instead of 13,538); checking 150 such calls took 0.08–0.09 s instead of 0.07 s.Verification
test:benchmarks,size,test:package,test:build-watch,type-checkand 5,040 tests with full coverage of the 28 source files. Commits 1, 2 and 9 pass type-check, lint, format and the tests on their own.main, and the test of commit 9 on commit 8. 20,000 random recipes ofshift,unshift,splice,reverse,pushandpopwith reads, edits, assignments and searches over arrays of up to 70 elements drawn from one to four repeated objects, frozen or not, with or without auto-freeze and with both path formats, gave the same results, return values, patches, inverse patches and replays asmain; 907 of them searched after the 16th copy.main. 76 call shapes, checked on TypeScript 4.8.4, 5.0.4, 5.8.3 and 6.0.3 with and without strict mode, change only for the seven shapes with a primitive state type and a recipe that may return a Promise; TypeScript 7.0.2 gives the same types for them. 47 other call shapes, on TypeScript 5.1.6, 5.8.3 and 5.9.3, change only for the 12 with a primitive state type and a recipe that may return a Promise.{ enablePatches: boolean } | undefinedgive the union withcreate<State, false, boolean>andcreate<State, [], false, boolean>, andcreate<State>with them does not compile. A patch through a Map key__proto__orconstructorapplies; one through an object's__proto__throws.