Repository navigation
Description
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:
ModalPanel's 150mscloseTimeoutMSexit animation never plays — the modal unmounts the momentshowwould flip.- Keybind ownership is split: the page has to remember
useToggleModalKeybinds(!selectedActionID, 'primary'), which silently breaks if the page ever hosts a second modal. - As a side effect of the current
useSyncedState(action.Triggers, …), every background refetch (src/core/react-query/scheduled-action/queries.ts:16polls at 2s while busy / 30s idle) hands the modal a freshTriggersarray 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 keybindsBody — every action. becomes renderAction., plus:
handleResetadopts the server response explicitly.DELETE …/Triggersreturns the action as it is now (ScheduledActionController.cs:128-130; typed inmutations.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 hardcodedshow.
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
useToggleModalKeybindsimport and theuseToggleModalKeybinds(!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.
isEditednow compares the snapshot against liveaction.Triggers, so an external mid-edit change flipsisEditedinstead 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
harshithmohan commentedon Oct 26, 2026
When the clanker does things you never asked for 😔