From 348bccae34fa9274b28e9f0b3378f31e9e5f8924 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 14:49:22 +0200 Subject: [PATCH] fix(frontend): stop PermissionEditor draft resync from clobbering in-flight edits (#79) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The draft-resync useEffect fired on every new `draft` object (initial load, refresh, another save), even when its content matched `local`. If it flushed after the user started editing, it silently discarded the edit — flaky in tests where a passive effect can settle after a synchronous fireEvent sequence, and a real risk in production if a fetch lands mid-edit. Now it only resyncs when `local` still matches the last-adopted draft. Co-Authored-By: Claude Sonnet 5 --- .../features/permissions/PermissionsPanel.tsx | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) 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(