diff --git a/README.md b/README.md index cb65c9a..d040a99 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,68 @@ Returns `undefined` when no instance with that key is currently mounted. The han | `'manual'` | Fires `onConflict`, library does nothing automatic | | `false` | Disables multi-tab (no BroadcastChannel overhead) | +## Conflict UI + +When `multiTab: 'warn'` fires a conflict, `draft.onConflictData` holds the remote values and `draft.resolveConflict` accepts `'local' | 'remote' | merged`. Building the merge UI by hand is the same code in every app, so formdraft ships two helpers from the `formdraft/ui` subpath (separate chunk — main bundle is unaffected). + +### Drop-in: `` + +Renders a modal dialog showing only the fields that differ, with per-field "Yours / Other tab" buttons and "Keep all mine / Take all theirs" shortcuts. Renders `null` when there is no conflict, so you can mount it unconditionally: + +```tsx +import { ConflictDialog } from 'formdraft/ui'; + +function ProfileForm() { + const draft = useFormDraft({ /* ... */ }); + return ( + <> + {/* your form */} + + + ); +} +``` + +Optional props: `title`, `fieldLabels` (per-field display names), `formatValue` (custom value renderer). + +### Headless: `` + +Same orchestration with a render-prop API, so you bring your own markup: + +```tsx +import { ConflictResolver } from 'formdraft/ui'; + +{draft.onConflictData && ( + ( +
+ {name} + + +
+ )} + > + {({ fields, apply, canApply, pendingCount }) => ( +
+ {fields} + +
+ )} +
+)} +``` + +Diffing is shallow object-equality (`Object.is` per key) — sufficient for v0.2. Deep diff and string char-level diff are tracked for later. + ## Zero runtime dependencies formdraft has **no** runtime dependencies. Only peer deps (which you'd install anyway): `react`, optionally `react-hook-form`, optionally `zod`. diff --git a/package.json b/package.json index 741306c..94741a2 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,11 @@ "import": "./dist/tanstack-form/index.mjs", "require": "./dist/tanstack-form/index.js" }, + "./ui": { + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.mjs", + "require": "./dist/ui/index.js" + }, "./storage/indexedDB": { "types": "./dist/storage/indexedDB.d.ts", "import": "./dist/storage/indexedDB.mjs", diff --git a/src/__tests__/ui.test.tsx b/src/__tests__/ui.test.tsx new file mode 100644 index 0000000..b0c18b1 --- /dev/null +++ b/src/__tests__/ui.test.tsx @@ -0,0 +1,512 @@ +import { StrictMode } from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ConflictResolver } from '../ui/ConflictResolver'; +import { ConflictDialog } from '../ui/ConflictDialog'; +import type { FormDraftResult } from '../types'; + +type Form = { name: string; bio: string; age: number }; + +function basicRender( + args: Parameters>[0]['renderField']>[0], +) { + return ( +
+ {args.picked ?? 'none'} + + +
+ ); +} + +describe('ConflictResolver (headless)', () => { + it('renders only fields that differ between local and remote', () => { + const onResolve = vi.fn(); + render( + + local={{ name: 'Alice', bio: 'hi', age: 30 }} + remote={{ name: 'Alice', bio: 'hello', age: 31 }} + onResolve={onResolve} + renderField={basicRender} + />, + ); + expect(screen.queryByTestId('row-name')).toBeNull(); + expect(screen.getByTestId('row-bio')).toBeTruthy(); + expect(screen.getByTestId('row-age')).toBeTruthy(); + }); + + it('records pickLocal / pickRemote per field and applies merged result', () => { + const onResolve = vi.fn(); + render( + + local={{ name: 'Alice', bio: 'hi', age: 30 }} + remote={{ name: 'Alice', bio: 'hello', age: 31 }} + onResolve={onResolve} + renderField={basicRender} + > + {({ fields, apply, canApply, pendingCount }) => ( +
+ {fields} + {pendingCount} + {String(canApply)} + +
+ )} +
, + ); + + expect(screen.getByTestId('pending').textContent).toBe('2'); + expect(screen.getByTestId('can-apply').textContent).toBe('false'); + + act(() => fireEvent.click(screen.getByTestId('pl-bio'))); + expect(screen.getByTestId('picked-bio').textContent).toBe('local'); + expect(screen.getByTestId('pending').textContent).toBe('1'); + + act(() => fireEvent.click(screen.getByTestId('pr-age'))); + expect(screen.getByTestId('picked-age').textContent).toBe('remote'); + expect(screen.getByTestId('can-apply').textContent).toBe('true'); + + act(() => fireEvent.click(screen.getByTestId('apply'))); + expect(onResolve).toHaveBeenCalledTimes(1); + expect(onResolve).toHaveBeenCalledWith({ name: 'Alice', bio: 'hi', age: 31 }); + }); + + it('pickAllLocal / pickAllRemote shortcut resolves every field at once', () => { + const onResolve = vi.fn(); + render( + + local={{ name: 'A', bio: 'b', age: 1 }} + remote={{ name: 'A', bio: 'B', age: 2 }} + onResolve={onResolve} + renderField={basicRender} + > + {({ fields, pickAllLocal, pickAllRemote, apply, canApply }) => ( +
+ {fields} + + + + {String(canApply)} +
+ )} + , + ); + + act(() => fireEvent.click(screen.getByTestId('all-remote'))); + expect(screen.getByTestId('can-apply').textContent).toBe('true'); + expect(screen.getByTestId('picked-bio').textContent).toBe('remote'); + expect(screen.getByTestId('picked-age').textContent).toBe('remote'); + act(() => fireEvent.click(screen.getByTestId('apply'))); + expect(onResolve).toHaveBeenLastCalledWith({ name: 'A', bio: 'B', age: 2 }); + + onResolve.mockClear(); + act(() => fireEvent.click(screen.getByTestId('all-local'))); + act(() => fireEvent.click(screen.getByTestId('apply'))); + expect(onResolve).toHaveBeenLastCalledWith({ name: 'A', bio: 'b', age: 1 }); + }); + + it('keepLocal resolves to "local" (string), keeping the local copy without merging', () => { + const onResolve = vi.fn(); + render( + + local={{ name: 'A', bio: 'b', age: 1 }} + remote={{ name: 'A', bio: 'B', age: 1 }} + onResolve={onResolve} + renderField={basicRender} + > + {({ fields, keepLocal }) => ( +
+ {fields} + +
+ )} + , + ); + act(() => fireEvent.click(screen.getByTestId('keep-local'))); + expect(onResolve).toHaveBeenCalledWith('local'); + }); + + it('resets per-field picks when the diff key set changes (C1 regression)', () => { + const onResolve = vi.fn(); + function Host({ + local, + remote, + }: { + local: Form; + remote: Form; + }) { + return ( + + local={local} + remote={remote} + onResolve={onResolve} + renderField={basicRender} + > + {({ fields, pendingCount, canApply }) => ( +
+ {fields} + {pendingCount} + {String(canApply)} +
+ )} + + ); + } + const { rerender } = render( + , + ); + act(() => fireEvent.click(screen.getByTestId('pl-bio'))); + expect(screen.getByTestId('picked-bio').textContent).toBe('local'); + expect(screen.getByTestId('can-apply').textContent).toBe('true'); + + // New conflict event: now `name` differs instead of `bio`. + rerender(); + expect(screen.queryByTestId('row-bio')).toBeNull(); + expect(screen.getByTestId('row-name')).toBeTruthy(); + expect(screen.getByTestId('picked-name').textContent).toBe('none'); + expect(screen.getByTestId('pending').textContent).toBe('1'); + expect(screen.getByTestId('can-apply').textContent).toBe('false'); + }); + + it('deletion-aware merge: picking "remote" for a key absent from remote drops the key (C2 regression)', () => { + type LooseForm = { a: number; b?: number }; + const onResolve = vi.fn(); + render( + + local={{ a: 1, b: 2 }} + remote={{ a: 1 }} + onResolve={onResolve} + renderField={basicRender} + > + {({ fields, apply, canApply }) => ( +
+ {fields} + +
+ )} + , + ); + // `b` is diffed because hasOwnProperty differs even though remote[b] is undefined. + expect(screen.getByTestId('row-b')).toBeTruthy(); + act(() => fireEvent.click(screen.getByTestId('pr-b'))); + act(() => fireEvent.click(screen.getByTestId('apply'))); + const merged = onResolve.mock.calls[0][0]; + expect('b' in merged).toBe(false); + expect(merged).toEqual({ a: 1 }); + }); + + it('deletion-aware merge: picking "local" for a key absent from local drops the key', () => { + type LooseForm = { a: number; b?: number }; + const onResolve = vi.fn(); + render( + + local={{ a: 1 }} + remote={{ a: 1, b: 9 }} + onResolve={onResolve} + renderField={basicRender} + > + {({ fields, apply, canApply }) => ( +
+ {fields} + +
+ )} + , + ); + expect(screen.getByTestId('row-b')).toBeTruthy(); + act(() => fireEvent.click(screen.getByTestId('pl-b'))); + act(() => fireEvent.click(screen.getByTestId('apply'))); + const merged = onResolve.mock.calls[0][0]; + expect('b' in merged).toBe(false); + }); + + it('diffKeys distinguishes missing key from explicit undefined value (A1)', () => { + type LooseForm = { a: number; b?: number }; + const onResolve = vi.fn(); + render( + + local={{ a: 1, b: undefined }} + remote={{ a: 1 }} + onResolve={onResolve} + renderField={basicRender} + />, + ); + // `b` exists on local but not on remote → must show as diffed even though + // both values read back as `undefined`. + expect(screen.getByTestId('row-b')).toBeTruthy(); + }); + + it('dev warns when apply() is called with unresolved picks (M3)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const onResolve = vi.fn(); + render( + + local={{ name: 'A', bio: 'b', age: 1 }} + remote={{ name: 'A', bio: 'B', age: 2 }} + onResolve={onResolve} + renderField={basicRender} + > + {({ fields, apply }) => ( +
+ {fields} + {/* deliberately ungated to exercise the warning */} + +
+ )} + , + ); + act(() => fireEvent.click(screen.getByTestId('force-apply'))); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('unresolved'), + ); + expect(onResolve).toHaveBeenCalledTimes(1); + // Un-resolved fields default to local; merged equals local. + expect(onResolve).toHaveBeenCalledWith({ name: 'A', bio: 'b', age: 1 }); + warn.mockRestore(); + }); + + it('diff signature is stable across key-order shuffles in input objects (L1)', () => { + const onResolve = vi.fn(); + function Host({ local, remote }: { local: Form; remote: Form }) { + return ( + + local={local} + remote={remote} + onResolve={onResolve} + renderField={basicRender} + > + {({ fields, pendingCount }) => ( +
+ {fields} + {pendingCount} +
+ )} + + ); + } + const { rerender } = render( + , + ); + act(() => fireEvent.click(screen.getByTestId('pl-bio'))); + expect(screen.getByTestId('pending').textContent).toBe('1'); + // Same diff set ({bio, age}) but the parent reconstructed inputs with + // different key insertion order. Picks must survive. + rerender( + , + ); + expect(screen.getByTestId('picked-bio').textContent).toBe('local'); + expect(screen.getByTestId('pending').textContent).toBe('1'); + }); + + it('survives StrictMode double-mount with picks intact', () => { + const onResolve = vi.fn(); + render( + + + local={{ name: 'A', bio: 'b', age: 1 }} + remote={{ name: 'A', bio: 'B', age: 1 }} + onResolve={onResolve} + renderField={basicRender} + > + {({ fields, apply, canApply }) => ( +
+ {fields} + + {String(canApply)} +
+ )} + +
, + ); + act(() => fireEvent.click(screen.getByTestId('pl-bio'))); + expect(screen.getByTestId('can-apply').textContent).toBe('true'); + act(() => fireEvent.click(screen.getByTestId('apply'))); + expect(onResolve).toHaveBeenCalledTimes(1); + expect(onResolve).toHaveBeenLastCalledWith({ name: 'A', bio: 'b', age: 1 }); + }); + + it('no conflict (objects equal): totalCount=0, apply still resolves cleanly', () => { + const onResolve = vi.fn(); + render( + + local={{ name: 'A', bio: 'b', age: 1 }} + remote={{ name: 'A', bio: 'b', age: 1 }} + onResolve={onResolve} + renderField={basicRender} + > + {({ fields, apply, canApply, totalCount }) => ( +
+ {fields} + {totalCount} + {String(canApply)} + +
+ )} + , + ); + expect(screen.getByTestId('total').textContent).toBe('0'); + expect(screen.getByTestId('can-apply').textContent).toBe('true'); + act(() => fireEvent.click(screen.getByTestId('apply'))); + expect(onResolve).toHaveBeenCalledWith('local'); + }); + + it('apply produces a fresh object — does not mutate local or remote', () => { + const onResolve = vi.fn(); + const local: Form = { name: 'A', bio: 'b', age: 1 }; + const remote: Form = { name: 'A', bio: 'B', age: 2 }; + const localBefore = JSON.stringify(local); + const remoteBefore = JSON.stringify(remote); + render( + + local={local} + remote={remote} + onResolve={onResolve} + renderField={basicRender} + > + {({ fields, pickAllRemote, apply }) => ( +
+ {fields} + + +
+ )} + , + ); + act(() => fireEvent.click(screen.getByTestId('all-remote'))); + act(() => fireEvent.click(screen.getByTestId('apply'))); + const merged = onResolve.mock.calls[0][0]; + expect(merged).not.toBe(local); + expect(merged).not.toBe(remote); + expect(JSON.stringify(local)).toBe(localBefore); + expect(JSON.stringify(remote)).toBe(remoteBefore); + }); +}); + +describe('ConflictDialog (styled)', () => { + function fakeDraft(overrides?: Partial>): FormDraftResult
{ + const resolve = vi.fn(); + return { + values: { name: 'Alice', bio: 'hi', age: 30 }, + set: vi.fn(), + patch: vi.fn(), + status: 'conflict', + lastSavedAt: null, + pendingChanges: false, + error: null, + save: vi.fn(), + discard: vi.fn(), + submit: vi.fn(), + onConflictData: { name: 'Alice', bio: 'hello', age: 31 }, + resolveConflict: resolve, + ...overrides, + } as FormDraftResult; + } + + it('renders nothing when there is no onConflictData', () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders only diffed fields with default markup; clicking apply calls resolveConflict with merged values', () => { + const draft = fakeDraft(); + render(); + + expect(screen.queryByTestId('conflict-row-name')).toBeNull(); + expect(screen.getByTestId('conflict-row-bio')).toBeTruthy(); + expect(screen.getByTestId('conflict-row-age')).toBeTruthy(); + + const applyBtn = screen.getByTestId('conflict-apply') as HTMLButtonElement; + expect(applyBtn.disabled).toBe(true); + + act(() => fireEvent.click(screen.getByTestId('conflict-pick-remote-bio'))); + act(() => fireEvent.click(screen.getByTestId('conflict-pick-local-age'))); + expect(applyBtn.disabled).toBe(false); + + act(() => fireEvent.click(applyBtn)); + expect(draft.resolveConflict).toHaveBeenCalledWith({ + name: 'Alice', + bio: 'hello', + age: 30, + }); + }); + + it('Esc keydown resolves with local (H1 regression)', () => { + const draft = fakeDraft(); + render(); + const dialog = screen.getByRole('dialog'); + act(() => { + fireEvent.keyDown(dialog, { key: 'Escape' }); + }); + expect(draft.resolveConflict).toHaveBeenCalledWith('local'); + }); + + it('moves focus into the dialog when conflict arrives AFTER mount (H1 deferred-show)', () => { + const trigger = document.createElement('button'); + document.body.appendChild(trigger); + trigger.focus(); + + const draftBefore = fakeDraft({ onConflictData: null }); + const { rerender } = render(); + // No dialog yet; focus stays on trigger. + expect(screen.queryByRole('dialog')).toBeNull(); + expect(document.activeElement).toBe(trigger); + + // Conflict arrives in a later render. + const draftAfter = fakeDraft(); + rerender(); + const dialog = screen.getByRole('dialog'); + expect( + dialog.contains(document.activeElement) || document.activeElement === dialog, + ).toBe(true); + document.body.removeChild(trigger); + }); + + it('moves focus into the dialog on mount and restores it on unmount', () => { + const trigger = document.createElement('button'); + document.body.appendChild(trigger); + trigger.focus(); + expect(document.activeElement).toBe(trigger); + + const draft = fakeDraft(); + const { unmount } = render(); + const dialog = screen.getByRole('dialog'); + expect(dialog.contains(document.activeElement) || document.activeElement === dialog).toBe(true); + + unmount(); + expect(document.activeElement).toBe(trigger); + document.body.removeChild(trigger); + }); +}); diff --git a/src/ui/ConflictDialog.tsx b/src/ui/ConflictDialog.tsx new file mode 100644 index 0000000..91661e5 --- /dev/null +++ b/src/ui/ConflictDialog.tsx @@ -0,0 +1,268 @@ +import { useEffect, useId, useRef, type CSSProperties, type ReactNode } from 'react'; +import { ConflictResolver } from './ConflictResolver'; +import type { FormDraftResult } from '../types'; + +export type ConflictDialogProps> = { + draft: FormDraftResult; + title?: string; + fieldLabels?: Partial>; + formatValue?: (value: unknown) => string; +}; + +const styles: Record = { + backdrop: { + position: 'fixed', + inset: 0, + background: 'rgba(0,0,0,0.45)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 9999, + }, + dialog: { + background: '#fff', + color: '#111', + borderRadius: 8, + padding: 20, + minWidth: 360, + maxWidth: 560, + maxHeight: '80vh', + overflow: 'auto', + boxShadow: '0 8px 32px rgba(0,0,0,0.25)', + fontFamily: + 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + outline: 'none', + }, + title: { margin: '0 0 4px', fontSize: 18, fontWeight: 600 }, + subtitle: { margin: '0 0 16px', fontSize: 13, color: '#555' }, + fieldRow: { padding: '10px 0', borderTop: '1px solid #eee' }, + fieldName: { fontWeight: 600, marginBottom: 6, fontSize: 14 }, + choices: { display: 'flex', gap: 8, flexWrap: 'wrap' }, + choiceBtn: { + flex: '1 1 0', + minWidth: 0, + padding: '8px 10px', + borderRadius: 6, + border: '1px solid #ccc', + background: '#fafafa', + cursor: 'pointer', + textAlign: 'left', + fontSize: 13, + color: '#111', + }, + choiceBtnPicked: { + borderColor: '#1f6feb', + background: '#e8f0fe', + color: '#0b3a8a', + }, + choiceLabel: { fontSize: 11, color: '#666', display: 'block', marginBottom: 2 }, + choiceValue: { + fontSize: 13, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + footer: { + marginTop: 16, + display: 'flex', + gap: 8, + justifyContent: 'flex-end', + flexWrap: 'wrap', + }, + bulkBtn: { + padding: '6px 10px', + borderRadius: 6, + border: '1px solid #ccc', + background: '#fff', + cursor: 'pointer', + fontSize: 12, + color: '#111', + }, + applyBtn: { + padding: '8px 14px', + borderRadius: 6, + border: '1px solid #1f6feb', + background: '#1f6feb', + color: '#fff', + cursor: 'pointer', + fontSize: 13, + }, + applyBtnDisabled: { + opacity: 0.5, + cursor: 'not-allowed', + }, +}; + +function defaultFormat(v: unknown): string { + if (v === null || v === undefined) return '(empty)'; + if (typeof v === 'string') return v === '' ? '(empty)' : v; + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + try { + return JSON.stringify(v); + } catch { + return String(v); + } +} + +function sanitizeForTestId(name: string): string { + return name.replace(/[^A-Za-z0-9_-]/g, '_'); +} + +export function ConflictDialog>({ + draft, + title = 'Another tab edited this form', + fieldLabels, + formatValue = defaultFormat, +}: ConflictDialogProps): ReactNode { + // The dominant consumer pattern is to mount + // unconditionally and let it gate on `draft.onConflictData`. If we hang a + // mount-time useEffect off the outer component, it fires once before a + // conflict ever exists (dialog returns null) and never re-fires when one + // arrives. Split the focus-managing subtree into a child that only mounts + // when there IS a conflict, so its useEffect tracks the dialog lifecycle. + if (!draft.onConflictData) return null; + return ( + + draft={draft} + title={title} + fieldLabels={fieldLabels} + formatValue={formatValue} + remote={draft.onConflictData} + /> + ); +} + +function ConflictDialogInner>({ + draft, + remote, + title, + fieldLabels, + formatValue, +}: { + draft: FormDraftResult; + remote: T; + title: string; + fieldLabels: Partial> | undefined; + formatValue: (value: unknown) => string; +}): ReactNode { + const dialogRef = useRef(null); + const titleId = useId(); + + // Focus on mount, restore on unmount. Safe to use [] deps: this inner + // component only mounts when a conflict actually exists. + useEffect(() => { + const prev = (typeof document !== 'undefined' ? document.activeElement : null) as + | HTMLElement + | null; + const firstButton = dialogRef.current?.querySelector( + 'button:not([disabled])', + ); + (firstButton ?? dialogRef.current)?.focus?.(); + return () => { + if (prev && typeof document !== 'undefined' && document.body.contains(prev)) { + prev.focus?.(); + } + }; + }, []); + + return ( + + local={draft.values as T} + remote={remote} + onResolve={draft.resolveConflict} + renderField={({ name, localValue, remoteValue, pickLocal, pickRemote, picked }) => { + const safe = sanitizeForTestId(name); + return ( +
+
{fieldLabels?.[name as keyof T & string] ?? name}
+
+ + +
+
+ ); + }} + > + {({ fields, pickAllLocal, pickAllRemote, apply, keepLocal, canApply, pendingCount, totalCount }) => ( +
{ + if (e.key === 'Escape') { + e.stopPropagation(); + keepLocal(); + } + }} + > +
+

+ {title} +

+

+ {totalCount === 0 + ? 'No differences detected.' + : `Pick which version to keep for each of the ${totalCount} changed field${totalCount === 1 ? '' : 's'}.`} +

+ {fields} +
+ + + +
+
+
+ )} + + ); +} diff --git a/src/ui/ConflictResolver.tsx b/src/ui/ConflictResolver.tsx new file mode 100644 index 0000000..97d4b70 --- /dev/null +++ b/src/ui/ConflictResolver.tsx @@ -0,0 +1,168 @@ +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; + +export type ConflictFieldRenderArgs = { + name: string; + localValue: unknown; + remoteValue: unknown; + pickLocal: () => void; + pickRemote: () => void; + picked: 'local' | 'remote' | null; +}; + +export type ConflictResolverChildrenArgs = { + fields: ReactNode; + pickAllLocal: () => void; + pickAllRemote: () => void; + apply: () => void; + /** + * Resolve immediately with the local copy, discarding remote without merge. + * Destructive — clears `onConflictData` upstream. Use for "close = keep mine" + * shortcuts (Esc, explicit "Keep mine" button), not as a generic dismiss. + */ + keepLocal: () => void; + canApply: boolean; + pendingCount: number; + totalCount: number; +}; + +export type ConflictResolverProps> = { + local: T; + remote: T; + onResolve: (choice: 'local' | 'remote' | T) => void; + renderField: (args: ConflictFieldRenderArgs) => ReactNode; + children?: (args: ConflictResolverChildrenArgs) => ReactNode; +}; + +function diffKeys(local: Record, remote: Record): string[] { + const seen = new Set(); + const out: string[] = []; + for (const obj of [local, remote]) { + for (const k of Object.keys(obj)) { + if (seen.has(k)) continue; + seen.add(k); + const lHas = Object.prototype.hasOwnProperty.call(local, k); + const rHas = Object.prototype.hasOwnProperty.call(remote, k); + if (lHas !== rHas) { + out.push(k); + continue; + } + if (!Object.is(local[k], remote[k])) out.push(k); + } + } + return out; +} + +export function ConflictResolver>({ + local, + remote, + onResolve, + renderField, + children, +}: ConflictResolverProps): ReactNode { + const diffed = useMemo(() => diffKeys(local, remote), [local, remote]); + // Stringify (not join) so the signature is robust against key-order changes + // upstream and against pathological key characters (NUL, separators). Two + // diff sets with the same content but different orders should produce the + // same signature so we don't wipe in-progress picks. + const diffSignature = useMemo(() => JSON.stringify([...diffed].sort()), [diffed]); + + const [picks, setPicks] = useState>({}); + + // Round-2 fix (C1): when the conflict set itself changes (new conflict event, + // remote re-broadcast with different fields), the stale per-field picks must + // not leak into the new resolution. We key on the diff-key signature rather + // than `local`/`remote` identity so unrelated re-renders (same conflict, + // different parent state) don't wipe in-progress picks. + useEffect(() => { + setPicks({}); + }, [diffSignature]); + + const pickField = useCallback((name: string, side: 'local' | 'remote') => { + setPicks((p) => ({ ...p, [name]: side })); + }, []); + + const pickAllLocal = useCallback(() => { + setPicks(() => { + const next: Record = {}; + for (const k of diffed) next[k] = 'local'; + return next; + }); + }, [diffed]); + + const pickAllRemote = useCallback(() => { + setPicks(() => { + const next: Record = {}; + for (const k of diffed) next[k] = 'remote'; + return next; + }); + }, [diffed]); + + const apply = useCallback(() => { + if (diffed.length === 0) { + onResolve('local'); + return; + } + if (process.env.NODE_ENV !== 'production') { + const unresolved = diffed.filter((k) => !picks[k]); + if (unresolved.length > 0) { + // eslint-disable-next-line no-console + console.warn( + `[formdraft] ConflictResolver.apply() called with ${unresolved.length} unresolved field${unresolved.length === 1 ? '' : 's'} (${JSON.stringify(unresolved)}). These will keep the local value. Gate apply() on \`canApply\` to surface unresolved picks to the user.`, + ); + } + } + const merged: Record = { ...local }; + for (const k of diffed) { + const side = picks[k]; + // Round-2 fix (C2 + A1): handle deletion symmetrically. If the chosen + // side does not have the key, drop it from the merged result rather than + // leaving `undefined` behind. Otherwise round-tripping through e.g. + // setValues leaves the key present-with-undefined, which diverges from + // post-JSON.stringify storage state. + if (side === 'remote') { + if (Object.prototype.hasOwnProperty.call(remote, k)) { + merged[k] = remote[k]; + } else { + delete merged[k]; + } + } else if (side === 'local') { + if (!Object.prototype.hasOwnProperty.call(local, k)) { + delete merged[k]; + } + } + } + onResolve(merged as T); + }, [diffed, local, remote, onResolve, picks]); + + const keepLocal = useCallback(() => { + onResolve('local'); + }, [onResolve]); + + const fieldNodes = diffed.map((name) => + renderField({ + name, + localValue: local[name], + remoteValue: remote[name], + pickLocal: () => pickField(name, 'local'), + pickRemote: () => pickField(name, 'remote'), + picked: picks[name] ?? null, + }), + ); + + const pendingCount = diffed.reduce((n, k) => (picks[k] ? n : n + 1), 0); + const canApply = pendingCount === 0; + + if (children) { + return children({ + fields: <>{fieldNodes}, + pickAllLocal, + pickAllRemote, + apply, + keepLocal, + canApply, + pendingCount, + totalCount: diffed.length, + }); + } + return <>{fieldNodes}; +} diff --git a/src/ui/index.ts b/src/ui/index.ts new file mode 100644 index 0000000..83204da --- /dev/null +++ b/src/ui/index.ts @@ -0,0 +1,8 @@ +export { ConflictResolver } from './ConflictResolver'; +export type { + ConflictResolverProps, + ConflictFieldRenderArgs, + ConflictResolverChildrenArgs, +} from './ConflictResolver'; +export { ConflictDialog } from './ConflictDialog'; +export type { ConflictDialogProps } from './ConflictDialog'; diff --git a/tsup.config.ts b/tsup.config.ts index 5b1b621..551448f 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ 'rhf/index': 'src/rhf/index.ts', 'formik/index': 'src/formik/index.ts', 'tanstack-form/index': 'src/tanstack-form/index.ts', + 'ui/index': 'src/ui/index.ts', 'storage/indexedDB': 'src/storage/indexedDB.ts', 'storage/sessionStorage': 'src/storage/sessionStorage.ts', },