Files
sure/app/javascript/controllers/sync_toast_controller.js
Guillem Arias Fauste a461ff97bb fix(sync-toast): morph refresh, defer behind modals, DS conformance (#2105)
* fix(sync-toast): morph refresh, defer behind modals, DS conformance

Follow-up to #1964 (addresses #2071).

- Refresh via Turbo morph visit instead of window.location.reload, so
  scroll position and data-turbo-permanent elements (the AI chat panel)
  survive and there is no white flash.
- Defer the toast while a <dialog> is open and reveal it on close. A
  refresh mid-modal closes the dialog and discards its in-progress input,
  which is the exact data loss this toast exists to prevent. Handles
  stacked modals.
- Refresh CTA and close button now use DS::Button (secondary / icon). The
  close is always visible, inside the card, focusable, and has an
  aria-label; the old hover-only corner chip was unreachable on touch and
  not keyboard-focusable.
- Add role="status" / aria-live="polite" to the toast.
- Fix icon color: "inverse" is not a key in the icon helper color map, so
  it silently rendered no color class (dark icon on bg-info). Use "white",
  which maps to the functional text-inverse token.
- Tighten copy: "New data available" / "Refresh".
- Sync the broadcast comment with the actual replace/morph behavior.

* fix(sync-toast): detach deferred dialog listener on disconnect

A toast replaced by a newer broadcast_replace_to while a <dialog> was open kept
its 'close' listener attached, so the detached controller fired #reveal()/#arm()
when the dialog closed — a spurious auto-refresh from a stale toast (and repeated
syncs could queue several). Store the dialog + handler refs and remove the
listener in disconnect(). Flagged by codex + coderabbit on #2105.

* fix(sync-toast): re-check interaction and dialogs at refresh-fire time

The interaction check ran once at arm time but the refresh fired two
seconds later. The post-dialog reveal made that window matter: the user
closes a dialog sitting on a form, resumes typing, and the timer morphs
the page — wiping non-turbo-permanent input, the exact data-loss class
this toast exists to prevent. A dialog opened during the window had the
mirror problem (the refresh would close it).

Bail inside the callback instead, leaving the toast visible for a
manual refresh, matching the mid-form behavior. Also documents the
dialog-removed-without-close edge on the deferred listener.
2026-06-06 17:02:26 +02:00

116 lines
3.9 KiB
JavaScript

import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="sync-toast"
//
// Shown when a background sync completes and the family's data has changed.
// - Idle → morph-refreshes the page after a short delay.
// - Mid-form → stays put; the user refreshes when ready.
// - A modal <dialog> is open → the toast is deferred (it would otherwise sit
// dimmed-but-clickable behind the dialog's top-layer backdrop, and a refresh
// would close the dialog and discard its in-progress input). It is revealed
// once the dialog closes — the first moment a refresh is actually safe.
export default class extends Controller {
static values = {
autoRefreshDelay: { type: Number, default: 2000 },
};
connect() {
if (this.#dialogOpen()) {
this.#deferUntilDialogCloses();
return;
}
this.#arm();
}
disconnect() {
clearTimeout(this._timer);
this.#removeDeferredDialogListener();
}
// Turbo 8 morph refresh (the app sets `turbo_refreshes_with method: :morph,
// scroll: :preserve`) instead of window.location.reload(): no white flash,
// scroll position and `data-turbo-permanent` elements (the AI chat panel)
// are preserved.
refresh() {
clearTimeout(this._timer);
Turbo.visit(window.location.href, { action: "replace" });
}
#arm() {
if (this.#userIsInteracting()) return; // mid-form: wait for a manual refresh
// Re-check at fire time, not just arm time: the post-dialog reveal often
// lands on a form the dialog was sitting on, and the user resumes typing
// inside this window (a morph would wipe their non-turbo-permanent
// input). A dialog opened during the window is the same hazard — the
// refresh would close it. Either way, bail and leave the toast visible
// for a manual refresh, matching the mid-form behavior.
this._timer = setTimeout(() => {
if (this.#userIsInteracting() || this.#dialogOpen()) return;
this.refresh();
}, this.autoRefreshDelayValue);
}
#deferUntilDialogCloses() {
this.element.style.display = "none";
const dialog = document.querySelector("dialog[open]");
if (!dialog) {
this.#reveal();
return;
}
// Keep refs so disconnect() can detach this listener. Otherwise a toast
// replaced by a newer broadcast while the dialog is still open stays
// subscribed, and its now-detached controller fires #reveal()/#arm() on
// close — a spurious auto-refresh from a stale toast.
//
// Known edge: if this dialog leaves the DOM without firing `close` (e.g.
// a morph removes it), the toast stays hidden until the next broadcast
// replaces it. Acceptable: the next sync re-delivers the toast.
this._deferredDialog = dialog;
this._dialogCloseHandler = () => this.#onDialogClose();
dialog.addEventListener("close", this._dialogCloseHandler, { once: true });
}
#onDialogClose() {
// The `once` listener has already fired and detached itself.
this._deferredDialog = null;
this._dialogCloseHandler = null;
// Another dialog may still be open (stacked modals) — keep deferring until
// every dialog has closed.
if (this.#dialogOpen()) {
this.#deferUntilDialogCloses();
return;
}
this.#reveal();
}
#removeDeferredDialogListener() {
if (this._deferredDialog && this._dialogCloseHandler) {
this._deferredDialog.removeEventListener(
"close",
this._dialogCloseHandler,
);
}
this._deferredDialog = null;
this._dialogCloseHandler = null;
}
#reveal() {
this.element.style.display = "";
this.#arm();
}
#dialogOpen() {
return !!document.querySelector("dialog[open]");
}
#userIsInteracting() {
const el = document.activeElement;
if (!el || el === document.body || el === document.documentElement)
return false;
return (
el.isContentEditable ||
el.closest("form, dialog, [role='dialog']") !== null
);
}
}