Skip to content

feat(core): placeholderColor — solid color placeholder while a texture loads#96

Merged
chiefcll merged 2 commits into
mainfrom
feat/placeholder-color
Jun 10, 2026
Merged

feat(core): placeholderColor — solid color placeholder while a texture loads#96
chiefcll merged 2 commits into
mainfrom
feat/placeholder-color

Conversation

@chiefcll

@chiefcll chiefcll commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

What

New per-node placeholderColor prop: a node with a texture renders a solid rect of this color until the texture loads, instead of rendering nothing. The placeholder renders through the node's own shader, so Rounded / RoundedWithBorder corners apply to it. It also shows while a memory-pressure-freed texture reloads (the #87/#95 lifecycle) and remains if the texture permanently fails. Default 0 = off, so existing apps are unaffected.

renderer.createNode({
  src: posterUrl,
  placeholderColor: 0x333333ff,
  shader: renderer.createShader('Rounded', { radius: [20] }),
  ...
});

Why

color is a per-vertex attribute multiplied into the texture sample, so setting it with src tints the image — and an unloaded texture renders nothing (isRenderable = textureLoaded). There was no single-node way to express "rect now, image when loaded". The two-node workaround costs an extra quad of rounded-shader fragment work per card plus event boilerplate.

How

  • CoreNode.placeholderActive — true iff placeholderColor !== 0, texture set, texture not loaded. Recomputed by one private helper from exactly the code paths where its inputs change (texture setter, loaded/failed/freed handlers, placeholderColor setter). Toggles raise PremultipliedColors | IsRenderable, processed in the same frame's update pass.
  • While active: the premult-color block writes all four corners from placeholderColor; both renderers substitute the stage's shared 1×1 white defaultTexture for the quad (one boolean check per quad); updateIsRenderable() treats the node as renderable, including the exhausted-retry branch; the renderTexture getter substitution also fixes the RTT loaded-state guard.

TV cost model (why this shape)

  • Zero extra nodes/quads/overdraw — the alternative two-node approach doubles rounded-shader fragment work per card while loading.
  • Nothing added to the per-translation scroll path; placeholder state changes only on texture lifecycle events.
  • Shader uniform value-key caching untouched: uniforms remain a function of resolvedProps + w/h; the placeholder→image swap changes neither, so shared uniform collections and the bindRenderOp reference-equality skip (perf(webgl): skip redundant uniform uploads in bindRenderOp #92) survive the transition.
  • All placeholder quads share one texture, so a run of loading cards batches into fewer texture binds than the loaded state needs.

Testing

  • Unit: 10 new tests in CoreNode.test.ts driving the real loaded/freed/failed event chain via an EventEmitter-backed texture fake: loading shows the placeholder with correct premultiplied colors; loaded switches to the texture + regular colors and marks the quad dirty; freed re-shows it; permanently failed keeps it; no-placeholder / no-texture / already-loaded cases unchanged; clearing or changing the color while active. 416 tests pass.
  • Visual regression: new examples/tests/texture-placeholder-color.ts with four deterministic states (failed-texture placeholder rounded + bordered, loaded image untinted, no-placeholder control). Certified chromium-ci snapshot captured in Docker; the full 176-snapshot suite passes in the CI container — relevant because the change touches the quad path for every node.
  • Live-verified on both backends (WebGL + Canvas2D) in the examples app.

Notes for reviewers

  • onTextureFailed/onTextureFreed still set isRenderable = false immediately (pre-existing behavior); with a placeholder, the same frame's update pass recomputes it to true before quads are submitted, so there is no blank frame.
  • A texture's failed state keeps its listeners, so the fix(textures): evict orphaned cached textures during cleanup #95 orphan-eviction invariant is unaffected: a failed texture shown as a placeholder is never evicted while its node lives.

🤖 Generated with Claude Code

chiefcll and others added 2 commits June 10, 2026 13:59
A node with src + color tints the image (color is a vertex attribute
multiplied into the texture sample), and a node with an unloaded texture
renders nothing — so there was no single-node way to show a background
placeholder while an image loads.

New per-node placeholderColor prop (default 0 = off): while the texture
is not loaded the quad samples the stage's shared 1x1 white default
texture tinted by the premultiplied placeholder color — exactly the
color-rect path, through the node's own shader, so rounded corners and
borders apply. The placeholder also shows while a memory-pressure-freed
texture reloads and remains if the texture permanently fails.

Cost model: zero extra nodes/quads/overdraw; placeholder state is
recomputed only from texture lifecycle events (nothing added to the
per-translation scroll path); shader uniform value-key caching is
untouched (uniforms remain a function of resolvedProps + w/h); all
placeholder quads share one texture so they batch at least as well as
loaded images. Implemented for both WebGL and Canvas2D.

Also fixes animation color detection to match camelCase *Color props so
animating placeholderColor interpolates in color space.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
placeholderColor is not animatable, so the broader isColor match is
unnecessary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@chiefcll chiefcll merged commit 23ae427 into main Jun 10, 2026
1 check passed
@chiefcll chiefcll deleted the feat/placeholder-color branch June 10, 2026 18:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant