feat(text): bound SDF text layout cache with idle LRU eviction#55
Merged
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
0770e80 to
7b9ad02
Compare
chiefcll
added a commit
that referenced
this pull request
Jun 4, 2026
…troy (#56) Two lifecycle leaks that accumulate over a long-lived session (embedded device, page never reloaded) as nodes/textures are created and destroyed during navigation: 1. Shader value cache never released on node destroy. CoreShaderManager.valuesCache/valuesCacheUsage are keyed by resolved shader props + node size. Usage is incremented when values are computed and only decremented in the shader node's update() when the key changes, so the idle cleanup() (which evicts entries at usage <= 0) could never reclaim a destroyed node's last entry — it stayed pinned at usage >= 1 forever. This affects nearly every built-in shader (RoundedRectangle, Border, Shadow, gradients, ...). Fix: move the held `valueKey` onto the base CoreShaderNode and add detachNode(), which decrements its usage and detaches from the node. CoreNode.destroy() calls it for non-default (per-node) shaders. 2. SubTexture never unsubscribed from its parent atlas. SubTexture attaches 4 listeners (loading/loaded/failed/freed) to the parent texture, which is typically a long-lived shared atlas (preventCleanup), but had no destroy() override — so every destroyed SubTexture (and everything its handlers captured) was retained by the parent's listener lists. Fix: SubTexture.destroy() off()s the four handlers; Texture.destroy() now also removeAllListeners() defensively. Found via a memory-leak audit prompted by the SDF text-cache fix (#55). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The SDF text layout cache (
layoutCacheinSdfTextRenderer) is content-keyed (text+ font + layout props) and shared across nodes — so identical strings (badges, labels) correctly reuse layout work. But it had no eviction: every distinct combination ever rendered stays in the map for the life of the JS context.Node
destroy()only releases the per-node caches (_sdfCache,_cachedLayout); it never touches the shared map. On a long-lived embedded session (set-top box, no page reload), browsing many entity pages with unique descriptions grows the cache monotonically. Each ~200-char description retains a glyph-layout array (~30 KB of JS heap — plain numbers, no GPU/texture/ImageDatareferences) that is never freed.Rough scale: ~1k distinct descriptions ≈ ~30 MB, ~10k ≈ ~300 MB.
Changes
textLayoutCacheSizerenderer option (default250), plumbed through to the SDF/Canvas renderers via stage options.cleanup()trims to the cap, evicting least-recently-used first. Cold entries (e.g. descriptions from pages you've navigated away from) age out automatically.Stage.cleanupTextRenderers()is called from the "entering idle" block inWebPlatform(besideshManager.cleanup()), so trimming runs once per idle transition and never competes with active rendering.cleanupto theTextRendererinterface. Canvas implements it for uniformity, though itslayoutCacheis currently dead code (declared/cleared but never read/written), so it's inert today.The cache is bounded purely by the LRU cap regardless of entry size — long strings simply age out as least-recently-used rather than being refused entry, so long strings that do repeat across nodes still benefit from caching.
Testing
pnpm buildclean,pnpm testpasses (4 new SDF cache tests), lint clean.SdfTextRenderer.test.ts): identical-string reuse, long strings cached too (no length skip), LRU eviction order, and the under-cap no-op.Reviewer notes
textLayoutCacheSizeoption for content-dense UIs.layoutCacheis a pre-existing latent issue left untouched here — candidate for a follow-up to either wire up or remove.🤖 Generated with Claude Code