fix(List): preserve stored undefined across tail/root boundary shift by spokodev · Pull Request #2230 · immutable-js/immutable-js · GitHub
Skip to content

fix(List): preserve stored undefined across tail/root boundary shift - #2230

Closed
spokodev wants to merge 1 commit into
immutable-js:mainfrom
spokodev:fix/list-undefined-to-null-corruption
Closed

spokodev wants to merge 1 commit into
immutable-js:mainfrom
spokodev:fix/list-undefined-to-null-corruption

Conversation

@spokodev

Copy link
Copy Markdown

Bug

A sequence of List operations silently corrupts a stored undefined into null, breaking get, toArray, equals and hashCode.

const { List } = require('immutable');

const L = List()
  .unshift(86).unshift(87).unshift(54) // [54, 87, 86]
  .delete(1)                           // [54, 86]
  .setSize(6)                          // [54, 86, undefined, undefined, undefined, undefined]
  .shift().shift()                     // [undefined, undefined, undefined, undefined]
  .delete(1);                          // remove index 1

// Array model (correct): [undefined, undefined, undefined]
// Immutable (current):   [null,      undefined, undefined]   <- index 0 corrupted

L.get(0);                                          // null    (should be undefined)
L.toArray();                                       // [null, undefined, undefined]
L.equals(List([undefined, undefined, undefined])); // false   (should be true)

This violates the documented get(index): T | undefined contract and the setSize "fills with undefined" semantics: an authentic stored undefined must never read back as null.

Root cause

At the corrupting step the List has reached an intermediate state where its single logical element lives entirely in the tail: _origin 31, _capacity 32, _root null. Because getTailOffset(32) === 0, every in-bounds raw index correctly routes to the tail.

A later grow pushes _capacity across the next 32-element boundary, so getTailOffset jumps 0 -> 32. Raw index 31 (logical 0) must now route through _root instead of the tail. In setListBounds the "Merge Tail into tree" step is responsible for grafting the old tail into a freshly created root subtree, but its guard required oldTail.array.length to be truthy. At this point the tail array is empty (the element is a virtual undefined, not physically present), so the merge was skipped and no root was created. _root stayed null while getTailOffset(_capacity) > _origin.

listNodeFor then returns null for that index, and in List.get the expression node && node.array[index & MASK] evaluates to null instead of the stored undefined. The same null then surfaces through toArray, iteration, equals and hashCode.

Fix

Allow the "Merge Tail into tree" step to run for an empty tail as well, when the origin will sit below the new tail offset (newOrigin < newTailOffset) and therefore genuinely needs a root to route those raw indices. The (possibly empty) tail node is grafted into a real root subtree, so reads return undefined rather than null. The empty-tail skip remains an optimization in every other case.

-  // Merge Tail into tree.
   if (
     oldTail &&
     newTailOffset > oldTailOffset &&
     newOrigin < oldCapacity &&
-    oldTail.array.length
+    (oldTail.array.length || newOrigin < newTailOffset)
   ) {

Tests

Added a List test that runs the 8-operation repro and asserts get(0) is undefined, toArray() is [undefined, undefined, undefined], and that equals/hashCode match List([undefined, undefined, undefined]).

  • Red before the fix: get(0) returns null, toArray() yields [null, ...], equals is false.
  • Green after: all assertions pass.
  • Full unit suite green: 54 suites, 799 tests (including the existing List fast-check property tests). Flow type-check, ESLint and Prettier all clean.

A sequence of List operations could corrupt a stored `undefined` into
`null`, breaking `get`, `toArray`, `equals` and `hashCode`.

When a List's only element lives entirely in its tail (`_origin 31`,
`_capacity 32`, `_root null`, so `getTailOffset(32) === 0`), a later grow
pushes `_capacity` across the next 32-boundary and `getTailOffset` jumps
`0 -> 32`. Raw index 31 (logical 0) now routes to `_root` instead of the
tail, but `setListBounds`'s "Merge Tail into tree" step skipped grafting
the tail whenever `oldTail.array.length` was falsy. The element was never
migrated, `_root` stayed `null`, and `listNodeFor` returned `null`; in
`List.get` the expression `node && node.array[index & MASK]` then yielded
`null` instead of the stored `undefined`.

Allow the merge step to run for an empty tail too when the origin will
sit below the new tail offset, so those raw indices route through a real
(possibly empty) root subtree and read back as `undefined`.
@jdeniau

jdeniau commented Jun 29, 2026

Copy link
Copy Markdown
Member

@jdeniau jdeniau closed this Jun 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants