Documentation Index
Fetch the complete documentation index at: https://mintlify.com/ragaeeb/dyelight/llms.txt
Use this file to discover all available pages before exploring further.
DyeLight is designed for performance, but understanding its optimization strategies helps you get the best results, especially with large documents or complex highlighting.
ResizeObserver debouncing
One of the most important performance optimizations is preventing infinite ResizeObserver loops.
The problem
Using ResizeObserver to trigger layout sync or auto-resize is powerful but dangerous. If the callback updates state or styles that change the element’s size, it immediately re-triggers the observer, creating an infinite loop.
Without debouncing, ResizeObserver callbacks can cause infinite loops and freeze the browser.
The solution
DyeLight uses requestAnimationFrame to debounce all ResizeObserver callbacks. This is implemented in src/hooks/useHighlightSync.ts:
const syncLayout = useCallback(
(textareaRefParam: React.RefObject<HTMLTextAreaElement | null>) => {
// Cancel any pending sync
if (syncFrameId.current !== undefined) {
cancelAnimationFrame(syncFrameId.current);
}
// Schedule sync for next animation frame
syncFrameId.current = requestAnimationFrame(() => {
syncStyles(textareaRefParam);
syncScroll(textareaRefParam);
syncFrameId.current = undefined;
});
},
[syncStyles, syncScroll],
);
This approach:
- Breaks the synchronous feedback loop - Changes don’t immediately trigger the observer
- Batches layout operations - Multiple rapid changes result in a single sync
- Aligns with browser paint cycle - Syncs happen at 60fps, not hundreds of times per second
ResizeObserver usage
The main ResizeObserver is set up in DyeLight.tsx:
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const observer = new ResizeObserver(() => {
if (enableAutoResize) {
handleAutoResize(textarea);
}
syncLayout(textareaRef); // Debounced!
});
observer.observe(textarea);
return () => observer.disconnect();
}, [textareaRef, syncLayout, handleAutoResize, enableAutoResize]);
Always use requestAnimationFrame to debounce ResizeObserver callbacks in your own code.
Large text handling
DyeLight can handle large documents, but you should be aware of performance characteristics.
Auto-resize with large content
The auto-resize system (in src/hooks/useAutoResize.ts) temporarily collapses the textarea to measure content:
export const createAutoResizeHandler = (
enableAutoResize: boolean,
setTextareaHeight: (height: number | undefined) => void,
resize: (element: HTMLTextAreaElement) => void = autoResize,
) => {
return (element: HTMLTextAreaElement) => {
if (!enableAutoResize) return;
resize(element); // Temporarily set height: 0, read scrollHeight
setTextareaHeight(element.scrollHeight);
};
};
For very large documents (>100KB), consider:
Disable auto-resize and use fixed height:
<DyeLight
value={largeText}
onChange={setLargeText}
enableAutoResize={false}
rows={20}
className="h-96" // Fixed height
/>
Use virtualization for extremely large documents (>1MB)
The useHighlightedContent hook memoizes highlight calculations:
const highlightedContent = useHighlightedContent(
currentValue,
highlights,
lineHighlights,
renderHighlightedLine,
);
This ensures highlights are only recalculated when currentValue, highlights, or lineHighlights change.
Memoization prevents expensive highlight recalculation on every render.
Memoization strategies
DyeLight uses several memoization techniques to avoid unnecessary work:
Layout sync key
A composite key triggers re-sync only when layout-affecting props change:
const layoutStyleKey = useMemo(() => {
if (!style) return '';
return Object.entries(style as Record<string, unknown>)
.toSorted(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}:${String(value)}`)
.join(';');
}, [style]);
const layoutSyncKey = useMemo(
() => `${dir}|${className}|${rows.toString()}|${layoutStyleKey}`,
[dir, className, rows, layoutStyleKey],
);
This key is used to trigger useLayoutEffect only when necessary:
useLayoutEffect(() => {
void layoutSyncKey; // Dependency
if (textareaRef.current && enableAutoResize) {
handleAutoResize(textareaRef.current);
}
syncLayout(textareaRef);
}, [layoutSyncKey, handleAutoResize, enableAutoResize, syncLayout, textareaRef]);
Callback memoization
All event handlers are memoized with useCallback:
const handleChangeWithResize = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
handleChange(e);
},
[handleChange],
);
const handleScroll = useCallback(
(e: React.UIEvent<HTMLTextAreaElement>) => {
onScrollProp?.(e);
syncLayout(textareaRef);
},
[onScrollProp, syncLayout, textareaRef],
);
This prevents unnecessary re-renders of child components and ensures stable function references.
The debug mode telemetry system is designed to have minimal performance impact:
Event buffer management
Events are stored in a circular buffer with configurable size:
record(/* ... */) {
// ... record event
this.events.push(event);
// Keep buffer at max size
if (this.events.length > this.maxEvents) {
this.events = this.events.slice(-this.maxEvents);
}
}
Use debugMaxEvents={500} for better performance in production debugging scenarios.
Conditional geometry capture
Geometry measurements (which trigger layout) are only captured when necessary:
const captureGeometry = category === 'sync' || type === 'snapshot' || type === 'selectionChange';
const { /* ... */ } = this.buildSnapshots(
textareaRef,
currentValue,
textareaHeight,
isControlled,
captureGeometry, // Only measure when needed
context
);
This avoids expensive getComputedStyle() calls on every event.
Value deduplication
The ValueRegistry class (in src/telemetry.ts) prevents memory bloat from large text values:
class ValueRegistry {
private registry = new Map<string, string>();
private reverseRegistry = new Map<string, string>();
private readonly threshold = 1000; // Only deduplicate >1KB
store(value: string): string {
// Small values stored inline
if (value.length <= this.threshold) {
return value;
}
// Large values deduplicated
if (this.reverseRegistry.has(value)) {
return this.reverseRegistry.get(value)!;
}
const ref = `<REF:value_${this.nextId}>`;
this.registry.set(ref, value);
this.reverseRegistry.set(value, ref);
this.nextId++;
return ref;
}
}
Without deduplication, a single 40KB paste would create a 40MB JSON report after 1000 events.
Avoid synchronous DOM queries in loops
Never read layout properties in tight loops:
// ❌ Bad - triggers layout on every iteration
highlights.forEach(h => {
const element = document.querySelector(`[data-pos="${h.start}"]`);
const top = element?.offsetTop; // Layout thrashing!
});
// ✅ Good - batch reads
const positions = highlights.map(h => h.start);
const tops = positions.map(pos => {
const element = document.querySelector(`[data-pos="${pos}"]`);
return element?.offsetTop;
});
Use useMemo for expensive highlight calculations
If you’re generating highlights from complex data:
const highlights = useMemo(() => {
// Expensive calculation
return analyzeSyntax(code).map(token => ({
start: token.pos,
end: token.pos + token.length,
className: getTokenClass(token.type),
}));
}, [code]); // Only recalculate when code changes
Disable telemetry in production
Only enable debug mode when actively troubleshooting:
const isDev = process.env.NODE_ENV === 'development';
<DyeLight
debug={isDev && debugModeEnabled} // Only in dev + explicit enable
value={text}
onChange={setText}
/>
Consider virtualization for huge documents
For documents >100KB, consider using a virtualization library or implementing windowing:
import { VirtualizedTextarea } from 'your-virtualization-lib';
function HugeDocumentEditor() {
// Only render visible portion of document
return (
<VirtualizedTextarea
value={hugeDocument}
renderLine={(line, index) => (
<DyeLight
value={line}
onChange={(newLine) => updateLine(index, newLine)}
/>
)}
/>
);
}
Lessons learned from production
From AGENTS.md, key performance insights:
State synchronization
Rely SOLELY on onChange for React inputs. It normalizes all input types (typing, paste, IME). Remove manual polling/mutation observers; let React’s state be the source of truth.
Avoid polling or observers that read DOM state frequently.
Layout fidelity
When a vertical scrollbar appears in the textarea, it subtracts from clientWidth. The overlay (usually overflow: hidden) does not shrink. Calculate scrollbarWidth = offsetWidth - clientWidth - borders.
DyeLight handles this automatically, but be aware it requires DOM measurements.
Paste handling
Call e.preventDefault() as the FIRST LINE in your paste handler, before reading clipboard data.
This prevents the browser from inserting raw content into the DOM, avoiding a race condition that requires expensive recovery.
Large paste operations can cause temporary performance spikes. Consider showing a loading state for pastes >10KB.