From 6e73a23b421e71e8444b13b2c1723d4a76b90458 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Thu, 4 Jun 2026 16:43:32 -0400 Subject: [PATCH 1/3] feat(text): bound SDF text layout cache with idle LRU eviction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDF text layout cache (`layoutCache` in SdfTextRenderer) is content-keyed and shared across nodes, but had no eviction: every distinct text+font+layout combination ever rendered stayed in the map for the life of the JS context. On long-lived embedded sessions (no page reload), navigating many entity pages with unique descriptions grows the cache monotonically — each ~200-char description retains a glyph-layout array (~30KB) that is never freed, since node destroy only releases per-node caches, not the shared map. Changes: - Add configurable `textLayoutCacheSize` renderer option (default 250), plumbed through to the SDF/Canvas renderers via stage options. - Skip caching strings over 100 chars: long strings are almost always unique and set once, so they have a near-zero hit rate while being the largest entries. They still lay out and render normally; they just don't enter the cache. This removes the leak at its source. - Make the cache a true LRU: cache hits move the entry to most-recently-used, and a new `cleanup()` trims to the cap evicting least-recently-used first. - Run eviction on idle: Stage.cleanupTextRenderers() is called from the "entering idle" block in WebPlatform, so trimming never competes with active rendering. - Add `cleanup` to the TextRenderer interface; Canvas implements it for parity (its layoutCache is currently dead code — declared/cleared but never read/written — so it is inert today). Rendering output is unchanged (byte-identical), so no visual regression test is added. Unit tests cover hit reuse, the 100-char skip boundary, LRU eviction order, and the under-cap no-op. Co-Authored-By: Claude Opus 4.8 --- src/core/Stage.ts | 13 ++ src/core/platforms/web/WebPlatform.ts | 1 + src/core/text-rendering/CanvasTextRenderer.ts | 26 ++++ .../text-rendering/SdfTextRenderer.test.ts | 143 ++++++++++++++++++ src/core/text-rendering/SdfTextRenderer.ts | 41 ++++- src/core/text-rendering/TextRenderer.ts | 7 + src/main-api/Renderer.ts | 22 +++ 7 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 src/core/text-rendering/SdfTextRenderer.test.ts diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 016eee7..69e882b 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -509,6 +509,19 @@ export class Stage { ); } + /** + * Trim text renderer caches back to their configured limits. + * + * Called when the stage goes idle so layout-cache eviction never competes + * with active rendering. + */ + cleanupTextRenderers() { + const textRenderers = this.textRenderers; + for (const key in textRenderers) { + textRenderers[key]!.cleanup(); + } + } + /** * Start a new frame draw */ diff --git a/src/core/platforms/web/WebPlatform.ts b/src/core/platforms/web/WebPlatform.ts index 80a7390..15176fc 100644 --- a/src/core/platforms/web/WebPlatform.ts +++ b/src/core/platforms/web/WebPlatform.ts @@ -86,6 +86,7 @@ export class WebPlatform extends Platform { stage.txMemManager.handleOutOfMemory(); } stage.shManager.cleanup(); + stage.cleanupTextRenderers(); stage.eventBus.emit('idle'); isIdle = true; } diff --git a/src/core/text-rendering/CanvasTextRenderer.ts b/src/core/text-rendering/CanvasTextRenderer.ts index ec1c4ca..43bded2 100644 --- a/src/core/text-rendering/CanvasTextRenderer.ts +++ b/src/core/text-rendering/CanvasTextRenderer.ts @@ -40,10 +40,22 @@ const layoutCache = new Map< } >(); +// Upper bound on layoutCache entries, enforced on idle via `cleanup`. +// Overridden from stage options in `init`. Note: the Canvas path does not +// currently populate `layoutCache`, so this is effectively inert today and +// exists to keep the eviction policy uniform with the SDF backend should +// Canvas layout caching be wired up later. +let maxLayoutCacheSize = 250; + // Initialize the Text Renderer const init = (stage: Stage): void => { const dpr = stage.options.devicePhysicalPixelRatio; + const configuredCacheSize = stage.options.textLayoutCacheSize; + if (configuredCacheSize !== undefined) { + maxLayoutCacheSize = configuredCacheSize; + } + // Drawing canvas and context canvas = stage.platform.createCanvas() as HTMLCanvasElement | OffscreenCanvas; context = canvas.getContext('2d', { willReadFrequently: true }) as @@ -224,6 +236,19 @@ const clearLayoutCache = (): void => { layoutCache.clear(); }; +/** + * Trim the layout cache back down to `maxLayoutCacheSize`, evicting the + * least-recently-used entries first. Called when the stage goes idle. The + * Canvas path does not currently populate `layoutCache`, so this is a no-op in + * practice today; it mirrors the SDF backend's eviction policy. + */ +const cleanup = (): void => { + while (layoutCache.size > maxLayoutCacheSize) { + const oldest = layoutCache.keys().next().value as string; + layoutCache.delete(oldest); + } +}; + /** * Add quads for rendering (Canvas doesn't use quads) */ @@ -252,6 +277,7 @@ const CanvasTextRenderer = { renderQuads, init, clearLayoutCache, + cleanup, }; export default CanvasTextRenderer; diff --git a/src/core/text-rendering/SdfTextRenderer.test.ts b/src/core/text-rendering/SdfTextRenderer.test.ts new file mode 100644 index 0000000..91fe3fb --- /dev/null +++ b/src/core/text-rendering/SdfTextRenderer.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { CoreTextNodeProps } from '../CoreTextNode.js'; + +// Mock the font handler so renderText/generateTextLayout can run without a +// real loaded font. getFontData is only invoked on a layout-cache MISS, so the +// call count is our probe for cache hits vs misses. +vi.mock('./SdfFontHandler.js', () => { + const fontData = { + data: { + common: { base: 0, scaleW: 512, scaleH: 512, lineHeight: 50 }, + info: { size: 42 }, + distanceField: { distanceRange: 4 }, + }, + glyphMap: new Map(), + kernings: {}, + atlasTexture: {}, + metrics: {}, + maxCharHeight: 50, + }; + const metrics = { + ascender: 40, + descender: -10, + lineGap: 0, + capHeight: 30, + xHeight: 20, + }; + return { + type: 'sdf', + init: vi.fn(), + getFontData: vi.fn(() => fontData), + getFontMetrics: vi.fn(() => metrics), + measureText: vi.fn((text: string) => text.length * 10), + getAtlas: vi.fn(() => null), + }; +}); + +import SdfTextRenderer from './SdfTextRenderer.js'; +import * as SdfFontHandler from './SdfFontHandler.js'; + +const makeProps = (text: string): CoreTextNodeProps => + ({ + text, + fontFamily: 'Test', + fontStyle: 'normal', + fontSize: 42, + letterSpacing: 0, + lineHeight: 0, + maxHeight: 0, + maxWidth: 100000, + maxLines: 0, + textAlign: 'left', + wordBreak: 'normal', + overflowSuffix: '', + } as unknown as CoreTextNodeProps); + +const initRenderer = (cacheSize: number): void => { + const fakeStage = { + options: { textLayoutCacheSize: cacheSize }, + shManager: { + registerShaderType: vi.fn(), + createShader: vi.fn(() => ({})), + }, + }; + SdfTextRenderer.init(fakeStage as never); +}; + +const render = (text: string): void => { + SdfTextRenderer.renderText(makeProps(text)); +}; + +describe('SdfTextRenderer layout cache', () => { + beforeEach(() => { + // Empty the module-level cache between tests, then reset call counts. + initRenderer(0); + SdfTextRenderer.cleanup(); + vi.clearAllMocks(); + }); + + it('reuses the cached layout for identical strings', () => { + initRenderer(10); + + render('Badge'); + render('Badge'); + + // Second render is a cache hit: no fresh layout generation. + expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1); + }); + + it('does not cache strings longer than the threshold (100 chars)', () => { + initRenderer(10); + const long = 'x'.repeat(101); + + render(long); + render(long); + + // Never cached, so every render recomputes. + expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(2); + }); + + it('caches strings exactly at the threshold (100 chars)', () => { + initRenderer(10); + const atLimit = 'y'.repeat(100); + + render(atLimit); + render(atLimit); + + expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1); + }); + + it('cleanup trims to the cap and evicts least-recently-used first', () => { + initRenderer(2); + + render('A'); + render('B'); + render('C'); + // Re-access 'A' so it becomes most-recently-used; 'B' is now the LRU. + render('A'); + + SdfTextRenderer.cleanup(); + + vi.clearAllMocks(); + render('B'); // evicted -> miss + render('A'); // survived -> hit + render('C'); // survived -> hit + + expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1); + }); + + it('cleanup is a no-op while under the cap', () => { + initRenderer(10); + + render('one'); + render('two'); + + SdfTextRenderer.cleanup(); + + vi.clearAllMocks(); + render('one'); // still cached -> hit + render('two'); // still cached -> hit + + expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(0); + }); +}); diff --git a/src/core/text-rendering/SdfTextRenderer.ts b/src/core/text-rendering/SdfTextRenderer.ts index bcfd4cc..4973114 100644 --- a/src/core/text-rendering/SdfTextRenderer.ts +++ b/src/core/text-rendering/SdfTextRenderer.ts @@ -24,10 +24,27 @@ const type = 'sdf' as const; let sdfShader: WebGlShaderNode | null = null; +// Strings longer than this are never cached. Long strings (e.g. show +// descriptions) are almost always unique and set once, so they have a +// near-zero cache hit rate while being the largest entries — caching them is +// all cost and no benefit. They still lay out and render normally; they just +// don't enter the shared cache. +const MAX_CACHED_TEXT_LENGTH = 100; + +// Upper bound on layoutCache entries, enforced on idle via `cleanup`. +// Overridden from stage options in `init`. The cache is allowed to grow past +// this during active rendering and is trimmed back to it when the stage idles. +let maxLayoutCacheSize = 250; + // Initialize the SDF text renderer const init = (stage: Stage): void => { SdfFontHandler.init(); + const configuredCacheSize = stage.options.textLayoutCacheSize; + if (configuredCacheSize !== undefined) { + maxLayoutCacheSize = configuredCacheSize; + } + // Register SDF shader with the shader manager stage.shManager.registerShaderType('Sdf', Sdf); sdfShader = stage.shManager.createShader('Sdf') as WebGlShaderNode; @@ -58,6 +75,11 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => { const cacheKey = getLayoutCacheKey(props); let layout = layoutCache.get(cacheKey); if (layout !== undefined) { + // Refresh LRU recency: re-insert moves the key to the most-recently-used + // end so idle `cleanup` evicts genuinely cold entries first. renderText + // runs on text/layout change, not per frame, so this re-insert is cheap. + layoutCache.delete(cacheKey); + layoutCache.set(cacheKey, layout); return { remainingLines: 0, hasRemainingText: false, @@ -79,7 +101,9 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => { // Calculate text layout and generate glyph data for caching layout = generateTextLayout(props, fontData); - layoutCache.set(cacheKey, layout); + if (props.text.length <= MAX_CACHED_TEXT_LENGTH) { + layoutCache.set(cacheKey, layout); + } // For SDF renderer, ImageData is null since we render via WebGL return { @@ -357,6 +381,20 @@ const generateTextLayout = ( }; }; +/** + * Trim the layout cache back down to `maxLayoutCacheSize`, evicting the + * least-recently-used entries first. Called when the stage goes idle so this + * never competes with active rendering. A fresh iterator is taken each step so + * we always delete the current front (oldest) key without iterator-invalidation + * concerns; this runs at most once per idle transition and only when over cap. + */ +const cleanup = (): void => { + while (layoutCache.size > maxLayoutCacheSize) { + const oldest = layoutCache.keys().next().value as string; + layoutCache.delete(oldest); + } +}; + /** * SDF Text Renderer - implements TextRenderer interface */ @@ -367,6 +405,7 @@ const SdfTextRenderer = { addQuads, renderQuads, init, + cleanup, }; export default SdfTextRenderer; diff --git a/src/core/text-rendering/TextRenderer.ts b/src/core/text-rendering/TextRenderer.ts index 8a00ab8..93af242 100644 --- a/src/core/text-rendering/TextRenderer.ts +++ b/src/core/text-rendering/TextRenderer.ts @@ -435,6 +435,13 @@ export interface TextRenderer { renderProps: TextRenderProps, ) => void | SdfRenderOp | null; init: (stage: Stage) => void; + /** + * Trim internal caches back down to their configured limits. + * Called when the stage goes idle so cache eviction never competes with + * active rendering. Backends with no bounded cache may implement this as a + * no-op. + */ + cleanup: () => void; } /** diff --git a/src/main-api/Renderer.ts b/src/main-api/Renderer.ts index e8e130f..67ed5aa 100644 --- a/src/main-api/Renderer.ts +++ b/src/main-api/Renderer.ts @@ -393,6 +393,26 @@ export interface RendererRuntimeSettings { * Configuration settings for {@link RendererMain} */ export type RendererMainSettings = RendererRuntimeSettings & { + /** + * Maximum number of entries kept in the SDF text layout cache + * + * @remarks + * The SDF text renderer caches the computed glyph layout for a given + * `text` + font + layout-prop combination so that identical strings (e.g. + * repeated badges/labels) are not re-laid-out. The cache is content-keyed + * and shared across nodes, and is trimmed down to this many (most recently + * used) entries whenever the stage goes idle. + * + * Set this higher for content-dense UIs with many simultaneous unique + * strings, or lower to cap memory more aggressively. Strings longer than + * the renderer's internal length threshold are never cached regardless of + * this value, since long unique strings (e.g. descriptions) have a near-zero + * cache hit rate. + * + * @defaultValue `250` + */ + textLayoutCacheSize: number; + /** * Include context call (i.e. WebGL) information in FPS updates * @@ -678,6 +698,7 @@ export class RendererMain extends EventEmitter { fpsUpdateInterval: settings.fpsUpdateInterval || 0, enableClear: settings.enableClear ?? true, targetFPS: settings.targetFPS || 0, + textLayoutCacheSize: settings.textLayoutCacheSize ?? 250, numImageWorkers: settings.numImageWorkers !== undefined ? settings.numImageWorkers : 2, enableContextSpy: settings.enableContextSpy ?? false, @@ -755,6 +776,7 @@ export class RendererMain extends EventEmitter { textBaselineMode: settings.textBaselineMode!, inspector: settings.inspector !== null, targetFPS: settings.targetFPS!, + textLayoutCacheSize: settings.textLayoutCacheSize!, textureProcessingTimeLimit: settings.textureProcessingTimeLimit!, createImageBitmapSupport: settings.createImageBitmapSupport!, premultiplyAlphaHonored: settings.premultiplyAlphaHonored, From 15570a6bc8cd6999064ba185cf35e4941c400bb6 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Thu, 4 Jun 2026 16:49:45 -0400 Subject: [PATCH 2/3] refactor(text): drop length-based cache skip, rely on LRU alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove MAX_CACHED_TEXT_LENGTH and the >100-char skip guard. The idle LRU eviction already bounds the cache regardless of entry size, so the length skip is redundant — long unique strings simply age out as least-recently-used instead of being refused entry. This also lets long strings that *do* repeat (e.g. a description shown across multiple nodes) benefit from caching. Update the renderer option docs and tests accordingly. Co-Authored-By: Claude Opus 4.8 --- src/core/text-rendering/SdfTextRenderer.test.ts | 16 +++------------- src/core/text-rendering/SdfTextRenderer.ts | 11 +---------- src/main-api/Renderer.ts | 5 +---- 3 files changed, 5 insertions(+), 27 deletions(-) diff --git a/src/core/text-rendering/SdfTextRenderer.test.ts b/src/core/text-rendering/SdfTextRenderer.test.ts index 91fe3fb..89d3cb3 100644 --- a/src/core/text-rendering/SdfTextRenderer.test.ts +++ b/src/core/text-rendering/SdfTextRenderer.test.ts @@ -86,24 +86,14 @@ describe('SdfTextRenderer layout cache', () => { expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1); }); - it('does not cache strings longer than the threshold (100 chars)', () => { + it('caches long strings too (no length-based skip)', () => { initRenderer(10); - const long = 'x'.repeat(101); + const long = 'x'.repeat(500); render(long); render(long); - // Never cached, so every render recomputes. - expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(2); - }); - - it('caches strings exactly at the threshold (100 chars)', () => { - initRenderer(10); - const atLimit = 'y'.repeat(100); - - render(atLimit); - render(atLimit); - + // Bounded purely by the LRU cap, not by length. expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1); }); diff --git a/src/core/text-rendering/SdfTextRenderer.ts b/src/core/text-rendering/SdfTextRenderer.ts index 4973114..3ae298e 100644 --- a/src/core/text-rendering/SdfTextRenderer.ts +++ b/src/core/text-rendering/SdfTextRenderer.ts @@ -24,13 +24,6 @@ const type = 'sdf' as const; let sdfShader: WebGlShaderNode | null = null; -// Strings longer than this are never cached. Long strings (e.g. show -// descriptions) are almost always unique and set once, so they have a -// near-zero cache hit rate while being the largest entries — caching them is -// all cost and no benefit. They still lay out and render normally; they just -// don't enter the shared cache. -const MAX_CACHED_TEXT_LENGTH = 100; - // Upper bound on layoutCache entries, enforced on idle via `cleanup`. // Overridden from stage options in `init`. The cache is allowed to grow past // this during active rendering and is trimmed back to it when the stage idles. @@ -101,9 +94,7 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => { // Calculate text layout and generate glyph data for caching layout = generateTextLayout(props, fontData); - if (props.text.length <= MAX_CACHED_TEXT_LENGTH) { - layoutCache.set(cacheKey, layout); - } + layoutCache.set(cacheKey, layout); // For SDF renderer, ImageData is null since we render via WebGL return { diff --git a/src/main-api/Renderer.ts b/src/main-api/Renderer.ts index 67ed5aa..f4af1c0 100644 --- a/src/main-api/Renderer.ts +++ b/src/main-api/Renderer.ts @@ -404,10 +404,7 @@ export type RendererMainSettings = RendererRuntimeSettings & { * used) entries whenever the stage goes idle. * * Set this higher for content-dense UIs with many simultaneous unique - * strings, or lower to cap memory more aggressively. Strings longer than - * the renderer's internal length threshold are never cached regardless of - * this value, since long unique strings (e.g. descriptions) have a near-zero - * cache hit rate. + * strings, or lower to cap memory more aggressively. * * @defaultValue `250` */ From 7b9ad026751fa213a9d062a6678efba7aacc92e9 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Thu, 4 Jun 2026 16:55:50 -0400 Subject: [PATCH 3/3] test(webgl): add cleanupTextRenderers to idle-loop stage mock The idle block now calls stage.cleanupTextRenderers(); add it to the mocked stage in the out-of-memory render-loop test so the idle path doesn't throw. Co-Authored-By: Claude Opus 4.8 --- src/core/platforms/web/WebPlatform.outOfMemory.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/platforms/web/WebPlatform.outOfMemory.test.ts b/src/core/platforms/web/WebPlatform.outOfMemory.test.ts index ae544c2..71d2d6c 100644 --- a/src/core/platforms/web/WebPlatform.outOfMemory.test.ts +++ b/src/core/platforms/web/WebPlatform.outOfMemory.test.ts @@ -19,6 +19,7 @@ function makeIdleStage(outOfMemory: boolean) { drawFrame: vi.fn(), flushFrameEvents: vi.fn(), shManager: { cleanup: vi.fn() }, + cleanupTextRenderers: vi.fn(), eventBus: { emit: vi.fn() }, txMemManager: { checkCleanup: vi.fn(() => false),