Register a dialog when it is shown, not when it is constructed - #285
Merged
Conversation
`Dialog`'s constructor pushed `this` onto the static `s_openDialogs`, and `close()` was the only thing that took an entry back out. `super()` runs before any subclass body, so a subclass constructor that threw afterwards left an entry behind for a dialog that was never shown. `Dialog.anyOpen()` then answered true with nothing on screen, and the `E` keybind - which reads exactly that to choose between closing the top dialog and opening the inventory - went to the wrong branch. Two fixes, and they are not alternatives. Narrow: `DisplayPanelEditor` draws three icons whose names come out of the blueprint's `control_behavior.parameters`, and `F.CreateIcon` ends in a bare `throw` for a name FD does not have. No planet prototype is exported at all, so `nauvis`, `vulcanus`, `fulgora` and `gleba` all reach it, and 19 icon references in the committed corpus use one (issue #231). Those three calls now go through `F.SafeIcon`, so a missing icon costs the icon and a warning naming it, not the whole dialog. `F.SafeIcon` is `ToolsPanel`'s own `safeIcon`, moved into `controls/functions.ts` beside the two throwing icon builders it guards. Moved rather than copied: two copies of a fallback are two things to keep in step, and the one nothing exercises is the one that rots. General: registration moved from the constructor to the `added` event, which is the point a subclass cannot skip and cannot reach early. A constructor that throws never returns the object to be added, so it never registers. All four construction sites are in `UIContainer` and add on the next line, so nothing can read the registry in between. The rejected alternative - a try/catch in `UIContainer.createEditor` - is measured in the spec header: it does not clear the registry, since `s_openDialogs` is protected, and it buys the silence of every future broken editor. One correction to the issue text, measured. The phantom is not permanent. `closeLast()` calls `close()` on it and `close()` filters the registry before it destroys anything, so the entry clears itself. The cost is one swallowed `E` or `Escape` per failed open, not a dead keybind for the session. `tests/dialog-registry-leak.spec.ts` covers both halves and carries the mutation record. The general half needs its own fixture, because once the narrow half is in, no display panel can reach the throw any more: it uses an assembling machine carrying a recipe name FD does not have, which reaches an unguarded `F.CreateIcon` through `Editor`'s `Recipe` slot. That editor is still broken and this does not fix it - it is the fixture precisely because it is broken. `openDialogCount()` reads the pixi child count, so it cannot see a phantom entry at all. The only thing that can is the keybind that branches on it, which is also the thing the user loses. Closes #280
This was referenced Aug 31, 2026
wormeyman
added a commit
that referenced
this pull request
Aug 31, 2026
`F.CreateIcon` ends in a bare `throw` for a name FD does not have, and the names come out of the blueprint. `UIContainer.createEditor` has no `try` above it on purpose, so an unguarded call cost the user the whole editor: clicking the entity did nothing at all. Six sites, not the one #286 names. Four are icon slots and take `F.SafeIcon`, which #285 added for exactly this: - `Recipe.ts:38`, the site #286 names - `DisplayPanelIcon.ts:39` - the branch #285 did not reach. `DisplayPanel Editor` returns early when the panel is connected, and both display panel specs set `connect_to_logistic_network: true`, so an ordinary unconnected panel with a planet icon still lost its whole dialog - `Modules.ts:40` and `:68` - `Filters.ts:242` and `:251`, the amounts-on and amounts-off arms The other two are not icon slots and are why guarding `Recipe.ts` alone leaves the machine editor still refusing to open: - `Preview.generatePreview` called the *static* `OverlayContainer.createEntityInfo` bare, where `OverlayContainer`'s own instance method wraps the identical call. `Preview` is built by `Editor`'s base constructor, so that throw cost *every* editor its dialog. - `EntityInfoPanel.updateVisualization` calls `getModule`, which throws by design (#55). It runs from `pointerOverEventHandler`, so an unknown module threw on hover, before any click. Those two warn with `console.warn` rather than `G.logger`, which is the toast: both repeat on every hover or redraw, and the icon slots already raise one toast when the dialog opens. `tests/editor-icon-guards.spec.ts` covers all six, one case each, mutation checked - every guard reverted in turn fails at least one test, and the two `Filters` arms need both the chest and splitter cases. `tests/dialog-registry-leak.spec.ts` borrowed the machine editor's throw as the fixture for its registry half, and said so. That fixture is gone, and replacing it with another live bug would mean a test that argues against fixing it. It now uses a `ThrowingDialog` behind a `__fbe_test` hook - constructed, never added, so registration on the `added` event leaves nothing behind. Re-measured: reverting the registry fix still fails that test at the keybind, 0 against 1. Closes #286. Closes #288. Claude-Session: https://claude.ai/code/session_016ZkWutgXP9XeVoh2RaAX3S Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
wormeyman
added a commit
that referenced
this pull request
Aug 31, 2026
…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
added a commit
that referenced
this pull request
Aug 31, 2026
…uilt (#290) `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. Claude-Session: https://claude.ai/code/session_016ZkWutgXP9XeVoh2RaAX3S Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Closes #280.
Dialog's constructor pushed ontos_openDialogsbefore any subclass body ran,and
close()is the only thing that takes an entry back out. So a subclassconstructor that throws after
super()left an entry for a dialog that wasnever shown and that nothing holds a reference to.
Reachable, not theoretical: icon names come out of the blueprint,
F.CreateIconthrows for a name FD does not have,data.jsonexports no planetprototype at all (issue #231), and
UIContainer.createEditoris a bare callwith no
tryabove it. A display panel drawing anauvisicon is the measuredpath.
Both halves, and they are not alternatives
General - registration moved onto the pixi
addedevent:A constructor that throws never returns the object to be added, so it never
registers. All four construction sites are in
UIContainerand calladdChildon the next line, so nothing can read the registry in the gap.
onceratherthan
on, so a re-add cannot register the same object twice.Narrow -
DisplayPanelEditor's threeF.CreateIconcalls now go throughF.SafeIcon, which warns naming the icon and returns an empty container. Thathelper moved out of
ToolsPanel.tsintofunctions.tsrather than beingcopied.
Why not a try/catch in
UIContainer.createEditorThat is the alternative the issue offered, and it was measured rather than
argued: a bare catch there does not clear the registry at all, because
s_openDialogsisprotected- clearing it from outside would need a newpublic escape hatch on
Dialog. It also swallows the throw.createEditornowcarries a comment saying it stays a bare call on purpose: an editor that cannot
be built is a bug in that editor and should say so loudly.
Mutation record
F.SafeIconback toF.CreateIcon[...named nauvis]vs[]vulcanusonce('added')back to a constructor pushE, 0 vs 1createEditorNeither half covers for the other, and the last row is the rejected alternative
failing on its control rather than on the keybind.
A correction to the issue
The issue says
Edies "for the rest of the session". That is wrong, andI have corrected it there.
closeLast()callsclose()on the phantom, andclose()filters the registry on its first line, so the entry clears itself.The real cost is one swallowed
EorEscapeper failed open. That changeswhat a test has to do - a spec pressing
Eonce would pass against the leak, sothe spec here presses twice.
Found while in here, not fixed
Recipe.tshas the same unguardedF.CreateIcon. An assembling machinecarrying a recipe name FD does not have still throws and opens no editor. Same
class of bug, deserves its own issue - it was the fixture for the general half
here.
Panel's resize listener leaks the same way. Its constructor callswindow.addEventListener('resize', ...)and onlydestroy()removes it, so athrowing subclass leaves it attached. Not addressed, and no test here can see
it.
space-locationicon also breaks the display panel's own entity sprite withMissing signal type mapping!. That one is caught and logged, so the entitydraws a placeholder. Adjacent to F.CreateIcon throws on planet icon names, taking down whatever called it #231.
Test plan
vp checkclean - 0 warnings, lint or type errors across 188 filesvp test- 221 passedtests/dialog-registry-leak.spec.ts(new) - 3 passeddisplay-panel-editor,quick-actions,tools-panel,settings-pane-book-index- 26 passed--list🤖 Generated with Claude Code
https://claude.ai/code/session_01L7dF9eT5yMLqdJZ2rwytCz