Startseite / Artikel

Animation von Spannung mit ViewTransition: Ersatzlösungen, Bilder, Schriftarten

Erfahren Sie, wie das Einhüllen von Suspense in ViewTransition den Wechsel vom Skelett zum Inhalt animiert, welche Props überflüssige Animationen verhindern und wie React auf Bilder und Schriftarten wartet.

Verlag: REACTAPP.TOP14 Min. Lesezeit3151 Wörter

Quellen: React 19.3 View Transitions with Suspense: Animate the Fallback, Wait for Images and Fonts

Eine Suspense-Grenze beschreibt bereits einen Übergang: Sie zeigt zunächst eine Ersatzansicht an und wechselt anschließend zum eigentlichen Inhalt. Früher war dieser Wechsel ein abrupter Schnitt, oft gefolgt von dem Erscheinen eines Bildes kurz darauf sowie dem Einbinden einer Web-Schriftart danach. React 19.3 ermöglicht es, die Grenze in <ViewTransition> einzubetten, sodass der Wechsel zu einer Animation wird. Zudem verzögert React diese Animation, bis die Bilder und Web-Schriftarten im neuen Inhalt geladen sind – was weniger offensichtlich ist. Dieser Leitfaden zeigt, wie man das einrichtet, wie man verhindert, dass die Animation ausgelöst wird, wenn sie nicht sollte, wo man die Grenzen platzieren muss und was die integrierten Wartezeiten tatsächlich bewirken.

Weil dieses Verhalten erst kürzlich veröffentlicht wurde, sollten Sie zum Zeitpunkt der Erstellung die Details anhand der aktuellen React-Dokumentation überprüfen, bevor Sie sich auf genaue Zeiten in der Produktion verlassen. Die Beispiele gehen davon aus, dass Sie bereits wissen, dass <ViewTransition> bei bestimmten Auslösern (enter, exit, update) animiert und dass er den DOM umschließen muss, den er animieren will.

In eine Animation umwandeln

Die minimale Version platziert einfach einen <ViewTransition> um den Suspense-Bereich herum:

<ViewTransition>
  <Suspense fallback={<Skeleton />}>
    <Forecast city={city} />
  </Suspense>
</ViewTransition>

Man benötigt startTransition für den Austausch nicht, da React das Zeigen eines Suspense-Bereichs bereits als Transition behandelt. Sobald Forecast mit dem Suspending fertig ist, wird der Commit, der das Skelett ersetzt, innerhalb einer View-Transition ausgeführt. Da das Skelett und die Vorhersage sich innerhalb derselben <ViewTransition> befinden, teilen sie sich denselben generierten Snapshot-Namen; daher wird die Änderung als update behandelt und der Browser wechselt schrittweise von einem zum anderen.

Ein ausführbares Vorhersagebeispiel

Die Demo beginnt mit den erforderlichen Imports:

import { ViewTransition, Suspense, use, useState, startTransition } from 'react';

Der Komponentencodierung speichert für jede Stadt einen Promise in einem Map ab, da use() bei jeder Neuaufladung dieselbe Promise-Instanz erhalten muss; die Erstellung eines neuen Promises bei jeder Neuaufladung würde den Ablauf endlos verzögern. Forecast liest die bereits bearbeiteten Daten mit use() ab, Skeleton zeigt eine Platzhalterkarte an, und App schaltet den gesamten Bereich innerhalb von startTransition um:

// use() needs the same Promise instance across renders, so cache it
const cache = new Map();
function fetchForecast(city) {
  if (!cache.has(city)) {
    cache.set(city, new Promise(resolve =>
      setTimeout(() => resolve({ city, high: 31, low: 24, sky: '⛅' }), 1100)
    ));
  }
  return cache.get(city);
}function Forecast({ city }) {
  const f = use(fetchForecast(city));
  return <div className="card">{f.sky} {f.city}: {f.high}° / {f.low}°</div>;
}function Skeleton() {
  return <div className="card skeleton">Loading forecast…</div>;
}export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => startTransition(() => setShow(s => !s))}>
        {show ? 'Hide' : 'Load'} forecast
      </button>
      {show && (
        <ViewTransition>
          <Suspense fallback={<Skeleton />}>
            <Forecast city="Pune" />
          </Suspense>
        </ViewTransition>
      )}
    </>
  );
}

Mehrere CSS-Regeln gestalten die Karte und verleihen dem Platzhalter einen neutralen Hintergrund, wobei für beide Farbschemata light-dark() verwendet wird:

.card { margin-top: 12px; padding: 14px 16px; border-radius: 10px; background: light-dark(#e0f2fe, #0c3350); }
.skeleton { color: transparent; background: light-dark(#e2e8f0, #2a2f3a); }

Eine letzte Regel verlangsamt den Standard-Übergang, sodass die Veränderung beim Experimentieren leicht sichtbar ist:

/* slow the default cross-fade so the swap is visible */
::view-transition-old(*), ::view-transition-new(*) { animation-duration: 450ms; }

Durch Klicken auf die Schaltfläche wird das Skelett angezeigt, es wird etwa eine Sekunde lang auf den simulierten Abruf gewartet, anschließend erscheint die Vorhersage schrittweise über dem Skelett. Verbergen und erneut laden Sie es jedoch, werden ein Problem feststellen: Die Daten sind nun im Cache gespeichert, sodass kein Ersatzinhalt angezeigt wird, dennoch erscheint die Karte genauso wie beim Verbergen wieder. Das führt zum nächsten Abschnitt.

Beendigung von Animationen, die Sie nicht angefordert haben

Ein <ViewTransition> ohne Eigenschaften animiert jeden Auslöser: enter beim Einbinden, exit beim Entbinden sowie update, sobald sich der Inhalt ändert. In Kombination mit Suspense entstehen dadurch bis zu drei Animationen, obwohl ursprünglich nur eine beabsichtigt war. In Reacts Dokumentation wird das gewünschte Verhalten bei der Laden der Benutzeroberfläche detailliert beschrieben:

  • Der Ersatzinhalt sollte sofort und ohne Animation angezeigt werden, da der Benutzer gerade eine Aktion ausgeführt hat und umgehende Rückmeldung erwartet.
  • Der Wechsel vom Ersatzinhalt zum eigentlichen Inhalt sollte animiert werden.
  • Inhalte, die bereits im Cache sind und daher nie pausiert werden, sollten sofort und ohne Animation angezeigt werden.

Zwei Eigenschaften drücken genau das aus. default="none" deaktiviert alle Auslöser, während update="auto" denjenigen wieder aktiviert, der für Sie relevant ist:

<ViewTransition update="auto" default="none">
  <Suspense fallback={<Skeleton />}>
    <Forecast city={city} />
  </Suspense>
</ViewTransition>

Vergleich der beiden Konfigurationen

Die zweite Demonstration verwendet dieselben Importe:

import { ViewTransition, Suspense, use, useState, startTransition } from 'react';

Es ermöglicht es Ihnen, eine von drei Städten zu laden und zwischen der einfachen Version von <ViewTransition> sowie der Version mit zwei Eigenschaften zu wechseln. Beachten Sie key={city} in <ViewTransition>: Beim Wechsel der Stadt wird eine neue Grenze erstellt, anstatt die vorhandene zu aktualisieren.

const cache = new Map();
function fetchForecast(city) {
  if (!cache.has(city)) {
    const data = { Pune: '⛅ 31° / 24°', Goa: '🌧 29° / 26°', Shimla: '☀️ 18° / 9°' }[city];
    cache.set(city, new Promise(r => setTimeout(() => r(data), 1100)));
  }
  return cache.get(city);
}function Forecast({ city }) {
  const text = use(fetchForecast(city));
  return <div className="card">{city}: {text}</div>;
}export default function App() {
  const [city, setCity] = useState(null);
  const [onlyUpdates, setOnlyUpdates] = useState(false);
  const props = onlyUpdates ? { update: 'auto', default: 'none' } : {};  return (
    <>
      <div className="bar">
        {['Pune', 'Goa', 'Shimla'].map(c => (
          <button key={c} onClick={() => startTransition(() => setCity(city === c ? null : c))}>
            {city === c ? 'Hide' : 'Load'} {c}
          </button>
        ))}
        <label><input type="checkbox" checked={onlyUpdates} onChange={e => setOnlyUpdates(e.target.checked)} /> only animate updates</label>
      </div>
      {city && (
        <ViewTransition key={city} {...props}>
          <Suspense fallback={<div className="card skeleton">Loading {city}…</div>}>
            <Forecast city={city} />
          </Suspense>
        </ViewTransition>
      )}
    </>
  );
}

Die Stylesheet fügt eine flexible Werkzeugleiste sowie dieselben Karten-, Skelett- und verlangsamten Übergangsregeln hinzu:

.bar { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; }
.card { margin-top: 12px; padding: 14px 16px; border-radius: 10px; background: light-dark(#e0f2fe, #0c3350); }
.skeleton { color: transparent; background: light-dark(#e2e8f0, #2a2f3a); }
::view-transition-old(*), ::view-transition-new(*) { animation-duration: 450ms; }

Wenn „nur animierte Aktualisierungen“ aktiviert sind, zeigt eine noch nicht geladene Stadt sofort ihr Skelett an und animiert sich anschließend zu Inhalten, während eine zuvor geladene Stadt einfach ohne Animation erscheint. Die Dokumentation empfiehlt, die Anzeige während des Suspense-Zustands nur gelegentlich zu animieren – und bei in Cache gespeicherten Inhalten, die sofort rendern könnten, überhaupt nicht, da die Animation dort lediglich Verzögerungen verursacht. In der Praxis sollte update="auto" default="none" in fast jedem <ViewTransition> verwendet werden, der eine Suspense-Grenze umschließt.

Die Platzierung bestimmt, welche Auslöser aktiviert werden

Die Position des <ViewTransition> in Bezug auf Suspense bestimmt, welche Auslöser ausgeführt werden und somit welche Teile separat animiert werden können.

Außerhalb der Grenze: eine einzige Aktualisierung

Durch das Umhüllen des Suspense von außen entsteht eine einzige Grenze, die den Wandel als Update wahrnimmt:

<ViewTransition update="cross">
  <Suspense fallback={<A />}>
    <B />
  </Suspense>
</ViewTransition>

Fallback und Inhalt teilen sich denselben Snapshot-Name, sodass man nur zwischen ihnen cross-fade oder morphen kann – hier mit einer benutzerdefinierten cross-Klasse.

In beiden Zweigen: Exit und Enter

Durch das getrennte Umhüllen von Fallback und Inhalt entstehen zwei unabhängige Grenzen:

<Suspense
  fallback={<ViewTransition exit="out"><A /></ViewTransition>}
>
  <ViewTransition enter="in"><B /></ViewTransition>
</Suspense>

Der Fallback verlässt den Bereich mit einer Exit-Animation, während der Inhalt mit einer Enter-Animation eintrifft. Dadurch kann der Platzhalter nach unten verschwinden, während der eigentliche Inhalt an seine Position steigt.

Eine Profilkarte mit der Innenplatzierung

Auch die dritte Demo beginnt wieder mit den Imports:

import { ViewTransition, Suspense, use, useState, startTransition } from 'react';

loadProfile erzeugt bei jedem Klick einen neuen Promise, wodurch die Suspension jedes Mal ausgelöst wird. Sowohl Profile als auch Skeleton umschließen ihre Markup-Elemente jeweils in einen eigenen <ViewTransition>, wobei für Profile enter="rise" und für Skeleton exit="sink" verwendet werden, während default="none" gilt. Der Wert key={attempt} bei Suspense ist wichtig: Er erzeugt für jede Ladeoperation eine neue Grenze, denn eine bereits vorhandene Grenze, die innerhalb einer Transition erneut suspendiert wird, zeigt weiterhin den vorherigen Inhalt an anstelle des Ersatzinhalts.

function loadProfile() {
  // a fresh Promise per click, so it suspends every time
  return new Promise(r => setTimeout(() => r({ name: 'Rahul', role: 'Frontend' }), 1100));
}function Profile({ promise }) {
  const p = use(promise);
  return (
    <ViewTransition enter="rise" default="none">
      <div className="card">👤 {p.name} — {p.role}</div>
    </ViewTransition>
  );
}function Skeleton() {
  return (
    <ViewTransition exit="sink" default="none">
      <div className="card skeleton">loading…</div>
    </ViewTransition>
  );
}export default function App() {
  const [attempt, setAttempt] = useState(0);
  const [promise, setPromise] = useState(null);
  return (
    <>
      <button onClick={() => startTransition(() => { setAttempt(a => a + 1); setPromise(loadProfile()); })}>
        {attempt ? 'Load again' : 'Load profile'}
      </button>
      {promise && (
        // key: a NEW boundary each time. A mounted boundary that re-suspends
        // inside a Transition keeps showing the old content instead of the fallback.
        <Suspense key={attempt} fallback={<Skeleton />}>
          <Profile promise={promise} />
        </Suspense>
      )}
    </>
  );
}

Die Basisstile verleihen der Karte sowie dem Skeleton ihr Aussehen:

.card { margin-top: 12px; padding: 14px 16px; border-radius: 10px; background: light-dark(#e0f2fe, #0c3350); }
.skeleton { color: light-dark(#64748b, #94a3b8); background: light-dark(#e2e8f0, #2a2f3a); }

Die Animationen richten sich auf die benannten Klassen. ::view-transition-old(.sink) gilt für das ausgehende Skelett-Snapshot und ::view-transition-new(.rise) für den eingehenden Inhalt, wobei jeweils eigene Keyframes verwendet werden:

::view-transition-old(.sink) { animation: 240ms ease-in  both sink; }
::view-transition-new(.rise) { animation: 320ms ease-out both rise; }
@keyframes sink { to   { transform: translateY(16px);  opacity: 0; } }
@keyframes rise { from { transform: translateY(-16px); opacity: 0; } }

Warten auf Bilder, Schriftarten und Stylesheets

Suspense-Grenzen haben seit Langem Daten sowie verschoben ladenden Code zurückgehalten. In React 19.3 erweitert sich die Liste der Elemente, die von einer Grenze zurückgehalten werden, wenn die Anzeige durch eine <ViewTransition> animiert wird:

  • Daten, die mit use() und verschoben ladenden Komponenten abgerufen werden: In beiden Fällen wird darauf gewartet.
  • Stylesheets, die mit <link rel="stylesheet" precedence="…"> deklariert sind: In beiden Fällen wird darauf gewartet (mit einer Zeitüberschreitung für normales Suspense).
  • Schriften, die durch den neuen Inhalt eingeführt werden: Von einfachem Suspense ignoriert; während eines Updates bei einem Ansichtswechsel wird mit einer Zeitbeschränkung darauf gewartet.
  • Sichtbare <img>-Elemente: Von einfachem Suspense ignoriert; während eines Updates bei einem Ansichtswechsel wartet React bis zu 800 ms auf die Dekodierung, außer bei Bildern, die loading="lazy" verwenden oder einen onLoad-Handler haben.
  • Der Grund ist visueller Natur. In eine Karte einzufügen, deren Foto nur teilweise geladen ist oder deren Text noch in einer Systemschrift angezeigt wird, sieht schlechter aus als ein einfacher Schnitt. Deshalb verschiebt React die Darstellung bei einem anstehenden Ansichtswechsel, bis die Bilder und neuen Schriften in diesem Bereich verfügbar sind. Ohne Ansichtswechsel ändert sich nichts, und Bilder erscheinen weiterhin sofort, sobald sie ankommen.

    Wie lange ist React bereit zu warten

    Die Wartezeiten sind begrenzt. Diese Details stammen aus der Lektüre der react-dom-Implementierung und nicht aus der Dokumentation, daher sollten sie als Implementierungsdetails betrachtet werden, die sich ändern können:

    • Für Bilder ruft React img.decode() auf und verzögert den Commit um höchstens 800 ms.
    • Außerdem schätzt React anhand der width- und height-Attribute, wie groß jedes Bild ist, sowie anhand der Resource Timing-Einträge, wie schnell das Netzwerk ist. Wenn diese Schätzung besagt, dass die Herunterladung länger als etwa 500 ms dauern würde, gibt React nur 50 ms zu und zeigt das Bild dennoch an.
    • Fehlen width und height, geht die Schätzung von einem 100×100-Pixel-Bild aus; daher machen die tatsächlichen Abmessungen die Schätzung sinnvoll.
    • Für Schriftarten liegt die Grenze bei etwa 500 ms.

    Demonstration des Bild-Wartezeits

    In der Bild-Demo werden einige zusätzliche Hooks importiert:

    import { ViewTransition, Suspense, use, useState, useRef, useLayoutEffect, startTransition } from 'react';
    

    Jeder Klick lädt eine Liste mit einer zufällig generierten Foto-URL herunter, sodass das Bild niemals im Cache gespeichert wird. Zwei Felder zeigen dieselbe Karte nebeneinander an. Das linke lässt React auf das Foto warten; das rechte weist einen leeren onLoad-Handler zu, der das Anzeigen des Bildes verhindert, sodass die Karte sofort sichtbar wird und das Foto anschließend ankommt. Ein useLayoutEffect protokolliert img.complete im Moment, in dem jede Karte angezeigt wird, damit man den Unterschied in einem schnellen Netzwerk erkennen kann, wo dieser sonst schwer zu bemerken ist.

    function fetchListing() {
      return new Promise(r => setTimeout(() => r({
        title: 'Sea-facing 2BHK, Bandra',
        // a new seed every time → a fresh download every time
        photo: `https://picsum.photos/seed/${Math.random().toString(36).slice(2)}/600/300`,
      }), 300));
    }function Listing({ promise, optOut, label }) {
      const l = use(promise);
      const img = useRef(null);
      // runs at the moment the content is committed — was the photo already there?
      useLayoutEffect(() => {
        console.log(`${label}: photo decoded before reveal → ${img.current.complete}`);
      }, [label]);
      return (
        <div className="card">
          <img
            ref={img}
            src={l.photo}
            width={600}
            height={300}
            alt=""
            onLoad={optOut ? () => {} : undefined}
          />
          <strong>{l.title}</strong>
        </div>
      );
    }function Slot({ label, optOut }) {
      const [promise, setPromise] = useState(null);
      return (
        <div>
          <button onClick={() => startTransition(() => setPromise(fetchListing()))}>{label}</button>
          {promise && (
            <ViewTransition update="auto" default="none">
              <Suspense fallback={<div className="card skeleton"><div className="ph" /><strong>loading…</strong></div>}>
                <Listing promise={promise} optOut={optOut} label={label} />
              </Suspense>
            </ViewTransition>
          )}
        </div>
      );
    }export default function App() {
      return (
        <div className="two">
          <Slot label="Wait for photo" optOut={false} />
          <Slot label="onLoad opt-out" optOut={true} />
        </div>
      );
    }
    

    Die Styles legen die Anordnung der beiden Spalten fest und reservieren das Seitenverhältnis des Bildes sowohl in der Karte als auch im Platzhalter.

    .two { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
    .card { margin-top: 10px; padding: 10px; border-radius: 10px; background: light-dark(#f1f5f9, #262a33); }
    .card img, .ph { display: block; width: 100%; height: auto; aspect-ratio: 2 / 1; border-radius: 6px; object-fit: cover; margin-bottom: 8px; }
    .ph { background: light-dark(#e2e8f0, #363b47); }
    .skeleton strong { color: light-dark(#64748b, #94a3b8); }
    ::view-transition-old(*), ::view-transition-new(*) { animation-duration: 450ms; }
    

    Schriften als nachverfolgbare Ressourcen

    Schriften folgen demselben Muster. Der Trick besteht darin, die @font-face-Regel in einem <style>-Element mit sowohl href als auch precedence zu deklarieren, wodurch React sie an die Oberfläche bringt und als Ressource verfolgt, auf die das System warten kann:

    <ViewTransition update="auto" default="none">
      <Suspense fallback={<QuoteSkeleton />}>
        <style href={fontUrl} precedence="default">
          {`@font-face {
            font-family: 'Quote';
            src: url(${fontUrl}) format('woff2');
            font-display: swap;
          }`}
        </style>
        <blockquote style={{ fontFamily: 'Quote' }}>{quote}</blockquote>
      </Suspense>
    </ViewTransition>
    

    In der React-Dokumentation gibt es ein umfassenderes Beispiel, das heruntergeladene Daten, eine precedence-Stylesheet, eine benutzerdefinierte Schriftart sowie ein Porträtfoto hinter einem gemeinsamen Rahmen zusammen zeigt. Diese Kombination entspricht den meisten echten Karten: ein Profil, ein Produktbild, eine Chatnachricht mit Avatar.

    Wie das früher gemacht wurde

    Zuvor erforderte das Zurückhalten von Inhalten, bis ein Bild bereit war, manuelles Vorausladen: Es musste ein Image-Objekt erstellt werden, auf onload gewartet und anschließend ein Zustandswert geändert werden. Ein typischer Hook sah so aus:

    function useImageReady(src) {
      const [ready, setReady] = useState(false);
      useEffect(() => {
        const img = new Image();
        img.onload = () => setReady(true);
        img.src = src;
      }, [src]);
      return ready;
    }
    

    Jedes Komponente hatte seine eigene Kopie und koordinierte sich nicht miteinander, wodurch eine Karte mit Foto und benutzerdefiniertem Schriftart immer noch in zwei sichtbaren Schritten angezeigt wurde. Die Schriftarten waren sogar noch schlimmer, da dafür entweder document.fonts.load() zusammen mit einem Flag oder font-display: block sowie ein kurzer Moment unsichtbaren Textes erforderlich waren. Um den Wechsel zu animieren, musste startViewTransition manuell aufgerufen werden, sobald Daten eintrafen – und da dieser Vorgang über flushSync abgewickelt wurde, konnte er nicht selbst auf etwas warten.

    Warum der Wandel wichtig ist

    Das Laden der Benutzeroberfläche reduziert sich von vier Entscheidungen auf eine einzige. Die bereits vorhandene Suspense-Grenze bestimmt nun auch, wann Inhalt als bereit gilt – und umfasst dabei Daten, Code, Styles, Schriftarten sowie Bilder. Die Animation wird anschließend genau zu diesem Zeitpunkt einmal ausgeführt. Ressourcenbezogene Effekte verschwinden genauso wie der Verbindungscode, der versucht hatte, sie zu synchronisieren.

    Dies gibt React außerdem die Möglichkeit, mitzubestimmen, wann der Browser ein geladenes Bild zum ersten Mal darstellt. Früher wurde ein <img> bereits sofort nach der Dekodierung angezeigt. Die Verknüpfung mit dem View-Transition erzeugt das „vollständig sichtbar“-Verhalten nativer Apps – ohne manuelles Vorausladen.

    Momente, die man berücksichtigen muss

    • Schriften und Bilder werden nur während eines View-Transition-Updates abgewartet. Ohne <ViewTransition> oder mit update="none" erscheinen Bilder wie zuvor sofort.
    • Durch Hinzufügen eines onLoad-Handlers wird dieses Bild unabhängig vom View-Transition von der Wartezeit ausgenommen. Das ist nützlich für Bilder unterhalb des Scrollbereichs, überraschend jedoch, wenn der Handler zur Analyse hinzugefügt wurde.
  • Die Grenzwerte betragen etwa eine halbe Sekunde für Schriftarten, 800 ms für Bilder und 50 ms, wenn React vorhersagt, dass ein Bild für das aktuelle Netzwerk zu groß ist. Ein langsames CDN kann die Anzeige etwas verzögern, aber niemals blockieren.
  • Bilder außerhalb des Sichtbereichs werden zu diesem Zweck ignoriert.
  • Gecachter Inhalt animiert standardmäßig erneut, daher sollte Suspense zusammen mit update="auto" default="none" verwendet werden.
  • Durch die Platzierung außerhalb zwingen ein Skelett und Inhalt unterschiedlicher Höhe den Browser, das Snapshot anzupassen, was sich als visuelle Dehnung zeigt. Größen Sie den Platzhalter wie den endgültigen Inhalt an oder wechseln Sie auf die Platzierung innerhalb, damit beide eigenständig animiert werden.
  • Die Übergangstypen werden nach jedem Commit zurückgesetzt, sodass ein Typ wie next oder back, der zur Anzeige des Fallbacks hinzugefügt wurde, nicht mehr vorhanden ist, wenn der eigentliche Inhalt kommittet wird.
  • Fallback für ältere React-Versionen

    Falls Sie noch nicht upgraden können, ist die zuverlässige Methode, um ein plötzliches Auftauchen von Bildern zu vermeiden, das Bild als Teil dessen zu behandeln, was geladen wird: Warten Sie auf eine Promise, die erst dann erfüllt wird, wenn das JSON eingetroffen ist und das Foto decodiert wurde:

    function loadListing() {
      return fetch('/api/listing').then(r => r.json()).then(async data => {
        const img = new Image();
        img.src = data.photo;
        await img.decode().catch(() => {});   // don't let a broken image block forever
        return data;
      });
    }
    

    Der .catch(() => {})-Block in decode() sorgt dafür, dass ein beschädigtes Bild die Anzeige nicht endlos blockieren kann. Es kommt weiterhin zu einem plötzlichen Wechsel anstelle einer Animation, doch das Bild befindet sich zum Zeitpunkt der Darstellung bereits im Cache und erscheint im selben Frame wie der Rest der Karte. Schriften können auf ähnliche Weise behandelt werden, indem man die Promise von document.fonts.load('1em Quote') verknüpft.

    Wichtige Erkenntnisse

    • Durch das Einpacken von Suspense in <ViewTransition> wird der Wechsel zum Ersatzinhalt animiert, ohne dass ein expliziter startTransition für die Anzeige erforderlich ist.
    • Verwenden Sie update="auto" default="none", damit Ersatzinhalte und im Cache gespeicherter Inhalt sofort angezeigt werden und nur die eigentliche Anzeige animiert wird.
  • Platzieren Sie den <ViewTransition> außerhalb von Suspense für eine einzige Übergangseffekt-Animation oder innerhalb beider Zweige für unabhängige Ein- und Ausgangsanimationen.
  • Während eines Ansichtsübergangs wartet React außerdem innerhalb strenger Grenzen auf sichtbare Bilder und neue Schriftarten, wodurch die meisten manuell durchgeführten Vorauladungen entfallen.
  • Geben Sie Bildern echte width- und height-Werte an, und bedenken Sie, dass onLoad diese Funktionen deaktiviert.
  • Hauptreferenzen: die Release-Notes von React 19.3, die Referenz zu Suspense, die Referenz zu ViewTransition sowie die Referenz zur Style-Komponente.