From 416271136f3b27d36213cbc61bcd406cf13029fe Mon Sep 17 00:00:00 2001 From: mayrang Date: Thu, 28 May 2026 04:51:30 +0900 Subject: [PATCH] feat(ui): add ConflictResolver + ConflictDialog (issue #7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `formdraft/ui` subpath ships two helpers for field-level multi-tab conflict resolution: - `` — headless render-prop. Shallow-diffs local vs remote, exposes per-field pickLocal/pickRemote/picked plus bulk pickAllLocal/pickAllRemote and apply/keepLocal. Resets picks when the diff key set changes (handles re-fired conflict events without leaking prior picks). Deletion-aware merge: if the chosen side lacks a key, the merged result drops it instead of leaving `undefined` behind. - `` — styled modal wrapping ConflictResolver. Mount unconditionally; renders `null` when there's no conflict. Esc key resolves with local, initial focus moves into the dialog, focus is restored on close (split into an inner component so focus management correctly fires on deferred-show, not just initial mount). aria-modal=true, aria-labelledby, data-testid hooks sanitized for keys with special characters. Main bundle remains 5.42 KB / 8 KB; UI ships as a separate chunk. 18 unit tests covering happy path plus regressions for diff-set change, deletion symmetry, undefined-vs-missing-key, StrictMode double-mount, Esc handler, deferred-show focus, focus restore, key-order signature stability, and dev warn on unresolved apply. --- README.md | 62 +++++ package.json | 5 + src/__tests__/ui.test.tsx | 512 ++++++++++++++++++++++++++++++++++++ src/ui/ConflictDialog.tsx | 268 +++++++++++++++++++ src/ui/ConflictResolver.tsx | 168 ++++++++++++ src/ui/index.ts | 8 + tsup.config.ts | 1 + 7 files changed, 1024 insertions(+) create mode 100644 src/__tests__/ui.test.tsx create mode 100644 src/ui/ConflictDialog.tsx create mode 100644 src/ui/ConflictResolver.tsx create mode 100644 src/ui/index.ts 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', },