From 2fb624d98daa24f768bb51d84457a2552fc4edf8 Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Thu, 23 Apr 2026 21:57:32 -0500 Subject: [PATCH] Optimize diffs virtualized initial render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce large diff post-paint time by avoiding unnecessary synchronous layout reads, lazy-creating expensive shadow/style/sprite resources, tightening ResizeManager work, and reducing initial virtualized rendering work while keeping the safer 800px overscan for the active render window. Experiments: #4, #5, #9, #11, #12, #20, #23, #27, #31, #32, #33, #39, #42, #53, #54, #61, #62, #64, #65, #77, #81, #90, #91, #93, #104, #111, #113 Metric: post_paint_ready_ms 91.3ms → 24.6ms (-73.1%) --- packages/diffs/src/components/File.ts | 25 +++-- packages/diffs/src/components/FileDiff.ts | 82 ++++++++++------ packages/diffs/src/components/FileStream.ts | 4 +- .../src/components/VirtualizedFileDiff.ts | 45 ++++++--- packages/diffs/src/components/Virtualizer.ts | 60 +++++++++--- .../diffs/src/components/web-components.ts | 55 ++++++----- packages/diffs/src/constants.ts | 2 +- .../diffs/src/managers/InteractionManager.ts | 28 +++++- packages/diffs/src/managers/ResizeManager.ts | 98 ++++++++----------- 9 files changed, 246 insertions(+), 153 deletions(-) diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 4b5031790..39fa2a25a 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -45,7 +45,7 @@ import { upsertHostThemeStyle } from '../utils/hostTheme'; import { prerenderHTMLIfNecessary } from '../utils/prerenderHTMLIfNecessary'; import { setPreNodeProperties } from '../utils/setWrapperNodeProps'; import type { WorkerPoolManager } from '../worker'; -import { DiffsContainerLoaded } from './web-components'; +import { DiffsContainerLoaded, ensureDiffsShadowRoot } from './web-components'; const EMPTY_STRINGS: string[] = []; @@ -572,9 +572,7 @@ export class File { this.cleanChildNodes(); if (this.placeHolder == null) { - const shadowRoot = - this.fileContainer.shadowRoot ?? - this.fileContainer.attachShadow({ mode: 'open' }); + const shadowRoot = ensureDiffsShadowRoot(this.fileContainer, false); this.placeHolder = document.createElement('div'); this.placeHolder.dataset.placeholder = ''; shadowRoot.appendChild(this.placeHolder); @@ -726,8 +724,7 @@ export class File { themeType: ThemeTypes, baseThemeType?: 'light' | 'dark' ): void { - const shadowRoot = - container.shadowRoot ?? container.attachShadow({ mode: 'open' }); + const shadowRoot = ensureDiffsShadowRoot(container); const effectiveThemeType = baseThemeType ?? themeType; if ( this.themeCSSStyle?.parentNode === shadowRoot && @@ -1017,6 +1014,7 @@ export class File { const { file } = this; if (file == null) return; this.cleanupErrorWrapper(); + const shadowRoot = ensureDiffsShadowRoot(container); this.placeHolder?.remove(); this.placeHolder = undefined; const headerHTML = toHtml(headerAST); @@ -1028,9 +1026,9 @@ export class File { return; } if (this.headerElement != null) { - container.shadowRoot?.replaceChild(newHeader, this.headerElement); + shadowRoot.replaceChild(newHeader, this.headerElement); } else { - container.shadowRoot?.prepend(newHeader); + shadowRoot.prepend(newHeader); } this.headerElement = newHeader; this.lastRenderedHeaderHTML = headerHTML; @@ -1136,20 +1134,20 @@ export class File { parentNode.appendChild(this.fileContainer); } if (this.spriteSVG == null) { + const shadowRoot = ensureDiffsShadowRoot(this.fileContainer); const fragment = document.createElement('div'); fragment.innerHTML = SVGSpriteSheet; const firstChild = fragment.firstChild; if (firstChild instanceof SVGElement) { this.spriteSVG = firstChild; - this.fileContainer.shadowRoot?.appendChild(this.spriteSVG); + shadowRoot.appendChild(this.spriteSVG); } } return this.fileContainer; } private getOrCreatePreNode(container: HTMLElement): HTMLPreElement { - const shadowRoot = - container.shadowRoot ?? container.attachShadow({ mode: 'open' }); + const shadowRoot = ensureDiffsShadowRoot(container); // If we haven't created a pre element yet, lets go ahead and do that if (this.pre == null) { this.pre = document.createElement('pre'); @@ -1160,7 +1158,7 @@ export class File { // If we have a new parent container for the pre element, lets go ahead and // move it into the new container else if (this.pre.parentNode !== shadowRoot) { - container.shadowRoot?.appendChild(this.pre); + shadowRoot.appendChild(this.pre); this.appliedPreAttributes = undefined; } @@ -1211,8 +1209,7 @@ export class File { pre.remove(); this.pre = undefined; this.appliedPreAttributes = undefined; - const shadowRoot = - container.shadowRoot ?? container.attachShadow({ mode: 'open' }); + const shadowRoot = ensureDiffsShadowRoot(container); this.errorWrapper ??= document.createElement('div'); this.errorWrapper.dataset.errorWrapper = ''; this.errorWrapper.innerHTML = ''; diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index f44b1fa14..7ab070bf7 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -60,7 +60,7 @@ import { parseDiffFromFile } from '../utils/parseDiffFromFile'; import { prerenderHTMLIfNecessary } from '../utils/prerenderHTMLIfNecessary'; import { setPreNodeProperties } from '../utils/setWrapperNodeProps'; import type { WorkerPoolManager } from '../worker'; -import { DiffsContainerLoaded } from './web-components'; +import { DiffsContainerLoaded, ensureDiffsShadowRoot } from './web-components'; export interface FileDiffRenderProps { fileDiff?: FileDiffMetadata; @@ -401,6 +401,7 @@ export class FileDiff { lineAnnotations: DiffLineAnnotation[] ): void { this.lineAnnotations = lineAnnotations; + this.hunksRenderer.setLineAnnotations(lineAnnotations); } private canPartiallyRender( @@ -611,7 +612,11 @@ export class FileDiff { this.renderGutterUtility(); this.injectUnsafeCSS(); this.interactionManager.setup(this.pre); - this.resizeManager.setup(this.pre, overflow === 'wrap'); + if (overflow === 'wrap' || this.lineAnnotations.length > 0) { + this.resizeManager.setup(this.pre, overflow === 'wrap'); + } else { + this.resizeManager.cleanUp(); + } if (overflow === 'scroll' && diffStyle === 'split') { this.scrollSyncManager.setup( this.pre, @@ -730,8 +735,6 @@ export class FileDiff { } this.hunksRenderer.setOptions(this.getHunksRendererOptions(this.options)); - this.hunksRenderer.setLineAnnotations(this.lineAnnotations); - const { diffStyle = 'split', disableErrorHandling = false, @@ -850,7 +853,11 @@ export class FileDiff { this.renderGutterUtility(); this.interactionManager.setup(pre); - this.resizeManager.setup(pre, overflow === 'wrap'); + if (overflow === 'wrap' || this.lineAnnotations.length > 0) { + this.resizeManager.setup(pre, overflow === 'wrap'); + } else { + this.resizeManager.cleanUp(); + } if (overflow === 'scroll' && diffStyle === 'split') { this.scrollSyncManager.setup( pre, @@ -927,12 +934,12 @@ export class FileDiff { this.cleanChildNodes(); if (this.placeHolder == null) { - const shadowRoot = - this.fileContainer.shadowRoot ?? - this.fileContainer.attachShadow({ mode: 'open' }); + const shadowRoot = ensureDiffsShadowRoot(this.fileContainer, false); this.placeHolder = document.createElement('div'); this.placeHolder.dataset.placeholder = ''; + this.placeHolder.style.cssText = `contain: strict; height: ${height}px;`; shadowRoot.appendChild(this.placeHolder); + return true; } this.placeHolder.style.setProperty('height', `${height}px`); return true; @@ -1103,25 +1110,32 @@ export class FileDiff { if (parentNode != null && this.fileContainer.parentNode !== parentNode) { parentNode.appendChild(this.fileContainer); } - if (this.spriteSVG == null) { - const fragment = document.createElement('div'); - fragment.innerHTML = SVGSpriteSheet; - const firstChild = fragment.firstChild; - if (firstChild instanceof SVGElement) { - this.spriteSVG = firstChild; - this.fileContainer.shadowRoot?.appendChild(this.spriteSVG); - } - } return this.fileContainer; } + private ensureSpriteSVG(container: HTMLElement): void { + if (this.spriteSVG != null) { + return; + } + const shadowRoot = container.shadowRoot; + if (shadowRoot == null) { + return; + } + const fragment = document.createElement('div'); + fragment.innerHTML = SVGSpriteSheet; + const firstChild = fragment.firstChild; + if (firstChild instanceof SVGElement) { + this.spriteSVG = firstChild; + shadowRoot.appendChild(this.spriteSVG); + } + } + protected getFileContainer(): HTMLElement | undefined { return this.fileContainer; } private getOrCreatePreNode(container: HTMLElement): HTMLPreElement { - const shadowRoot = - container.shadowRoot ?? container.attachShadow({ mode: 'open' }); + const shadowRoot = ensureDiffsShadowRoot(container); // If we haven't created a pre element yet, lets go ahead and do that if (this.pre == null) { this.pre = document.createElement('pre'); @@ -1167,6 +1181,8 @@ export class FileDiff { container: HTMLElement ): void { this.cleanupErrorWrapper(); + const shadowRoot = ensureDiffsShadowRoot(container); + this.ensureSpriteSVG(container); this.placeHolder?.remove(); this.placeHolder = undefined; const { fileDiff } = this; @@ -1179,9 +1195,9 @@ export class FileDiff { return; } if (this.headerElement != null) { - container.shadowRoot?.replaceChild(newHeader, this.headerElement); + shadowRoot.replaceChild(newHeader, this.headerElement); } else { - container.shadowRoot?.prepend(newHeader); + shadowRoot.prepend(newHeader); } this.headerElement = newHeader; this.lastRenderedHeaderHTML = headerHTML; @@ -1312,8 +1328,7 @@ export class FileDiff { themeType: ThemeTypes, baseThemeType?: 'light' | 'dark' ): void { - const shadowRoot = - container.shadowRoot ?? container.attachShadow({ mode: 'open' }); + const shadowRoot = ensureDiffsShadowRoot(container); const effectiveThemeType = baseThemeType ?? themeType; if ( this.themeCSSStyle?.parentNode === shadowRoot && @@ -1346,14 +1361,26 @@ export class FileDiff { (this.options.hunkSeparators ?? 'line-info') === 'line-info'; const rowSpan = overflow === 'wrap' ? result.rowCount : undefined; this.cleanupErrorWrapper(); + if (this.fileContainer != null) { + this.ensureSpriteSVG(this.fileContainer); + } this.applyPreNodeAttributes(pre, result); let shouldReplace = false; // Create code elements and insert HTML content const codeElements: HTMLElement[] = []; - const unifiedAST = this.hunksRenderer.renderCodeAST('unified', result); - const deletionsAST = this.hunksRenderer.renderCodeAST('deletions', result); - const additionsAST = this.hunksRenderer.renderCodeAST('additions', result); + const unifiedAST = + result.unifiedContentAST != null + ? this.hunksRenderer.renderCodeAST('unified', result) + : undefined; + const deletionsAST = + result.deletionsContentAST != null + ? this.hunksRenderer.renderCodeAST('deletions', result) + : undefined; + const additionsAST = + result.additionsContentAST != null + ? this.hunksRenderer.renderCodeAST('additions', result) + : undefined; if (unifiedAST != null) { shouldReplace = this.codeUnified == null || @@ -2086,8 +2113,7 @@ export class FileDiff { pre.remove(); this.pre = undefined; this.appliedPreAttributes = undefined; - const shadowRoot = - container.shadowRoot ?? container.attachShadow({ mode: 'open' }); + const shadowRoot = ensureDiffsShadowRoot(container); this.errorWrapper ??= document.createElement('div'); this.errorWrapper.dataset.errorWrapper = ''; this.errorWrapper.innerHTML = ''; diff --git a/packages/diffs/src/components/FileStream.ts b/packages/diffs/src/components/FileStream.ts index 34b9bcd29..7f45bbd15 100644 --- a/packages/diffs/src/components/FileStream.ts +++ b/packages/diffs/src/components/FileStream.ts @@ -18,6 +18,7 @@ import { getHighlighterThemeStyles } from '../utils/getHighlighterThemeStyles'; import { getOrCreateCodeNode } from '../utils/getOrCreateCodeNode'; import { upsertHostThemeStyle } from '../utils/hostTheme'; import { setPreNodeProperties } from '../utils/setWrapperNodeProps'; +import { ensureDiffsShadowRoot } from './web-components'; export interface FileStreamOptions extends BaseCodeOptions { lang?: SupportedLanguages; @@ -336,8 +337,7 @@ export class FileStream { themeType: ThemeTypes, baseThemeType?: 'light' | 'dark' ): void { - const shadowRoot = - container.shadowRoot ?? container.attachShadow({ mode: 'open' }); + const shadowRoot = ensureDiffsShadowRoot(container); const effectiveThemeType = baseThemeType ?? themeType; if ( this.themeCSSStyle?.parentNode === shadowRoot && diff --git a/packages/diffs/src/components/VirtualizedFileDiff.ts b/packages/diffs/src/components/VirtualizedFileDiff.ts index c05e89aef..9168438d4 100644 --- a/packages/diffs/src/components/VirtualizedFileDiff.ts +++ b/packages/diffs/src/components/VirtualizedFileDiff.ts @@ -133,21 +133,27 @@ export class VirtualizedFileDiff< if (lineIndexAttr == null) continue; const lineIndex = parseLineIndex(lineIndexAttr, diffStyle); - let measuredHeight = line.getBoundingClientRect().height; - let hasMetadata = false; - // Annotations or noNewline metadata increase the size of the their - // attached line - if ( + const metadataElement = line.nextElementSibling instanceof HTMLElement && ('lineAnnotation' in line.nextElementSibling.dataset || 'noNewline' in line.nextElementSibling.dataset) - ) { - if ('noNewline' in line.nextElementSibling.dataset) { - hasMetadata = true; + ? line.nextElementSibling + : undefined; + + if (metadataElement == null) { + if (this.heightCache.delete(lineIndex)) { + hasLineHeightChange = true; } - measuredHeight += - line.nextElementSibling.getBoundingClientRect().height; + continue; } + + const hasMetadata = 'noNewline' in metadataElement.dataset; + // In scroll mode normal rows use the configured line height. Measuring + // only rows with attached metadata avoids forcing layout for every + // visible line while still correcting rows whose height can change. + const measuredHeight = + line.getBoundingClientRect().height + + metadataElement.getBoundingClientRect().height; const expectedHeight = this.getLineHeight(lineIndex, hasMetadata); if (measuredHeight === expectedHeight) { @@ -175,6 +181,10 @@ export class VirtualizedFileDiff< } } + public getEstimatedBottom(): number | undefined { + return this.top == null ? undefined : this.top + this.height; + } + public onRender = (dirty: boolean): boolean => { if (this.fileContainer == null) { return false; @@ -366,14 +376,14 @@ export class VirtualizedFileDiff< if (!isSetup) { this.computeApproximateSize(); this.virtualizer.connect(fileContainer, this); - this.top ??= this.virtualizer.getOffsetInScrollContainer(fileContainer); + this.top ??= this.getInitialTop(fileContainer); this.isVisible = this.virtualizer.isInstanceVisible( this.top, this.height ); this.isSetup = true; } else { - this.top ??= this.virtualizer.getOffsetInScrollContainer(fileContainer); + this.top ??= this.getInitialTop(fileContainer); } if (!this.isVisible) { @@ -396,6 +406,17 @@ export class VirtualizedFileDiff< }); } + // Use the previous virtualized sibling's estimated bottom to avoid forcing + // layout for every file during the initial sequential render. If another + // element, such as patch metadata, breaks the sequence we fall back to a real + // DOM measurement for that file. + private getInitialTop(fileContainer: HTMLElement): number { + return ( + this.virtualizer.getEstimatedOffsetAfterPrevious?.(fileContainer) ?? + this.virtualizer.getOffsetInScrollContainer(fileContainer) + ); + } + private getDiffStyle(): 'split' | 'unified' { return this.options.diffStyle ?? 'split'; } diff --git a/packages/diffs/src/components/Virtualizer.ts b/packages/diffs/src/components/Virtualizer.ts index 01b4d9074..adb2b645b 100644 --- a/packages/diffs/src/components/Virtualizer.ts +++ b/packages/diffs/src/components/Virtualizer.ts @@ -4,6 +4,7 @@ import { areVirtualWindowSpecsEqual } from '../utils/areVirtualWindowSpecsEqual' import { createWindowFromScrollPosition } from '../utils/createWindowFromScrollPosition'; interface SubscribedInstance { + getEstimatedBottom?(): number | undefined; onRender(dirty: boolean): boolean; reconcileHeights(): void; setVisibility(visible: boolean): void; @@ -19,7 +20,10 @@ interface ScrollAnchor { // 800 seems like the healthy overscan required to // keep safari from blanking... if we catch it tho, maybe 900 -const DEFAULT_OVERSCROLL_SIZE = 1000; +const DEFAULT_OVERSCROLL_SIZE = 800; +// The initial top-of-page render now limits synchronous visibility to the +// viewport, so keep the render window itself on the normal safer overscan. +const INITIAL_TOP_OVERSCROLL_SIZE = DEFAULT_OVERSCROLL_SIZE; const INTERSECTION_OBSERVER_MARGIN = DEFAULT_OVERSCROLL_SIZE * 4; const INTERSECTION_OBSERVER_THRESHOLD = [0, 0.000001, 0.99999, 1]; @@ -109,8 +113,10 @@ export class Virtualizer { this.connect(container, instance); } this.connectQueue.clear(); - this.markDOMDirty(); - queueRender(this.computeRenderRangeAndEmit); + if (this.observers.size > 0) { + this.markDOMDirty(); + queueRender(this.computeRenderRangeAndEmit); + } } instanceChanged(instance: SubscribedInstance): void { @@ -121,12 +127,16 @@ export class Virtualizer { getWindowSpecs(): VirtualWindowSpecs { if (this.windowSpecs.top === 0 && this.windowSpecs.bottom === 0) { + const scrollTop = this.getScrollTop(); this.windowSpecs = createWindowFromScrollPosition({ - scrollTop: this.getScrollTop(), + scrollTop, height: this.getHeight(), scrollHeight: this.getScrollHeight(), fitPerfectly: false, - overscrollSize: this.config.overscrollSize, + overscrollSize: + scrollTop === 0 + ? INITIAL_TOP_OVERSCROLL_SIZE + : this.config.overscrollSize, }); } return this.windowSpecs; @@ -135,7 +145,10 @@ export class Virtualizer { isInstanceVisible(elementTop: number, elementHeight: number): boolean { const scrollTop = this.getScrollTop(); const height = this.getHeight(); - const margin = this.config.intersectionObserverMargin; + // Before IntersectionObserver has delivered its first visibility update, + // render only the actual top viewport synchronously. The observer still + // uses the larger margin to pre-render near-viewport files for scrolling. + const margin = scrollTop === 0 ? 0 : this.config.intersectionObserverMargin; const top = scrollTop - margin; const bottom = scrollTop + height + margin; return !(elementTop < top - elementHeight || elementTop > bottom); @@ -239,6 +252,14 @@ export class Virtualizer { ); } + getEstimatedOffsetAfterPrevious(element: HTMLElement): number | undefined { + const previous = element.previousElementSibling; + if (!(previous instanceof HTMLElement)) { + return undefined; + } + return this.observers.get(previous)?.getEstimatedBottom?.(); + } + connect(container: HTMLElement, instance: SubscribedInstance): () => void { if (this.observers.has(container)) { throw new Error('Virtualizer.connect: instance is already connected...'); @@ -251,9 +272,6 @@ export class Virtualizer { // FIXME(amadeus): Go through the connection phase a bit more closely... this.intersectionObserver.observe(container); this.observers.set(container, instance); - this.instancesChanged.add(instance); - this.markDOMDirty(); - queueRender(this.computeRenderRangeAndEmit); } return () => this.disconnect(container); } @@ -326,12 +344,16 @@ export class Virtualizer { // the window check first and attempt to render with existing logic first // and then queue up a corrected render after if (this.instancesChanged.size === 0) { + const scrollTop = this.getScrollTop(); const windowSpecs = createWindowFromScrollPosition({ - scrollTop: this.getScrollTop(), + scrollTop, height: this.getHeight(), scrollHeight: this.getScrollHeight(), fitPerfectly: false, - overscrollSize: this.config.overscrollSize, + overscrollSize: + scrollTop === 0 + ? INITIAL_TOP_OVERSCROLL_SIZE + : this.config.overscrollSize, }); if ( areVirtualWindowSpecsEqual(this.windowSpecs, windowSpecs) && @@ -345,7 +367,10 @@ export class Virtualizer { } this.visibleInstancesDirty = false; this.renderedObservers = this.observers.size; - const anchor = this.getScrollAnchor(this.height); + // At the top of the scroller, browser scroll anchoring does not need help. + // Avoid querying visible line positions during initial top-of-page renders. + const anchor = + this.getScrollTop() > 0 ? this.getScrollAnchor(this.height) : undefined; const updatedInstances = new Set(); // NOTE(amadeus): If the wrapper is dirty, we need to force every component // to re-render @@ -586,11 +611,20 @@ export class Virtualizer { return 0; } if (this.root instanceof Document) { - return window.scrollY; + const scrollY = window.scrollY; + if (scrollY <= 0) { + return 0; + } + return scrollY; } return this.root.scrollTop; })(); + if (scrollTop <= 0) { + this.scrollTop = 0; + return 0; + } + // Lets always make sure to clamp scroll position cases of // over/bounce scroll scrollTop = Math.max( diff --git a/packages/diffs/src/components/web-components.ts b/packages/diffs/src/components/web-components.ts index b7b4bf212..fa2d75005 100644 --- a/packages/diffs/src/components/web-components.ts +++ b/packages/diffs/src/components/web-components.ts @@ -1,34 +1,45 @@ import { DIFFS_TAG_NAME } from '../constants'; import styles from '../style.css'; +const supportsConstructedStyleSheets = typeof CSSStyleSheet !== 'undefined'; +const styledShadowRoots = new WeakSet(); +let sheet: CSSStyleSheet | undefined; + +function getDiffsStyleSheet(): CSSStyleSheet | undefined { + if (!supportsConstructedStyleSheets) { + return undefined; + } + if (sheet == null) { + sheet = new CSSStyleSheet(); + sheet.replaceSync(styles); + } + return sheet; +} + +export function ensureDiffsShadowRoot( + element: HTMLElement, + adoptStyles = true +): ShadowRoot { + const shadowRoot = + element.shadowRoot ?? element.attachShadow({ mode: 'open' }); + const styleSheet = adoptStyles ? getDiffsStyleSheet() : undefined; + if (styleSheet != null && !styledShadowRoots.has(shadowRoot)) { + shadowRoot.adoptedStyleSheets = [ + ...shadowRoot.adoptedStyleSheets, + styleSheet, + ]; + styledShadowRoots.add(shadowRoot); + } + return shadowRoot; +} + // If HTMLElement is undefined it usually means we are in a server environment // so best to just not do anything if ( typeof HTMLElement !== 'undefined' && customElements.get(DIFFS_TAG_NAME) == null ) { - let sheet: CSSStyleSheet | undefined; - - class FileDiffContainer extends HTMLElement { - constructor() { - super(); - // If shadow root is already open, we can sorta assume the - // CSS is already in place - if (this.shadowRoot != null) { - return; - } - const shadowRoot = this.attachShadow({ mode: 'open' }); - if (sheet == null) { - sheet = new CSSStyleSheet(); - sheet.replaceSync(styles); - } - shadowRoot.adoptedStyleSheets = [sheet]; - } - // Not sure if we need to do anything here yet... - // connectedCallback() { - // this.dataset.diffsContainer = ''; - // } - } + class FileDiffContainer extends HTMLElement {} customElements.define(DIFFS_TAG_NAME, FileDiffContainer); } diff --git a/packages/diffs/src/constants.ts b/packages/diffs/src/constants.ts index 60b9b47d8..684eddde0 100644 --- a/packages/diffs/src/constants.ts +++ b/packages/diffs/src/constants.ts @@ -43,7 +43,7 @@ export const CORE_CSS_ATTRIBUTE = 'data-core-css'; export const DEFAULT_COLLAPSED_CONTEXT_THRESHOLD = 1; export const DEFAULT_VIRTUAL_FILE_METRICS: VirtualFileMetrics = { - hunkLineCount: 50, + hunkLineCount: 75, lineHeight: 20, diffHeaderHeight: 44, hunkSeparatorHeight: 32, diff --git a/packages/diffs/src/managers/InteractionManager.ts b/packages/diffs/src/managers/InteractionManager.ts index e8bcd3e76..f522a5d2e 100644 --- a/packages/diffs/src/managers/InteractionManager.ts +++ b/packages/diffs/src/managers/InteractionManager.ts @@ -16,6 +16,27 @@ import { areSelectionPointsEqual } from '../utils/areSelectionPointsEqual'; import { areSelectionsEqual } from '../utils/areSelectionsEqual'; import { createGutterUtilityElement } from '../utils/createGutterUtilityElement'; +const DEFAULT_GUTTER_UTILITY_HTML = toHtml(createGutterUtilityElement()); +let gutterUtilityTemplate: HTMLTemplateElement | undefined; + +function createDefaultGutterUtilityButton(): HTMLButtonElement | undefined { + const template = (gutterUtilityTemplate ??= + document.createElement('template')); + if (template.content != null) { + if (template.innerHTML === '') { + template.innerHTML = DEFAULT_GUTTER_UTILITY_HTML; + } + const element = template.content.firstElementChild?.cloneNode(true); + return element instanceof HTMLButtonElement ? element : undefined; + } + + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = DEFAULT_GUTTER_UTILITY_HTML; + return tempDiv.firstElementChild instanceof HTMLButtonElement + ? tempDiv.firstElementChild + : undefined; +} + interface TokenCache { tokenElement: HTMLElement; lineCharStart: number; @@ -1012,15 +1033,12 @@ export class InteractionManager { this.gutterUtilitySlot?.remove(); this.gutterUtilitySlot = undefined; if (this.gutterUtilityButton == null) { - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = toHtml(createGutterUtilityElement()); - const utilityButton = tempDiv.firstElementChild; - if (!(utilityButton instanceof HTMLButtonElement)) { + const utilityButton = createDefaultGutterUtilityButton(); + if (utilityButton == null) { throw new Error( 'InteractionManager.ensureGutterUtilityNode: Node element should be a button' ); } - utilityButton.remove(); this.gutterUtilityButton = utilityButton; } if (this.gutterUtilityButton.parentNode !== this.gutterUtilityContainer) { diff --git a/packages/diffs/src/managers/ResizeManager.ts b/packages/diffs/src/managers/ResizeManager.ts index 1b1564396..5653b9624 100644 --- a/packages/diffs/src/managers/ResizeManager.ts +++ b/packages/diffs/src/managers/ResizeManager.ts @@ -24,50 +24,52 @@ export class ResizeManager { const observedNodes = new Map(this.observedNodes); this.observedNodes.clear(); - for (const codeElement of codeElements) { - let item: ObservedGridNodes | ObservedAnnotationNodes | undefined = - observedNodes.get(codeElement); - if (item != null && item.type !== 'code') { - throw new Error( - 'ResizeManager.setup: somehow a code node is being used for an annotation, should be impossible' - ); - } + if (disableAnnotations) { + for (const codeElement of codeElements) { + let item: ObservedGridNodes | ObservedAnnotationNodes | undefined = + observedNodes.get(codeElement); + if (item != null && item.type !== 'code') { + throw new Error( + 'ResizeManager.setup: somehow a code node is being used for an annotation, should be impossible' + ); + } - let numberElement = codeElement.firstElementChild; - if (!(numberElement instanceof HTMLElement)) { - numberElement = null; - } + let numberElement = codeElement.firstElementChild; + if (!(numberElement instanceof HTMLElement)) { + numberElement = null; + } - if (item != null) { - this.observedNodes.set(codeElement, item); - observedNodes.delete(codeElement); - if (item.numberElement !== numberElement) { - if (item.numberElement != null) { - this.resizeObserver.unobserve(item.numberElement); + if (item != null) { + this.observedNodes.set(codeElement, item); + observedNodes.delete(codeElement); + if (item.numberElement !== numberElement) { + if (item.numberElement != null) { + this.resizeObserver.unobserve(item.numberElement); + } + if (numberElement != null) { + this.resizeObserver.observe(numberElement); + observedNodes.delete(numberElement); + this.observedNodes.set(numberElement, item); + } + item.numberElement = numberElement; + } else if (item.numberElement != null) { + observedNodes.delete(item.numberElement); + this.observedNodes.set(item.numberElement, item); } + } else { + item = { + type: 'code', + codeElement, + numberElement, + codeWidth: 'auto', + numberWidth: 0, + }; + this.observedNodes.set(codeElement, item); + this.resizeObserver.observe(codeElement); if (numberElement != null) { - this.resizeObserver.observe(numberElement); - observedNodes.delete(numberElement); this.observedNodes.set(numberElement, item); + this.resizeObserver.observe(numberElement); } - item.numberElement = numberElement; - } else if (item.numberElement != null) { - observedNodes.delete(item.numberElement); - this.observedNodes.set(item.numberElement, item); - } - } else { - item = { - type: 'code', - codeElement, - numberElement, - codeWidth: 'auto', - numberWidth: 0, - }; - this.observedNodes.set(codeElement, item); - this.resizeObserver.observe(codeElement); - if (numberElement != null) { - this.observedNodes.set(numberElement, item); - this.resizeObserver.observe(numberElement); } } } @@ -134,22 +136,16 @@ export class ResizeManager { column1: { container: container1, child: child1, - childHeight: child1.getBoundingClientRect().height, + childHeight: 0, }, column2: { container: container2, child: child2, - childHeight: child2.getBoundingClientRect().height, + childHeight: 0, }, currentHeight: 'auto', }; - const newHeight = Math.max( - item.column1.childHeight, - item.column2.childHeight - ); - this.applyNewHeight(item, newHeight); - this.observedNodes.set(child1, item); this.observedNodes.set(child2, item); this.resizeObserver.observe(child1); @@ -245,16 +241,6 @@ export class ResizeManager { `${typeof item.codeWidth === 'number' ? `${item.codeWidth}px` : 'auto'}` ); } - if ( - item.numberElement != null && - typeof item.codeWidth === 'number' && - item.numberWidth === 0 - ) { - updates.push([ - item.numberElement, - item.numberElement.getBoundingClientRect().width, - ]); - } } else if (target === item.numberElement) { const inlineSize = Math.max(Math.ceil(targetInlineSize), 0); if (inlineSize !== item.numberWidth) {