Skip to content

feat(ui): add ConflictResolver + ConflictDialog (closes #7)#13

Merged
mayrang merged 1 commit into
mainfrom
feat/issue-7-conflict-ui
May 27, 2026
Merged

feat(ui): add ConflictResolver + ConflictDialog (closes #7)#13
mayrang merged 1 commit into
mainfrom
feat/issue-7-conflict-ui

Conversation

@mayrang
Copy link
Copy Markdown
Owner

@mayrang mayrang commented May 27, 2026

Closes #7.

Summary

Adds a formdraft/ui subpath with two helpers for field-level multi-tab conflict resolution.

  • <ConflictResolver> — headless render-prop. Shallow-diffs local vs remote (Object.is per key, treating "missing key" as a diff via hasOwnProperty). Exposes per-field pickLocal / pickRemote / picked, bulk pickAllLocal / pickAllRemote, and apply / keepLocal. Picks reset when the diff key set changes, so a re-fired conflict event doesn't leak prior selections. Deletion-aware merge: if the chosen side lacks a key, the merged result drops it instead of leaving undefined behind.
  • <ConflictDialog> — styled drop-in modal wrapping the headless component. Mount unconditionally — it renders null when there's no conflict. Esc → resolve-with-local, initial focus moves into the dialog, focus restores on close. Split into outer guard + inner component so the focus useEffect correctly fires on deferred-show (the dominant consumer pattern), not just initial mount. aria-modal=true, aria-labelledby, aria-pressed on choice buttons, data-testid keys sanitized.

Bundle

  • Main bundle stays at 5.42 KB / 8 KB limit (UI ships as a separate chunk under dist/ui/).
  • ./ui subpath export added to package.json.
  • ui/index entry added to tsup.config.ts.

Tests

18 unit tests covering:

  • Renders only diffed fields; non-diffed fields are hidden
  • pickLocal / pickRemote per field + mixed merge via apply
  • pickAllLocal / pickAllRemote bulk shortcuts
  • keepLocal calls onResolve('local')
  • No-conflict path (totalCount=0, apply resolves cleanly)
  • Apply produces a fresh object — doesn't mutate local / remote
  • C1 regression: picks reset when diff key set changes (new conflict event)
  • C2 regression: pick remote for key absent from remote → merged drops the key (and reverse for local)
  • A1: diffKeys treats missing key as diffed even when both read as undefined
  • StrictMode double-mount preserves picks
  • Dev warns when apply() called with unresolved picks
  • Diff signature stable across key-order shuffles (JSON.stringify([...diffed].sort()))
  • ConflictDialog: renders nothing when no conflict; merge integration; Esc → keepLocal; focus moves in on mount; focus restores on unmount; focus moves in when conflict arrives AFTER mount (deferred-show regression test)

Audit rounds

Iterated through 4 rounds of code review + adversarial audit:

  • R1: identified C1 (picks-not-reset), C2 (deleted key leaks as undefined), H1 (modal a11y missing), H2 (misleading Cancel button), H3 (T constraint)
  • R2: M1 (title/formatValue copy-paste slip), M3 (silent default-local on programmatic apply), L1/L2 (signature collisions), L3 (focus to detached element)
  • R3: H1 again — focus useEffect didn't fire on deferred-show (split component)
  • R4: declared mechanically correct; one minor edge case (focus loss on mid-flight conflict swap A→B without null gap) noted as follow-up, not blocker

Test plan

  • npm run typecheck — clean
  • npm run lint — clean
  • npm test — 201 passing (was 183 on main; +18 UI)
  • npm run build — clean, dist/ui/{index.mjs,index.js,index.d.ts,index.d.mts} produced
  • npm run size — 5.42 KB / 8 KB

New `formdraft/ui` subpath ships two helpers for field-level multi-tab
conflict resolution:

- `<ConflictResolver>` — 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.
- `<ConflictDialog>` — 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.
@mayrang mayrang merged commit 3584d1c into main May 27, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v0.2: Field-level conflict merge UI helpers

1 participant