Skip to content

[Feature Request] Suspense Component — Async Rendering Boundaries with Fallback, Error, and Streaming Support #63

@senrecep

Description

@senrecep

Suspense Component — Async Rendering Boundaries

Summary

Add a <Suspense> 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

import { Suspense } from '@geajs/core'

class Dashboard extends Component {
  template() {
    return (
      <Suspense
        fallback={<Spinner />}
        error={(err, retry) => <ErrorCard message={err.message} onRetry={retry} />}
      >
        <UserProfile />    {/* has async created() */}
        <ActivityFeed />   {/* has async created() */}
      </Suspense>
    )
  }
}

Props Interface

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:

<Suspense
  fallback={<Skeleton />}
  staleWhileRefresh={true}
>
  <DataTable query={this.currentQuery} />
</Suspense>

State transitions:

[initial] → fallback → content
                          ↓ (re-fetch triggered)
                       content + refreshing class → new content

Advanced: Trigger-Based Loading (Inspired by Angular @defer)

<Suspense
  fallback={<Placeholder />}
  trigger="viewport"          // Load when entering viewport
  prefetch="idle"             // Prefetch during browser idle
>
  <HeavyChart />
</Suspense>

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:

<Suspense fallback={<PageSkeleton />}>
  <Header />
  <Suspense fallback={<FeedSkeleton />}>
    <ActivityFeed />
  </Suspense>
  <Suspense fallback={<SidebarSkeleton />}>
    <Recommendations />
  </Suspense>
</Suspense>

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 and facebook/react#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 <ErrorBoundary> component wrapping each Suspense. Two components for one concern.
Gea: The error prop is built into <Suspense> 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).
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.
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 <ErrorBoundary> 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. <Suspense> 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:

// 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 <script> 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions