fix(toast): initialize toaster state in initialize() so pre-existing targets don't throw - #499
Conversation
…g targets Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Jean Pierry <pierrybm@gmail.com>
There was a problem hiding this comment.
1 issue found across 1 file (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="docs/app/javascript/controllers/ruby_ui/toaster_controller.js">
<violation number="1" location="docs/app/javascript/controllers/ruby_ui/toaster_controller.js:43">
P3: The docs and gem copies of toaster_controller.js, which the PR states are kept in sync, have drifted: this file reorders the property assignments in initialize() and drops the explanatory comment, while the gem copy still has the old order plus the comment. Behavior is identical, so unless the drift is intentional, mirror one to the other to keep the sync claim accurate and the intent comment preserved.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
|
|
||
| connect() { | ||
| initialize() { | ||
| this._expanded = false |
There was a problem hiding this comment.
P3: The docs and gem copies of toaster_controller.js, which the PR states are kept in sync, have drifted: this file reorders the property assignments in initialize() and drops the explanatory comment, while the gem copy still has the old order plus the comment. Behavior is identical, so unless the drift is intentional, mirror one to the other to keep the sync claim accurate and the intent comment preserved.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/app/javascript/controllers/ruby_ui/toaster_controller.js, line 43:
<comment>The docs and gem copies of toaster_controller.js, which the PR states are kept in sync, have drifted: this file reorders the property assignments in initialize() and drops the explanatory comment, while the gem copy still has the old order plus the comment. Behavior is identical, so unless the drift is intentional, mirror one to the other to keep the sync claim accurate and the intent comment preserved.</comment>
<file context>
@@ -40,13 +40,9 @@ export default class extends Controller {
- // can run safely for toasts already present in the DOM (e.g. server-rendered
- // flash) before `connect` runs. Stimulus fires `targetConnected` before
- // `connect` for pre-existing targets, so any state they touch must exist first.
+ this._expanded = false
this._heights = new Map()
this._resizeObservers = new WeakMap()
</file context>
There was a problem hiding this comment.
Resolved — the docs and gem copies are back in sync. The drift you caught was transient: it existed only between the two "Update toaster_controller.js" commits (the first touched only the docs copy). As of e4dc783 both files are byte-for-byte identical (diff -q confirms), with the same initialize() order and no comment. Behavior is unchanged. The MCP registry has also been rebuilt to match the current source.
Signed-off-by: Jean Pierry <pierrybm@gmail.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`connect()` resolves `_listEl`, but `disconnect()` left it pointing at the detached `<ol>`. Stimulus tears down in this order (Context#disconnect): `controller.disconnect()` first, then `targetObserver.stop()` -> `disconnectAllTargets()`, so every `toastTargetDisconnected` — and the `_reflow()` it triggers — runs *after* the controller disconnected, writing inline styles onto nodes on their way out. Null out `_listEl` at the end of `disconnect()` so `_reflow()`'s existing `if (!this._listEl) return` guard actually does its job. The global API closure (`window.RubyUI.toast`) outlives the controller and calls `_dismissById` / `_mutate` directly rather than through the removed window listeners, so those reads become optional and `_spawn` bails early instead of throwing on a null list.
`position` and `expand` are Stimulus values, but nothing reacted to them changing: `_spawn` mirrored `positionValue` into `data-position` by hand, and `expand` was only read once in `connect()`. Flipping either attribute at runtime (`element.dataset.rubyUiToasterExpandValue = "true"`) had no effect until the next pointer event. Add `positionValueChanged` / `expandValueChanged` so the DOM follows the value: the position callback owns the `data-position` sync the CSS placement rules key off, and both reflow. `_spawn` now just assigns `positionValue`. Both callbacks fire during `valueObserver.start()`, before `connect()`, so they rely on `_reflow()`'s `if (!this._listEl) return` guard — same pattern as the target callbacks. `connect()` keeps `_expanded = this.expandValue`: `StringMapObserver#stop` does not clear its map, so an unchanged attribute fires no callback when the observer restarts. The region is `data-turbo-permanent`, so it really does disconnect/reconnect with the same controller instance, and without that line a toaster left hover-expanded would reconnect still expanded.
Now that `_resizeObservers` lives in `initialize()` it survives disconnect/reconnect cycles of the same element, so a second `toastTargetConnected` for an element that still has an observer would overwrite the WeakMap entry and leak the first one — it keeps observing and keeps calling `_reflow()`. Stimulus guards against that today (`TargetObserver#connectTarget` skips elements already in `targetsByName`), but the callback should not depend on that internal: disconnect any existing observer for the element first.
|
Reviewed this and the fix is correct — verified the ordering against the Stimulus 3.2.2 source rather than trusting the docs: Worth noting the blast radius is bigger than the description suggests: I pushed three follow-up commits to this branch — each one is self-contained, revert any you'd rather handle separately.
This one needed four extra guards to avoid trading one crash for another:
I deliberately kept
One accepted side effect: Gem suite is green (281 runs, 0 failures), standardrb clean, |
Bump RubyUI to 1.6.0 (minor: new component + new component options since v1.5.0). - gem/lib/ruby_ui.rb → 1.6.0; regenerate gem/ and docs/ Gemfile.lock - docs home hero badge → headline features (InputOtp, Combobox placement) - rebuild mcp/data/registry.json Highlights since v1.5.0: - New component: InputOtp (#456) - Combobox: configurable popover placement (#480), CheckboxGroup reuse for required ComboboxCheckbox (#479) - DataTable: custom label + initial column visibility in DataTableColumnToggle (#466) - Popover: data-state/data-side, clear closeTimeout on disconnect, close on Escape (#495) - Accordion: no longer clips content that grows after opening (#500, #490) - Toast: toaster state initialized in initialize() so server-rendered toasts don't throw (#499) - Docs: Stimulus controllers symlinked to gem source, no more hand-copied drift (#493)
Result
Problem
toaster_controller.jsthrows on page load whenever a toast target is already in the DOM at boot (e.g. a server-rendered flash message):Stimulus fires target lifecycle callbacks in this order for pre-existing targets:
The controller initializes
_heights/_resizeObserversinsideconnect(), buttoastTargetConnected()callsthis._heights.set(...)andthis._resizeObservers.set(...). For a toast present in the initial HTML,toastTargetConnectedruns beforeconnect, sothis._heightsis stillundefined→ the whole controller (and often the whole Stimulus application) blows up.This only manifests when a
<Toast>is rendered server-side inside the<Toaster>on first paint. Toasts created at runtime via the stream action / JS API connect afterconnect()has run, which is why it slips through interactive testing.Fix
Move the state that
toastTargetConnectedtouches intoinitialize(), which is guaranteed to run before anytargetConnected:_heights,_resizeObservers,_expandedare initialized ininitialize().connect()still sets_expanded = this.expandValue(values are read inconnect).this._reflow()at the end ofconnect()so server-rendered toasts get positioned: theirtoastTargetConnectedran before_listElexisted and_reflow()returned early via itsif (!this._listEl) returnguard.No behavior change for runtime-created toasts.
Files
Both copies kept in sync:
gem/lib/ruby_ui/toast/toaster_controller.jsdocs/app/javascript/controllers/ruby_ui/toaster_controller.jsHow to reproduce
Render a
Toasterwith aToastalready inside it on initial page load (a flash message), then load the page →TypeErrorin the console before the fix, clean after.