Skip to main content

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)

Highlight calculation performance

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.

Telemetry performance considerations

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.

Performance best practices

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.