Skip to content

refactor(settings): move TriggersModal onto the always-mounted show-prop modal convention #1526

Closed as not planned

Description

@harshithmohan

Follow-up to the mount-model question raised in #1521.

TriggersModal (src/components/Settings/ScheduledActions/TriggersModal.tsx) is mounted conditionally ({selectedActionID && <TriggersModal … show />}) instead of following the convention every other modal in the repo uses: always mounted, show prop, and both keybind lines owned by the modal itself.

Conditional mounting was expedience, not a deliberate pattern change. It buys a fresh draft and cleared errors on every open, but costs:

  1. ModalPanel's 150ms closeTimeoutMS exit animation never plays — the modal unmounts the moment show would flip.
  2. Keybind ownership is split: the page has to remember useToggleModalKeybinds(!selectedActionID, 'primary'), which silently breaks if the page ever hosts a second modal.
  3. As a side effect of the current useSyncedState(action.Triggers, …), every background refetch (src/core/react-query/scheduled-action/queries.ts:16 polls at 2s while busy / 30s idle) hands the modal a fresh Triggers array identity, wiping an admin's in-progress draft while the modal sits open.

Spec

TriggersModal.tsx

Props — action optional, show added:

type Props = {
  show: boolean;
  action?: ScheduledActionType;
  onClose: () => void;
};

Draft moves from useSyncedState to useState, seeded on open and on action change only. All hooks must precede the early return:

// After close the page hands `undefined`, but the 150ms exit animation still
// needs content. Ref (not state) so caching it never re-renders.
const lastActionRef = useRef<ScheduledActionType | undefined>(action);
useEffect(() => {
  if (action) lastActionRef.current = action;
}, [action]);
const renderAction = action ?? lastActionRef.current;

// Seed on open and on action change only — never on background refetches
// (guarded by ID). Clearing the guard on close reseeds when the same action is
// reopened. This replaces `useSyncedState`.
const seededForRef = useRef<string | null>(null);
useEffect(() => {
  if (!show || !action) {
    seededForRef.current = null;
    return;
  }
  if (seededForRef.current === action.ID) return;
  seededForRef.current = action.ID;
  setDraft(withKeys(action.Triggers));
  setErrors({});
}, [show, action]);

The seededForRef.current = null on !show is load-bearing: without it, reopening the same action skips the seed and leaks the previous session's draft and validation errors.

Keybinds — both move into the modal:

useToggleModalKeybinds(show, 'modal');
useToggleModalKeybinds(!show, 'primary');

Early return after all hooks, including mutations and the escape hotkey (which keeps its !isSavePending && !isResetPending guard — ModalPanel sets onRequestClose to undefined while pending, so dropping the guard would let Escape close mid-save):

if (!renderAction) return null; // never opened; hooks above still own keybinds

Body — every action. becomes renderAction., plus:

  • handleReset adopts the server response explicitly. DELETE …/Triggers returns the action as it is now (ScheduledActionController.cs:128-130; typed in mutations.ts:36-40):
    resetTriggers(renderAction.ID, {
      onSuccess: updatedAction => {
        setDraft(withKeys(updatedAction.Triggers));
        toast.success(\`Triggers of "${renderAction.Name}" reset to its defaults!\`);
      },
    });
  • <ModalPanel show={show} …> instead of hardcoded show.

Do not add an Enter-to-save hotkey: unlike EditEpisodeTitleModal's single input, this modal contains text inputs where Enter must not save.

ScheduledActionsSettings.tsx

  • Delete the useToggleModalKeybinds import and the useToggleModalKeybinds(!selectedActionID, 'primary') call.
  • Always-mount:
    <TriggersModal show={selectedAction !== undefined} action={selectedAction} onClose={() => setSelectedActionID(null)} />

Deliberate behavior changes

  • The draft is an open-time snapshot. Background polls and invoke/cancel cache updates no longer touch it; only Reset adopts. isEdited now compares the snapshot against live action.Triggers, so an external mid-edit change flips isEdited instead of clobbering the draft.
  • Reset adoption depends on the mutation's response payload rather than a cache round-trip. The contract is solid; don't "harden" it with ?? fallbacks that would silently desync the draft from the cache.

Notes

HashTypesModal.tsx:57 has the same flaw (if (!provider) return null skips its own exit animation) — pre-existing, out of scope here, but worth a look while adopting the convention.

useEffect seeding despite AGENTS.md's general preference: the sanctioned alternatives don't fit. Derive-at-render can't produce stable per-row keys, key-remount on open/close would remount on close and kill the exit animation being restored, and useSyncedState resyncs on poll identity changes. HashTypesModal.tsx:25-29 is the passing-lint precedent.

Activity

  1. harshithmohan commented on Oct 26, 2026

    @harshithmohan
    MemberAuthor

    When the clanker does things you never asked for 😔

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions