Files
sure/app/models/family/sync_complete_event.rb
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

32 lines
1.2 KiB
Ruby

class Family::SyncCompleteEvent
attr_reader :family
def initialize(family)
@family = family
end
def broadcast
# Replace the #sync-toast slot with a lightweight toast instead of a full
# page refresh. The sync-toast Stimulus controller handles three cases:
# - User is idle → morph-refreshes after a short delay
# - User is mid-form → toast stays visible; user clicks "Refresh"
# - A modal is open → toast defers until the dialog closes
#
# This avoids wiping in-progress form state when a background sync fires.
# The partial contains no user-scoped data (Current.user is nil here), so
# each browser re-fetches the page on its own authenticated request.
family.broadcast_replace_to(
family,
target: "sync-toast",
partial: "shared/notifications/sync_toast"
)
# Schedule recurring transaction pattern identification (debounced to run after all syncs complete)
begin
RecurringTransaction.identify_patterns_for(family)
rescue => e
Rails.logger.error("Family::SyncCompleteEvent recurring transaction identification failed: #{e.message}\n#{e.backtrace&.join("\n")}")
end
end
end