[Feature Request] Suspense Component — Async Rendering Boundaries with Fallback, Error, and Streaming Support
- Dominant language
- JavaScript
- Stars
- 1.2k
- Forks
- 47
- PR merge metrics
- No merged PRs in 30d
Description
# Suspense Component — Async Rendering Boundaries
## Summary
Add a `` component to Gea that provides declarative async rendering boundaries with fallback UI, error handling, and SSR streaming integration — all while staying true to Gea's "just JavaScript" philosophy.
Unlike React's Suspense (which relies on promise-throwing), Gea's Suspense builds on existing primitives: `async created()`, `GEA_SWAP_CHILD`, and SSR `deferreds`. The result is a zero-new-concepts async boundary that feels native to the framework.
---
## Motivation
### The Problem Today
Gea currently has no unified way to handle async component loading states:
1. **Lazy routes show empty containers** — When `resolveLazy()` loads a route component (`router.ts:320-346`), the user sees nothing until the module resolves. There's no loading indicator, no error fallback.
2. **`async created()` has no loading boundary** — Components with async lifecycle hooks (`component.tsx:434`) block silently. Parent components can't display fallback UI while children load.
3. **SSR deferreds are disconnected from client components** — The SSR streaming system (`context.deferreds`) is powerful but operates at the server level only. There's no client-side equivalent that hydrates into the same boundary.
4. **No error recovery** — When async operations fail, components either crash silently or require manual try/catch in every component.
### Why Now
- The router already needs loading states for lazy routes
- SSR streaming (`deferreds`) already implements the server-side pattern — the client side is the missing half
- `docs/philosophy.md` line 64 explicitly lists suspense boundaries as something Gea doesn't have — this proposal addresses that gap while respecting the philosophy
### Design Principles
- **No new primitives** — No `createResource()`, no `use()` hook, no promise-throwing. Just classes, methods, and props.
- **Explicit over magic** — Loading states, error states, and timeouts are all visible in the template.
- **Composable** — Works with existing `async created()`, lazy routes, and SSR deferreds.
- **Safe by default** — Built-in race condition prevention, memory leak cleanup, and flicker avoidance.
---
## Proposed API
### Basic Usage
```jsx
import { Suspense } from '@geajs/core'
class Dashboard extends Component {
template() {
return (
}
error={(err, retry) => }
>
{/* has async created() */}
{/* has async created() */}
)
}
}
```
### Props Interface
```typescript
interface SuspenseProps {
// --- Core ---
fallback: Component | (() => JSX) // UI shown while children load
error?: (err: Error, retry: () => void) => JSX // Error boundary with retry
// --- Timing (Flicker Prevention) ---
timeout?: number // ms before showing fallback (default: 0, instant)
minimumFallback?: number // ms minimum fallback display (default: 300)
// --- Advanced ---
staleWhileRefresh?: boolean // Keep old content during re-fetch (default: false)
onResolve?: () => void // Callback when all children resolve
onError?: (err: Error) => void // Callback on error (for logging/telemetry)
onFallback?: () => void // Callback when fallback becomes visible
// --- SSR ---
ssrStreamId?: string // Links to SSR deferred chunk ID
}
```
### Advanced: Stale-While-Refresh
Inspired by Solid.js's 5-state resource model. When `staleWhileRefresh` is enabled, re-fetches show the previous content with an optional overlay instead of flashing back to the fallback:
```jsx
}
staleWhileRefresh={true}
>
```
State transitions:
```
[initial] → fallback → content
↓ (re-fetch triggered)
content + refreshing class → new content
```
### Advanced: Trigger-Based Loading (Inspired by Angular `@defer`)
```jsx
}
trigger="viewport" // Load when entering viewport
prefetch="idle" // Prefetch during browser idle
>
```
Supported triggers:
| Trigger | Behavior | Inspired By |
|---------|----------|-------------|
| `"immediate"` | Load on mount (default) | React |
| `"idle"` | Load during `requestIdleCallback` | Angular `@defer(on idle)` |
| `"viewport"` | Load when boundary enters viewport | Angular `@defer(on viewport)` |
| `"interaction"` | Load on first user interaction | Angular `@defer(on interaction)` |
| `"timer(ms)"` | Load after delay | Angular `@defer(on timer)` |
| `"hover"` | Load on hover | Angular `@defer(on hover)` |
### Advanced: Nested Suspense
Nested boundaries are independent — an inner Suspense doesn't bubble up to the outer one:
```jsx
}>
}>
}>
```
Unlike React, where nested Suspense boundaries interact in confusing ways (especially during hydration), each Gea Suspense boundary is fully self-contained.
---
## React Suspense Problems This Design Solves
This proposal explicitly addresses 10 documented problems with React's Suspense implementation:
### 1. Waterfall Problem
**React**: Sequential promise-throwing causes child components to load one-by-one instead of in parallel.
**Gea**: All `async created()` calls within a Suspense boundary are collected and run with `Promise.all()`. Children always load in parallel.
### 2. Promise-Throwing Anti-Pattern
**React**: Components communicate loading state by throwing promises — an abuse of the exception mechanism. Libraries must implement complex caching to avoid re-throws.
**Gea**: No promise-throwing. `async created()` is a normal async method. The Suspense boundary detects pending children through the component lifecycle, not exceptions.
### 3. No Native Data Fetching
**React**: Suspense has no built-in fetch integration — requires React Query, SWR, or the unfinished `use()` hook.
**Gea**: `async created()` already serves as the data-fetching lifecycle hook. Any async operation works — no wrapper library needed.
### 4. Race Conditions
**React**: Documented in [facebook/react#35399](https://github.com/facebook/react/issues/35399) and [facebook/react#33939](https://github.com/facebook/react/issues/33939). Rapid re-renders with Suspense can show stale data or flicker between states.
**Gea**: Built-in generation counter. Each async operation gets a monotonically increasing ID. When results arrive, stale responses (where `generation < currentGeneration`) are silently discarded.
### 5. Error Handling Gaps
**React**: Suspense has no error handling — requires a separate `` component wrapping each Suspense. Two components for one concern.
**Gea**: The `error` prop is built into `` itself, with a `retry` callback. One component handles both loading and error states.
### 6. SSR Streaming Bugs
**React**: Hydration mismatches with Suspense boundaries are a persistent source of bugs ([facebook/react#36003](https://github.com/facebook/react/issues/36003)).
**Gea**: SSR integration uses the existing `deferreds` streaming system. The server renders the fallback HTML with a known ID; the client Suspense boundary picks up where SSR left off via `ssrStreamId`. No hydration mismatch possible because both sides use the same placeholder→replace mechanism (`GEA_SWAP_CHILD`).
### 7. Hardcoded `FALLBACK_THROTTLE_MS`
**React**: A 300ms hardcoded throttle (`FALLBACK_THROTTLE_MS`) controls when fallbacks appear. It's not configurable. [128 upvotes requesting configurability](https://github.com/facebook/react/issues/31819).
**Gea**: Both `timeout` (delay before showing fallback) and `minimumFallback` (minimum display duration) are configurable per-boundary. Defaults are sensible (0ms and 300ms) but fully overridable.
### 8. Nested Suspense Complexity
**React**: Nested Suspense boundaries have complex interaction rules. Inner boundaries can "reveal" before outer ones in unintuitive ways during concurrent rendering.
**Gea**: Each Suspense boundary is fully independent. No cross-boundary state leaking. Nesting is simple composition — inner boundaries resolve on their own timeline.
### 9. Memory Leaks
**React**: When a Suspense-wrapped component unmounts during an async operation, the pending promise may hold references to unmounted component state.
**Gea**: Built-in `AbortController` integration (inspired by Qwik's `cleanup()`). When a Suspense boundary unmounts, all pending async operations are aborted. The `created()` lifecycle receives an abort signal.
### 10. Poor Developer Experience
**React**: Suspense error messages are cryptic. The "thrown promise" mechanism makes debugging difficult — stack traces point to React internals, not user code.
**Gea**: Standard async/await errors with clear stack traces. No promise-throwing means errors propagate naturally through `try/catch`. The `onError` callback provides a hook for logging and telemetry.
---
## Framework Comparison: What We Adopted and Why
| Feature | Source Framework | Why Adopted | Gea Adaptation |
|---------|-----------------|-------------|----------------|
| Fallback + Error in one component | **Vue** | Eliminates the two-component pattern (Suspense + ErrorBoundary) | `error` prop with `retry` callback |
| Configurable timing | **Vue** (`timeout` prop) | Prevents UI flicker for fast responses | `timeout` + `minimumFallback` props |
| Stale-while-refresh | **Solid.js** (5-state resource) | Better UX for data re-fetching | `staleWhileRefresh` prop, CSS class-based |
| Trigger-based loading | **Angular** (`@defer`) | Reduces initial bundle size, loads on demand | `trigger` prop with same trigger types |
| Prefetch during idle | **Angular** (`prefetch`) | Improves perceived performance | `prefetch` prop |
| Abort on unmount | **Qwik** (`cleanup()`) | Prevents memory leaks | `AbortController` passed to `async created()` |
| Event callbacks | **Vue** (`onResolve`, `onPending`, `onFallback`) | Enables telemetry and coordination | `onResolve`, `onError`, `onFallback` callbacks |
| SSR streaming integration | **Gea's own `deferreds`** | Unified server/client async boundary | `ssrStreamId` prop linking to SSR deferreds |
| Parallel child loading | **Original design** | Solves React's waterfall problem | `Promise.all()` for all children in boundary |
| Generation-based race prevention | **Original design** | Solves React's documented race conditions | Monotonic generation counter |
### What We Deliberately Did NOT Adopt
| Rejected Feature | Source | Why Rejected |
|------------------|--------|-------------|
| Promise-throwing | React | Anti-pattern: abuses exception mechanism, makes debugging hard |
| `createResource()` / signals | Solid.js | New primitive — violates "just JavaScript" philosophy |
| `$state` runes for async | Svelte | Compiler-specific syntax — Gea prefers standard JS |
| Separate `` | React | Unnecessary indirection — error handling belongs in the async boundary |
| `v-model` / `emit` for Suspense state | Vue | Framework-specific communication — Gea uses direct prop mutation |
| Concurrent mode / lanes | React | Massive complexity for marginal benefit in Gea's architecture |
---
## Technical Architecture
### Integration Points in Existing Codebase
```
packages/gea/src/lib/
├── base/
│ ├── component.tsx # Hook into async created() lifecycle (line 434)
│ │ # Reuse GEA_SWAP_CHILD for fallback↔content swap (line 1266)
│ │ # Reuse GEA_PATCH_COND for conditional rendering (line 1031)
│ └── component-manager.ts # Track Suspense boundaries in component tree
├── suspense/ # NEW — Suspense module
│ ├── suspense.ts # Core Suspense component class
│ ├── types.ts # SuspenseProps, SuspenseState interfaces
│ ├── abort.ts # AbortController lifecycle integration
│ └── triggers.ts # Viewport/idle/interaction trigger implementations
├── router/
│ ├── lazy.ts # Integrate with Suspense for loading states
│ └── router.ts # Wrap lazy route resolution in Suspense boundary
└── index.ts # Export Suspense from @geajs/core
```
### How It Works: Lifecycle
```
1. mounts
├── Checks for ssrStreamId → if found, hydrate from SSR deferred
├── Evaluates trigger prop → if not "immediate", sets up observer/listener
└── When triggered:
├── Collects all child components with async created()
├── Starts timeout timer (if timeout > 0)
├── Runs Promise.all(children.map(c => c.created()))
│ ├── On resolve:
│ │ ├── If minimumFallback not elapsed → wait remaining time
│ │ ├── GEA_SWAP_CHILD: replace fallback with resolved content
│ │ └── Call onResolve()
│ └── On reject:
│ ├── Increment error state
│ ├── Render error prop with (err, retry) args
│ └── Call onError(err)
└── On unmount:
├── AbortController.abort() for all pending operations
└── Clean up IntersectionObserver/listeners
```
### How It Works: GEA_SWAP_CHILD Reuse
The existing `GEA_SWAP_CHILD` mechanism (`component.tsx:1266-1292`) is a perfect fit:
```typescript
// Current: swaps child component at a marker position
Component.prototype[GEA_SWAP_CHILD] = function(markerId, newChild) {
const marker = _getEl(this.id + '-' + markerId)
const oldEl = marker.nextElementSibling
if (oldEl) oldEl.remove()
if (!newChild) return
marker.insertAdjacentHTML('afterend', String(newChild.template(newChild.props)).trim())
// ... mount new child
}
// Suspense uses same mechanism:
// 1. Initial render: insert fallback after marker
// 2. On resolve: GEA_SWAP_CHILD replaces fallback with actual content
// 3. On error: GEA_SWAP_CHILD replaces fallback with error UI
```
### How It Works: SSR Integration
```
Server:
1. Suspense renders fallback HTML with ssrStreamId as element ID
2. Adds a DeferredChunk to context.deferreds
3. When async data resolves, streams that replaces placeholder
Client (Hydration):
1. Suspense finds existing element with ssrStreamId
2. If content already replaced by SSR stream → skip to "resolved" state
3. If still showing fallback → take over async operation client-side
```
### Compiler Support (`@geajs/vite-plugin`)
The Vite plugin needs minimal changes:
1. **Detect `<Suspense>` in JSX** — Treat as a built-in component (like how fragments are handled)
2. **Generate observer bindings** — Wire up reactive props (`fallback`, `error`, `timeout`, etc.)
3. **Async child detection** — At compile time, identify children with `async created()` and generate the collection code
---
## Implementation Plan
### Phase 1: Core Suspense (Fallback + Resolve)
**Scope**: Minimal viable Suspense with fallback and content swapping.
- [ ] Create `packages/gea/src/lib/suspense/suspense.ts` — Suspense class extending Component
- [ ] Implement `fallback` prop rendering via `GEA_SWAP_CHILD`
- [ ] Collect async children and await with `Promise.all()`
- [ ] Implement fallback→content transition on resolve
- [ ] Add basic unit tests
- [ ] Export from `@geajs/core`
**Deliverable**: `<Suspense fallback={<Spinner />}><AsyncChild /></Suspense>` works.
### Phase 2: Error Handling + Retry
**Scope**: Integrated error boundary with retry mechanism.
- [ ] Implement `error` prop — `(err: Error, retry: () => void) => JSX`
- [ ] Add retry mechanism that re-runs `async created()` on all failed children
- [ ] Implement `onError` callback for telemetry
- [ ] Handle partial failures (some children resolve, some fail)
- [ ] Add error state tests
**Deliverable**: `error={(err, retry) => <ErrorUI onRetry={retry} />}` works.
### Phase 3: Timing + Flicker Prevention
**Scope**: Configurable timing to prevent UI flicker.
- [ ] Implement `timeout` prop — delay before showing fallback
- [ ] Implement `minimumFallback` prop — minimum fallback display duration
- [ ] Add `onResolve` and `onFallback` callbacks
- [ ] Implement generation counter for race condition prevention
- [ ] Add timing-related tests
**Deliverable**: Fast responses don't flash a loading spinner. Slow responses show spinner for at least `minimumFallback` ms.
### Phase 4: Stale-While-Refresh + Abort
**Scope**: Advanced data fetching patterns.
- [ ] Implement `staleWhileRefresh` — keep old content during re-fetch, add CSS class
- [ ] Integrate `AbortController` — pass signal to `async created()`, abort on unmount
- [ ] Implement generation-based stale response discarding
- [ ] Memory leak prevention tests
**Deliverable**: Re-fetching data shows old content with a "refreshing" indicator instead of flashing to skeleton.
### Phase 5: Trigger-Based Loading
**Scope**: Deferred loading based on viewport, interaction, idle, etc.
- [ ] Implement `trigger` prop with `IntersectionObserver` (viewport), `requestIdleCallback` (idle), event listeners (interaction, hover)
- [ ] Implement `prefetch` prop for early loading
- [ ] Implement `timer(ms)` trigger
- [ ] Router integration — wrap lazy routes with Suspense automatically
- [ ] Trigger-related tests
**Deliverable**: `<Suspense trigger="viewport"><HeavyChart /></Suspense>` loads the chart only when scrolled into view.
### Phase 6: SSR Streaming Integration
**Scope**: Unified server/client Suspense boundary.
- [ ] Implement `ssrStreamId` prop linking to SSR `deferreds`
- [ ] Server-side: Suspense renders fallback with deferred chunk registration
- [ ] Client-side: Hydration picks up SSR stream state
- [ ] Handle edge case: SSR stream already resolved before hydration
- [ ] End-to-end SSR streaming tests
**Deliverable**: Suspense works identically in SSR and CSR, with streaming support for slow server-side data.
---
## Philosophy Alignment
`docs/philosophy.md` states:
> *"Gea offers fewer abstractions than React or Vue. It doesn't have hooks, context providers, portals, suspense boundaries, or server components."*
This proposal adds suspense boundaries while respecting every other principle in that document:
- **No new primitives** — Uses `async created()` (existing), `GEA_SWAP_CHILD` (existing), and standard `AbortController` (web platform)
- **No arbitrary rules** — No "hooks must be called at the top level", no dependency arrays, no `.value` unwrapping
- **JavaScript semantics** — Props are props. Callbacks are functions. Async is async/await.
- **The magic is invisible** — The Vite plugin handles wiring. User code is plain classes and JSX.
- **Object-oriented** — Suspense is a class extending Component. It has methods, properties, and lifecycle hooks — standard OOP.
The philosophy doc will need a small update: remove "suspense boundaries" from the list of things Gea doesn't have, and add a note about how Gea's Suspense differs from React's (no promise-throwing, no new concepts).
---
## Open Questions
1. **Should `trigger` be Phase 1 or Phase 5?** — Trigger-based loading is valuable but adds complexity. Starting simple and iterating seems prudent.
2. **Should `staleWhileRefresh` use a CSS class or a render prop?** — CSS class (`suspense-refreshing`) is simpler. Render prop gives more control. Could support both.
3. **Should lazy routes automatically wrap in Suspense?** — The router currently shows empty containers during lazy loading. Auto-wrapping would be a breaking change (now there's a fallback where there wasn't one). Opt-in via router config might be better.
4. **AbortController API** — Should `async created()` receive the signal as a parameter, or should it be available as `this.abortSignal`? The latter is more "Gea-like" (property on class).
---
## References
- [React Suspense API](https://react.dev/reference/react/Suspense)
- [React Suspense FALLBACK_THROTTLE_MS issue (128 upvotes)](https://github.com/facebook/react/issues/31819)
- [React Suspense race condition](https://github.com/facebook/react/issues/35399)
- [React Suspense stale state](https://github.com/facebook/react/issues/33939)
- [React SSR streaming bug](https://github.com/facebook/react/issues/36003)
- [Vue Suspense](https://vuejs.org/guide/built-ins/suspense.html)
- [Solid.js createResource](https://docs.solidjs.com/reference/basic-reactivity/create-resource)
- [Angular @defer](https://angular.dev/guide/defer)
- [Plain Vanilla Web: Sweet Suspense](https://plainvanillaweb.com/blog/articles/2024-09-09-sweet-suspense/)
- Gea internals: `component.tsx` (`async created()`, `GEA_SWAP_CHILD`, `GEA_PATCH_COND`), `lazy.ts`, SSR `deferreds`
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.