diff --git a/frontend/src/features/permissions/PermissionsPanel.tsx b/frontend/src/features/permissions/PermissionsPanel.tsx index ae1b6fa..887133c 100644 --- a/frontend/src/features/permissions/PermissionsPanel.tsx +++ b/frontend/src/features/permissions/PermissionsPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Button, Panel, Spinner, cn } from "@/shared"; import type { PermissionSet, PermissionPosture } from "@/domain"; @@ -232,9 +232,26 @@ function PermissionEditor({ onClear, }: PermissionEditorProps) { const [local, setLocal] = useState(draft); - + // `draft` is a NEW object every time the underlying document changes for ANY + // reason (initial load landing after mount, a Refresh, another tab's save…), + // even when its content matches what is already in `local`. Naively + // re-syncing on every `draft` change is a genuine race: if it fires after the + // user has started editing (in production: a slow initial fetch landing + // right as the user types; in tests: a passive effect flushing later than a + // synchronous `fireEvent` sequence — ticket #79 flake), it silently discards + // the user's in-progress edit. `prevDraftRef` tracks the last draft this + // effect adopted `local` from; we only re-sync when `local` still matches it + // (nothing has been edited since), so a legitimate incoming draft (first + // load, or the echo of the user's own just-saved edit) is adopted, but an + // edit in flight never gets clobbered. + const prevDraftRef = useRef(draft); useEffect(() => { - setLocal(draft); + setLocal((current) => + JSON.stringify(current) === JSON.stringify(prevDraftRef.current) + ? draft + : current, + ); + prevDraftRef.current = draft; }, [draft]); const changed = useMemo(