Content
View differences
Updated by Alexander Coles 3 days ago
_Filed _N.B. This was filed as a "hypothetical bug" — "hypothetical bug" - an issue found with AI tooling. Since It has since been confirmed real, as real but not reachable from the UI today._ today; see "Can a user hit this?" below._
## Context
Found while adding test coverage in #OP-19666 (custom-element morph policy consolidation) for a code path that had no zero prior tests. Pre-existing, not introduced by that work — -- confirmed the buggy `replaceWith` call is unchanged on `dev`.
## The bug
In `DialogPreviewController`'s `frontend/src/stimulus/controllers/dynamic/work-packages/dialog/preview.controller.ts`, in `DialogPreviewController`'s `beforeNodeMorphed` callback (`frontend/src/stimulus/controllers/dynamic/work-packages/dialog/preview.controller.ts`, shared (shared by the date-picker `date-picker/preview.controller.ts` and progress preview controllers): `progress/preview.controller.ts`):
```js
if (oldNode.tagName?.startsWith('OPCE-')) {
if (schedulingChanged) {
oldNode.replaceWith(newNode);
}
return false;
}
```
`replaceWith` `oldNode.replaceWith(newNode)` mutates both trees idiomorph is walking, breaking and breaks the walk in two ways: independent ways.
1. **Stale **1. Stale insertion point.** idiomorph's `morphChildren` loop tracks its walk position via `insertionPoint = bestMatch.nextSibling`, evaluated after _after_ `morphNode` returns — -- but `bestMatch` is the now-detached `oldNode`, so `nextSibling` is `null`. Remaining new children get appended then fall through to `createNode(oldParent, newChild, null, ctx)`, which is `insertBefore(node, null)` -- an append at the end of the parent, and the parent. The trailing "remove "remove any remaining old nodes" nodes" loop is guarded on the same `insertionPoint`, so stale old siblings survive. are never removed either. The result is duplication, not just merely staleness.
2. **Skipped
**2. Skipped new sibling.** `morphChildren` iterates `for (const newChild of newParent.childNodes)`. `childNodes` is a _live_ `NodeList` and the live `newParent.childNodes` `NodeList` with an index-based iterator. Moving iterator is index-based. `replaceWith` moves `newNode` out shifts of `newParent`, shifting every later entry down one index, so the new sibling immediately after following the OPCE node is never visited.
Net effect: visited at all.
**Net effect:** when `schedulingChanged` is true and an `OPCE-*` node is replaced, DOM siblings that follow it in the same parent are left stale, skipped, or duplicated.
Verified empirically with a standalone debug spec calling `Idiomorph.morph()` directly:
```text
BEFORE replaceWith, nextSibling: <div data-marker="old">old</div>
AFTER replaceWith, oldNode.nextSibling: null
AFTER replaceWith, oldNode.parentNode: null
FINAL HTML: <opce-test-marker data-marker="new"></opce-test-marker><div data-marker="old">old</div>
```
## Can a user hit this?
No. No -- not in the current markup, and no UI reproduction exists.
The only `OPCE-*` element in inside any dialog served by `DialogPreviewController` is `opce-wp-date-picker-instance` (`app/components/work_packages/date_picker/form_component.html.erb`), (`app/components/work_packages/date_picker/form_component.html.erb`). It is emitted by `angular_component_tag` as `angular_component_tag`, which renders a single empty element element, and rendered as it is the sole child of its own `body.with_row` wrapper. With no siblings in that parent there is nothing for the broken walk to skip.
The Angular component adds no does not introduce siblings either: it `OpWpDatePickerInstanceComponent` renders its `<input>` inside the custom element host, and flatpickr's flatpickr is configured with `inline: true` puts true`, so the calendar there too. container is inserted next to that input -- still inside the host.
The progress dialog has contains no `OPCE-*` elements and never sets `schedule_manually`. `schedule_manually`, so `schedulingChanged` is always false there.
So That makes this is a latent trap, not trap rather than a live defect: adding a second element to that row, or flattening the `with_row` wrapper, would silently break the morph with no error and no failing test.
## Why it went unnoticed
No existing spec mounted a sibling alongside the OPCE node in the `schedulingChanged`\-replace `schedulingChanged`-replace case, so the gap was invisible until #OP-19666 added coverage. coverage for it.
## Hints for QA Fix
**No UI reproduction exists https://github.com/opf/openproject/pull/24603
Replacements are collected during the walk and none applied once `Idiomorph.morph()` has returned. `oldNode` then stays attached for the whole walk, so the insertion point stays valid, and the queued node is expected** — see "Can a user hit this?" above. There is nothing to test manually, `newNode.cloneNode(true)` rather than `newNode` itself, so this can be closed directly after the fix new tree idiomorph is merged. Coverage iterating is left untouched.
The narrowed spec from #OP-19666 (`date-picker/preview.controller.spec.ts`, "replaces an OPCE-* node when scheduling has changed") is un-narrowed, and a Vitest regression dedicated case in `date-picker/preview.controller.spec.ts` that fails on `dev` now asserts the sibling morphs and passes with the fix. is not duplicated.
## Context
Found while adding test coverage in #OP-19666
## The bug
In `DialogPreviewController`'s
```js
if (oldNode.tagName?.startsWith('OPCE-')) {
if (schedulingChanged) {
oldNode.replaceWith(newNode);
}
return false;
}
```
`replaceWith`
1. **Stale
2. **Skipped
**2. Skipped
Net effect:
**Net effect:**
Verified
```text
BEFORE replaceWith, nextSibling: <div data-marker="old">old</div>
AFTER replaceWith, oldNode.nextSibling: null
AFTER replaceWith, oldNode.parentNode: null
FINAL HTML: <opce-test-marker data-marker="new"></opce-test-marker><div data-marker="old">old</div>
```
## Can a user hit this?
No.
So
## Why it went unnoticed
No existing spec mounted a sibling alongside the OPCE node in the `schedulingChanged`\-replace
## Hints for QA
**No UI reproduction exists
Replacements are collected during the walk
The narrowed spec from #OP-19666 (`date-picker/preview.controller.spec.ts`, "replaces an OPCE-* node when scheduling has changed") is un-narrowed, and