Register a panel's resize listener when it is shown, not when it is built - #290
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #289 (
fix/286), which it needs for thethrowingDialogAttempthook. Review that one first; this targets it rather than the base branch.
The bug
Panel's constructor put aresizelistener onwindow:and
destroy()was the only thing that ever took it off. A subclass constructorthat throws after
super()returns never hands back the object, so nothing cancall
destroy()on it. The listener stays onwindowfor 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 aPaneland this is the firstconstructor to run - #280's own measured path (
DisplayPanelEditordrawing aplanet 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()readsG.app.screenand the background sprite the constructordid finish building, so the orphaned callback runs cleanly and silently forever.
The fix
Registration moves onto the pixi
addedevent and comes off onremoved- themove #285 made for the dialog registry.
added/removedrather thanonce, so a panel that leaves the tree and comesback stays balanced. Re-adding is safe: the DOM ignores an identical
(type, listener) pair, and
_setPositionis 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 intests/can see any ofthis - a leaked listener changes no behaviour at all, it only retains an object -
so the spec counts listeners directly, wrapping
addEventListener/removeEventListenerin an init script before the page's ownscripts 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
addedline alone:tools-panel.spec.tsnarrow viewport (x2)The throwing panel comes from
ThrowingDialog, added in #289. There is no livebug 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 filesvp test- 221 passednpx playwright test- 236 passed, full suiteCloses #287.
🤖 Generated with Claude Code
https://claude.ai/code/session_016ZkWutgXP9XeVoh2RaAX3S