Skip to content

Docs

Components

useDiffReview

DiffReview without its markup: the review’s state, keyboard and result, for your own design system.

Example

  1. @@ -1,6 +1,7 @@not decided
    + import { ratelimit } from '@/lib/ratelimit';
  2. @@ -11,6 +12,15 @@not decided
    +   const ip = req.headers.get('x-forwarded-for') ?? 'anonymous';
    +   const { success, reset } = await ratelimit.limit(ip);
    +   if (!success) {
    +     return new Response('Too many requests', {
    +       status: 429,
    +       headers: { 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)) },
    +     });
    +   }
    + 
  3. @@ -18,7 +28,7 @@not decided
    -     model: openai('gpt-4o'),
    +     model: openai('gpt-4.1-mini'),

J and K move, A and R decide, C writes a note.

Interactive, and the same on the components page. Try the keyboard below on it.

Installation

npm i signoff-ui ai
import { useDiffReview, reviewToolOutput } from 'signoff-ui';
// Once per app (with Tailwind v4: @import 'signoff-ui/tailwind.css'; in your CSS)
import 'signoff-ui/styles.css';

The styles are once per app; Getting started has the Tailwind v4 and plain CSS options.

Usage

function MyReview({ files, onSubmit }: { files: FileChange[]; onSubmit: (r: DiffReviewResult) => void }) {
  const review = useDiffReview({ files, onSubmit });
  return (
    <section aria-label="Review" {...review.getRootProps()}>
      {review.items.map((item, i) => (
        <div key={item.id} {...review.getItemProps(i)}>
          <MyHunk hunk={item.hunk} />
          <button {...review.getDecisionProps(i, 'accepted')}>Keep</button>
          <button {...review.getDecisionProps(i, 'rejected')}>Drop</button>
        </div>
      ))}
      {review.draft && <textarea aria-label="Comment" {...review.getDraftProps()} />}
      <button {...review.getSubmitProps()}>Apply</button>
      <p role="status">{review.announcement}</p>
    </section>
  );
}

API reference

Generated from the types the package ships, so it matches the version you install.

useDiffReview

The review without its markup: parsing (in a worker for large files), decisions, comments, viewed files, unchanged context, the keyboard and the result, for teams that render their own. DiffReview is this hook and its styled markup. Spread getRootProps() on the element that contains the review, getItemProps(i) on each item's element, and render announcement in a polite live region; the keys are DiffReview's.

useDiffReview({ files: changes, decisions: decisionsProp, defaultDecisions, onDecisionsChange, comments: commentsProp, defaultComments, onCommentsChange, viewed: viewedProp, defaultViewed, onViewedChange, onSubmit, readOnly, context, maxEditLength, diffWorker, autoAdvance, collapseViewed, labels }: UseDiffReviewOptions): UseDiffReviewResult

Parameter: UseDiffReviewOptions

filesrequired
readonly FileChange[]
decisions
Readonly<Record<string, HunkDecision>>

Controlled decisions keyed by item id: a hunk's ${path}:${index}, or ${path}:file for a file with no hunks (binary, renamed or empty). A repeated path gets ${path}#2, #3 and so on, skipping a suffix another file's path already has.

defaultDecisions
Readonly<Record<string, HunkDecision>>
onDecisionsChange
(decisions: Record<string, HunkDecision>) => void
comments
readonly DiffReviewComment[]

Controlled comments.

defaultComments
readonly DiffReviewComment[]
onCommentsChange
(comments: DiffReviewComment[]) => void
viewed
Readonly<Record<string, boolean>>

Controlled "Viewed" marks, by file id (the path, or path#2… for a repeated path).

defaultViewed
Readonly<Record<string, boolean>>
onViewedChange
(viewed: Record<string, boolean>) => void
onSubmit
(result: DiffReviewResult, review: { toPatch: () => string; }) => void | PromiseLike<void>

Called with the reviewed result, and a toPatch that gives the accepted changes as a unified diff. Pending hunks are not applied. Fires once per review state, so a double click or a repeated ⌘/Ctrl+Enter sends one review: submitting again needs a changed decision, comment or viewed mark, changed files, the returned promise to settle, or the handler to throw. Files compare by content and the rest by value: an equal new array or object does not count as a change.

readOnly
boolean

Decide nothing, comment on nothing: only read.

context
number

Lines of context around each change. Default 3.

maxEditLength
number

Past this many lines added plus lines removed in one file, its changed region (from the first changed line to the last) is shown as one hunk that replaces it, and the review says so. Diffing costs about the square of this number. Default 2,000.

diffWorker
DiffWorkerFactory | false

Where files too large to diff while rendering are diffed: by default in the package's own worker, which bundlers with worker support emit (webpack 5 and Next.js, Vite, Parcel). Pass a function that starts your own Worker running signoff-ui's diff worker, or false to diff them on the main thread after the first paint. Without a working worker the latter is the fallback.

autoAdvance
boolean

After accepting or rejecting with the keyboard, move to the next undecided item. Default true.

collapseViewed
boolean

Fold a file away once it is marked viewed. Default true.

labels
SignoffLabelsInput

Words to use instead of the English defaults, for what the hook announces and names: see SignoffLabelsProvider.

Returns: UseDiffReviewResult

filesrequired
DiffReviewFileState[]
itemsrequired
ReviewItem[]

Every item to decide, in file order; index in the getters refers to this list.

activeIndexrequired
number

The item in the tab order, focused by J and K.

countsrequired
{ accepted: number; rejected: number; pending: number; total: number; }
comparingrequired
boolean

Some file is still being diffed in the background; applying waits for it.

decisionsrequired
Readonly<Record<string, HunkDecision>>
commentsrequired
readonly DiffReviewComment[]
viewedrequired
Readonly<Record<string, boolean>>
selectionrequired
DiffReviewSelection
draftrequired
DiffReviewDraft
shownrequired
Readonly<Record<string, ShownContext>>

Unchanged lines shown around hunks, by ${fileId}:${gap.before}.

announcementrequired
string

What to announce: render it in a polite live region.

readOnlyrequired
boolean
isMacrequired
boolean
deciderequired
(id: string, decision: HunkDecision, options?: { advance?: boolean; }) => void
decideFilerequired
(fileId: string, decision: Exclude<HunkDecision, 'pending'>) => void
decideAllrequired
(decision: Exclude<HunkDecision, 'pending'>) => void
setViewedrequired
(fileId: string, viewed: boolean) => void
setCollapsedrequired
(fileId: string, collapsed: boolean) => void
focusItemrequired
(index: number) => void

Focus an item by index (clamped), as J and K do.

activaterequired
(index: number) => void

Make an item the one in the tab order, as focusing it does. Stable across renders.

registerItemrequired
(id: string, el: HTMLElement | null) => void

Record an item's element, for focus. Stable across renders.

focusFilerequired
(fileId: string) => void

Unfold a file and focus its first item, as the file navigator does.

selectLinesrequired
(itemId: string, from: number, to?: number) => void

Select lines from…to of a hunk item; to defaults to from.

clearSelectionrequired
() => void
startCommentrequired
(options?: { itemId?: string; commentId?: string; }) => void

Start a comment: on the selected lines, else on the item (a hunk or a whole file), or edit one.

setDraftTextrequired
(text: string) => void
registerDraftrequired
(el: HTMLTextAreaElement | null) => void

The comment editor's element, for focus. Stable across renders.

saveDraftrequired
() => void
cancelDraftrequired
() => void
removeCommentrequired
(id: string) => void
showContextrequired
(fileId: string, gap: ContextGap, from: 'start' | 'end' | 'all') => void

Show more unchanged lines of a file's gap: from its start, its end, or all of it.

submitrequired
() => void

Send the review to onSubmit, once per review state.

resultrequired
() => DiffReviewResult

The review as onSubmit receives it, for the current state.

toPatchrequired
() => string

The accepted changes as a unified diff.

getRootPropsrequired
() => { onKeyDown: (event: KeyboardEvent<HTMLElement>) => void; }
getItemPropsrequired
(index: number) => { ref: (el: HTMLElement | null) => void; role: 'group'; tabIndex: 0 | -1; 'aria-label': string; 'data-decision': HunkDecision; onFocus: () => void; }

A focusable item: a roving tabindex, its accessible name and its decision.

getDecisionPropsrequired
(index: number, decision: Exclude<HunkDecision, 'pending'>) => { type: 'button'; 'aria-pressed': boolean; onClick: () => void; }

Accept or reject one item; pressing it again resets it.

getFileDecisionPropsrequired
(fileId: string, decision: Exclude<HunkDecision, 'pending'>) => { type: 'button'; onClick: () => void; 'aria-keyshortcuts': string; }
getViewedPropsrequired
(fileId: string) => { ref: (el: HTMLInputElement | null) => void; type: 'checkbox'; checked: boolean; onChange: (event: ChangeEvent<HTMLInputElement>) => void; 'aria-keyshortcuts': 'V'; }
getLineNumberPropsrequired
(index: number, line: number) => { onClick: (event: MouseEvent) => void; }

Click a line number to select it; Shift-click extends the selection in the same hunk.

getDraftPropsrequired
() => { ref: (el: HTMLTextAreaElement | null) => void; value: string; onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void; onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void; }
getSubmitPropsrequired
() => { type: 'button'; onClick: () => void; 'aria-disabled': true | undefined; 'aria-keyshortcuts': string; }
getNavigatorItemPropsrequired
(fileId: string) => { type: 'button'; tabIndex: 0 | -1; ref: (el: HTMLButtonElement | null) => void; 'aria-current': true | undefined; onClick: () => void; onKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => void; }

One button per file in a navigator list, with a roving tabindex: ↑ and ↓ move, Enter goes.

DiffReviewFileState

idrequired
string

The file's id: its path, or path#2… for a path that repeats.

pathrequired
string
changerequired
FileChange
parsedrequired
ParsedFileDiff

undefined while the file is diffed in the background ("Comparing…").

itemsrequired
ReviewItem[]

What it asks to decide: its hunks, or the file as a whole.

decisionrequired
FileDecision
'pending' | 'accepted' | 'rejected' | 'partial' | 'unchanged'
decidedrequired
number

How many of its items are decided.

viewedrequired
boolean
collapsedrequired
boolean

Folded away: its items are skipped by J and K.

gapsrequired
ContextGap[]

Unchanged lines around its hunks that can be shown, when both contents are known.

DiffReviewSelection

Lines selected in a hunk, by index into hunk.lines.

itemrequired
ReviewItem
hunkrequired
DiffHunk
anchorrequired
number

Where the selection started, and where it ends (the line moved with the keyboard).

headrequired
number
fromrequired
number

The first and last selected index.

torequired
number
rangerequired
LineRange

DiffReviewDraft

A comment being written: a new one, or an edit of commentId.

fileIdrequired
string
targetrequired
DiffReviewComment['target']
textrequired
string
commentId
string
hunkId
string
range
LineRange

ReviewItem

One thing to decide: a hunk, or a file with no hunks as a whole (binary, a rename, an empty file).

idrequired
string

The decision key: the hunk's id, or ${fileId}:file.

filerequired
ParsedFileDiff
hunkrequired
DiffHunk

undefined for a whole file.

Accessibility

  • The hook has no markup of its own. Its getters bring what DiffReview relies on: a roving tabindex and a name for each item (getItemProps), aria-pressed on decision buttons, aria-keyshortcuts, aria-disabled on Apply while files are being compared, and a roving tabindex for a file list (getNavigatorItemProps).
  • Every key DiffReview has works once getRootProps() is on the element around the review and getItemProps(i) on each item. Render announcement in a polite live region: it says what each decision, selection, comment and viewed mark did.
  • Give the comment editor a label: getDraftProps() gives it focus, its value and its keys (⌘ or Ctrl + Enter saves, Escape cancels).