Skip to content

Register a panel's resize listener when it is shown, not when it is built - #290

Merged
wormeyman merged 1 commit into
wormeyman-space-age-supportfrom
fix/287
Aug 31, 2026
Merged

Register a panel's resize listener when it is shown, not when it is built#290
wormeyman merged 1 commit into
wormeyman-space-age-supportfrom
fix/287

Conversation

@wormeyman

Copy link
Copy Markdown
Collaborator

Stacked on #289 (fix/286), which it needs for the throwingDialogAttempt
hook. Review that one first; this targets it rather than the base branch.

The bug

Panel's constructor put a resize listener on window:

this._setPosition = () => this.setPosition()
window.addEventListener('resize', this._setPosition)

and destroy() was the only thing that ever took it off. A subclass constructor
that throws after super() returns never hands back the object, so nothing can
call destroy() on it. The listener stays on window for the life of the page,
holds the half-built panel and its whole pixi subtree alive, and calls
setPosition() on that object at every resize.

The same shape as #280, one constructor lower down. Editor extends Dialog extends Panel, so every entity editor is a Panel and this is the first
constructor to run - #280's own measured path (DisplayPanelEditor drawing a
planet icon) and #286's (Recipe.ts) both throw well after that line.

It costs nothing visible, which is what makes it worth a test: Dialog .setPosition() reads G.app.screen and the background sprite the constructor
did finish building, so the orphaned callback runs cleanly and silently forever.

The fix

Registration moves onto the pixi added event and comes off on removed - the
move #285 made for the dialog registry.

added/removed rather than once, so a panel that leaves the tree and comes
back stays balanced. Re-adding is safe: the DOM ignores an identical
(type, listener) pair, and _setPosition is one closure per panel. destroy()
still removes it as well, for a panel destroyed while already detached.

Coverage

tests/panel-resize-listener.spec.ts. Nothing else in tests/ can see any of
this - a leaked listener changes no behaviour at all, it only retains an object -
so the spec counts listeners directly, wrapping
addEventListener/removeEventListener in an init script before the page's own
scripts run. It tracks the listener functions rather than counting calls,
because the DOM dedupes an identical pair and a call count would disagree with
what is actually registered.

Measured before the fix: two throwing constructors, 12 listeners against 10.

The third test is the one to read. A "fix" that simply never registers the
listener passes the other two perfectly - nothing leaks if nothing is added - and
quietly stops every panel following the window. Mutation checked by deleting the
added line alone:

result
test 1, nothing leaks after a throw passes
test 2, nothing leaks on open/close passes
test 3, an open dialog follows a resize fails
tools-panel.spec.ts narrow viewport (x2) fail

The throwing panel comes from ThrowingDialog, added in #289. There is no live
bug left to borrow for it, which is the point - #286 guarded all six sites that
used to throw in an editor constructor.

Verification

  • vp check - 0 warnings, lint errors or type errors across 191 files
  • vp test - 221 passed
  • npx playwright test - 236 passed, full suite

Closes #287.

🤖 Generated with Claude Code

https://claude.ai/code/session_016ZkWutgXP9XeVoh2RaAX3S

@wormeyman
wormeyman changed the base branch from fix/286 to wormeyman-space-age-support August 31, 2026 02:03
…uilt

`Panel`'s constructor put a `resize` listener on `window` and `destroy()`
was the only thing that took it off. A subclass constructor that throws
after `super()` returns never hands back the object, so nothing can ever
call `destroy()` on it: the listener stayed on `window` for the life of the
page, held the half-built panel and its whole pixi subtree alive, and called
`setPosition()` on that object at every resize.

The same shape as #280, one constructor lower down - `Editor extends Dialog
extends Panel`, so `Panel`'s is the first constructor to run and the last
thing a throwing subclass can undo. #280's own measured path
(`DisplayPanelEditor` drawing a planet icon) and #286's (`Recipe.ts`) both
throw well after that line.

Registration moves onto the pixi `added` event and comes off on `removed`,
which is the move #285 made for the dialog registry. `added`/`removed`
rather than `once`, so a panel that leaves the tree and returns stays
balanced; re-adding is safe because the DOM ignores an identical
(type, listener) pair. `destroy()` still removes it too, for a panel
destroyed while already detached.

`tests/panel-resize-listener.spec.ts` counts the listeners directly, by
wrapping `addEventListener`/`removeEventListener` before the page's scripts
run - nothing else in tests/ can see this, since a leaked listener changes
no behaviour at all, it only retains an object. It tracks listener functions
rather than counting calls, because the DOM dedupes an identical pair.

The third test is the load-bearing one: a "fix" that simply never registers
passes the other two perfectly. Measured - deleting the `added` line alone
leaves tests 1 and 2 green and fails test 3, along with both of
tools-panel.spec.ts's narrow-viewport cases.

Closes #287.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZkWutgXP9XeVoh2RaAX3S
@wormeyman
wormeyman merged commit d7e6e1d into wormeyman-space-age-support Aug 31, 2026
9 checks passed
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.

Panel's constructor adds a window resize listener that only destroy() removes

1 participant