Overview

Diffs is a library for rendering code and diffs on the web. This includes both high-level, easy-to-use components, as well as exposing many of the internals if you want to selectively use specific pieces. We've built syntax highlighting on top of Shiki which provides a lot of great theme and language support.

We have an opinionated stance in our architecture: browsers are rather efficient at rendering raw HTML. We lean into this by having all the lower level APIs purely rendering strings (the raw HTML) that are then consumed by higher-order components and utilities. This gives us great performance and flexibility to support popular libraries like React as well as provide great tools if you want to stick to vanilla JavaScript and HTML. The higher-order components render all this out into Shadow DOM and CSS grid layout.

Generally speaking, you're probably going to want to use the higher level components since they provide an easy-to-use API that you can get started with rather quickly. We currently only have components for vanilla JavaScript and React, but will add more if there's demand.

For this overview, we'll talk about the vanilla JavaScript components for now but there are React equivalents for all of these.

Rendering Diffs

Our goal with visualizing diffs was to provide some flexible and approachable APIs for how you may want to render diffs. For this, we provide a component called FileDiff.

There are two ways to render diffs with FileDiff:

  1. Provide two versions of a file or code snippet to compare
  2. Consume a patch file

You can see examples of these approaches below, in both JavaScript and React.

Merge conflict resolution UI

Render conflicts through a dedicated diff primitive that treats current and incoming sections as structured additions/deletions without running text diffing. Resolve by choosing current, incoming, or both changes and preview the updated file instantly.

Installation

Diffs is published as an npm package. Install Diffs with the package manager of your choice:

Package Exports

The package provides several entry points for different use cases:

PackageDescription
@pierre/diffsVanilla JS components, plus utility functions
@pierre/diffs/reactReact components for rendering diffs and files
@pierre/diffs/editorLow-level edit mode Editor for attaching editing to rendered file and diff surfaces
@pierre/diffs/ssrServer-side rendering utilities for pre-rendering diffs with syntax highlighting
@pierre/diffs/workerWorker pool utilities for offloading syntax highlighting to background threads

Core Types

Before diving into the components, it's helpful to understand the core file, diff, and annotation data structures used throughout the library.

FileContents

FileContents represents one existing file version. Use it when rendering a file with the <File> component, or pass it as oldFile and/or newFile to diff components. For added or deleted files, pass null for the intentionally missing side.

An omitted side is not the same as null. If you provide either oldFile or newFile, provide the other side too, using null only when that file side does not exist. Empty file contents are still a real file; represent them with contents: '', not null.

For read-only rendering and Worker Pool caching, cacheKey is optional; when provided, treat it as a revision identity and change it with the contents, filename, language, or revision. When Editor.persistState is enabled, every editable file requires an explicit, non-empty cacheKey. Use unique, stable keys for editing sessions, and change the key when incoming contents should replace the cached document.

FileDiffMetadata

FileDiffMetadata represents the differences between file versions. It contains the hunks (changed regions), line counts, and optionally the full file contents for expansion (if possible).

When a component uses loadDiffFiles, treat FileDiffMetadata as mutable render metadata. A partial metadata object parsed from a patch can be upgraded in place: isPartial flips to false, hunks and line arrays are replaced with hydrated values, and the object identity is preserved.

If you reparse a patch or create a new partial FileDiffMetadata, the renderer treats it as a fresh partial model. Keep the same metadata object stable when you want hydration to persist.

When loaded files provide cache keys, hydration uses those keys so full-file highlights can be reused across diffs. Change each FileContents.cacheKey whenever the loaded contents, filename, language, or revision changes. If loaded files are unkeyed but the partial metadata has a cacheKey, hydration appends a hydrated segment as a fallback.

Tip: You can generate FileDiffMetadata using parseDiffFromFile (from file contents) or parsePatchFiles (from a patch string).

LineAnnotation and DiffLineAnnotation

LineAnnotation<T> places content on a file line and contains lineNumber plus typed metadata. Metadata is required when T is a concrete type and omitted when T is undefined. DiffLineAnnotation<T> adds side: 'additions' | 'deletions' to select a file side. Line coordinates are one-based on the selected file side, not row positions in the rendered diff. Use lineNumber: 0 for a file-level annotation above the first file line or, in a diff, above the first hunk or row on that side.

Callbacks shared by file and diff surfaces use LineAnnotation[] | DiffLineAnnotation[]. Use isFileAnnotationCollection or isDiffAnnotationCollection to narrow the collection before reading shape-specific fields. For individual annotation unions, use isFileAnnotation or isDiffAnnotation.

Store a stable, position-independent application ID in metadata when an annotation owns drafts or other interactive state. For annotations that survive an edit, edit mode preserves metadata while remapping line coordinates. For the controlled update pattern and exact remapping rules, see Editing with line annotations.

Creating Diffs

There are two ways to create a FileDiffMetadata.

From File Contents

Use parseDiffFromFile when you have the full file contents. Pass both sides for a changed file, oldFile: null for a new file, or newFile: null for a deleted file. This approach allows collapsed regions to be expanded.

From a Patch String

Use parsePatchFiles when you have a unified diff or patch file. This is useful when working with git output or patch files from APIs. Patch-derived metadata is partial until a renderer hydrates it with full files from loadDiffFiles.

Tip: If you need to change the language after creating a FileContents or FileDiffMetadata, use the setLanguageOverride utility function.

React API

Import React components from @pierre/diffs/react.

We offer a variety of components to render diffs and files. Many of them share similar types of props, which you can find documented in Shared Props.

Components

The React API exposes six main components:

  • CodeView renders a mixed, virtualized list of files and diffs inside one scroll container
  • MultiFileDiff compares file contents directly
  • PatchDiff renders from a patch string
  • FileDiff renders a pre-parsed FileDiffMetadata
  • File renders a single code file without a diff
  • UnresolvedFile renders merge conflict markers with built-in resolution UI
    • Currently in beta/experimental and may change in future releases.

For editing, mount one stable EditProvider high in the tree. Standalone surfaces use edit and editOptions; CodeView uses item edit flags and its own editOptions. Each active surface or item receives an independent editor. UnresolvedFile is not editable. See Edit mode → React and CodeView → Editing for lifecycle and callback details.

Keep non-primitive props stable across renders. Define static files, diffs, options, styles, and factories at module scope; when they depend on component state or props, use useMemo for objects and arrays and useCallback for functions. This applies to options, editOptions, annotations, and render callbacks as well as file and fileDiff.

UnresolvedFile is intentionally uncontrolled in React. Treat file as initial input and remount (for example, with a changing key) when you want to reset.

MultiFileDiff accepts FileContents for each existing side. Pass oldFile={null} for a new file, or newFile={null} for a deleted file.

The CodeView tab above is the quick-start version. For the full guide on controlled items, imperative initialItems, ids, version, selection, and scrollTo, see CodeView.

Partial Diff Hydration

When loadDiffFiles is configured, partial FileDiffMetadata passed to FileDiff may be hydrated in place. Keep the same fileDiff object identity stable across parent rerenders when you want the hydrated full metadata to persist.

Return both sides for changed diffs and { oldFile: null, newFile } for pure renames. Added and deleted diffs do not need to be hydrated.

Passing a freshly parsed partial object resets hydration for that render. Avoid calling parsePatchFiles during every render before passing the result to FileDiff; store or memoize the parsed metadata instead.

Shared Props

The three diff components (MultiFileDiff, PatchDiff, and FileDiff) share a common set of props for configuration, annotations, and styling. The File component has similar props, but uses LineAnnotation instead of DiffLineAnnotation (no side property).

When one of these components is attached to an Editor, keep its annotations in application-owned state and replace them with the current collection emitted by Editor.onChange. See Editing with line annotations for the React flushSync pattern and annotation-content lifetime guidance.

CodeView reuses many of the same option names internally, but it has its own controlled items mode, imperative mode with optional initialItems, viewer ref, and mixed-item render props. See CodeView for the dedicated guide.

Header customization and collapsing behavior:

  • Use renderHeaderPrefix to render custom UI at the beginning of the built-in header, before the filename and icons, while keeping the default header layout.
  • Use renderHeaderFilenameSuffix for compact UI immediately after the displayed filename, such as badges, review state, or generated-file labels.
  • Use renderHeaderMetadata to render custom UI at the end of the built-in header, after the diff stats, while keeping the default header layout.
  • Use renderCustomHeader when you want to replace the built-in header content with your own custom designed one.
  • For diff components, these header callbacks receive fileDiff: FileDiffMetadata.
  • For File, the corresponding header callbacks receive file: FileContents.
  • Use options.collapsed to hide file body content while keeping the file header visible.

Post Render Lifecycle

options.onPostRender(node, instance, phase) is a DOM-node lifecycle callback. It fires with phase: 'mount' after the first committed render or hydration for a container node, phase: 'update' after later DOM-committing renders, and phase: 'unmount' before a mounted container node is removed, replaced, cleaned up, or recycled.

Use this callback when native DOM selection listeners need access to the rendered diff node or its shadow DOM. Attach listeners such as selectstart and selectionchange during mount, and remove them during unmount with teardown state captured by node.

Token callbacks (onTokenClick, onTokenEnter, onTokenLeave) and useTokenTransformer are documented in Token Hooks, including examples, payload details, performance notes, and Worker Pool caveats.

Vanilla JS API

Import vanilla JavaScript classes, components, and methods from @pierre/diffs.

Components

The Vanilla JS API exposes four core components: CodeView (render a mixed, virtualized list of files and diffs in one scroll container), FileDiff (compare file contents directly or render a pre-parsed FileDiffMetadata), File (render a single code file without a diff), and UnresolvedFile (render merge conflicts with built-in resolution controls). Start with these components for syntax highlighting, theming, layout, and interactivity.

UnresolvedFile is currently beta/experimental and may change in future releases.

See Edit mode → Vanilla JS for attaching Editor to a rendered File or FileDiff with edit().

UnresolvedFile in vanilla supports both uncontrolled and controlled callbacks (onMergeConflictResolve / onMergeConflictAction).

The CodeView tab above is the quick-start version. For the deeper guide on setup, setItems, addItems, getItem, updateItem, selection, and scrollTo, see CodeView.

Props

Both FileDiff and File accept an options object in their constructor. The File component has similar options, but excludes diff-specific settings and uses LineAnnotation instead of DiffLineAnnotation (no side property).

When one of these components is attached to an Editor, keep its annotations in an external variable or store and replace them with the current collection emitted by Editor.onChange. See Editing with line annotations for the synchronization pattern and remapping rules.

When rendering direct file contents with FileDiff.render, pass FileContents for each existing side. Use oldFile: null for a new file, or newFile: null for a deleted file.

For partial diffs parsed from patches, pass loadDiffFiles to FileDiff constructor options when you want collapsed unchanged context to expand from full file contents. The loader receives the partial FileDiffMetadata and returns { oldFile, newFile }: changed and rename-changed diffs return both sides, while pure renames return { oldFile: null, newFile }. Added and deleted patch diffs do not need loader hydration. Components catch loader errors by default; set disableErrorHandling: true when you want errors to rethrow.

CodeView forwards many of those same options to each rendered item, while adding CodeView-specific controls like layout, itemMetrics, stickyHeaders, pointerEventsOnScroll, and smoothScrollSettings. Its class instance also exposes item-level methods such as addItems, getItem, and updateItem. See CodeView for the dedicated guide.

Header customization and collapsing behavior:

  • Use renderHeaderPrefix to render custom UI at the beginning of the built-in FileDiff header, before the filename and icon, while keeping the default header layout.
  • Use renderHeaderFilenameSuffix for compact UI immediately after the displayed filename, such as badges, review state, or generated-file labels.
  • Use renderHeaderMetadata to render custom UI at the end of the built-in FileDiff header, after the diff stats, while keeping the default header layout.
  • Use renderCustomHeader when you want to replace the built-in header content entirely.
  • In File, header callbacks receive file: FileContents.
  • Use collapsed in constructor options to hide file body content while keeping the file header visible.

Post Render Lifecycle

onPostRender(node, instance, phase) is a DOM-node lifecycle callback. It fires with phase: 'mount' after the first committed render or hydration for a container node, phase: 'update' after later DOM-committing renders, and phase: 'unmount' before a mounted container node is removed, replaced, cleaned up, or recycled.

Use this callback when native DOM selection listeners need access to the rendered diff node or its shadow DOM. Attach listeners such as selectstart and selectionchange during mount, and remove them during unmount with teardown state captured by node.

Token callbacks (onTokenClick, onTokenEnter, onTokenLeave) and useTokenTransformer are documented in Token Hooks, including examples, payload details, performance notes, and Worker Pool caveats.

Custom Hunk Separators

Start with the Hunk Separators section first. In most cases, styling the built-in separator markup with unsafeCSS is the better approach.

If that is still not enough, the low-level hunkSeparators(hunkData, instance) function remains available in Vanilla JS as a last-resort escape hatch. It is being phased out and is not the recommended path for new integrations, but the example below shows how it works when you truly need to render your own elements:

Renderers

For most use cases, you should use the higher-level components like FileDiff and File (vanilla JS) or the React components (MultiFileDiff, FileDiff, PatchDiff, File). These renderers are low-level building blocks intended for advanced use cases.

These renderer classes handle the low-level work of parsing and rendering code with syntax highlighting. Useful when you need direct access to the rendered output as HAST nodes or HTML strings for custom rendering pipelines.

DiffHunksRenderer

Takes a FileDiffMetadata data structure and renders out the raw HAST (Hypertext Abstract Syntax Tree) elements for diff hunks. You can generate FileDiffMetadata via parseDiffFromFile or parsePatchFiles utility functions.

FileRenderer

Takes a FileContents object (just a filename and contents string) and renders syntax-highlighted code as HAST elements. Useful for rendering single files without any diff context.

CodeView

CodeView is the high-level API for rendering one large scroll region that can contain files, diffs, or both.

CodeView renders a list of CodeViewItem[] and manages the hard parts for you: virtualization, measured layout reconciliation, sticky headers, selection across items, and scrollTo targeting by item, line, or absolute position.

You can check out a live demo at diffshub.com

If you need to render one or more files or diffs in a scrollable container, use CodeView to avoid handling scaling yourself.

What It Gives You

  • One scroll container for a mixed list of file and diff items.
  • Built-in per-line virtualization that should scale to nearly any file or diff that can fit in memory.
  • scrollTo APIs for items, line targets, and raw scroll positions.
  • Unified selection API, support for custom annotations, custom headers, and gutter utilities across the entire viewer.
  • Optional per-item edit mode for files and diffs.
  • Optional non-virtualized header and footer regions rendered inside the scroll container — ideal for PR summary cards and approval bars.

Core Model

CodeView is designed to enable easy rendering of any files or diffs, regardless of scale, so its data model does not depend on traditional immutability or deep equality checks, which can quickly become expensive.

  • Every item needs a stable unique id. That id is how scrollTo, line selection, getItem, updateItem, and reconciliation find the correct records.
  • Items are either { type: 'file', file } or { type: 'diff', fileDiff }.
  • If you keep the same item id but change its content or annotations, you must increment the version so CodeView can make an efficient targeted updates based only on what changed without recomputing everything.
  • Selection is viewer-wide, meaning a selection in one file will remove the selection in another file in the same scroll view. The payload shape is { id, range } instead of only a line range.
  • The collapsed property on an item controls whether file or diff content is shown. You'll have to wire up your own custom header or utilities if you want to control it interactively. Remember to update version when this value changes.
  • The edit property enables edit mode for an item when React CodeView has an EditProvider, or vanilla CodeView has a createEditor option. Update version when toggling it.
  • CodeView-level options such as layout, itemMetrics, stickyHeaders, pointerEventsOnScroll, and smoothScrollSettings allow you to configure the scroll view. All other options are shared between all files and diffs.
  • loadDiffFiles is one of those shared diff options. It applies to diff items rendered inside CodeView, which is useful for large patch-driven review UIs where full file contents should be fetched only when users expand unchanged context. Hydration updates the existing fileDiff object in place, so keep its identity stable when the hydrated metadata should persist across later renders.

Editing

React CodeView gets its editor factory from the nearest EditProvider; unlike vanilla CodeView, it does not accept createEditor directly or inside options. Keep the provider mounted, set edit: true on the items that should be editable, and pass creation-time item-editor behavior through editOptions. onItemEditChange reports live contents with the owning item. If a session produces a change, onItemEditComplete reports its latest contents when editing is disabled or the item is collapsed or removed. Direct reset, cleanup, and viewer unmount are silent.

Each edited item receives an independent editor whose history survives virtualization. Changes to the provider factory or editOptions do not disturb active sessions; their latest values apply the next time an item enters edit mode.

For vanilla CodeView, keep using CodeViewOptions.createEditor. It exposes the same item-aware callbacks, and CodeView owns each returned editor's lifecycle.

Padding & Gap

For controlling layout inside and between items in CodeView, you can use the layout prop. Unlike itemMetrics, these values actually set internal values and adjust the layout. You should not apply these values with CSS yourself.

Use the renderCodeViewHeader and renderCodeViewFooter options to render your own element at the start and end of the scroll content; before the first item and after the last one.

  • Headers and footers are not virtualized. Unlike items, they are always in the DOM while the viewer is mounted.
  • They are rendered inside the scroll container, as part of the scrollable content. CodeView doesn't apply any positioning of its own, and they don't affect stickyHeaders behavior for item headers.
  • You never declare a height. CodeView measures the element on mount and tracks later size changes with a ResizeObserver, so async content, font loads, and late-arriving React portals stay coherent, and scroll position is re-anchored when a header's height changes.
  • Both render even when the item list is empty, which makes them useful for loading or empty states in review UIs.
  • In React, return plain JSX from the renderCodeViewHeader / renderCodeViewFooter props. The node is portaled into a host element the viewer manages, so state-driven updates just work. Memoize the callbacks with useCallback (listing any state they read as deps) so the header and footer don't re-render on every parent render (don't trust React Compiler).
  • In Vanilla JS, return the same element across calls and mutate it in place to update; returning undefined empties the host. The callback's presence controls whether the host element exists at all.
  • The host elements carry data-diffs-code-view-header and data-diffs-code-view-footer attributes for styling, and are exposed via getHeaderElement() / getFooterElement() on the instance.

File & Diff Size Estimation

CodeView uses a line-based virtualization system that renders a minimal snapshot to keep browser performance top of mind. Under the hood, it estimates the mathematical size of all code, then corrects and caches those estimates as you scroll and more content renders. These estimates are based on itemMetrics, and can be verified with the __devOnlyValidateItemHeights property.

Examples

React Item Ownership

React CodeView supports two item ownership models. Use one per mounted viewer; do not switch between them without remounting with a new key.

ModeUseItem propItem updates
ControlledReact state owns the complete item listitemsPublish a new items array. Append-only changes are optimized; other changes reconcile the list.
ImperativeThe viewer instance owns the item list after mountoptional initialItemsUse the ref APIs: addItems, getItem, and updateItem.

Use controlled mode when item data already lives naturally in React state and the list is small enough that mutating arrays or items is cheap. Use imperative mode for very large or streaming surfaces where routing every item update through React would be expensive. In imperative mode, omit items, optionally seed the viewer with initialItems, and use the CodeViewHandle to add new items or update existing ones.

Editing Item Annotations

When an editable item has annotations, onItemEditChange receives the owning item, its edited FileContents, and the complete current annotation collection. For a diff item, those contents represent the editable new-file side. When the emitted annotation array is a different object, replace item.annotations and increment the item's version. CodeView does not write either value for you.

The callback annotation type is LineAnnotation[] | DiffLineAnnotation[]: file items emit the side-less shape and diff items emit annotations with a side. Use isFileAnnotationCollection or isDiffAnnotationCollection to narrow it before reading shape-specific fields.

When React owns the CodeView item list through items, publish a new items array containing the updated item inside flushSync so annotation placement updates with the edited content before paint. When CodeView owns the list in React's imperative mode, call updateItem through the component ref; in vanilla JS, call updateItem on the CodeView instance. Skip the update when the emitted array is the same object as the item's current annotations, since ordinary same-line typing reuses that array.

Keep onItemEditComplete focused on committing the final file contents or rebuilding fileDiff with a fresh cacheKey. Live annotations should already be synchronized through onItemEditChange. See Editing with line annotations for remapping rules, stable metadata IDs, and annotation-content lifetime guidance.

Usage Notes

  • In React, pass items for controlled item ownership.
  • In React, pass initialItems instead of items for imperative item ownership. initialItems seeds the viewer once; later item changes should go through the ref.
  • In React, addItems and updateItem require imperative item ownership and throw if the viewer is controlled with items.
  • In React, use selectedLines and onSelectedLinesChange when selection needs to live in component state.
  • In React, use the ref for scrollTo, setSelectedLines, getSelectedLines, clearSelectedLines, getItem, updateItem, addItems, and getInstance.
  • renderCustomHeader, renderHeaderPrefix, renderHeaderFilenameSuffix, renderHeaderMetadata, renderAnnotation, and renderGutterUtility receive the whole CodeViewItem, which makes it easy to branch on item.type.
  • In Vanilla JS, CodeView owns a scrollable root that you set up once and update over time.
  • In Vanilla JS, call setup(root) once with the scrollable container.
  • In Vanilla JS, use setItems, addItem, or addItems to populate the viewer, and getItem / updateItem for item-level imperative changes.
  • Shared callbacks receive the normal file/diff payload plus a context argument containing the current viewer item and instance.
  • onPostRender receives (node, instance, phase, context). Its unmount phase will fire when an item scrolls out of the rendered window and CodeView recycles that item's DOM shell.
  • By default, CodeView temporarily disables pointer events on rendered content while scrolling for smoother scroll performance. Set pointerEventsOnScroll: true only when pointer interactions must remain active during scroll.
  • In Vanilla JS, call cleanUp() when the viewer is removed so observers, timers, and DOM state are released.

Scroll Targets

scrollTo supports four target shapes:

Line, range, and item targets resolve against live measured layout, so they continue to work even when wrapped lines or annotations change the rendered heights after initial paint.

Relationship To Virtualization

If your scrollable region is only code, CodeView should usually be your starting point. It is heavily optimized for that case: it owns the whole code surface, only renders what is visible, and is generally more performant and less prone to blanking than the lower-level virtualization APIs.

Drop down to Virtualization when you need a more flexible, mixed-content layout that CodeView cannot own directly. That flexibility comes with trade-offs: the lower-level virtualizer always mounts every top-level file or diff container, can blank more easily during aggressive scroll, and is generally less performant than CodeView.

Edit modeBeta

Edit mode is experimental and subject to change.

Edit mode is a pluggable editing layer for File-style code surfaces. It adds keyboard-driven editing to an already-rendered File or FileDiff while the existing renderer continues to own syntax highlighting, layout, annotations, and virtualization.

Edit mode features include:

  • Text editing
  • Selection management, including multiple cursors
  • Automatic indentation
  • History (undo and redo)
  • Find-in-file search and replace
  • Selection Action (opt-in, custom UI)
  • SSR support
  • Mobile-friendly
  • Lightweight

Use the Editor API to add editing to File, FileDiff, MultiFileDiff, and PatchDiff, or to individual CodeView items. In React, wrap editable surfaces in EditProvider, then set a standalone surface's edit prop or a CodeView item's edit flag. In vanilla JS, call editor.edit(component) after rendering a standalone surface, or give CodeView its own createEditor option.

Edit mode is not a full-fledged IDE, though you may certainly use it to create your own IDE-like experiences. Edit mode is purpose-built and best suited for editing code rendered by our Diffs library. It builds on our already powerful file and diff surfaces to preserve diff layout, annotations, syntax highlighting, SSR, and virtualization while adding editing, multiple selections, history, search and replace, and markers. This makes it most ideal for “review and correct” flows with generative code changes.

Demo

To enable edit mode on a File instance, import Editor from @pierre/diffs/editor. It is intentionally separate from the core bundle so you can lazy-load it when editing is optional.

Edit mode demo

Click into the code and type to try edit mode.

Changes: 0

How It Works

Edit mode does not replace File, FileDiff, or their virtualized variants. You render the surface first, then attach editing:

  1. Attach — Call editor.edit(component) in vanilla JS, or pass edit on a React File, FileDiff, MultiFileDiff, or PatchDiff wrapped in EditProvider. For React CodeView, use the same provider and set edit: true on an item. Vanilla CodeView instead takes CodeViewOptions.createEditor. The hookup returns a dispose function for a standalone vanilla surface; React surfaces and CodeView manage it automatically when editing toggles.
  2. Edit surface — Edit mode sets contentEditable on the code content element so mobile keyboards, paste, and native selection UI work as users expect. Clipboard shortcuts are handled by the browser; edit mode commands handle structure-aware actions like indent and undo.
  3. Selections — Carets and ranges are tracked with the native Selection API. Multiple non-overlapping selections are supported; Cmd/Ctrl-click adds another cursor without clearing existing ones.
  4. Updates — Keystrokes update an internal TextDocument, then changed lines are re-highlighted through the same tokenizer pipeline as read-only mode. Your onChange handler receives updated FileContents (and line annotations when present).

When attaching, edit mode turns on the token transformer and triggers a re-render if necessary. Collapsed unchanged regions stay collapsed: arrow keys skip over them like code folds, jumps that must land inside one (search matches, undo, Cmd/Ctrl+End) expand it just enough to show the target, and the separator expand buttons keep working. The diff layout is preserved, so a FileDiff or MultiFileDiff stays in whatever view it was rendered in; in unified diffs, deleted lines and annotation lines remain read-only.

Editing with line annotations

Applications own the annotation collection passed to a File, FileDiff, or other editable surface. When an edit affects annotation coordinates, the editor remaps them and passes the complete current collection to onChange alongside the edited file. This collection is authoritative when present; it is not a delta. Replace your application-owned collection with it before a later render can reapply stale coordinates.

The editor preserves the existing array reference for ordinary same-line edits and other edits that do not affect any annotation. When a structural edit touches, moves, or removes an annotation, it returns a new array. Check that identity before publishing state so ordinary typing does not cause unnecessary annotation renders.

Remapping follows these rules:

  • A LineAnnotation, or a DiffLineAnnotation on the additions side, follows structural edits in the editable file. Inserting lines above it moves it down; deleting earlier lines moves it up.
  • A DiffLineAnnotation on the deletions side stays attached to the read-only old-file side and is not remapped.
  • lineNumber: 0 remains a file-level annotation and is not remapped.
  • Deleting only an annotated line's text leaves an annotated blank line. To remove a non-final line and its annotation, delete from its start through the start of the next line, consuming its trailing line break. To remove a final line that has a preceding line, delete from the end of the preceding line through EOF, consuming the preceding line break. A one-line document always retains one blank line.
  • Deleting only the line break before an annotated line while retaining its text merges that text into the previous line and moves the annotation to the merged line.
  • Undo and redo use the same onChange path, restoring or removing annotations with the corresponding document state.

For annotations that survive, remapping changes only their coordinates. Metadata is preserved, so give each interactive annotation a stable, position-independent ID in metadata and key application-owned drafts or UI state by that ID.

The shared onChange callback accepts either annotation shape: standalone File surfaces emit LineAnnotation[], while diff surfaces emit DiffLineAnnotation[]. Narrow the collection before reading the diff-only side property. Use isFileAnnotationCollection or isDiffAnnotationCollection when the surface type is not already known.

In React, synchronously publish a changed annotation array with flushSync so the annotation placement updates with the edited content before paint. Do not wrap every onChange update in flushSync: bail out when the emitted array is the same object.

React may recreate annotation content when annotations are removed, restored, or reordered, or when a CodeView item leaves the virtualized window. flushSync keeps the placement update visually atomic, but it does not preserve component-local state. Keep drafts and other interactive state in application-owned state keyed by a stable annotation metadata ID. We intend to preserve annotation node identity in a future release, so current remounting behavior is not a long-term API contract.

In vanilla JS, replace the annotation collection in your external variable or store before updating the surface. When the emitted array changes, schedule the same render function used for the initial surface after onChange returns, passing the edited file and replacement annotations. For CodeView, pass the collection to updateItem with a version increment. In every case, updating the source of truth prevents a later render from restoring stale line numbers.

Use one Editor per concurrently editable standalone surface. CodeView instead creates and manages one editor per editable item. See the playground for the complete interaction: submit a line annotation, enable editing, insert a line above it, remove its logical line, then use undo and redo. The playground controls demonstrate the integration; they are not additional public editor options.

React

Give a permanently mounted EditProvider a stable createEditor factory, then toggle only the standalone surface's edit prop or a CodeView item's edit flag. The provider does not own an editor instance. Each editable File, FileDiff, MultiFileDiff, or PatchDiff surface and each editable CodeView item creates its own editor when an edit session starts. Standalone surface DOM and imperative file/diff instances stay mounted across the toggle.

Pass creation-time callbacks and behavior through the surface's editOptions. A provider can add shared defaults by spreading them before the surface options, so the surface wins. Keep factories and object props stable: define static values at module scope, or use useCallback for factories and callbacks and useMemo for options and editOptions when they depend on component values.

const createEditor = useCallback<CreateEditor<undefined>>(
  (surfaceOptions) =>
    new Editor({
      ...sharedEditorDefaults,
      ...surfaceOptions,
    }),
  []
);

const editOptions = useMemo<EditorOptions<undefined>>(
  () => ({
    onChange: handleChange,
    onAttach(editor) {
      editorRef.current = editor;
    },
  }),
  [handleChange]
);

// This example is self-contained. Apps should usually mount EditProvider near
// the root so its factory is available to every editable File, diff, and
// CodeView.
return (
  <EditProvider createEditor={createEditor}>
    <File file={file} edit={editing} editOptions={editOptions} />
  </EditProvider>
);

Changes to createEditor or editOptions do not disturb an active edit session. Their latest values apply the next time edit transitions from false to true. Re-entering edit mode creates a fresh editor with fresh history and state. Sibling editable surfaces receive independent editors and callbacks; use onAttach with your own ref when controls need imperative APIs such as history, selections, markers, save, or search. Use Virtualizer for large editable files. The FileDiff tab below shows the controlled annotation feedback loop.

Vanilla JS

Render with File or FileDiff first, then attach the editor with editor.edit(component). Use VirtualizedFile and VirtualizedFileDiff for large editable files. Keep any emitted annotation collection in your external source of truth before a later render. Call cleanUp() when the editor is removed. The FileDiff tab below includes this synchronization.

CodeView

CodeView manages one Editor per editable item. In React, wrap it in the same app-level EditProvider used by other editable surfaces, set edit: true on an item, and pass creation-time item-editor behavior through the CodeView editOptions prop. In vanilla JS, pass createEditor in CodeViewOptions instead. Increment the item's version whenever edit changes.

CodeView uses item-aware callbacks instead of editOptions.onChange. onItemEditChange receives the owning item with each live change: replace that item's annotations whenever the emitted array changes, and increment its version. This live update keeps annotation coordinates synchronized throughout the edit session. onItemEditComplete receives the item and latest contents when a changed session ends because editing is disabled or the item is collapsed or removed. Sessions with no changes do not emit it. Resetting, cleaning up, or unmounting the viewer is silent teardown.

Persist edited contents to the item's file, or rebuild its fileDiff. Assign a fresh cacheKey and increment version; CodeView does not update item data for you.

Editors retain their document and history while items move in and out of the virtualized window.

editOptions and provider-factory changes are creation-time inputs: active item sessions keep their current editor, while the latest values apply to the next item that enters edit mode. Simultaneously edited items always receive independent editor instances.

Editor Options

Pass these when constructing new Editor({ ... }), or update them later with setOptions.

Persisting file state

State persistence is disabled by default. Set persistState: true to preserve an editable File's text document, undo history, selections, and scroll position when the attached file renders a different file and later returns. This currently applies to File and VirtualizedFile surfaces; diff surfaces do not use the persistence cache.

Every editable file must provide an explicit, non-empty file.cacheKey while persistState is enabled. The editor throws if it encounters an unkeyed file; it does not fall back to file.name. Cache keys must be unique and stable within the persistence storage namespace. Reuse the same key when a rename or move should resume the same editing session; change it when new incoming contents should start a fresh document.

import type { EditorState, FileContents } from '@pierre/diffs';
import { Editor, type IStateStorage } from '@pierre/diffs/editor';

const editor = new Editor({
  persistState: true,
  persistStateStorage: 'inMemory',
});

const file: FileContents = {
  name: 'src/example.ts',
  contents: 'export const value = 1;',
  cacheKey: 'workspace-file-1',
};

// Custom storage may be synchronous or asynchronous.
const states = new Map<string, EditorState>();
const customStorage: IStateStorage = {
  get(cacheKey) {
    return states.get(cacheKey);
  },
  set(cacheKey, state) {
    states.set(cacheKey, state);
  },
};

const editorWithCustomStorage = new Editor({
  persistState: true,
  persistStateStorage: customStorage,
});

persistStateStorage accepts 'inMemory', 'indexedDB', or an IStateStorage implementation. It defaults to 'inMemory'. Text documents and undo history remain scoped to the Editor instance; IndexedDB and custom storage persist only the serializable editor state (selections and view).

API Reference

These methods are available on an Editor instance. Attach it to a rendered File or FileDiff with edit() first.

Markers

Markers add inline diagnostics—errors, warnings, and other annotations—to the editable surface, with a hover popover that shows each marker's message. They are useful for surfacing linter or language-server output alongside live edits.

Each marker is positioned with zero-based start and end positions and a severity that drives its color and popover styling:

Call editor.setMarkers(markers) after the editor has attached to a surface (calling it before attaching throws). Inlining the array lets TypeScript check the severity literals against the Marker type without importing it.

Markers re-anchor as the document changes, so they stay attached to their text while you edit. Pass an empty array to clear them.

History

The editor keeps a single undo stack covering user edits and programmatic edits alike — applyEdits joins the same timeline as typed input, so a mixed sequence undoes exactly like an all-local one. Its optional updateHistory argument defaults to true; passing false remaps live selections instead of restoring snapshots but keeps the text edit undoable. Use editor.canUndo and editor.canRedo to enable or disable toolbar buttons, and call editor.undo() or editor.redo() to step through history from code. These methods share the same stack as the keyboard shortcuts (Cmd/Ctrl-Z and

Cmd/Ctrl-Shift-Z).

undo() and redo() are no-ops when there is nothing to undo or redo. Both run through the same change path as a normal edit, so your onChange handler fires and you can re-read canUndo / canRedo to keep UI state in sync.

Limit the stack size with historyMaxEntries in Editor Options (default: 100).

Using Worker Pool

When you offload syntax highlighting to a worker pool, edit mode still needs the token transformer pipeline to re-highlight lines as you type. Set useTokenTransformer: true on the pool's highlighterOptions.

See Worker Pool for worker factory setup and pool options. In vanilla JS, pass the pool as the second argument to File or FileDiff. In React, wrap your tree in WorkerPoolContextProvider. Then attach the editor as usual.

Lazy Loading

Because @pierre/diffs/editor is a standalone entry point, you can dynamic-import it only when the user enters edit mode. That keeps the initial page bundle smaller and can improve LCP (Largest Contentful Paint) on pages where editing is rare.

Selection Action

Selection Action is an opt-in edit mode feature for showing custom UI alongside the current selection—useful for quick transforms, refactor prompts, or other selection-scoped tools. Set enabledSelectionAction: true and return your UI from renderSelectionAction; a floating popover holding your UI appears automatically, anchored to the active selection. The popover can hold any number of actions.

renderSelectionAction runs when the popover opens. Its context includes the active selection, the editable textDocument, helpers to read or modify the selection (getSelectionText, replaceSelectionText, applyEdits), and close to dismiss the popover:

Keyboard Shortcuts

Shortcuts use Cmd on macOS and Ctrl on Windows and Linux. Jumping to the document start or end uses the modifier with the Home and End keys; on macOS, the modifier with ↑ and ↓ arrows works too.

ActionShortcut
Indent line or selectionTab
Outdent line or selectionShiftTab
Move selected line(s) upAlt
Move selected line(s) downAlt
Move selected line(s) upAltCtrlP (macOS/Linux)
Move selected line(s) downAltCtrlN (macOS/Linux)
CutCmdX
CopyCmdC
PasteCmdV
Move the cursor↑ ↓ ← →
Extend the selectionShift↑ ↓ ← →
Jump to line start / endCmd← →
Jump to document start / endCmdHome / End
Add a cursorCmdClick
Collapse to a single cursorEsc
Select allCmdA
Open the search panelCmdF
Go to next search matchCmdG
Go to previous search matchCmdShiftG
Find next match of selectionCmdD
UndoCmdZ
RedoCmdShiftZ

Virtualization

Virtualization in Diffs uses estimated line and file heights to keep large renders fast. It renders placeholders and spacer buffers for off-screen content, then renders visible lines in hunk-sized batches as you scroll.

If your scrollable region is only code, start with CodeView instead. It is the more optimized path: it owns the entire code surface, only renders what you can actually see, and is generally more performant and less prone to blanking.

Use the lower-level virtualization APIs when you need a more flexible, mixed-content layout where the code has to live alongside other DOM that is harder for CodeView to control. That flexibility comes with tradeoffs: every top-level file or diff container stays mounted, and the experience is more likely to blank during fast scroll.

Internally, the virtualizer listens to scroll and resize updates, computes a window with overscan, and reconciles measured DOM heights after render. This keeps scroll position stable even when line heights change because of wrapped lines or annotations.

For best results, you'll need to pass a metrics config object to your files or diffs when your layout differs from the defaults. These metrics help the Virtualizer estimate file and diff sizes more accurately before content is measured. For large diffs, using virtualization with a Worker Pool is strongly recommended.

Getting Started

To use virtualization, start with a scrollable container (an HTML element or the window). Directly inside that container, add a content wrapper that holds all diff/file instances and any other content you render. The virtualizer uses this wrapper to track content size changes.

Inside that scroll container, render the VirtualizedFile and VirtualizedFileDiff components. In React, this is handled automatically by the built-in Virtualizer context. In vanilla JS, you manage this explicitly by creating a Virtualizer instance and wiring it to VirtualizedFile / VirtualizedFileDiff instead of the traditional APIs.

React

In React, wrap your diff/file components in Virtualizer from @pierre/diffs/react. The Virtualizer component is your scroll container. Currently, the React wrapper does not support window scrolling unless you orchestrate your own provider via VirtualizerContext.Provider (from @pierre/diffs/react) and pass a manually created Virtualizer instance (from @pierre/diffs).

You can tune virtualization behavior with the config prop.

Virtualizer props:

  • config: partial virtualizer config (overscrollSize, intersectionObserverMargin, resizeDebugging)
  • className / style: applied to the outer scroll root
  • contentClassName / contentStyle: applied to the inner content wrapper

Vanilla JS

In vanilla JS, create a Virtualizer instance and pass it into VirtualizedFileDiff or VirtualizedFile.

Notes

  • Prefer virtualization for very large files or long diff lists any sort of scenario where it's hard to anticipate the constraints of the of the scroll view
  • Keep metrics aligned with your layout if you customize heights.
  • Use resizeDebugging with the Virtualizer temporarily when tuning metrics, and to confirm everything is working properly. Don't forget to disable it in production.
  • Edit mode's annotation feedback contract is unchanged on virtualized surfaces: replace the current collection from onChange or onItemEditChange before a later render. A virtualized surface may recreate rendered annotation content as it leaves and re-enters the rendered window, so keep interactive annotation state outside that content. See Editing with line annotations.

Hunk Separators

The hunkSeparators option controls how collapsed unchanged regions are displayed. For customization, we recommend starting with a built-in preset and layering unsafeCSS on top.

Passing a render function is only documented for the Vanilla JS APIs. It is being phased out, does not work well with the container-managed and virtualization-oriented React APIs, and is not compatible with SSR. We strongly recommend avoiding that path and customizing built-in separators with unsafeCSS instead.

The Custom CSS example below keeps the built-in line-info-basic markup and tweaks it with CSS.

  • blends the separator row with the diff background
  • hunk content and controls are rendered in every gutter and content region, but the custom CSS targets only the left-most gutter elements
  • aligns the arrow glyphs with the number column
  • replaces the built-in SVG icon with CSS-generated arrows
  • renders the Expand All button, which is normally hidden by default

Built-in Types

  • line-info: Rounded corner separator with collapsed line count and expansion controls.
  • line-info-basic: Compact, full-width variant of line-info with expansion controls.
  • metadata: Patch-style separator (@@ -x,y +a,b @@) with no expansion controls.
  • simple: Minimal separator bar.

Custom CSS Example

If CSS hooks are not enough, the low-level hunkSeparators(hunkData, instance) function still exists on the Vanilla JS FileDiff API. We only recommend that escape hatch as a last resort. It is being phased out, and it does not fit the container-managed and virtualization-oriented APIs that the React components rely on.

Utilities

Import utility functions from @pierre/diffs. These can be used with any framework or rendering approach.

Annotation type guards

Use isDiffAnnotation and isFileAnnotation to tell diff annotations from file annotations. Use isDiffAnnotationCollection and isFileAnnotationCollection for arrays.

Both collection helpers return true for an empty array. Use the current file or diff context if that distinction matters.

diffAcceptRejectHunk

Programmatically accept or reject individual hunks (or specific change blocks inside a hunk) in a diff. This is useful for building interactive code review interfaces, AI-assisted coding tools, or any workflow where users need to selectively apply changes.

To resolve an entire hunk, pass 'accept', 'reject', or 'both'. To resolve only one change block in a hunk, pass an object with type and changeIndex (for example: diffAcceptRejectHunk(diff, hunkIndex, { type: 'accept', changeIndex: 0 })). changeIndex maps to the target entry in that hunk's hunkContent array.

When you accept a hunk, the new (additions) version is kept and the hunk is converted to context lines. When you reject a hunk, the old (deletions) version is restored. You can also use both as a lower-level way to mux the two sides together, which keeps the old lines first and then appends the new lines before collapsing the result back to context. The function returns a new FileDiffMetadata object with all line numbers properly adjusted for subsequent hunks.

resolveMergeConflict

Apply a merge conflict action payload to a file string and return the next contents.

Experimental: UnresolvedFile-related merge conflict APIs are currently beta/experimental and may change in future releases.

Default merge-conflict buttons work even without callbacks: UnresolvedFile applies the resolution internally.

In vanilla, provide onMergeConflictAction for controlled state (for example, to persist resolved contents, sync external stores, or trigger side effects). Use onMergeConflictResolve when you want uncontrolled resolution plus a notification with the resolved file. React UnresolvedFile is intentionally uncontrolled.

disposeHighlighter

Dispose the shared Shiki highlighter instance to free memory. Useful when cleaning up resources in single-page applications.

getSharedHighlighter

Get direct access to the shared Shiki highlighter instance used internally by all components. Useful for custom highlighting operations.

parseDiffFromFile

Compare file contents and generate a FileDiffMetadata structure. Use this when you have full file contents rather than a patch string. Pass both oldFile and newFile for changed files, oldFile: null for added files, or newFile: null for deleted files.

null means that file side intentionally does not exist. Empty files should use contents: '', not null. Passing null for both sides throws because there is no file to diff.

If both oldFile and newFile have a cacheKey, the resulting FileDiffMetadata will automatically receive a combined cache key (format: oldKey:newKey). See Render Cache for more information.

An optional throwOnError parameter (default: false) controls error handling. When true, parsing errors throw exceptions; when false, errors are logged to the console and parsing continues on a best-effort basis.

parsePatchFiles

Parse unified diff / patch file content into structured data. Handles both single patches and multi-commit patch files (like those from GitHub pull request .patch URLs). An optional second parameter cacheKeyPrefix can be provided to generate cache keys for each file in the patch (format: prefix-patchIndex-fileIndex), enabling caching of rendered diff results in the worker pool.

An optional throwOnError parameter (default: false) controls error handling. When true, parsing errors throw exceptions; when false, errors are logged to the console and parsing continues on a best-effort basis.

trimPatchContext

Trim patches with large context windows down to a fixed context window while keeping valid diff headers.

preloadHighlighter

Preload specific themes and languages before rendering to ensure instant highlighting with no async loading delay.

registerCustomTheme

Register a custom Shiki theme for use with any component. The theme name you register must match the name field inside your theme JSON file.

registerCustomLanguage

Register a custom Shiki language loader and optionally map it to file names or extensions. Use this when you're working with languages not bundled by Shiki or want custom highlighting grammars.

setLanguageOverride

Override the syntax highlighting language for a FileContents or FileDiffMetadata object. This is useful when the filename doesn't have an extension or doesn't match the actual language.

Styling

Diff and code components are rendered using shadow DOM APIs, allowing styles to be well-isolated from your page's existing CSS. However, it also means you may have to utilize some custom CSS variables to override default styles. These can be done in your global CSS, as style props on parent components, or on the FileDiff component directly.

Advanced: Unsafe CSS

For advanced customization, you can inject arbitrary CSS into the shadow DOM using the unsafeCSS option. This CSS will be wrapped in an @layer unsafe block, giving it the highest priority in the cascade. Use this sparingly and with caution, as it bypasses the normal style isolation.

We also recommend that any CSS you apply uses simple, direct selectors targeting the existing data attributes. Avoid structural selectors like :first-child, :last-child, :nth-child(), sibling combinators (+ or ~), deeply nested descendant selectors, or bare tag selectors—these are susceptible to breaking in future versions or in edge cases that may be difficult to anticipate.

We cannot currently guarantee backwards compatibility for this feature across any future changes to the library, even in patch versions. Please reach out so that we can discuss a more permanent solution for modifying styles.

Themes

Pierre Diffs ships with our custom open source themes, Pierre Light and Pierre Dark. We generate our themes with a custom build process that takes a shared color palette, assigns colors to specific roles for syntax highlighting, and builds JSON files and editor extensions. This makes our themes compatible with Shiki, Visual Studio Code, Cursor, and Zed.

Editor / PlatformSource
Visual Studio CodeVS Code Marketplace
CursorOpen VSX
ZedZed Extensions
ShikiTheme repository

While you can use any Shiki theme with Pierre Diffs by passing the theme name to the theme option, you can also create and register custom themes compatible with Shiki and Visual Studio Code. We recommend using our themes as a starting point for your own custom themes—head to our themes documentation to get started.

Themes documentation

Token Hooks

Token hooks are experimental and subject to change.

Token hooks let you attach callbacks to syntax-highlighted tokens for custom hover UI, and LSP textDocument/hover integrations.

The shared prop tables in the React API and Vanilla JS API sections list the exact option names. This section covers behavior, examples, and performance tradeoffs.

Available on:

  • React: MultiFileDiff, PatchDiff, FileDiff, and File
  • Vanilla JS: FileDiff and File

Shared behavior:

  • onTokenEnter, onTokenLeave, and onTokenClick receive tokenText, lineNumber, lineCharStart, lineCharEnd, and tokenElement. Diff variants also receive side.
  • lineCharStart is zero-based and lineCharEnd is end-exclusive.
  • If both token and line click handlers are attached, both will fire.
  • Whitespace-only tokens are excluded unless enableTokenInteractionsOnWhitespace is true.
  • tokenElement is usually the simplest way to apply temporary hover styles.
  • Set useTokenTransformer: true when you want token wrappers or experimental selectors like data-char without token callbacks.
  • Enabling token metadata increases DOM size because more token wrappers and attributes are preserved. On large files or many mounted diffs, this can have a noticeable performance cost.
  • If you are using a Worker Pool, set useTokenTransformer: true on WorkerPoolManager. Worker pools can move highlighting work off the main thread, but they do not reduce the extra DOM size created by token metadata.
  • If you are using SSR don't forget to set useTokenTransformer: true on your preload option configs

Worker Pool

This feature is experimental and undergoing active development. There may be bugs and the API is subject to change.

Import worker utilities from @pierre/diffs/worker.

By default, syntax highlighting runs on the main thread using Shiki. If you're rendering large files or many diffs, this can cause a bottleneck on your JavaScript thread resulting in jank or unresponsiveness. To work around this, we've provided some APIs to run all syntax highlighting in worker threads. The main thread will still attempt to render plain text synchronously and then apply the syntax highlighting when we get a response from the worker threads.

Basic usage differs a bit depending on if you're using React or Vanilla JS APIs, so continue reading for more details.

Setup

One unfortunate side effect of using Web Workers is that different bundlers and environments require slightly different approaches to create a Web Worker. You'll need to create a function that spawns a worker that's appropriate for your environment and bundler and then pass that function to our provided APIs.

Lets begin with the workerFactory function. We've provided some examples for common use cases below.

Only the Vite and NextJS examples have been tested by us. Additional examples were generated by AI. If any of them are incorrect, please let us know.

Vite

You may need to explicitly set the worker.format option in your Vite Config to 'es'.

NextJS

Workers only work in client components. Ensure your function has the 'use client' directive if using App Router.

VS Code Webview Extension

VS Code webviews have special security restrictions that require a different approach. You'll need to configure both the extension side (to expose the worker file) and the webview side (to load it via blob URL).

Extension side: Add the worker directory to localResourceRoots in your getWebviewOptions():

Create the worker URI in _getHtmlForWebview(). Note: use worker-portable.js instead of worker.js — the portable version is designed for environments where ES modules aren't supported in web workers.

Pass the URI to the webview via an inline script in your HTML:

Your Content Security Policy must include worker-src and connect-src:

Webview side: Declare the global type for the URI:

Fetch the worker code and create a blob URL:

Create the workerFactory function:

Webpack 5

esbuild

Rollup / Static Files

If your bundler doesn't have special worker support, build and serve the worker file statically:

Vanilla JS (No Bundler)

For projects without a bundler, host the worker file on your server and reference it directly:

Usage

With your workerFactory function created, you can integrate it with our provided APIs. In React, you'll want to pass this workerFactory to a <WorkerPoolContextProvider> so all components can inherit the pool automatically. If you're using the Vanilla JS APIs, we provide a getOrCreateWorkerPoolSingleton helper that ensures a single pool instance that you can then manually pass to all your File/FileDiff instances.

When using the worker pool, the theme, lineDiffType, tokenizeMaxLineLength, and useTokenTransformer render options are controlled by WorkerPoolManager, not individual components. Passing these options into component instances will be ignored.

To change render options after WorkerPoolManager instantiates, call setRenderOptions(). Changing render options will force mounted components to re-render and clear the render cache.

If you need token callbacks or experimental token selectors such as data-char, enable useTokenTransformer: true on the worker pool itself. Worker pools can move highlighting work off the main thread, but they do not reduce the extra DOM size created by token metadata. For token callback behavior and performance tradeoffs, see Token Hooks.

If you need to control which Shiki engine is used, set preferredHighlighter when initializing the pool ('shiki-js' by default, 'shiki-wasm' optional).

React

Wrap your component tree with WorkerPoolContextProvider from @pierre/diffs/react. All FileDiff and File components nested within will automatically use the worker pool for syntax highlighting.

The WorkerPoolContextProvider will automatically spin up or shut down the worker pool based on its react lifecycle. If you have multiple context providers, they will all share the same pool, and termination won't occur until all contexts are unmounted.

Workers only work in client components. Ensure your function has the 'use client' directive if using App Router.

To change themes or other render options dynamically, use the useWorkerPool() hook to access the pool manager and call setRenderOptions().

Vanilla JS

Use getOrCreateWorkerPoolSingleton to spin up a singleton worker pool. Then pass that as the second argument to File and/or FileDiff. When you are done with the worker pool, you can use terminateWorkerPoolSingleton to free up resources.

To change themes or other render options dynamically, call setRenderOptions(options) on the pool instance.

Render Cache

This is an experimental feature being validated in production use cases. The API is subject to change.

The worker pool can cache rendered AST results to avoid redundant highlighting work. When a file or diff has a cacheKey, subsequent requests with the same key will return cached results immediately instead of reprocessing through a worker. This works automatically for both React and Vanilla JS APIs.

Caching is enabled per-file/diff by setting a cacheKey property. Files and diffs without a cacheKey will not be cached. The cache also validates against render options — if options like theme or line diff type change, the cached result is skipped and re-rendered.

API Reference

These methods are exposed for advanced use cases. In most scenarios, you should use the WorkerPoolContextProvider for React or pass the pool instance via the workerPool option for Vanilla JS rather than calling these methods directly.

Architecture

The worker pool manages a configurable number of worker threads that each initialize their own Shiki highlighter instance. Tasks are distributed across available workers, with queuing when all workers are busy.

SSR

Import SSR utilities from @pierre/diffs/ssr.

The SSR API allows you to pre-render file diffs on the server with syntax highlighting, then hydrate them on the client for full interactivity.

If you pass prerenderedHTML, onPostRender still fires on the client after hydration with phase: 'mount'. On later renders, it also fires after DOM-committing updates with phase: 'update', whether those updates are a full replacement or a partial update. Before a mounted container is removed, replaced, or recycled, it fires with phase: 'unmount'. This is useful for measuring, observing, cleaning up, or otherwise manipulating the mounted diff container or content of the diffs itself.

Usage

Each preload function returns an object containing the original inputs plus a prerenderedHTML string. This object can be spread directly into the corresponding React component for automatic hydration.

Inputs used for pre-rendering must exactly match what's rendered in the client component. We recommend spreading the entire result object into your File or Diff component to ensure the client receives the same inputs that were used to generate the pre-rendered HTML.

Server Component

Client Component

Preloaders

We provide several preload functions to handle different input formats. Choose the one that matches your data source.

preloadFile

Preloads a single file with syntax highlighting (no diff). Use this when you want to render a file without any diff context. Spread into the File component.

preloadUnresolvedFile

Preloads a merge-conflict file for UnresolvedFile hydration. Use this when the file contains conflict markers (<<<<<<<, =======, >>>>>>>) and you want to preserve the unresolved conflict UI from SSR to client.

Experimental: UnresolvedFile and preloadUnresolvedFile are currently beta/experimental and may change in future releases.

preloadFileDiff

Preloads a diff from a FileDiffMetadata object. Use this when you already have parsed diff metadata (e.g., from parseDiffFromFile or parsePatchFiles). Spread into the FileDiff component.

The lower-level preloadDiffHTML helper also accepts direct file contents. Use oldFile: null for a new file, or newFile: null for a deleted file.

preloadMultiFileDiff

Preloads a diff directly from file contents. This is the simplest option when you have raw file contents and want to generate a diff. Pass both sides for a changed file, oldFile: null for a new file, or newFile: null for a deleted file. Spread into the MultiFileDiff component.

preloadPatchDiff

Preloads a diff from a unified patch string for a single file. Use this when you have a patch in unified diff format. Spread into the PatchDiff component.

preloadPatchFile

Preloads multiple diffs from a multi-file patch string. Returns an array of results, one for each file in the patch. Each result can be spread into a FileDiff component.