fix: end a tab drag when the card is disconnected
Validate / docs (push) Failing after 31s
Validate / provenance (push) Successful in 1m7s
Validate / process-gate (push) Failing after 1m11s
Validate / changes (push) Successful in 57s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / reuse (push) Successful in 1m30s
Validate / backend (push) Skipped
Validate / frontend (push) Successful in 12m25s
Validate / golden (push) Failing after 12m39s
Validate / performance_smoke (push) Failing after 11m36s
Validate / smoke (push) Failing after 27m40s

Review CODE-REVIEW-220-r2/r3, F1.

The M1 fix installs window listeners for the length of the gesture, and
disconnectedCallback — which takes down everything else, down to the other
local gesture — did not take those down. Losing the card mid-drag (Lovelace
rebuilding its tree, the user leaving the view with the button still down)
left them alive: the closure holds the instance and its config, and the next
pointerup anywhere on the page would have an invisible card write its order.

The smoke now holds a tab, removes the card, and checks that the release it
should no longer hear changes nothing. Registered as a mutant too.

Issue: #220
User-Visible: no
This commit is contained in:
Codex
2026-08-21 08:02:03 +03:00
parent a8aeecc32c
commit 82bb03cdf9
6 changed files with 62 additions and 6 deletions
File diff suppressed because one or more lines are too long
+37
View File
@@ -180,6 +180,43 @@ const res = await page.evaluate(async () => {
await settle();
out.panelStillSwitchesAfterStrayRelease = c._space === strayTarget;
// --- review r2/r3 F1: the card is destroyed mid-drag -----------------------
//
// Lovelace rebuilds its tree, or the user leaves the view with the button
// still down. The window listeners the gesture installed must not outlive
// the card: they hold the instance alive and would let an invisible card
// write its order on the next pointerup anywhere on the page.
c._mode = 'plan';
c.requestUpdate();
await settle();
const orderBeforeDetach = ids();
const detachTabs = tabs();
const held = detachTabs[detachTabs.length - 1];
const heldRect = held.getBoundingClientRect();
const heldEvent = (type, x, y) => held.dispatchEvent(new PointerEvent(type, {
pointerId: 13, pointerType: 'mouse', clientX: x, clientY: y,
bubbles: true, composed: true,
}));
heldEvent('pointerdown', heldRect.x + heldRect.width / 2, heldRect.y + heldRect.height / 2);
heldEvent('pointermove', heldRect.x + heldRect.width / 2 + 40, heldRect.y + heldRect.height / 2);
out.dragWasActiveBeforeDetach = c._tabDrag !== null && c._tabDrag.moved === true;
const parent = c.parentNode;
const next = c.nextSibling;
const writesBeforeDetach = writes.length;
c.remove();
await new Promise((r) => setTimeout(r, 50));
out.detachEndedTheDrag = c._tabDrag === null;
// the release the detached card must no longer hear
window.dispatchEvent(new PointerEvent('pointerup', {
pointerId: 13, pointerType: 'mouse', bubbles: true, composed: true,
}));
await new Promise((r) => setTimeout(r, 700));
out.detachedCardDidNotWrite = writes.length === writesBeforeDetach;
parent.insertBefore(c, next);
await settle();
out.orderSurvivedDetach = JSON.stringify(ids()) === JSON.stringify(orderBeforeDetach);
c._writeConfig = realWrite;
return out;
});
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
+12
View File
@@ -762,6 +762,18 @@ export const MUTANTS = [
replace: ' void 0;',
}],
},
{
id: 'tab-drag-outlives-the-card',
guard: 'node demo/smoke_space_tab_reorder.mjs',
because: 'слушатели жеста, пережившие disconnectedCallback, держат инстанс карточки '
+ 'и дают невидимой карточке записать порядок по следующему pointerup на странице '
+ '(ревью r2/r3, F1)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' this._endTabDrag();\n clearInterval(this._cycleTimer);',
replace: ' clearInterval(this._cycleTimer);',
}],
},
{
id: 'tab-reorder-eats-click',
guard: 'node --test --test-name-pattern="issue 220" test/space-order.test.mjs',
+7
View File
@@ -2236,6 +2236,13 @@ class HouseplanCard extends LitElement {
this._bootSettling = false;
for (const rt of this._activityRt.values()) clearTimeout(rt.timer); // pending activity-window repaints
window.removeEventListener('keydown', this._keyHandler);
// A tab drag holds window listeners for the length of the gesture. Losing
// the card mid-drag — Lovelace rebuilding its tree, the user leaving the
// view with the button still down — would leave them alive: the closure
// keeps this instance (and its config) from being collected, and the next
// pointerup anywhere on the page would make an invisible card write its
// order (review CODE-REVIEW-220-r2/r3, F1).
this._endTabDrag();
clearInterval(this._cycleTimer);
clearTimeout(this._kioskDotsTimer);
clearTimeout(this._kioskHoldTimer);