Skip to content

[LiveComponent] Prevent LiveCollectionTrait from reusing removed indexes#3465

Open
Amoifr wants to merge 1 commit intosymfony:3.xfrom
Amoifr:fix/live-collection-reuse-removed-index
Open

[LiveComponent] Prevent LiveCollectionTrait from reusing removed indexes#3465
Amoifr wants to merge 1 commit intosymfony:3.xfrom
Amoifr:fix/live-collection-reuse-removed-index

Conversation

@Amoifr
Copy link
Copy Markdown
Contributor

@Amoifr Amoifr commented Apr 14, 2026

Q A
Bug fix? yes
New feature? no
Deprecations? no
Documentation? no
Issues Fix #3371
License MIT

Problem

LiveCollectionTrait::addCollectionItem() picks the next index as:

$index = [] !== $data ? max(array_keys($data)) + 1 : 0;

This only looks at the current keys. If a user removes the last item and then adds a new one, the new item reuses the index that was just freed. When the form is bound to a Doctrine collection, the form machinery matches the new entry back to the (previously removed) entity at that key, so the removed entity silently reappears and no new object is created.

Repro from the issue:

  1. Collection starts with entities at keys [0, 1, 2].
  2. removeCollectionItem on index 2[0, 1].
  3. addCollectionItemmax([0,1]) + 1 = 2 → form rebinds to the just-removed entity.

Fix

Track indexes freed by removeCollectionItem() in a #[LiveProp] tombstone keyed by collection field name, and include them in the max() computation of addCollectionItem(). After the scenario above, the next index becomes 3 as expected.

  • Added a regression test covering the remove-then-add scenario.
  • The new $liveCollectionRemovedKeys prop is additive and non-writable; existing serialized state defaults to [] for older clients.

Scope

  • Only fixes the integer-index case (which is what LiveCollectionType generates). String-keyed collections are already unsupported by the existing max(array_keys(...)) logic and are out of scope.

Thanks for the Live Component work — it's been a pleasure to dig into. 🙏

* @var array<string, list<int>>
*/
#[LiveProp]
public array $liveCollectionRemovedKeys = [];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Amoifr Do you think this could be "private" instead of "public"? I can’t think of a case where it would be useful outside this context

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — I'd love to make it private, but Symfony's PropertyAccess (used by LiveComponentHydrator) can't read or write private properties without an accessor, so the dehydration/rehydration cycle would silently drop the tombstone. I verified it with a tiny repro:

class T {
    #[LiveProp] private array $privateArray = ['init' => 1];
    #[LiveProp] public  array $publicArray  = ['init' => 1];
}

$pa = PropertyAccess::createPropertyAccessor();
$pa->getValue(new T(), 'privateArray'); // ❌ "Can't get a way to read the property "privateArray"…"
$pa->getValue(new T(), 'publicArray');  // ✅

Every existing #[LiveProp] in the codebase (ComponentWithFormTrait::$formName, $formValues, $isValidated, $validatedFields, ValidatableComponentTrait::$isValidated, …) is public for the same reason. I'll keep public here to match that convention — let me know if you'd rather I add a private + getter/setter pair instead, happy to rework.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Amoifr Thanks a lot for the explanation, I didn’t dig deep enough to understand the full context !

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JacquesMougin you're welcome ! I get to much pain in the first hours of the live component with this kind of things to remember about that 😄

@Amoifr Amoifr changed the base branch from 2.x to 3.x April 27, 2026 09:18
@Amoifr Amoifr force-pushed the fix/live-collection-reuse-removed-index branch from 1f55bc4 to 03400ff Compare April 27, 2026 09:18
@Amoifr Amoifr requested a review from Kocal as a code owner April 27, 2026 09:18
@github-actions
Copy link
Copy Markdown
Contributor

📊 Packages dist files size difference

Thanks for the PR! Here is the difference in size of the packages dist files between the base branch and the PR.
Please review the changes and make sure they are expected.

FileBefore (Size / Gzip)After (Size / Gzip)
LazyImage
controller.d.ts 395 B / 257 B Removed
controller.js 904 B / 457 B Removed
Map
abstract_map_controller.d.ts 7.6 kB / 1.49 kB 7.29 kB-4% 📉 / 1.46 kB-2% 📉
abstract_map_controller.js 4.92 kB / 1.4 kB 4.65 kB-5% 📉 / 1.26 kB-10% 📉
Map (Bridge Google)
map_controller.d.ts 10.56 kB / 1.92 kB 10.26 kB-3% 📉 / 1.9 kB-1% 📉
map_controller.js 12.9 kB / 3.19 kB 11.25 kB-13% 📉 / 2.84 kB-11% 📉
Map (Bridge Leaflet)
map_controller.d.ts 9.92 kB / 1.84 kB 9.61 kB-3% 📉 / 1.81 kB-2% 📉
map_controller.js 12.45 kB / 3.36 kB 11.38 kB-9% 📉 / 3.17 kB-6% 📉
Svelte
components.d.ts 200 B / 150 B Removed
components.js 46 B / 69 B Removed
loader.d.ts 435 B / 217 B Removed
loader.js 553 B / 313 B Removed
register_controller.d.ts 384 B / 235 B Removed
register_controller.js 531 B / 303 B Removed
render_controller.d.ts 629 B / 353 B Removed
render_controller.js 1.05 kB / 493 B Removed
Swup
controller.d.ts 1012 B / 360 B Removed
controller.js 1.71 kB / 653 B Removed
TogglePassword
controller.d.ts 896 B / 355 B Removed
controller.js 2.64 kB / 1.07 kB Removed
style.min.css 312 B / 218 B Removed
Typed
controller.d.ts 1.9 kB / 501 B Removed
controller.js 1.8 kB / 638 B Removed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[LiveCollection] LiveCollectionType may reuse removed Doctrine entity when adding a new item due to index reuse

3 participants