feat(core): placeholderImage — shared pinned image placeholder while a texture loads#97
Open
chiefcll wants to merge 2 commits into
Open
feat(core): placeholderImage — shared pinned image placeholder while a texture loads#97chiefcll wants to merge 2 commits into
chiefcll wants to merge 2 commits into
Conversation
…a texture loads Extends placeholderColor (#96) with a per-node placeholder image. While the node's texture is not loaded (initial load, freed-texture reload, permanent failure) the quad renders the placeholder image through the node's own shader, stretched to the node's dimensions. Fallback chain per frame: main texture -> placeholder image -> placeholderColor rect -> nothing. Lifecycle is pin-once instead of per-node ownership: the setter resolves the URL through the texture keyCache (src-only props, so every node using the same URL shares one instance — e.g. one image per poster size), sets preventCleanup, and eagerly priority-loads from idle states only (a state guard keeps N nodes from starting N duplicate fetches). Per-node loaded/failed/freed listeners drive the fallback state machine and are detached on swap, clear, and destroy so nodes never leak through the long-lived texture. The freed handler self-heals out-of-band frees (context loss, another node unpinning the shared URL) by re-pinning and reloading. Renderers additionally stop applying node.textureCoords while a placeholder shows — those coords belong to the main texture (resizeMode, flips) and would mis-crop a real placeholder image (the 1x1 white texture masked this). Once the image is showing it renders untinted; placeholderColor only colors the rect fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ently Covers the shared-placeholder contract explicitly: one cached instance and a single fetch across N nodes, every node notified on load, each switching to its own main texture independently, and one node's destroy leaving the others' subscriptions intact. Co-Authored-By: Claude Fable 5 <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.
What
New per-node
placeholderImageprop, extendingplaceholderColor(#96) with image placeholders. While the node's texture is not loaded — initial load, freed-texture reload (#87 lifecycle), or permanent failure — the quad renders the placeholder image through the node's own shader (rounded corners/borders apply), stretched to the node's dimensions. The per-frame fallback chain is:main texture → placeholder image →
placeholderColorrect → nothingDesign: pin-once, not per-node ownership
Apps use a small number of distinct placeholder images (e.g. one per poster size class) across many nodes, so the texture lifecycle is deliberately simple:
keyCachewith src-only props — 500 posters across 3 size classes = 3 texture instances.preventCleanup = true, so the memory manager never frees it and the fix(textures): evict orphaned cached textures during cleanup #95 orphan eviction never touches it. Eagerly priority-loaded at assignment, from idle states only — a state guard keeps N nodes from starting N duplicate fetches of the same source.loaded/failed/freedlisteners drive the fallback state machine (same pattern as the main-texture handlers). They are detached on swap, on clearing the prop, and indestroy(), so destroyed nodes never leak through the long-lived shared texture (unit-tested viahasListeners()).freedhandler is the self-heal: if the pinned texture is freed out-of-band (context loss, or another node'stextureOptionsunpinning the shared URL), the first notified node re-pins and reloads; the rest see'loading'. Everyone falls back to the color rect meanwhile.Trade-off documented on the prop: placeholder images are resident for the app lifetime — use a few app-level images, not per-item artwork. Cost is bounded by distinct URLs, not node count.
Reviewer notes
node.textureCoordsbelongs to the main texture (resizeMode, flips, sub-rects). The 1×1 white texture in feat(core): placeholderColor — solid color placeholder while a texture loads #96 masked stale coords; a real placeholder image would be mis-cropped by them, so placeholders now always sample full-quad.placeholderColoronly colors the rect fallback. Keeps "gray rect, then branded image, then poster" from becoming "gray-tinted branded image".Testing
CoreNode.test.ts(438 total pass): pin + eager load on assignment; already-loaded shared texture used immediately and untinted; color-rect fallback until the image loads; image-only placeholder renders nothing until loaded; loaded main wins; freed main re-shows the placeholder; failed placeholder falls back to the rect; freed self-heal re-pins and reloads exactly once; destroy/swap/clear listener hygiene on the shared texture.examples/tests/texture-placeholder-image.tswith five deterministic states (failed main + placeholder rounded, same under the border shader, placeholder-404 → color-rect fallback, loaded image wins, no-fallback control). Certifiedchromium-cisnapshot captured in Docker; the full 177-snapshot suite passes in the CI container.🤖 Generated with Claude Code