feat: implement LRU cache for string size measurements (#3955)#6176
Conversation
|
Hi @ckifer / @PavelVanecek 👋 |
|
How much time does this change save when rendering? How much more memory does it use? |
Replaces the previous "clear all" cache strategy with a proper LRU (Least Recently Used) cache to improve performance and reduce cache misses in text measurement operations.
62009d0 to
1d0f0ba
Compare
Did some bechmarking using the below code snippet. function getRandomInt(max: number): number {
return Math.floor(Math.random() * Math.floor(max));
}
interface DataPoint {
name: string;
uv: number;
}
const generateData = (n: number): DataPoint[] => {
const ret: DataPoint[] = [];
for (let i = 0; i < n; i++) {
ret.push({ name: `Page ${i}`, uv: getRandomInt(100) });
}
return ret;
};
export function LineChartBenchmarkSuite() {
interface BenchmarkResult {
dataSize: number;
dataGenTime: number;
renderTime: number;
totalTime: number;
rating: string;
}
const [results, setResults] = useState<BenchmarkResult[]>([]);
const [isRunning, setIsRunning] = useState(false);
const [currentTest, setCurrentTest] = useState<number | null>(null);
const runSingleBenchmark = (dataSize: number): Promise<BenchmarkResult> => {
return new Promise((resolve) => {
// Measure data generation
const dataGenStart = performance.now();
generateData(dataSize); // Generate data for timing purposes
const dataGenTime = performance.now() - dataGenStart;
// Measure rendering
const renderStart = performance.now();
performance.mark(`benchmark-${dataSize}-start`);
// Force a re-render by updating state
setCurrentTest(dataSize);
// Measure after React has updated
setTimeout(() => {
requestAnimationFrame(() => {
const renderEnd = performance.now();
performance.mark(`benchmark-${dataSize}-end`);
performance.measure(
`benchmark-${dataSize}`,
`benchmark-${dataSize}-start`,
`benchmark-${dataSize}-end`
);
const measure = performance.getEntriesByName(
`benchmark-${dataSize}`
)[0];
const totalTime = measure
? measure.duration
: renderEnd - renderStart;
const renderTime = renderEnd - renderStart;
const getRating = (time: number) => {
if (time < 16) return "🟢 Excellent";
if (time < 50) return "🟡 Good";
if (time < 100) return "🟠 Fair";
return "🔴 Slow";
};
resolve({
dataSize,
dataGenTime: Number(dataGenTime.toFixed(2)),
renderTime: Number(renderTime.toFixed(2)),
totalTime: Number(totalTime.toFixed(2)),
rating: getRating(totalTime),
});
});
}, 0);
});
};
const runFullBenchmark = async () => {
setIsRunning(true);
setResults([]);
const testSizes = [100, 500, 1000, 2000, 5000, 10000];
const benchmarkResults: BenchmarkResult[] = [];
for (const size of testSizes) {
const result = await runSingleBenchmark(size);
benchmarkResults.push(result);
setResults([...benchmarkResults]);
// Small delay between tests to ensure clean measurements
await new Promise((resolve) => setTimeout(resolve, 200));
}
setCurrentTest(null);
setIsRunning(false);
};
const testData = currentTest ? generateData(currentTest) : generateData(1000);
return (
<div style={{ padding: 20 }}>
<div style={{ marginBottom: 20 }}>
<h3>🧪 LineChart Benchmark Suite (With LRU Cache)</h3>
<button
onClick={runFullBenchmark}
disabled={isRunning}
style={{
padding: "10px 20px",
backgroundColor: isRunning ? "#ccc" : "#007acc",
color: "white",
border: "none",
borderRadius: 4,
cursor: isRunning ? "not-allowed" : "pointer",
marginBottom: 15,
}}
>
{isRunning ? "Running Benchmark..." : "Run Performance Benchmark"}
</button>
{isRunning && currentTest && (
<div style={{ color: "blue", marginBottom: 15 }}>
🔄 Testing {currentTest} data points...
</div>
)}
</div>
{results.length > 0 && (
<div style={{ marginBottom: 20 }}>
<h4>📊 Benchmark Results</h4>
<table
style={{
borderCollapse: "collapse",
width: "100%",
border: "1px solid #ddd",
backgroundColor: "white",
}}
>
<thead>
<tr style={{ backgroundColor: "#f8f9fa" }}>
<th
style={{
padding: 12,
textAlign: "left",
border: "1px solid #ddd",
}}
>
Data Size
</th>
<th
style={{
padding: 12,
textAlign: "left",
border: "1px solid #ddd",
}}
>
Data Gen
</th>
<th
style={{
padding: 12,
textAlign: "left",
border: "1px solid #ddd",
}}
>
Render Time
</th>
<th
style={{
padding: 12,
textAlign: "left",
border: "1px solid #ddd",
}}
>
Total Time
</th>
<th
style={{
padding: 12,
textAlign: "left",
border: "1px solid #ddd",
}}
>
Rating
</th>
</tr>
</thead>
<tbody>
{results.map((result, index) => (
<tr key={index}>
<td style={{ padding: 12, border: "1px solid #ddd" }}>
{result.dataSize} items
</td>
<td style={{ padding: 12, border: "1px solid #ddd" }}>
{result.dataGenTime}ms
</td>
<td style={{ padding: 12, border: "1px solid #ddd" }}>
{result.renderTime}ms
</td>
<td style={{ padding: 12, border: "1px solid #ddd" }}>
{result.totalTime}ms
</td>
<td style={{ padding: 12, border: "1px solid #ddd" }}>
{result.rating}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div style={{ border: "1px solid #ddd", padding: 10 }}>
<p>Current test data: {testData.length} items</p>
<LineChart
width={1400}
height={500}
data={testData}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<XAxis dataKey="name" />
<YAxis />
<CartesianGrid strokeDasharray="3 3" />
<Tooltip />
<Legend />
<Line
dot={false}
isAnimationActive={false}
type="monotone"
dataKey="uv"
stroke="#82ca9d"
/>
</LineChart>
</div>
</div>
);
}Result:Chrome:
Safari:
Firefox:
ExamplesWithout Cache: Codesandbox Although I did not feel much difference. One thing for sure, Safari's speed is on another level 🙌 |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6176 +/- ##
==========================================
+ Coverage 96.53% 96.74% +0.20%
==========================================
Files 217 222 +5
Lines 19835 19985 +150
Branches 4087 4137 +50
==========================================
+ Hits 19148 19334 +186
+ Misses 681 645 -36
Partials 6 6 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
It would have the exact same results unless you re-use words right? "Page" and then whatever numbers happen to collide on the Y axis but that's not a great hit ratio. Tbh there probably would never be the best hit ratio because of the nature of a chart |
May be I agree. |
|
There isn't much that can make the cache better because it needs exact matches. "Test" is not going to measure the same as "Recharts", etc. caching individual letters isn't an option because initial performance will bad and we'll have to stitch things together. The only alternative way to measure text is to use canvas and that doesn't result in the same measurement result |
In this I think the only issue that we can solve is
FYI tried canvas based implementation as well. It did not turn well. |
|
Pretty much yep. I think we can merge this, it's not that much more code. Just know that it doesn't fix the problem |






String Size Caching Improvements
Overview
This document outlines the implementation of LRU (Least Recently Used) cache for string size measurements in Recharts, addressing performance issues described in issue #3955.
Problem Statement
The original string size caching mechanism in Recharts had several limitations:
getStringSizecalls were causing performance issues with large datasetsSolution
LRU Cache Implementation
Implemented a standalone
LRUCacheclass with the following features:Enhanced DOMUtils
Improved the
getStringSizefunction with:API Reference
Configuration Functions
configureTextMeasurement(config)Configure text measurement behavior.
Parameters:
config.cacheSize(number): Maximum cache size (default: 2000)config.enableCache(boolean): Enable/disable caching (default: true)Example:
getTextMeasurementConfig()Get current configuration.
Returns: Current
TextMeasurementConfigclearStringCache()Clear the entire cache. Useful for testing or memory management.
getStringCacheStats()Get cache statistics for debugging.
Returns:
Performance Benefits
Before vs After
Use Case Optimizations
Large Datasets
Memory-Constrained Environments
Debugging
Implementation Details
LRU Cache Algorithm
The LRU cache uses JavaScript's
Mapdata structure, which maintains insertion order:Cache Key Generation
Optimized cache keys include only text-relevant CSS properties:
This reduces cache misses by normalizing keys and ignoring irrelevant style properties.
Testing
Test Coverage
Test Categories
Migration Guide
Existing Code
No changes required! All existing code continues to work:
Performance Optimization
For applications with specific needs:
Backward Compatibility
✅ Fully backward compatible