Skip to content

DWP-21210: fix broken arrays when mutated#493

Open
theonelucas wants to merge 1 commit into
teslamotors:masterfrom
theonelucas:DWP-21210
Open

DWP-21210: fix broken arrays when mutated#493
theonelucas wants to merge 1 commit into
teslamotors:masterfrom
theonelucas:DWP-21210

Conversation

@theonelucas

Copy link
Copy Markdown

TL;DR

Checkboxes/Toggles inside lists, such as tables, break/go into random states when the list content shifts.

Problem

When a field inside an ArrayField was removed, FormController called ObjectMap.delete() which spliced the array in every state map (values, touched, errors, modified, etc.). This shifted the indices of all following sibling fields — their defaultValue/initialValue props no longer matched their actual positions in the array, causing the wrong values to appear or fields to reset unexpectedly.

A related side effect: the useUpdateEffect hook in useField that resets a field to its initial value when defaultValue/initialValue changes was entirely commented out as a workaround, because enabling it on array fields would compound the problem. This meant non-array fields also lost the ability to react to initial value changes.

Root cause

ObjectMap.delete() calls lddelete (lodash-style delete), which splices arrays. For field removal the intended behavior is to clear the slot, not collapse the array — the array's index structure must stay intact so sibling fields remain at their declared positions.

Solution

1. New ObjectMap.unset() method (src/ObjectMap.js)

Adds a static unset() method that uses ldunset instead of lddelete. Unlike delete, unset sets the value to undefined without splicing the surrounding array. The parent-cleanup step (cleanup()) still runs to prune any now-empty objects/arrays above the field, matching existing behavior for non-array paths.

static unset(object, path) {
  ldunset(object, path);
  let pathArray = ldtoPath(path);
  pathArray = pathArray.slice(0, pathArray.length - 1);
  cleanup(object, pathArray);
}

2. FormController uses unset on field removal (src/FormController.js)

All eight ObjectMap.delete() calls in the field-removal path are replaced with ObjectMap.unset():

  • state.values
  • state.modified
  • state.maskedValues
  • state.touched
  • state.errors
  • state.dirt
  • state.focused
  • state.data

This keeps sibling field indices stable after a remove.

3. useUpdateEffect re-enabled in useField (src/hooks/useField.js)

With indices now stable, the previously-commented-out effect that resets a field when defaultValue/initialValue changes (while the form is pristine) is safely re-enabled.

A guard is added via ArrayFieldItemStateContext: fields inside an ArrayField skip this reset because their values are managed by the ArrayField's own index-shifting logic — applying the prop-based reset on top would conflict.

Test plan

  • All 260 existing unit tests pass (npm test)
  • Removing an item from an ArrayField does not corrupt the values/state of sibling fields at higher indices
  • A standalone field with a defaultValue resets correctly when the value prop changes and the form is pristine
  • A field inside an ArrayField does not reset when defaultValue changes (index management is handled by ArrayField)
  • Flat array fields: add/remove/reorder behaves correctly
  • Object array fields: add/remove/reorder behaves correctly

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.

1 participant