open-design/apps/web/tests/components/plugins-home-media-surface.test.tsx
YOMXXX 9e76bf0556
fix(web): swap in typographic fallback when a Home media poster fails to load (#2955) (#3070)
MediaSurface rendered preview.poster straight into an <img> with no error handler, so an official Community card whose poster URL 404'd / failed to decode / hit a dead host left the browser's default broken-image glyph on the discovery surface. Reported on the Home page where several official image-template cards looked unreliable side-by-side with healthy ones.

Track a per-URL load-failure flag and swap in the existing plugins-home__media-fallback element (the typographic glyph + media icon) when the <img> fires onError. The flag resets whenever preview.poster changes, so filter rotations or a daemon repopulating the preview after an offline flip get a fresh attempt instead of staying stuck on the fallback.

Regression tests cover the four shapes: default <img> render, error -> fallback swap, poster URL change resets the failed state, and the original no-poster branch still goes straight to the fallback.
2026-05-27 06:34:58 +00:00

102 lines
4.8 KiB
TypeScript

// @vitest-environment jsdom
// Plugins-home media preview surface — broken-poster fallback.
//
// Before #2955 the MediaSurface card rendered `preview.poster`
// straight into an `<img>` with no error handler. When an official
// project's poster URL pointed at an asset the browser could not
// fetch (404, decode error, CSP/CORS reject, or just a dead host),
// the card was left with the browser's default broken-image glyph
// instead of the typographic fallback that no-poster cards already
// use. On the Home page that turned the discovery surface into a
// row of "looks-broken" tiles for official content (issue #2955).
//
// This file pins the new behavior: a failed poster load swaps in
// the same `plugins-home__media-fallback` element that runs for
// poster-less entries, so the discovery surface degrades cleanly
// instead of leaving a broken-image state.
import { describe, expect, it, afterEach } from 'vitest';
import { cleanup, fireEvent, render } from '@testing-library/react';
import { MediaSurface } from '../../src/components/plugins-home/cards/MediaSurface';
import type { MediaPreviewSpec } from '../../src/components/plugins-home/preview';
const POSTER: MediaPreviewSpec = {
kind: 'media',
mediaType: 'image',
poster: 'https://example.invalid/poster.png',
videoUrl: null,
audioUrl: null,
imageOnly: true,
};
afterEach(() => {
cleanup();
});
describe('MediaSurface broken-poster fallback (#2955)', () => {
it('renders the poster <img> by default when a poster URL is provided and the card is in view', () => {
const { container } = render(
<MediaSurface preview={POSTER} pluginTitle="Example" inView={true} />,
);
const img = container.querySelector('img.plugins-home__media-img');
expect(img).not.toBeNull();
expect((img as HTMLImageElement).src).toContain('poster.png');
expect(container.querySelector('.plugins-home__media-fallback')).toBeNull();
});
it('swaps in the typographic fallback when the poster fails to load (the headline #2955 scenario)', () => {
// Browsers emit an `error` event on the <img> when the URL 404s,
// the decode fails, or the host is unreachable. Without an
// onError handler the element stayed in the DOM and the browser
// painted its default broken-image glyph — the broken tile users
// were seeing on official Home cards.
const { container } = render(
<MediaSurface preview={POSTER} pluginTitle="Example" inView={true} />,
);
const img = container.querySelector('img.plugins-home__media-img');
expect(img).not.toBeNull();
fireEvent.error(img as HTMLImageElement);
// After the error, the broken <img> is gone and the typographic
// fallback element is mounted in its place.
expect(container.querySelector('img.plugins-home__media-img')).toBeNull();
expect(container.querySelector('.plugins-home__media-fallback')).not.toBeNull();
});
it('resets the failed-state when the poster URL changes so a recovered/different poster gets a fresh attempt', () => {
// Two real-world scenarios that hit this path:
// 1. The Home page rotates through filters and the same
// `MediaSurface` instance gets a different plugin's poster
// assigned. The previous failure must not poison the new
// URL.
// 2. An offline-then-online flip where the daemon repopulates
// poster URLs. The card has to recover instead of staying
// stuck on the fallback for the session.
const { container, rerender } = render(
<MediaSurface preview={POSTER} pluginTitle="Example" inView={true} />,
);
const img = container.querySelector('img.plugins-home__media-img');
fireEvent.error(img as HTMLImageElement);
expect(container.querySelector('.plugins-home__media-fallback')).not.toBeNull();
const recovered: MediaPreviewSpec = {
...POSTER,
poster: 'https://example.invalid/different-poster.png',
};
rerender(<MediaSurface preview={recovered} pluginTitle="Example" inView={true} />);
expect(container.querySelector('img.plugins-home__media-img')).not.toBeNull();
expect(container.querySelector('.plugins-home__media-fallback')).toBeNull();
});
it('still renders the typographic fallback directly when the spec has no poster URL (no regression for poster-less entries)', () => {
// Regression guard for the original `!hasPoster` branch — the new
// error-handling logic must not break entries that never had a
// poster URL to begin with.
const noPoster: MediaPreviewSpec = { ...POSTER, poster: null };
const { container } = render(
<MediaSurface preview={noPoster} pluginTitle="Example" inView={true} />,
);
expect(container.querySelector('img.plugins-home__media-img')).toBeNull();
expect(container.querySelector('.plugins-home__media-fallback')).not.toBeNull();
});
});