Skip to content

perf: speed up syntax-highlight row building#177

Merged
benvinegar merged 4 commits into
mainfrom
perf/syntax-highlight-rendering
Apr 7, 2026
Merged

perf: speed up syntax-highlight row building#177
benvinegar merged 4 commits into
mainfrom
perf/syntax-highlight-rendering

Conversation

@benvinegar

@benvinegar benvinegar commented Apr 7, 2026

Copy link
Copy Markdown
Member

Summary

  • cache parsed inline token styles and normalized syntax colors inside src/ui/diff/pierre.ts
  • cache flattened highlighted line spans so revisiting/remounting highlighted files can reuse the expensive HAST-to-terminal-span conversion
  • reuse unchanged context highlight nodes across both diff sides so identical context can share the same flatten/cache path
  • keep the plain-text fallback lazy so raw line cleanup only runs when highlighted spans are unavailable or empty
  • add inline comments explaining why each cache exists and what work it avoids

Benchmark note

  • recovered syntax-highlight throughput benchmark averaged 1197.38ms over 5 runs for the promoted version
  • previous kept version averaged 1218.17ms
  • original main-based version averaged 1495.60ms
  • rendered output signature stayed identical during verification

Notes

  • This PR intentionally contains only the production optimization in src/ui/diff/pierre.ts
  • It does not include the autoresearch harness, benchmark scripts, or extra experiment-only files

Validation

  • bun run typecheck
  • bun test src/ui/diff/pierre.test.ts src/ui/AppHost.reload.test.tsx

This PR description was generated by Pi using OpenAI o3

@greptile-apps

greptile-apps Bot commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds three module-level caches to speed up syntax-highlight row building in src/ui/diff/pierre.ts: a parsed CSS style string cache (parsedStyleValueCache), a normalized token-color remap cache (normalizedColorCache), and a flattened highlighted line span cache (flattenedHighlightedLineCache). The inline comments explaining each cache are clear and the overall structural approach is sound.

  • parsedStyleValueCache and flattenedHighlightedLineCache are correctly keyed — the latter uses theme.id in its composite key (\${theme.id}:${emphasisBg}``), ensuring per-theme correctness.
  • normalizedColorCache is incorrectly keyed by theme.appearance only, but the resolved color comes from theme.syntaxColors[reserved], which varies per theme id. With three distinct dark themes (graphite, midnight, ember) all sharing the same \"dark\" bucket, switching themes causes Pierre token → syntax-color mappings to be permanently contaminated by whichever dark theme was rendered first.
  • The lazy plain-text fallback strategy in makeSplitCell/makeStackCell and the reuse of render-options objects are both correct improvements.

Confidence Score: 4/5

Safe to merge after fixing the normalizedColorCache keying bug; without the fix, switching between dark themes produces incorrect syntax highlight colors.

One confirmed P1 logic bug: normalizedColorCache is keyed by theme.appearance instead of theme.id, causing color normalization to return stale results when switching among the three dark themes (graphite, midnight, ember). All other caching additions are correct and the performance rationale is sound. Score is 4 because the P1 fix is a targeted two-line change.

src/ui/diff/pierre.ts — specifically the normalizedColorCache declaration (lines 163–166) and the normalizeHighlightedColor function (lines 173–191).

Important Files Changed

Filename Overview
src/ui/diff/pierre.ts Three module-level caches added to speed up HAST-to-span conversion; normalizedColorCache is incorrectly keyed by theme.appearance instead of theme.id, causing stale syntax colors when switching between dark themes.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[buildSplitRows / buildStackRows] --> B[makeSplitCell / makeStackCell]
    B --> C{highlightedLine?}
    C -- undefined --> D[cleanDiffLine plain-text fallback]
    C -- HAST node --> E[flattenHighlightedLine]
    E --> F{flattenedHighlightedLineCache hit?
key: theme.id + emphasisBg}
    F -- yes --> G[return cached RenderSpan array]
    F -- no --> H[visit HAST tree recursively]
    H --> I[parseStyleValue]
    I --> J{parsedStyleValueCache hit?}
    J -- yes --> K[return cached Map]
    J -- no --> L[parse CSS string into Map and cache]
    H --> M[normalizeHighlightedColor]
    M --> N{normalizedColorCache hit?
key: theme.appearance ONLY ⚠️}
    N -- yes --> O[return cached color — may be wrong theme's color!]
    N -- no --> P[resolve via RESERVED_PIERRE_TOKEN_COLORS
+ theme.syntaxColors → cache]
    H --> Q[mergeSpan → build spans array]
    Q --> R[store spans in flattenedHighlightedLineCache]
    G --> S[RenderSpan rows returned to renderer]
Loading

Reviews (1): Last reviewed commit: "perf: speed up syntax-highlight row buil..." | Re-trigger Greptile

Comment thread src/ui/diff/pierre.ts Outdated
Comment on lines 163 to 191
const normalizedColorCache = {
dark: new Map<string, string>(),
light: new Map<string, string>(),
};
// The expensive part after highlighting is walking Pierre's HAST line tree and flattening it
// into terminal spans. The same highlighted line objects are reused when files remount or when
// we build both split and stack rows, so memoize flattened spans by line node + theme/background.
const flattenedHighlightedLineCache = new WeakMap<HastNode, Map<string, RenderSpan[]>>();

/** Remap Pierre token hues that collide with diff add/remove semantics into theme-safe syntax colors. */
function normalizeHighlightedColor(color: string | undefined, theme: AppTheme) {
if (!color) {
return color;
}

const cached = normalizedColorCache[theme.appearance].get(color);
if (cached) {
return cached;
}

const normalized = color.trim().toLowerCase();
const reserved =
RESERVED_PIERRE_TOKEN_COLORS[theme.appearance][
normalized as keyof (typeof RESERVED_PIERRE_TOKEN_COLORS)[typeof theme.appearance]
];
if (!reserved) {
return color;
}

return theme.syntaxColors[reserved];
const resolvedColor = reserved ? theme.syntaxColors[reserved] : color;
normalizedColorCache[theme.appearance].set(color, resolvedColor);
return resolvedColor;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 normalizedColorCache keyed by appearance causes stale colors across dark themes

The cache maps theme.appearance ("dark" or "light") to a color map, but the resolved color comes from theme.syntaxColors[reserved] — which differs per theme id, not just per appearance. There are three dark themes with distinct keyword/string colors: graphite (#c4d0da / #d8c6ef), midnight (#8ed4ff / #c7b4ff), and ember (#ffb47f / #ffd3a8).

If the app first renders with the graphite theme, #ff6762 (Pierre's dark keyword indicator) gets cached as "#c4d0da" (graphite's keyword color). When the user switches to midnight, normalizedColorCache["dark"].get("#ff6762") returns "#c4d0da" instead of midnight's "#8ed4ff". The sibling flattenedHighlightedLineCache correctly uses theme.id in its composite key (line 214), so flattened spans are properly recomputed per theme — but normalizeHighlightedColor then poisons those freshly-walked spans with the wrong color.

Fix: key by theme.id instead of theme.appearance:

const normalizedColorCache = new Map<string, Map<string, string>>();

function normalizeHighlightedColor(color: string | undefined, theme: AppTheme) {
  if (!color) {
    return color;
  }

  let cacheForTheme = normalizedColorCache.get(theme.id);
  if (!cacheForTheme) {
    cacheForTheme = new Map<string, string>();
    normalizedColorCache.set(theme.id, cacheForTheme);
  }

  const cached = cacheForTheme.get(color);
  if (cached !== undefined) {
    return cached;
  }

  const normalized = color.trim().toLowerCase();
  const reserved =
    RESERVED_PIERRE_TOKEN_COLORS[theme.appearance][
      normalized as keyof (typeof RESERVED_PIERRE_TOKEN_COLORS)[typeof theme.appearance]
    ];
  const resolvedColor = reserved ? theme.syntaxColors[reserved] : color;
  cacheForTheme.set(color, resolvedColor);
  return resolvedColor;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — I changed the reserved-token remap cache to key by theme.id instead of appearance, and added a regression test that reuses the same dark highlighted output across graphite, midnight, and ember to make sure theme switches keep their own keyword/string colors.

This comment was generated by Pi using OpenAI o3

@benvinegar
benvinegar merged commit 5431c49 into main Apr 7, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant