fix(compiler): key-based remount on hard-delete dialogs

Replaces the `if (open) { setStage(...); setCode(''); ... }` reset
useEffect with a key-based remount of the dialog body. The body now
mounts fresh each time the dialog opens; useState initialisers
run naturally instead of being chased by an effect.

Pattern (apply to remaining dialogs in the same shape):

```tsx
export function MyDialog(props) {
  return (
    <Dialog open={props.open} onOpenChange={props.onOpenChange}>
      <DialogContent>
        {props.open && <MyDialogBody key={props.id} {...props} />}
      </DialogContent>
    </Dialog>
  );
}
```

Applied to:
- hard-delete-dialog (keyed on clientId)
- bulk-hard-delete-dialog (keyed on joined clientIds)

set-state-in-effect: 43 → 41.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 23:43:20 +02:00
parent 8a8cff4c4c
commit 4ae34dacda
2 changed files with 279 additions and 272 deletions

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Loader2, Mail } from 'lucide-react'; import { AlertTriangle, Loader2, Mail } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -32,7 +32,21 @@ interface SkippedRow {
reason: string; reason: string;
} }
export function BulkHardDeleteDialog({ open, onOpenChange, clientIds, onDeleted }: Props) { /**
* Key-based remount of the body when the dialog opens — fresh state per
* open without an open→reset useEffect (React Compiler-safe).
*/
export function BulkHardDeleteDialog(props: Props) {
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className="sm:max-w-md">
{props.open && <BulkHardDeleteDialogBody key={props.clientIds.join(',')} {...props} />}
</DialogContent>
</Dialog>
);
}
function BulkHardDeleteDialogBody({ onOpenChange, clientIds, onDeleted }: Props) {
const qc = useQueryClient(); const qc = useQueryClient();
const [stage, setStage] = useState<Stage>('intent'); const [stage, setStage] = useState<Stage>('intent');
const [code, setCode] = useState(''); const [code, setCode] = useState('');
@@ -43,17 +57,6 @@ export function BulkHardDeleteDialog({ open, onOpenChange, clientIds, onDeleted
const expectedPhrase = `DELETE ${clientIds.length} CLIENT${clientIds.length === 1 ? '' : 'S'}`; const expectedPhrase = `DELETE ${clientIds.length} CLIENT${clientIds.length === 1 ? '' : 'S'}`;
useEffect(() => {
if (open) {
setStage('intent');
setCode('');
setTypedPhrase('');
setMaskedEmail(null);
setSkipped([]);
setPartialDeleted(0);
}
}, [open]);
const requestCode = useMutation({ const requestCode = useMutation({
mutationFn: () => mutationFn: () =>
apiFetch<{ data: { count: number; sentToMaskedEmail: string } }>( apiFetch<{ data: { count: number; sentToMaskedEmail: string } }>(
@@ -105,8 +108,7 @@ export function BulkHardDeleteDialog({ open, onOpenChange, clientIds, onDeleted
const codeValid = /^\d{4}$/.test(code.trim()); const codeValid = /^\d{4}$/.test(code.trim());
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <>
<DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive"> <DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" /> <AlertTriangle className="h-5 w-5" />
@@ -120,8 +122,8 @@ export function BulkHardDeleteDialog({ open, onOpenChange, clientIds, onDeleted
{stage === 'intent' && ( {stage === 'intent' && (
<div className="space-y-3 text-sm text-muted-foreground"> <div className="space-y-3 text-sm text-muted-foreground">
<p> <p>
We&rsquo;ll email a 4-digit confirmation code to your account address. The code is We&rsquo;ll email a 4-digit confirmation code to your account address. The code is tied
tied to this exact set of clients and expires in 10 minutes. to this exact set of clients and expires in 10 minutes.
</p> </p>
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-amber-900 text-xs"> <div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-amber-900 text-xs">
For each client we delete: client record + addresses, contacts, notes, tags, portal For each client we delete: client record + addresses, contacts, notes, tags, portal
@@ -245,7 +247,6 @@ export function BulkHardDeleteDialog({ open, onOpenChange, clientIds, onDeleted
</Button> </Button>
)} )}
</DialogFooter> </DialogFooter>
</DialogContent> </>
</Dialog>
); );
} }

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Loader2, Mail } from 'lucide-react'; import { AlertTriangle, Loader2, Mail } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -29,22 +29,30 @@ interface Props {
type Stage = 'intent' | 'confirm'; type Stage = 'intent' | 'confirm';
export function HardDeleteDialog({ open, onOpenChange, clientId, clientName, onDeleted }: Props) { /**
* Outer wrapper keeps the Dialog mounted (so its close animation runs);
* the body only mounts when `open` is true and remounts on each
* open via the `clientId` key. This avoids the open→reset-state
* useEffect that React Compiler flags — fresh state per open is just
* the natural mount.
*/
export function HardDeleteDialog(props: Props) {
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className="sm:max-w-md">
{props.open && <HardDeleteDialogBody key={props.clientId} {...props} />}
</DialogContent>
</Dialog>
);
}
function HardDeleteDialogBody({ onOpenChange, clientId, clientName, onDeleted }: Props) {
const qc = useQueryClient(); const qc = useQueryClient();
const [stage, setStage] = useState<Stage>('intent'); const [stage, setStage] = useState<Stage>('intent');
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [typedName, setTypedName] = useState(''); const [typedName, setTypedName] = useState('');
const [maskedEmail, setMaskedEmail] = useState<string | null>(null); const [maskedEmail, setMaskedEmail] = useState<string | null>(null);
useEffect(() => {
if (open) {
setStage('intent');
setCode('');
setTypedName('');
setMaskedEmail(null);
}
}, [open]);
const requestCode = useMutation({ const requestCode = useMutation({
mutationFn: () => mutationFn: () =>
apiFetch<{ data: { sentToMaskedEmail: string } }>( apiFetch<{ data: { sentToMaskedEmail: string } }>(
@@ -82,8 +90,7 @@ export function HardDeleteDialog({ open, onOpenChange, clientId, clientName, onD
const codeValid = /^\d{4}$/.test(code.trim()); const codeValid = /^\d{4}$/.test(code.trim());
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <>
<DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive"> <DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" /> <AlertTriangle className="h-5 w-5" />
@@ -202,7 +209,6 @@ export function HardDeleteDialog({ open, onOpenChange, clientId, clientName, onD
</Button> </Button>
)} )}
</DialogFooter> </DialogFooter>
</DialogContent> </>
</Dialog>
); );
} }