Skip to content

Svelte 4/Bits-UI: Puppeteer testing fails to select Avatar fallback element despite it rendering

svelte 4 + bits-ui 0.22 (@melt-ui/svelte avatar builder): swapped a raw <img src={pfpUrl(id)}> for Avatar / Avatar.Image / Avatar.Fallback so a 404ing avatar shows initials instead of the browser's broken-image glyph. The failure mode is entirely silent — no exception, no console warning, no error event surfaced anywhere; the only signal is computed style. Two things then made verification look broken. (1) document.querySelector('span[data-bits-avatar-fallback]') matches nothing, even though the fallback is clearly in the DOM and img[data-bits-avatar-image] matches fine, so page.waitForSelector fails with selector currently matches no elements and it reads as 'the fallback never rendered'. (2) After a run where the avatar URL had been served a 404 via puppeteer request interception, a later navigation with interception off showed the <img> at style="display: none" with naturalWidth: 500 at the same time: the element decoded real pixels yet the component still believed the image had failed, so the initials stayed up. Assumed a hydration/{#key} staleness bug in my own component and went looking there; also assumed loading="lazy" passed through to Avatar.Image was throttling things, since the image had loading="lazy" and sat below the fold. Neither was it.

1 solution
ranked by outcome — not votes
Accepted

Both come from melt-ui's avatar builder, and neither is in your component. There is no error text to grep for: every path here is silent, which is why the only reliable assertion is on computed style.

Visibility is driven by a second, invisible request. createAvatar never listens to the rendered <img>. It runs an effect that does const image = new Image(); image.src = $src; and flips a loadingStatus store on that probe's onload / onerror. The rendered element only gets style="display: <loaded ? block : none>" derived from the store; the fallback gets display:none + hidden once loaded.

Consequences:

  • The <img> in the DOM can load successfully (naturalWidth > 0) while the component still shows the fallback, whenever the probe request resolved differently — a cached 404 from an earlier interception run, a 404 for new Image() that later got a 200 from cache for the element, an aborted probe. The two requests are independent, so cache state can desync them.
  • To force the failure path deterministically you must fail the probe, not the element. Request interception on the URL works because it catches both, but plain img.dispatchEvent(new Event('error')) or clobbering img.src in the page does nothing at all — the store never sees it.
  • Any loading="lazy" you pass to Avatar.Image is inert. It lands on the element ({...$$restProps}), but the probe is an eager new Image(), so the network fetch happens regardless. Drop the attribute; it only misleads the next reader.
  • SSR is safe: isBrowser gates the effect, so the server renders initials with the <img> at display:none — no broken-glyph flash before hydration.

Attribute names differ between the two elements. bits-ui's avatar-image.svelte hardcodes data-bits-avatar-image on top of melt's data-melt-avatar-image, but avatar-fallback.svelte goes through getAttrs('fallback'), which yields data-avatar-fallback (alongside data-melt-avatar-fallback). So the symmetric selector you'd guess does not exist, and the only feedback is puppeteer's own selector currently matches no elements. Actual rendered markup:

<div class="relative flex shrink-0 overflow-hidden ..." data-avatar-root="">
  <img data-melt-avatar-image data-bits-avatar-image style="display: none;" ...>
  <span data-melt-avatar-fallback data-avatar-fallback class="...">M</span>
</div>

Select the fallback with [data-avatar-fallback] or [data-melt-avatar-fallback]; data-bits-avatar-fallback is not emitted.

Assertion that actually pins the behavior (puppeteer; same shape in Playwright):

await page.setCacheEnabled(false);              // else the probe reuses a stale 404/200
await page.setRequestInterception(true);
page.on('request', (req) =>
  req.url().includes('/user_pfp/')
    ? req.respond({ status: 404, contentType: 'text/plain', body: 'missing' })
    : req.continue().catch(() => {}));
await page.goto(url, { waitUntil: 'domcontentloaded' });
// => img display:none, naturalWidth 0; [data-avatar-fallback] display:flex, no [hidden]

Run the same check with interception off and setCacheEnabled(false) to prove the happy path (img display:block, fallback hidden); without disabling the cache the two runs contaminate each other, which is exactly how the display:none + naturalWidth:500 state appears.

Verified on bits-ui@0.22.0 with @melt-ui/svelte under Svelte 4; the builder source is @melt-ui/svelte/dist/builders/avatar/create.js.