Back AsyncComponent with a RemoteData tagged union

Introduce RemoteData<E,T> (Loading | Empty | Failure | Success) plus
fromResource and an exhaustive foldRemote. The data lives ON the state,
so "loaded without value" or "error with stale value" are unrepresentable.

AsyncComponent now derives a single rd() and pulls value/error out via the
fold instead of a loose State string. Public API (resource/isEmpty inputs,
the four slot directives, the ASYNC array) is unchanged, so the dashboard,
detail page, and async stories need no edits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 16:55:40 +02:00
parent 43b2f83485
commit 0920063553
2 changed files with 68 additions and 22 deletions

View File

@@ -0,0 +1,47 @@
import type { Resource } from '@angular/core';
import { assertNever } from './fp';
/**
* The four mutually-exclusive states of an async fetch, as a tagged union.
* Crucially the data lives ON the state: only `Failure` has an `error`, only
* `Success` has a `value`. "Loaded but no value" or "error with stale value"
* are unrepresentable — Richard Feldman's RemoteData.
*/
export type RemoteData<E, T> =
| { tag: 'Loading' }
| { tag: 'Empty' }
| { tag: 'Failure'; error: E }
| { tag: 'Success'; value: T };
/** Project Angular's loosely-typed Resource into a RemoteData value. */
export function fromResource<T>(
r: Resource<T>,
isEmpty: (v: T) => boolean = () => false,
): RemoteData<Error | undefined, T> {
if (r.status() === 'error') return { tag: 'Failure', error: r.error() };
if (r.status() === 'loading') return { tag: 'Loading' };
if (r.hasValue()) {
const v = r.value();
return isEmpty(v) ? { tag: 'Empty' } : { tag: 'Success', value: v };
}
return { tag: 'Loading' };
}
/** Exhaustive fold: you must handle every case, checked at compile time. */
export function foldRemote<E, T, R>(
rd: RemoteData<E, T>,
h: { loading: () => R; empty: () => R; failure: (e: E) => R; success: (v: T) => R },
): R {
switch (rd.tag) {
case 'Loading':
return h.loading();
case 'Empty':
return h.empty();
case 'Failure':
return h.failure(rd.error);
case 'Success':
return h.success(rd.value);
default:
return assertNever(rd);
}
}

View File

@@ -4,6 +4,7 @@ import type { Resource } from '@angular/core';
import { SpinnerComponent } from '../../atoms/spinner/spinner.component';
import { AlertComponent } from '../../atoms/alert/alert.component';
import { ButtonComponent } from '../../atoms/button/button.component';
import { fromResource, foldRemote } from '../../core/remote-data';
/* Slot markers. Put on <ng-template> children of <app-async>. */
@Directive({ selector: '[appAsyncLoaded]' })
@@ -23,27 +24,26 @@ export class AsyncErrorDirective {
constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}
}
type State = 'loading' | 'error' | 'empty' | 'loaded';
/**
* Renders exactly ONE of loading / empty / error / loaded for a signal-based
* resource (e.g. httpResource). The states are mutually exclusive by
* construction, so the UI can never show two at once ("impossible states").
* Unprovided slots fall back to sensible defaults.
* resource (e.g. httpResource). Built on a RemoteData tagged union (see
* core/remote-data.ts), so the states are mutually exclusive by construction —
* the UI can never show two at once ("impossible states"). Unprovided slots
* fall back to sensible defaults.
*/
@Component({
selector: 'app-async',
imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],
template: `
@switch (state()) {
@case ('loading') {
@switch (rd().tag) {
@case ('Loading') {
@if (loadingTpl()) { <ng-container [ngTemplateOutlet]="loadingTpl()!.tpl" /> }
@else { <app-spinner /> }
}
@case ('error') {
@case ('Failure') {
@if (errorTpl()) {
<ng-container [ngTemplateOutlet]="errorTpl()!.tpl"
[ngTemplateOutletContext]="{ $implicit: resource().error(), retry: retry }" />
[ngTemplateOutletContext]="{ $implicit: error(), retry: retry }" />
} @else {
<app-alert type="error">Er ging iets mis bij het laden van de gegevens.</app-alert>
<div style="margin-top:1rem">
@@ -51,11 +51,11 @@ type State = 'loading' | 'error' | 'empty' | 'loaded';
</div>
}
}
@case ('empty') {
@case ('Empty') {
@if (emptyTpl()) { <ng-container [ngTemplateOutlet]="emptyTpl()!.tpl" /> }
@else { <p class="rhc-paragraph">Geen gegevens gevonden.</p> }
}
@case ('loaded') {
@case ('Success') {
<ng-container [ngTemplateOutlet]="loadedTpl().tpl"
[ngTemplateOutletContext]="{ $implicit: value() }" />
}
@@ -71,18 +71,17 @@ export class AsyncComponent<T> {
emptyTpl = contentChild(AsyncEmptyDirective);
errorTpl = contentChild(AsyncErrorDirective);
protected value = computed(() => {
const r = this.resource();
return r.hasValue() ? r.value() : undefined;
});
// Single source of truth: the resource projected into a RemoteData union.
protected rd = computed(() => fromResource(this.resource(), this.isEmpty()));
protected state = computed<State>(() => {
const r = this.resource();
if (r.status() === 'error') return 'error';
if (r.status() === 'loading') return 'loading';
if (r.hasValue()) return this.isEmpty()(r.value()) ? 'empty' : 'loaded';
return 'loading';
});
// value/error are pulled out via the exhaustive fold — only Success carries a
// value, only Failure carries an error, so these can't lie.
protected value = computed(() =>
foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: () => undefined, success: (v) => v }),
);
protected error = computed(() =>
foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: (e) => e, success: () => undefined }),
);
retry = () => {
const r = this.resource();