Diff Integration Guide
March 17, 2026 ยท View on GitHub
This guide focuses on integrating the stream-monaco diff editor, which options to pass, and which TypeScript types are available out of the box.
1. Minimal setup
import { useMonaco } from 'stream-monaco'
const container = document.getElementById('diff')!
const { createDiffEditor, updateDiff, cleanupEditor } = useMonaco({
themes: ['github-dark', 'github-light'],
languages: ['typescript', 'javascript', 'json'],
readOnly: true,
MAX_HEIGHT: 560,
})
await createDiffEditor(
container,
'export const a = 1\n',
'export const a = 2\n',
'typescript',
)
updateDiff('export const a = 1\n', 'export const a = 3\n', 'typescript')
cleanupEditor()
The mount/update/unmount pattern is the same in Vue, React, Svelte, Solid, and vanilla JS:
- call
createDiffEditor(...)on mount - call
updateDiff(...)/updateOriginal(...)/updateModified(...)when content changes - call
cleanupEditor()on unmount
2. Which options to pass
All options are passed through useMonaco(options).
Core options
const monaco = useMonaco({
themes: ['github-dark', 'github-light'],
languages: ['typescript', 'javascript'],
theme: 'github-light',
readOnly: true,
MAX_HEIGHT: 560,
})
Most common:
themes: available themes, ideally one dark and one lightlanguages: languages to registertheme: initial themereadOnly: usuallytruefor review/preview diffsMAX_HEIGHT: max editor height
stream-monaco diff UX options
const monaco = useMonaco({
themes: ['github-dark', 'github-light'],
languages: ['typescript'],
readOnly: true,
diffHideUnchangedRegions: {
enabled: true,
contextLineCount: 3,
minimumLineCount: 3,
revealLineCount: 5,
},
diffLineStyle: 'background',
diffAppearance: 'auto',
diffUnchangedRegionStyle: 'line-info',
diffHunkActionsOnHover: true,
diffHunkHoverHideDelayMs: 160,
diffUpdateThrottleMs: 50,
revealDebounceMs: 75,
})
What they do:
diffHideUnchangedRegionsControls unchanged-region folding. Acceptstrue,false, or Monaco's nativehideUnchangedRegionsobject.diffLineStyleVisual emphasis for changed lines.background: richer filled blocksbar: subtler review-style leading bars
diffAppearanceControls diff chrome appearance.auto: infer from active Monaco themelight: force light shelldark: force dark shell
diffUnchangedRegionStyleControls collapsed unchanged-region rendering.line-info:71 unmodified lineswith line-number-width reveal buttons in a compact 32px collapsed rowline-info-basic:71 unmodified lineswith the wider legacy reveal rail in a compact 32px rowmetadata:@@ -59,9 +59,11 @@in a compact 32px row; lines below the collapsed region reflow to close the extra whitespacesimple: gray placeholder bar in a tighter 28px row
diffHunkActionsOnHoverEnablesRevert / Stageon hovered hunksdiffHunkHoverHideDelayMsHide delay for hunk hover actionsdiffUpdateThrottleMsDiff streaming throttle. Default is50. WhendiffHideUnchangedRegionsis enabled, unchanged-region folding is deferred until streaming goes idle so the diff does not collapse while content is still streaming in.revealDebounceMsDebounce for reveal/auto-scroll behavior
Native Monaco diff options
MonacoOptions now includes both:
monaco.editor.IStandaloneEditorConstructionOptionsmonaco.editor.IDiffEditorConstructionOptions
So you can also pass native Monaco diff options directly:
useMonaco({
renderSideBySide: true,
enableSplitViewResizing: true,
ignoreTrimWhitespace: false,
originalEditable: false,
})
To switch between side-by-side and inline at runtime:
const diff = monaco.getDiffEditorView()
diff?.updateOptions({ renderSideBySide: false })
3. Runtime methods
const {
createDiffEditor,
updateDiff,
updateOriginal,
updateModified,
appendOriginal,
appendModified,
setDiffModels,
setTheme,
refreshDiffPresentation,
getDiffEditorView,
getDiffModels,
getCode,
} = useMonaco()
Most useful diff methods:
createDiffEditor(container, original, modified, language)updateDiff(original, modified, language?)updateOriginal(code, language?)updateModified(code, language?)appendOriginal(text, language?)appendModified(text, language?)await setDiffModels({ original, modified }, options?)setTheme(theme, force?)refreshDiffPresentation()getDiffEditorView()getDiffModels()getCode()
In diff mode, getCode() returns:
{
original: string
modified: string
}
In-place theme / presentation switches
If you are only changing theme, appearance, line style, or unchanged-region style, prefer the in-place runtime methods instead of remounting the diff editor:
await monaco.setTheme('github-dark')
monaco.refreshDiffPresentation()
This keeps the existing diff shell, scroll position, and unchanged-region overlay alive while recomputing the presentation layer.
Theme and appearance switches should stay visually stable. When you switch between unchanged-region styles with different collapsed heights, such as metadata to simple, the shell stays mounted but Monaco will reflow the lines below those collapsed regions to match the new height.
Whole-model swaps without remounting
If your app needs to replace the entire original/modified model pair, prefer await setDiffModels(...) over calling diffEditor.setModel(...) yourself:
const monacoApi = useMonaco()
const originalModel = monacoApi
.getMonacoInstance()
.editor.createModel(leftText, 'typescript')
const modifiedModel = monacoApi
.getMonacoInstance()
.editor.createModel(rightText, 'typescript')
await monacoApi.setDiffModels(
{
original: originalModel,
modified: modifiedModel,
},
{
codeLanguage: 'typescript',
},
)
Why this is better than raw diffEditor.setModel(...):
- the library can precompute Monaco's diff view model before the visible swap, so same-content transitions avoid the first-frame scroll jump
- if the new models contain the same text,
stream-monacoautomatically takes the low-jitter path and preserves view state by default - unchanged-region state and diff chrome are refreshed together
- internally-owned models are disposed safely, while externally-supplied models are left under your control
Options:
codeLanguage: normalize both models to the target language before swappingpreserveViewState: force preserving the diff view state even when content changed
4. Hunk action callback
If you want to intercept Revert / Stage, use onDiffHunkAction:
useMonaco({
diffHunkActionsOnHover: true,
onDiffHunkAction: async (ctx) => {
await saveHunk(ctx)
return false
},
})
Return semantics:
false: skip built-in edit behavior, fully controlled by your apptrueorundefined: continue built-in behavior
The callback context type is DiffHunkActionContext.
5. Binding to real Git (revert, stage, stash)
stream-monaco does not talk to Git by default. The built-in hover actions only edit the in-memory Monaco models.
Important distinctions:
- Hover
Revertis hunk-level undo. It is notgit revert <commit>, becausegit revertworks at commit granularity. - Hover
Stageis closest togit add -p/git apply --cached. - If you want a stash workflow, treat
onDiffHunkActionas a patch-intent callback and hand the selected half-hunk to a backend stash API or patch queue.
The practical pattern is:
- Intercept
onDiffHunkAction. - Send
action,side,lineChange,original, andmodifiedto your backend. - On the backend, compute the target file contents for that half-hunk.
- Turn the current content -> target content delta into a unified patch for that file.
- Apply it to Git:
- working tree revert:
git apply --recount - - index stage:
git apply --cached --recount - - custom stash: store the same patch in your own stash service, or wrap a server-side Git stash flow
- working tree revert:
- Return the refreshed
original/modifiedpair to the browser and callupdateDiff(...)orsetDiffModels(...).
Frontend example:
const monaco = useMonaco({
diffHunkActionsOnHover: true,
onDiffHunkAction: async (ctx) => {
const response = await fetch('/api/git/hunks/apply', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
repoPath,
filePath,
action: ctx.action,
side: ctx.side,
range: ctx.lineChange,
original: ctx.originalModel.getValue(),
modified: ctx.modifiedModel.getValue(),
}),
})
const next = await response.json()
monaco.updateDiff(next.original, next.modified, language)
// Skip the built-in local-only model edit because Git already applied it.
return false
},
})
Common variant: backend returns the refreshed file pair, and the client updates the diff in-place with updateDiff(...).
This is usually the simplest integration shape when your server already knows the post-apply file contents for both sides:
const monaco = useMonaco({
diffHunkActionsOnHover: true,
onDiffHunkAction: async (ctx) => {
const response = await fetch('/api/git/hunks/apply', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
action: ctx.action,
side: ctx.side,
range: ctx.lineChange,
original: ctx.originalModel.getValue(),
modified: ctx.modifiedModel.getValue(),
}),
})
const next = await response.json()
// The server returns the refreshed left/right file contents after Git apply.
monaco.updateDiff(next.original, next.modified, language)
return false
},
})
When to prefer this:
- your backend already computes the full refreshed
original/modified - you want to keep the current diff editor instance and just refresh its content
- you do not need to swap to externally-owned Monaco models
If the backend gives you brand-new Monaco models instead of plain strings, use setDiffModels(...) instead.
Backend mapping to keep in mind:
revert + lower: remove the modified-side lines from the working treerevert + upper: restore the original-side lines into the working treestage + lower: copy the modified-side lines into the indexstage + upper: remove the original-side lines from the index
For replace hunks that have both upper and lower halves, do not think in terms of a single Git command name. Instead, compute the next desired file text for the selected half-hunk, build a patch for that exact delta, then apply it to the worktree or index.
If your product wants a real "stash this hunk" action, the safest route is usually a backend "saved patch" queue. git stash push --patch exists, but it is interactive and repo-scoped, so browser-driven review tools typically wrap it server-side or implement their own stash layer around patch files.
Reference validations in this repo:
pnpm run validate:diff-hunk-actions: verifies the built-in local Revert/Stage semanticspnpm run validate:diff-hunk-custom-flow: verifies async custom intercept flow where user code mutates the models directlypnpm run validate:diff-hunk-update-diff-flow: verifies async custom intercept flow where the server returns refreshed file contents and the UI callsupdateDiff(...)
6. TypeScript completeness
The public TS surface for diff integration is now fairly complete. These types are exported:
MonacoOptionsUseMonacoReturnMonacoThemeMonacoLanguageMonacoEditorInstanceMonacoDiffEditorInstanceDiffHideUnchangedRegionsDiffLineStyleDiffAppearanceDiffUnchangedRegionStyleDiffModelsDiffModelPairDiffModelTransitionOptionsDiffCodeValueMonacoCodeValueDiffHunkActionContext
What is covered
useMonaco(options)now has an explicit return type:UseMonacoReturn- custom diff UX options have dedicated union types
MonacoOptionscovers both single-editor and native diff-editor construction options- return values such as
getCode()andgetDiffModels()have named exported types
Practical answer
If your question is "do I still need to write custom declarations for diff integration?", the answer is now basically no.
More directly:
- custom diff options: typed
useMonaco()return value: typed- native Monaco diff option pass-through: typed
6. Recommended typed setup
import type {
DiffAppearance,
DiffUnchangedRegionStyle,
MonacoOptions,
UseMonacoReturn,
} from 'stream-monaco'
const options: MonacoOptions = {
themes: ['github-dark', 'github-light'],
languages: ['typescript'],
readOnly: true,
diffAppearance: 'auto',
diffLineStyle: 'background',
diffUnchangedRegionStyle: 'line-info',
diffHideUnchangedRegions: true,
diffHunkActionsOnHover: true,
}
const monacoApi: UseMonacoReturn = useMonaco(options)
This gives the cleanest IDE autocomplete and keeps later UX upgrades easy to maintain.