# Boundaries

Error- und Loading-Boundaries sind ein zentrales Werkzeug, um Ladezustände
anzuzeigen und Fehler im UI gezielt abzufangen. Sie verhindern, dass Fehler
unkontrolliert die gesamte Anwendung zum Absturz bringen, und geben Usern eine
verständliche Rückmeldung. Gleichzeitig unterstützen sie Entwickler durch
Logging und Monitoring bei der Fehleranalyse.

Es gibt kein universelles Schema, wo Boundaries am besten sitzen. Diese Seite
führt vom Prinzip über die Entscheidung bis zu konkreten Rezepten.

---

  Guideline für interne Teams

    Diese Guideline richtet sich in erster Linie an das interne Team von
    mittwald und setzt entsprechendes Vorwissen voraus. Die darin genannten
    Components beziehen sich auf interne mStudio-Components.

# Grundlagen

## Zwei Primitive

Error- und Loading-Boundary sind **zwei getrennte Konzepte**, ausgedrückt über
zwei Wrapper-Typen:

| Primitiv             | Enthält             | Zweck                                                                          |
| -------------------- | ------------------- | ------------------------------------------------------------------------------ |
| `…WithBoundaries`    | Loading **+** Error | Standardfall; an der Seiten-Wurzel und für enge Loading-Ausnahmen.             |
| `…WithErrorBoundary` | nur Error           | Enges Error-Scoping für Sekundärdaten. Der Loading-Zustand blubbert nach oben. |

Eine reine Loading-Boundary gibt es bewusst **nicht**.

## Wann eine Boundary nötig ist

Liest eine Component eine Datenquelle, **die Suspense auslöst**
(`react-use-promise` / Ghost-`.use()`, eine Suspense auslösende Query), muss sie
von Boundaries abgedeckt sein.

**Umkehrung: kein Suspense → keine Boundary.** Löst der Render-Prop **kein**
Suspense aus (i18n-`useT…()`, Props und bereits oben geladene Cache-Hits zählen
**nicht**), braucht die Component **gar keine** Boundary – dann das **plain
Flow-Primitiv** verwenden (`<LayoutCard>`, `<Section>`), nicht
`…WithErrorBoundary`.

  Cache-Hit löst kein Suspense aus

    Ein `.use()` auf eine Ressource, die weiter oben auf derselben Seite bereits
    aufgelöst wurde, löst kein Suspense mehr aus. Die Boundary darum herum
    bewirkt nichts und gehört entfernt.

# Die richtige Boundary wählen

Drei Fragen führen zur passenden Boundary:

1. **Löst der Inhalt Suspense aus?** Nein → keine Boundary, plain Primitiv. Ja →
   weiter.
2. **Sind die Daten primär oder sekundär?** Primär (Seite ohne sie sinnlos) →
   keine eigene Boundary, die weite Boundary an der Seiten-Wurzel fängt den
   Fehler. Sekundär → eigene enge Boundary.
3. **Wie soll der Bereich laden?** Sichtbar Platz halten → kombinierte
   `…WithBoundaries` mit Skeleton. Nur Fehler abfangen (Loading blubbert nach
   oben) → `…WithErrorBoundary`. Bedingt/unsichtbar (`null` möglich) → bare
   `Fragment…`.

## Error-Scope: Kritikalität der Daten

- **Primär** – die Seite wäre ohne diese Daten sinnlos oder leer (Faustregel:
  die Ressource des Routen-Parameters, z. B. der Cronjob auf `$cronjobId`). →
  **Keine eigene enge Error-Boundary**; der Fehler fällt an die **weite Boundary
  an der Seiten-Wurzel**.
- **Sekundär** – die Seite bleibt ohne diese Daten nutzbar (z. B. eine
  verknüpfte App-Installation). → **Eigene enge `…WithErrorBoundary`**, damit
  ein Fehler nur diesen Teil betrifft.

**Einmalig genutzte Sections** (nur zur Lesbarkeit aufgeteilte
`-components/`-Sektionen, die **ausschließlich die Primär-Ressource** lesen)
bekommen darum **keine eigene Boundary** – die Seiten-Wurzel deckt sie ab. Eine
eigene behält man nur, wenn die Section **Sekundärdaten** liest, **mehrfach oder
geteilt** genutzt wird (`src/shared/`, Listenelemente) oder ein **Modal /
Kontextmenü** ist, das erst beim Öffnen lädt (siehe [Modals](#modals)).

## Loading-Scope: Layout-Shift vermeiden

- **Default: weit.** An der Seiten-Wurzel sitzt immer eine kombinierte
  `…WithBoundaries` (Loading **und** Error), oberhalb aller Sections – wenige,
  große Ladezustände statt vieler kleiner.
- **Ausnahme: eng** für lang ladende, unabhängige Elemente (z. B. Metriken auf
  Dashboards). Hier ist ein enger Error-Scope ohnehin erwünscht, daher nutzt man
  eine enge kombinierte `…WithBoundaries`.

## Darstellung

- **Loading, sichtbar** → **form-treuer Skeleton, der den finalen Platz
  reserviert**. Weder Spinner noch impliziter `null` – beide erzeugen den
  Layout-Shift, den die weite Boundary verhindern soll.
- **Loading, bedingt/unsichtbar** (kann nach dem Laden `null` sein) → `null` ist
  **korrekt**; ein Skeleton würde als Phantom aufblitzen. Das ist der Fall des
  bare `Fragment…`-Wrappers.
- **Error, sichtbarer Sekundär-Content** → sichtbarer degradierter Zustand
  (kompakter Inline-Fehler), **nie stumm** verschwinden.
- **Error, primär/weit** → volle `IllustratedMessage` an der Seiten-Wurzel.

Stummes `null`-on-error ist nur für den bare Fragment-Wrapper erlaubt; Sentry
protokolliert den Fehler ohnehin – es geht rein um die UX.

## Layout-Primitiv bewahren

Jeder Wrapper trägt neben dem Verhalten ein **Layout-Primitiv**: `Section…`
rendert `<Section>` (Abstände), `LayoutCard…` rendert `<LayoutCard>`
(Card-Chrome), `Fragment…` rendert **kein** strukturelles Element. Bei einem
Scope-Wechsel darf dieses Primitiv nicht stillschweigend verloren gehen – z. B.
beim Wechsel `SectionWithBoundaries` → `FragmentWithBoundaries` die Kinder
explizit in eine `<Section>` wrappen.

## Reset / Retry

Reset bei Routenwechsel (`resetKeys`) plus `QueryErrorResetBoundary`; der
Error-Fallback bietet ein Retry an, wenn der Platz es zulässt. Die Aktion sollte
zum Scope passen – ein enges Sekundär-Widget darf nicht die ganze App neu laden.

# Rezepte

## Seiten-Wurzel: LayoutCard

Sind Funktionen oder Informationen einer LayoutCard essenziell für die Seite,
umschließt die gesamte LayoutCard eine `LayoutCardWithBoundaries`.

### Detailseite

Die **Primär**-Ressource des Routen-Parameters wird zuerst geladen, damit im
Fehlerfall die ganze Seite in die ErrorView wechselt. Die einzelnen
`…Section`-Components lesen nur diese Ressource und bekommen daher **keine
eigene Boundary** (siehe [Error-Scope](#error-scope-kritikalität-der-daten)).

```tsx
export const CronjobPage: FC = () => (
  <LayoutCardWithBoundaries>
    {() => {
      const { cronjobId } = usePathParams("cronjobId");
      const cronjob = CronjobGhost.ofId(cronjobId).getCommon().use();

      return (
        <>
          <ProjectDeactivatedAlert project={cronjob.project} />
          <GeneralSection cronjob={cronjob} />
          <IntervalSection cronjob={cronjob} />
        </>
      );
    }}
  </LayoutCardWithBoundaries>
);
```

### Tab

Verteilt eine Seite Inhalte auf mehrere Tabs, sichert man sie mit
`SectionsFragmentWithBoundaries` einzeln ab.

```tsx
export const CronjobTabs: FC = () => (
  <LayoutCard>
    <Tabs>
      <Tab>
        <TabTitle>Allgemein</TabTitle>
        <SectionsFragmentWithBoundaries>
          {() => {
            const { cronjobId } = usePathParams("cronjobId");
            const cronjob = CronjobGhost.ofId(cronjobId).getCommon().use();

            return (
              <>
                <ProjectDeactivatedAlert project={cronjob.project} />
                <GeneralSection cronjob={cronjob} />
                <IntervalSection cronjob={cronjob} />
              </>
            );
          }}
        </SectionsFragmentWithBoundaries>
      </Tab>
    </Tabs>
  </LayoutCard>
);
```

## Section mit Sekundärdaten

Enthält eine Section ergänzende **Sekundär**-Daten, die das primäre
Nutzererlebnis nicht einschränken, lohnt sich eine enge Error-Boundary über
`SectionWithErrorBoundary`: Ein Fehler bleibt lokal, während der Loading-Zustand
zur Seiten-Wurzel blubbert. Lädt der Bereich unabhängig und sichtbar (eigener
Skeleton erwünscht), nimmt man stattdessen die kombinierte
`SectionWithBoundaries`.

```tsx
export const AppInstallationSection: FC<Props> = (props) => (
  <SectionWithErrorBoundary>
    {() => {
      const { cronjobGhost } = asGhostProps(props);
      const appInstallation = cronjobGhost
        .getCommon()
        .linkedAppInstallation.getCommon()
        .use();
      const t = useTCronjob();

      return <>...</>;
    }}
  </SectionWithErrorBoundary>
);
```

**Sonderfall:** Wird eine Section nur bedingt angezeigt, soll keine eigene
Loading View entstehen, damit User kein Ladeverhalten sehen, das zu nichts
Sichtbarem führt. Dafür eignet sich `FragmentWithBoundaries` – es schützt die
Section ohne sichtbaren Ladezustand; das Layout-Primitiv (`<Section>`) bleibt
bewusst erhalten.

```tsx
export const AppInstallationSection: FC<Props> = (props) => (
  <FragmentWithBoundaries>
    {() => {
      const { cronjobGhost } = asGhostProps(props);
      const cronjob = cronjobGhost.getCommon().use();
      const t = useTCronjob();

      if (!cronjob.linkedAppInstallation) {
        return null;
      }

      return <Section>...</Section>;
    }}
  </FragmentWithBoundaries>
);
```

## Modals

Modals und Kontextmenüs öffnen erst auf **Interaktion** – eine seiteninterne
Loading-Boundary ergäbe hier keinen Sinn, den Ladezustand trägt die
Flow-Component selbst. Deshalb sind `ModalWithErrorBoundary` und
`ContextMenuWithErrorBoundary` **reine Error-Boundaries**
(`disableSuspenseBoundary`); ein kombiniertes Pendant gibt es bewusst **nicht**.
`ModalWithErrorBoundary` verhindert außerdem, dass Daten im Modal schon vor dem
Öffnen nachgeladen werden.

### Forms in Modals

Eine teilweise geladene oder fehlerhafte Form untergräbt das Vertrauen der User.
Sobald in der Form ein Fehler auftritt, fällt das ganze Modal in die ErrorView –
auch wenn es darin noch Boundaries gibt.

```tsx
interface Props {
  cronjob: CronjobGhost;
  controller?: OverlayController;
}

export const RenameCronjobModal: FC<Props> = (props) => (
  <ModalWithErrorBoundary controller={props.controller}>
    {() => {
      const { cronjobGhost } = asGhostProps(props, ["cronjob"]);
      const controller = useModalController();
      const form = useForm();
      const handleOnSubmit = async () => {
        ...
        controller.close();
      };

      return (
        <Form form={form} onSubmit={handleOnSubmit}>
          ...
        </Form>
      );
    }}
  </ModalWithErrorBoundary>
);
```

## Diagramm

Diagramme beziehen viele Daten aus externen Services und sind fehleranfällig –
sie gehören immer in eine Boundary. Für das `CartesianChart` gibt es
`ChartFragmentWithBoundaries`.

```tsx
<ChartFragmentWithBoundaries>
  <CartesianChart>...</CartesianChart>
</ChartFragmentWithBoundaries>
```

## Einzelne Elemente

Kleine Elemente wie Texte, Links, Badges oder ContextMenus ergänzen die Seite
nur und werden separat geschützt, um sie nicht komplett zu blockieren. Sie
besitzen oft keine eigene ErrorView und erscheinen im Fehlerfall nicht.

### Text / String

`TextWithBoundaries` bzw. `StringWithBoundaries` – je nachdem, ob am Ende eine
`Text`-Component oder nur ein String gerendert wird.

```tsx
<LabeledValue>
  <Label>{t("projectShortId")}</Label>
  <TextWithBoundaries>
    {() => projectGhost.getCommon().use().shortId}
  </TextWithBoundaries>
</LabeledValue>
```

Zusätzlich gibt es Components, die direkt auf ein Model angewendet werden und
eine eingebaute Boundary besitzen.

```tsx
<ModelLink model={cronjob}>
  <ModelTitle model={cronjob} />
</ModelLink>

<ModelLabeledValue model={cronjob} />
```

### Alerts / Badges

Alerts und Badges ergänzen eine Seite nur um Hinweise oder Status. Sie kommen in
ein `FragmentWithBoundaries` – geschützt, ohne eigene Loading View (der Fall
„bedingter / unsichtbarer Content" mit null-Fallback).

```tsx
export const CustomerBankruptAlert: FC<Props> = (props) => (
  <FragmentWithBoundaries>
    {() => {
      const { customerGhost, asBadge } = asGhostProps(props, ["customer"]);
      const isBankrupt = customerGhost.getCommon().isBankrupt().use();

      if (!isBankrupt) {
        return null;
      }

      if (asBadge) {
        return <AlertBadge>...</AlertBadge>;
      }

      return <Alert>...</Alert>;
    }}
  </FragmentWithBoundaries>
);
```

### Field

`FieldFragmentWithBoundaries` zeigt im Loading State ein Textfeld, dem optional
ein Label mitgegeben werden kann.

```tsx
export const AppInstallationSelectField: FC<Props> = (props) => {
  const { name, label, projectGhost } = asGhostProps(props, ["project"]);

  return (
    <FieldFragmentWithBoundaries suspenseFallbackProps={{ label }}>
      {() => {
        const appInstallations = projectGhost.appInstallations
          .execute()
          .use().items;

        return (
          <Field name={name}>
            <Select isDisabled={appInstallations.length === 0}>
              <Label>{label}</Label>
              {sortedAppInstallations.map((i) => (
                <Option key={i.id} value={i.id}>
                  {i.description}
                </Option>
              ))}
            </Select>
          </Field>
        );
      }}
    </FieldFragmentWithBoundaries>
  );
};
```

### ContextMenu

`ContextMenuWithErrorBoundary` sichert das Menü ab. Damit sein Inhalt nicht
springt, kommen Elemente mit nachgeladenen Inhalten in ein
`MenuItemFragmentWithBoundaries`; diese Boundary enthält kein Suspense, damit
das ganze ContextMenu im Ladezustand verbleibt.

```tsx
export const ActionsContextMenu: FC<Props> = (props) => {
  const { appInstallationGhost } = asGhostProps(props);
  const deleteModalController = useModalController();
  const updateModalController = useModalController();

  return (
    <>
      <ContextMenuWithErrorBoundary placement="bottom end">
        {() => {
          const { isBusy } = appInstallationGhost.getCommon().use();
          return (
            <>
              <DeleteMenuItem
                onAction={() => deleteModalController.open()}
                isDisabled={isBusy}
              />
              <MenuItemFragmentWithBoundaries>
                {() => {
                  const updateAvailable = appInstallationGhost
                    .getCommon()
                    .appVersion.updateAvailable()
                    .use();
                  if (!updateAvailable) {
                    return null;
                  }
                  return (
                    <UpdateMenuItem
                      onAction={() => updateModalController.open()}
                    />
                  );
                }}
              </MenuItemFragmentWithBoundaries>
            </>
          );
        }}
      </ContextMenuWithErrorBoundary>

      <DeleteCronjobModal
        controller={deleteModalController}
        appInstallation={appInstallationGhost}
      />
      <UpdateCronjobModal
        controller={updateModalController}
        appInstallation={appInstallationGhost}
      />
    </>
  );
};
```

# Verfügbare Wrapper

Der Component-Name ergibt sich aus dem Element plus `WithBoundaries`
(kombiniert: Loading + Error) bzw. `WithErrorBoundary` (nur Error). Nicht jedes
Element hat beide Varianten:

| Element              | Kombiniert (`…WithBoundaries`) | Nur Error (`…WithErrorBoundary`) |
| -------------------- | :----------------------------: | :------------------------------: |
| `LayoutCard`         |               ✓                |                ✓                 |
| `LayoutCardFragment` |               ✓                |                ✓                 |
| `Section`            |               ✓                |                ✓                 |
| `SectionsFragment`   |               ✓                |                ✓                 |
| `ContentFragment`    |               ✓                |                ✓                 |
| `Fragment`           |               ✓                |                ✓                 |
| `Text`               |               ✓                |                ✓                 |
| `String`             |               ✓                |                ✓                 |
| `Link`               |               ✓                |                –                 |
| `LinkFragment`       |               ✓                |                –                 |
| `FieldFragment`      |               ✓                |                –                 |
| `ChartFragment`      |               ✓                |                –                 |
| `Avatar`             |               ✓                |                –                 |
| `MenuItemFragment`   |               ✓                |                –                 |
| `Modal`              |               –                |                ✓                 |
| `ContextMenu`        |               –                |                ✓                 |

---

# Weitere Empfehlungen

Ein durchdachtes Boundary-Konzept endet nicht bei der technischen Umsetzung.
Fehler sollten zuverlässig an Monitoring-Systeme wie Sentry oder Datadog
weitergegeben werden, damit Probleme früh sichtbar werden. Manuelle und
automatisierte Tests prüfen regelmäßig, dass die Fallbacks in allen relevanten
Situationen greifen. Ebenso wichtig ist eine klare, konsistente Formulierung der
Fehlermeldungen – konkrete Hinweise dazu gibt die Guideline
[Fehlermeldungen](/02-foundations/03-content-guidelines/03-fehlermeldungen).
