diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..0d215b3 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,1241 @@ +# `@geajs/suspense` — Implementation Plan + +> **Branch**: `feat/suspense` +> **Issue**: [#63 — Suspense Component](https://github.com/dashersw/gea/issues/63) +> **Author**: Recep Şen +> **Date**: 2026-04-13 +> **Status**: Planning complete — 31 design decisions resolved, ready for implementation + +--- + +## 1. Package Decision + +### Name: `@geajs/suspense` + +All official packages in this monorepo use the `@geajs/` scope: + +| Package | Name | +|---------|------| +| Core runtime | `@geajs/core` | +| SSR | `@geajs/ssr` | +| UI library | `@geajs/ui` | +| Mobile | `@geajs/mobile` | +| Vite plugin | `@geajs/vite-plugin` | + +Therefore: **`@geajs/suspense`** (not `@geajs/core/suspense`, not a subpath export). + +### Separate Package vs. Core Integration + +**Decision: Separate package** — following the `@geajs/ssr` precedent. + +Rationale: +- Tree-shaking: users who don't need Suspense pay zero cost +- Versioning independence: Suspense can ship patches/features without bumping `@geajs/core` +- API surface: keeps `@geajs/core` minimal per the framework's philosophy +- Precedent: `@geajs/ssr` is also a "core extension" shipped as its own package + +`@geajs/core` will NOT export `Suspense` directly. Users import from `@geajs/suspense`. + +```ts +// Usage +import { Suspense } from '@geajs/suspense' +``` + +--- + +## 2. Package Location + +```text +packages/ + gea-suspense/ ← new package + src/ + index.ts ← public exports + suspense.ts ← core Suspense component + types.ts ← SuspenseProps, SuspenseState + abort.ts ← AbortController integration + triggers.ts ← viewport/idle/interaction triggers + tests/ + suspense.test.ts ← unit tests + suspense-error.test.ts + suspense-timing.test.ts + suspense-abort.test.ts + suspense-triggers.test.ts + suspense-benchmarks.test.ts ← benchmarks + package.json + tsconfig.json + README.md +``` + +--- + +## 3. Package Configuration (`package.json` skeleton) + +```json +{ + "name": "@geajs/suspense", + "version": "0.1.0", + "type": "module", + "description": "Declarative async rendering boundaries for Gea framework", + "exports": { + ".": { + "source": "./src/index.ts", + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "peerDependencies": { + "@geajs/core": "*" + }, + "devDependencies": { + "@geajs/core": "*", + "tsdown": "^0.21.2", + "tsx": "^4.21.0", + "typescript": "~5.8.0", + "jsdom": "^29.0.0", + "@types/node": "^25.5.0" + } +} +``` + +**Zero runtime dependencies** — `@geajs/core` is a peer dep (not bundled), no third-party libs. + +Build tool: `tsdown` (same as `@geajs/core`, consistent toolchain). + +--- + +## 4. Implementation Phases + +### Phase 1 — Core Suspense (Fallback + Resolve) + +**Goal**: `}>` works. + +Tasks: +- [ ] Scaffold `packages/gea-suspense/` with `package.json`, `tsconfig.json` +- [ ] `src/types.ts` — `SuspenseProps` interface (Phase 1 subset: `fallback`, `onResolve`, `progressive`) +- [ ] `src/suspense.ts` — `Suspense` class extending `Component` + - Collect child components with pending `async created()` lifecycle + - `Promise.allSettled()` parallel resolution (anti-waterfall, enables partial render) + - After `allSettled()` completes: `delete child[GEA_CREATED_PROMISE]` on every child to prevent stale promise retention and enable correct retry semantics + - `queueMicrotask` batching — same-tick resolves grouped into single DOM update cycle + - CSS lifecycle classes: `suspense-entering` (loading), `suspense-entered` (resolved), `suspense-leaving` (unmounting) + - `GEA_SWAP_CHILD` reuse for fallback → content transition — **Suspense must call `dispose()` on the outgoing child instance BEFORE each swap** to prevent observer/listener leaks; `GEA_SWAP_CHILD` does not call `dispose()` internally + - Insert fallback on mount + - Swap to content on resolve +- [ ] Add package to workspace `package.json` +- [ ] `tests/suspense.test.ts` — unit tests: + - renders fallback initially + - replaces fallback with content on resolve + - parallel resolution (all children start simultaneously) + - works with no async children (immediate render) + - works with multiple async children +- [ ] Export from `src/index.ts` +- [ ] Add to monorepo root `tsconfig` + +**Deliverable**: Basic fallback/resolve works. + +--- + +### Phase 2 — Error Handling + Retry + +**Goal**: `error={(err, retry) => }` works. + +Tasks: +- [ ] `src/types.ts` — add `error`, `onError` to `SuspenseProps` +- [ ] `src/suspense.ts` — error state management + - `Promise.allSettled()` for error handling — each child's `result.status` checked independently; no short-circuiting on first failure + - render `error(err, retry)` JSX on failure for each rejected child + - `retry()` protocol: for each failed child → `dispose()` old instance → re-instantiate → call `created()` → capture new `GEA_CREATED_PROMISE` → run new `allSettled()` cycle; already-resolved children's DOM is preserved + - `GEA_SWAP_CHILD` to switch between fallback/error/content states + - `onError(err)` callback invocation for each failed child; `onResolve(results)` IS called after all settle (Q41) + - **Auto-retry on prop change**: if a child is in error state when a reactive prop changes, error state resets and `retry()` runs automatically +- [ ] Partial failure handling (some children resolve, some fail) +- [ ] `tests/suspense-error.test.ts`: + - shows error UI when child throws + - retry re-runs async created + - retry succeeds and shows content + - `onError` callback called with correct error + - partial failure: resolved children render, failed children show error state independently (Promise.allSettled semantics) +- [ ] Progressive mode (`progressive={true}`): render each child individually as it resolves; each failed child's error rendered at its position using `error(err, retry, index)` + +**Deliverable**: Error boundary with retry built into Suspense. + +--- + +### Phase 3 — Timing + Race Condition Prevention + +**Goal**: Fast responses don't flash spinner; slow responses display spinner for at least `minimumFallback` ms. + +Tasks: +- [ ] `src/types.ts` — add `timeout`, `minimumFallback`, `onFallback` to `SuspenseProps` +- [ ] `src/suspense.ts`: + - `timeout` — delay timer before showing fallback (`setTimeout` → show fallback) + - `minimumFallback` — track `fallbackShownAt` timestamp; if resolve arrives too early, wait remaining ms + - `onFallback()` callback when fallback becomes visible + - Generation counter (monotonic integer) — stale async responses are discarded silently +- [ ] `tests/suspense-timing.test.ts`: + - `timeout=200`: fast resolve (50ms) → fallback NEVER shown + - `timeout=200`: slow resolve (300ms) → fallback shown + - `minimumFallback=300`: resolve at 50ms but fallback shown → wait until 300ms + - timing interaction: `timeout=500, minimumFallback=300`, resolve at 750ms → fallback shown at 500ms, content shown at 800ms (250ms < 300ms minimum → wait remaining 50ms); if resolve at 400ms → fallback never shown, `minimumFallback` irrelevant + - generation counter: rapid re-mounts don't show stale content + - `onFallback` fires exactly once + +**Deliverable**: Configurable flicker prevention. + +--- + +### Phase 4 — Stale-While-Refresh + AbortController + +**Goal**: Re-fetches show stale content with CSS class instead of flashing to skeleton. + +Tasks: +- [ ] `src/abort.ts` — `AbortController` lifecycle integration + - Create controller on mount + - Abort on unmount + - Pass signal to `async created()` (TBD: parameter vs `this.abortSignal` — see Q4) +- [ ] `src/types.ts` — add `staleWhileRefresh`, `AbortSignal` propagation +- [ ] `src/suspense.ts`: + - `staleWhileRefresh=true`: on re-fetch, add `suspense-refreshing` CSS class to content container instead of swapping to fallback + - Optional `refreshing` render prop: `refreshing={(children) =>
{children}
}` — wraps stale content if provided + - Remove CSS class on new content arrival + - Abort previous operations when new fetch starts + - Memory leak prevention: abort on component unmount +- [ ] `tests/suspense-abort.test.ts`: + - abort signal is aborted on unmount + - `staleWhileRefresh`: old content stays visible during refresh + - `staleWhileRefresh`: CSS class added/removed correctly + - rapid re-mounts don't leak AbortControllers + +**Deliverable**: No more skeleton flash on data refresh. + +--- + +### Phase 5 — Trigger-Based Loading + +**Goal**: `` loads only when scrolled into view. + +Tasks: +- [ ] `src/triggers.ts`: + - `"immediate"` — default, loads on mount + - `"idle"` — `requestIdleCallback` (with `setTimeout` fallback for Safari) + - `"viewport"` — `IntersectionObserver` + - `"interaction"` — `addEventListener("click")` / `"keydown"` attached to `marker.nextElementSibling`; re-attached after each `GEA_SWAP_CHILD` call; removed once trigger fires (one-shot) + - `"hover"` — `addEventListener("mouseenter")` + - `"timer(ms)"` — `setTimeout(ms)` +- [ ] `src/types.ts` — add `trigger`, `prefetch` to `SuspenseProps` +- [ ] `src/suspense.ts`: + - Wire trigger logic: only start child loading after trigger fires + - `prefetch="idle"` — pre-load during idle, display on trigger; if `async created()` fails during prefetch, trigger arrival shows error UI immediately (promise is already rejected) — `retry()` available to re-run + - Clean up observers/listeners on unmount + - **Trigger vs refresh contract**: triggers are one-shot for initial load only; subsequent re-fetches are driven by reactive prop changes propagating via `GEA_ON_PROP_CHANGE` to the Suspense boundary, which re-runs `async created()` on children — `staleWhileRefresh` applies to these reactive re-fetches, not to trigger re-fires +- [ ] Router integration (TBD: auto-wrap or opt-in — see Q6) +- [ ] `tests/suspense-triggers.test.ts`: + - `"immediate"` starts on mount + - `"idle"` defers until requestIdleCallback + - `"viewport"` uses IntersectionObserver (mock in tests) + - `"timer(500)"` delays 500ms + - observers cleaned up on unmount + +**Deliverable**: Deferred loading with all Angular `@defer`-inspired triggers. + +--- + +### Phase 6 — SSR Streaming Integration + +**Goal**: Suspense works with `@geajs/ssr` streaming deferreds — same boundary on server and client. + +Tasks: +- [ ] `src/types.ts` — add `ssrStreamId` to `SuspenseProps` +- [ ] `src/suspense.ts` (client side): + - On hydration: find element by `ssrStreamId` + - If SSR stream already resolved → skip loading, go straight to "resolved" state + - If still showing SSR fallback → take over async operation client-side +- [ ] `@geajs/ssr` coordination (may need minor changes to SSR deferred chunk format) +- [ ] Tests (requires jsdom + SSR test helper): + - server renders fallback with correct ID + - client hydrates and continues where SSR left off + - edge case: SSR resolved before hydration + - edge case: SSR failed — client shows error boundary + +#### SSR Architecture (required design — currently underspecified) + +**Problem**: `@geajs/ssr`'s `renderToString` is synchronous — it calls `new ComponentClass(props)` and immediately calls `template()`. For `async created()` components, data is not available when `template()` runs, rendering empty/default content. + +**Required changes to `@geajs/ssr`**: + +1. **New `renderToStringAsync`** function (non-breaking addition): + ```ts + export async function renderToStringAsync( + ComponentClass: ComponentLike, + props?: object, + options?: SSROptions + ): Promise<{ html: string; deferreds: DeferredChunk[] }> + ``` + This function awaits all `GEA_CREATED_PROMISE` instances before calling `template()`. + +2. **Suspense boundary registration**: When a `Suspense` component mounts server-side, it registers a `DeferredChunk` in the SSR context with its `ssrStreamId` as the placeholder ID. + +3. **Fallback HTML generation**: Server-side Suspense renders the `fallback` prop as initial HTML with a wrapper element carrying `id={ssrStreamId}`. + +4. **Stream coordination**: The existing `createSSRStream` / `handleRequest` pipeline in `stream.ts` picks up `deferreds` from the Suspense boundaries and streams `