Skip to content

feat(suspense): add @geajs/suspense package — declarative async rendering boundaries#66

Open
senrecep wants to merge 8 commits intodashersw:mainfrom
senrecep:feat/suspense
Open

feat(suspense): add @geajs/suspense package — declarative async rendering boundaries#66
senrecep wants to merge 8 commits intodashersw:mainfrom
senrecep:feat/suspense

Conversation

@senrecep
Copy link
Copy Markdown

@senrecep senrecep commented Apr 13, 2026

Summary

This PR introduces @geajs/suspense, a new official package that adds declarative async rendering boundaries to Gea — addressing the gap noted in docs/philosophy.md (line 64).

Unlike React's Suspense (which relies on promise-throwing), Gea's Suspense builds entirely on existing primitives: async created(), GEA_SWAP_CHILD, and SSR deferreds. Zero new concepts for users.

Why a separate package?

Following the @geajs/ssr precedent:

  • Tree-shaking: users who don't need Suspense pay zero cost
  • Independent versioning: Suspense ships patches without bumping @geajs/core
  • Minimal core surface: aligns with Gea's "just JavaScript" philosophy
import { Suspense } from '@geajs/suspense'

What's in this PR (planning phase)

This PR currently contains PLAN.md — a comprehensive implementation plan covering all 6 phases across 17 sections. Implementation follows in subsequent commits on this branch.

The 6 Phases

Phase Scope Key Deliverable
1 Core fallback + resolve `<Suspense fallback={}>` works
2 Error handling + retry `error={(err, retry, index?) => }` + full retry protocol
3 Timing + race prevention `timeout`, `minimumFallback`, generation counter
4 Stale-while-refresh + AbortController `staleWhileRefresh`, `refreshingOverlay`, `this.abortSignal` lazy getter
5 Trigger-based loading `trigger="viewport"`, `"idle"`, `"interaction"`, `"hover"`, `"timer(ms)"`
6 SSR streaming integration `renderToStringAsync`, `ssrStreamId` auto-generated by compiler

Complete `SuspenseProps` Interface

interface SuspenseProps {
  // Core
  fallback: string | Component | (() => JSX)
  error?: (err: Error, retry: () => void, childIndex?: number) => JSX

  // Rendering
  progressive?: boolean
  revealOrder?: 'together' | 'forwards' | 'backwards'

  // Timing
  timeout?: number             // ms before showing fallback (default: 0); global for boundary
  minimumFallback?: number     // ms minimum fallback display (default: 300ms); batch-only (Q44)

  // Stale — Q61: extended to boolean | { delay: number }
  staleWhileRefresh?: boolean | { delay: number }
  refreshing?: (children: JSX) => JSX
  refreshingOverlay?: string | Component | (() => JSX)  // NEW (Q58): overlay on stale content

  // Callbacks — Q41: onResolve fires on ALL settled (success or error)
  onResolve?: (results: PromiseSettledResult<void>[]) => void
  onError?: (err: Error, childIndex?: number) => void
  onFallback?: () => void
  onLoadStart?: () => void

  // Triggers
  trigger?: 'immediate' | 'viewport' | 'idle' | 'interaction' | 'hover' | \`timer(\${number})\`
  prefetch?: 'idle'

  // Accessibility
  announceLabel?: string

  // SSR
  ssrStreamId?: string

  // Children (compiler-provided) — Q59: ComponentClass[] type
  children?: ComponentClass[]
}

Key Design Decisions (63 confirmed — updated from 40)

Core semantics:

  • `Promise.allSettled()` everywhere — parallel child resolution with partial render support; no short-circuiting
  • `onResolve(results)` = always fires on all-settled (Q41) — fires when all children settle, success or error; results: PromiseSettledResult<void>[] passed; caller inspects .status; onError() still fires per failed child independently
  • Zero-allocation fast path — no fallback, timers, or observers for sync-only subtrees (Q29, Q54: zero children shows dev warning + immediate resolved)
  • queueMicrotask batching — same-tick resolves grouped into single DOM update (Q18)

Progressive rendering:

  • progressive={true} — each child shown immediately as it resolves (opt-in)
  • revealOrder='forwards' — DOM-order reveal; unmounted buffer child dropped and skipped (Q50)
  • revealOrder='backwards' — mirror of forwards; failed child shows error independently
  • minimumFallback batch-only (Q44) — in progressive mode, children show immediately without waiting
  • timeout global in progressive mode (Q56) — single timer; still-loading slots get per-slot fallback when timer fires

Waterfall elimination (Q42, Q45):

  • GEA_ON_CHILD_MOUNTED core hook — new internal symbol called by component.tsx when a child is pushed to childComponents; Suspense hooks recursively on full subtree for N-level waterfall detection
  • scanAndHook() algorithm — sets hook on every node in subtree, stopping at nested Suspense boundaries; newly mounted children also receive the hook; cleaned up in dispose()

Stale-while-refresh (Q61, Q58):

  • staleWhileRefresh: boolean | { delay: number }delay withholds CSS class and refreshingOverlay for fast refreshes; no visual indicator if refresh completes before delay
  • refreshingOverlay prop — new prop for skeleton/shimmer overlay on top of stale content (position: absolute)
  • Cancel + restart concurrent refresh — new prop change aborts previous fetch, increments generation counter; state stays refreshing
  • Nested Suspense + staleWhileRefresh fully independent (Q63) — inner refresh never propagates to outer boundary

Error handling:

  • retry() in error render prop = single child (Q57) — retries only the specific failed child at that index; onError index = DOM order (Q51)
  • Auto-retry on prop change — if child in error state when reactive prop changes, resets and retries automatically

State machine — new transitions:

  • partial → refreshing queues (Q43) — prop change during initial load waits for allSettled to complete before refreshing
  • Full 8-state machine: idle → loading → partial / resolved / error / partial-error → refreshing → refresh-error

Reactive fallback (Q60):

  • Store references inside fallback / error / refreshing JSX are observed; compiler generates __observe() calls that invoke internal [GEA_UPDATE_FALLBACK](html) / [GEA_UPDATE_ERROR](html) symbol-keyed methods

CSS / DOM:

  • Comment marker DOM — zero wrapper elements; <!-- suspense-{id}-content --> anchor
  • CSS classes: suspense-entering / suspense-entered onlysuspense-leaving removed (Q32/Q19); GEA_SWAP_CHILD is synchronous
  • dispose() before each swap — prevents observer/listener leaks in all state transitions

Accessibility:

  • aria-busy auto on fallback container during loading
  • announceLabel: string (Q53) — opt-in; static string; compiler can inline it; enables aria-live=\"polite\"

Performance:

  • Shared IntersectionObserver pool — keyed by threshold only (Q52); O(1) per-boundary
  • GEA_CREATED_PROMISE cleared after allSettled — including waterfall-added children (Q55)
  • onResolve allocation guard (Q47) — results array only allocated when onResolve prop present
  • GEA_ON_CHILD_MOUNTED / GEA_UPDATE_FALLBACK / GEA_UPDATE_ERROR internal — NOT re-exported from @geajs/core (Q46, Q62)

Critical Implementation Notes

Compiled path fix (both packages must change together):

  • @geajs/core constructor captures async created() return value into GEA_CREATED_PROMISE
  • @geajs/vite-plugin generator.ts must also capture the promise — without this, Suspense is broken for all production compiled components

SSR architecture:

  • New renderToStringAsync in @geajs/ssr awaits all async children before calling template()
  • Server-side Suspense registers DeferredChunk entries linked by ssrStreamId
  • createSSRStream streams <script> replacements as deferred data resolves

Minimal Core Changes (@geajs/core)

Five new internal symbols — backwards compatible, public API unchanged:

  • GEA_CREATED_PROMISE — tracks pending async created() promise; cleared after resolution
  • GEA_ABORT_CONTROLLER — lazy lifecycle-bound abort controller
  • GEA_ON_CHILD_MOUNTED — waterfall detection hook; called when child added to childComponents
  • GEA_UPDATE_FALLBACK — internal Suspense method key for reactive fallback updates
  • GEA_UPDATE_ERROR — internal Suspense method key for reactive error content updates

Compiler Changes (@geajs/vite-plugin)

  • isBuiltInComponentTag recognizes <Suspense>
  • Render-prop slots compiled for fallback, error, refreshing, refreshingOverlay
  • Reactive observer bindings (gen-observer-wiring.ts) for store refs in render-prop JSX
  • Auto-generates ssrStreamId from \${file}-\${line}-\${key ?? ''} (list-safe)
  • generator.ts captures async created() return value (critical fix)

React Problems This Solves

  1. Waterfall — Promise.allSettled() + deep scan + GEA_ON_CHILD_MOUNTED waterfall hook
  2. Promise-throwing anti-pattern — standard async created(), no exception abuse
  3. Race conditions — monotonic generation counter, stale responses discarded
  4. Error handling gap — error prop built-in, no separate <ErrorBoundary>
  5. Hardcoded FALLBACK_THROTTLE_MS — configurable timeout + minimumFallback per boundary
  6. Memory leaks — lazy AbortController, dispose() before every swap
  7. Nested Suspense complexity — each boundary fully independent (inner shields outer)
  8. No progressive rendering — progressive={true} shows children as they resolve
  9. No reveal ordering — revealOrder='forwards'/'backwards' with independent partial-error handling
  10. No per-child error context — error(err, retry, childIndex?) with index-scoped retry
  11. SSR boilerplate — compiler auto-generates stable IDs (list-safe with key)
  12. Promise retention after settlement — GEA_CREATED_PROMISE cleared after allSettled()
  13. No stale-while-refresh UX — staleWhileRefresh + refreshingOverlay + delay threshold
  14. Observer cost at scale — shared IntersectionObserver pool (threshold key); O(1) per-boundary
  15. Concurrent refresh flicker — cancel+restart; state stays refreshing
  16. No built-in accessibility — aria-busy auto; announceLabel opt-in for aria-live
  17. No fast-refresh UX — staleWhileRefresh={{ delay: ms }} withholds overlay for sub-threshold refreshes

Testing Strategy

  • node:test + jsdom (consistent with @geajs/core)
  • Target: ≥90% line coverage (Phase 1–3), ≥85% (Phase 4–6)
  • Must include compiled-path tests to catch generator.ts regression
  • Benchmarks: mount time, queueMicrotask batch efficiency, memory leak verification, observer pool cost
  • New example app: examples/suspense-demo/ (kitchen-sink)

Checklist

  • Implementation plan written and reviewed (17 sections)
  • All design questions confirmed (63 decisions — Q1-Q63)
  • Compiler support documented including generator.ts compiled-path fix
  • Core integration details documented (backwards-compatible, 5 new internal symbols)
  • Progressive mode state machine fully specified (8 states, all transitions + revealOrder)
  • SSR architecture specified (renderToStringAsync, deferred registration)
  • DOM structure specified (comment markers, CSS lifecycle classes)
  • Deep scan + waterfall algorithm specified (GEA_ON_CHILD_MOUNTED, scanAndHook())
  • Reactive re-fetch contract specified (Suspense intercepts, cancel+restart)
  • Accessibility contract specified (aria-busy auto, announceLabel opt-in)
  • Stale-while-refresh extended: boolean | { delay }, refreshingOverlay prop
  • onResolve semantics: all-settled with PromiseSettledResult[]
  • Package scaffolded (packages/gea-suspense/)
  • Phase 1–6 implemented
  • Tests written (≥90% coverage, including compiled-path tests)
  • Benchmarks written
  • Example app created
  • Changeset file added (@geajs/suspense minor + @geajs/ssr patch)
  • docs/philosophy.md updated
  • README for @geajs/suspense written

References


This is the planning PR — implementation commits will follow on this branch.

senrecep and others added 3 commits April 13, 2026 12:37
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All 13 design questions answered. Key decisions:
- All 6 phases in scope
- Promise.allSettled() for partial render support
- this.abortSignal instance property
- 300ms minimumFallback default
- CSS class + render prop for staleWhileRefresh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Section 11: @geajs/vite-plugin changes (Suspense JSX detection,
  observer bindings, async child detection at runtime)
- Section 12: @geajs/core minimal changes (GEA_CREATED_PROMISE,
  GEA_ABORT_CONTROLLER symbols, abortSignal property, backwards compat)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 13, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Documents a complete design and phased rollout for a new @geajs/suspense package: package layout, six-phase feature roadmap (fallback, error/retry, timing, stale-refresh, triggers, SSR streaming), required vite-plugin and @geajs/core changes, testing, and monorepo integration details.

Changes

Cohort / File(s) Summary
Plan Document
PLAN.md
Adds the full implementation/design plan describing package layout (packages/gea-suspense), six-phase rollout for <Suspense>, DOM markers/state-machine, testing/benchmark checklist, and PR/monorepo integration steps.
New Package Skeleton
packages/gea-suspense/...
Specifies package responsibilities, API surface (props, lifecycle), CSS classes (e.g., suspense-refreshing), DOM marker structure, and phased feature milestones.
Compiler / Vite Plugin
packages/vite-plugin-gea/..., packages/@geajs/vite-plugin-gea
Requires treating <Suspense> as a built-in tag, compile-time extraction of JSX render-prop slots (fallback, error, refreshing), emit reactive wiring for Suspense props, and fix to capture return values of generated created() calls for async detection.
Runtime Core Changes
packages/geajs-core/..., packages/@geajs/core
Defines new internal symbols (e.g., GEA_CREATED_PROMISE, GEA_ABORT_CONTROLLER), adds abortSignal on Component, abort-on-dispose() behavior, and updates SuspenseProps interface to include new options (timeout, minimumFallback, staleWhileRefresh, trigger, ssrStreamId, etc.).
Behavior Specs & Algorithms
PLAN.md (sections), docs/...
Details runtime algorithms: parallel child created() via Promise.allSettled(), microtask batching, generation counter to discard stale results, per-child retry semantics, AbortController lifecycle integration, stale-while-refresh flow, trigger modes, SSR streaming/hydration protocol and ssrStreamId contract.
Testing & Tooling
tests/..., benchmarks/..., devtools/...
Lists unit/integration tests, SSR streaming tests, benchmarks, state-machine tests, and necessary test harness changes for created() async detection and abort behavior.
Integration Notes / Checklist
PLAN.md
Monorepo PR checklist, phased rollout plan (Phases 1–6), required API compat notes, migration and compiler rollout steps, and documentation/bench targets.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Suspense
  participant Children
  participant Renderer
  participant SSR
  Client->>Suspense: mount(props: fallback,error,timeout,...)
  Suspense->>Children: invoke created() for each child (parallel)
  Children-->>Suspense: return Promises (async) / values
  Suspense->>Suspense: Promise.allSettled() + generation counter
  alt timeout elapsed -> show fallback
    Suspense->>Renderer: render(fallback)
  end
  Suspense->>Suspense: all settled -> determine per-child success/error
  Suspense->>Renderer: render(resolved children) or render(error slots)
  Note right of SSR: SSR streaming uses ssrStreamId to emit placeholders -> client takeover on hydrate
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I nibble plans by lantern light,
Fallbacks set and retries tight;
Abort the stale, refresh the new,
Streams that stitch the server view.
A soft hop toward suspense's bright.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately describes the main change: introducing a new @geajs/suspense package with declarative async rendering boundaries, which is exactly what PLAN.md documents.
Linked Issues check ✅ Passed The PR comprehensively addresses all core objectives from issue #63: Suspense component design, fallback/error handling, async child resolution, generation counters, AbortController integration, timing controls, stale-while-refresh, trigger-based loading, SSR streaming, nested boundaries, and phased implementation plan.
Out of Scope Changes check ✅ Passed All changes in PLAN.md are directly scoped to the Suspense feature design and implementation planning; no unrelated modifications are present. The document focuses exclusively on the @geajs/suspense package specification.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
PLAN.md (2)

643-655: Consider lazy allocation of AbortController for better performance.

The plan creates an AbortController unconditionally in every component constructor (line 646), even for components that never use this.abortSignal. For component-heavy apps (virtualized lists, table cells, etc.), this could add measurable memory overhead and GC pressure.

Consider lazy allocation instead:

// In Component class:
declare abortSignal: AbortSignal

// Lazy getter (only allocates when accessed):
get abortSignal(): AbortSignal {
  let controller = (this as any)[GEA_ABORT_CONTROLLER]
  if (!controller) {
    controller = new AbortController()
    ;(this as any)[GEA_ABORT_CONTROLLER] = controller
  }
  return controller.signal
}

// In dispose() — only abort if controller was created:
;(this as any)[GEA_ABORT_CONTROLLER]?.abort()

This ensures only components that actually use async lifecycle hooks pay the allocation cost, while maintaining the same public API.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@PLAN.md` around lines 643 - 655, Remove the unconditional new
AbortController() creation from the constructor and switch to lazy allocation:
keep the declaration abortSignal: AbortSignal and replace the constructor
Object.defineProperty logic with a getter implementation for abortSignal that
checks (this as any)[GEA_ABORT_CONTROLLER], creates and stores a new
AbortController only when absent, and returns its .signal; keep the dispose()
change that calls (this as any)[GEA_ABORT_CONTROLLER]?.abort() so abort is
invoked only if the controller was created. Ensure you reference
GEA_ABORT_CONTROLLER, abortSignal, the constructor initialization (where the
current Object.defineProperty exists), and dispose() when making the edits.

106-255: Consider splitting into multiple PRs for easier review.

The plan commits to delivering all 6 phases in a single PR (per line 319). While this ensures feature completeness, it significantly increases review complexity, merge risk, and time-to-merge. Phases 1-3 form a natural MVP (basic Suspense with error handling and timing), while Phases 4-6 add advanced features (stale-while-refresh, triggers, SSR).

Consider delivering in 2-3 incremental PRs:

  1. MVP: Phases 1-3 (core functionality)
  2. Advanced: Phases 4-5 (stale-while-refresh + triggers)
  3. SSR: Phase 6 (streaming integration)

This approach allows earlier user feedback and reduces the blast radius of any issues. However, defer to the team's decision in the confirmed decisions table if a single PR is preferred for architectural reasons.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@PLAN.md` around lines 106 - 255, The plan is too large for a single PR; split
the work into incremental PRs: create an "MVP" PR covering Phase 1–3 (implement
packages/gea-suspense/, src/types.ts, src/suspense.ts core fallback/resolve,
error handling and timing tests), an "Advanced" PR for Phase 4–5
(abort/staleWhileRefresh, triggers, abort.ts, updating src/types.ts and
src/suspense.ts), and a final "SSR" PR for Phase 6 (ssrStreamId changes in
src/types.ts and client hydration logic in src/suspense.ts); update PLAN.md to
show these three PR milestones, adjust the task checklists under each Phase, and
add a note in the confirmed decisions table indicating the chosen multi-PR
approach so reviewers know which files (packages/gea-suspense/, src/suspense.ts,
src/types.ts, src/abort.ts) will land in which PR.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@PLAN.md`:
- Line 147: The "Partial failure handling" bullet is ambiguous and contradicts
later design notes; update that line to explicitly state that partial failures
do not short-circuit the whole boundary but instead use Promise.allSettled()
semantics so resolved children render and failed children render their error
state within the <Suspense> boundary—refer to Promise.allSettled() and the
<Suspense> partial render notes to align wording with lines describing "Partial
render via `Promise.allSettled()`" and "Each child independent via
`Promise.allSettled()`."
- Line 7: Update the top-level status line that currently reads "**Status**:
Planning — awaiting answers to open questions before implementation begins" to
reflect that section 8 ("All Questions Answered") is complete; for example
change it to "**Status**: Planning — all questions answered; ready for
implementation" or similar wording so the status and the "All Questions
Answered" section are consistent.
- Around line 48-67: The fenced code block in PLAN.md lacks a language
identifier causing MD040; update the opening fence to include a language (e.g.,
change ``` to ```text) for the directory structure block so markdownlint stops
complaining, ensure the closing fence remains ``` and verify the block around
the packages/gea-suspense listing still renders correctly.
- Around line 486-490: Replace the fragile name-based check in isAsyncCreated
with symbol-based detection: instead of testing child.created?.constructor?.name
=== 'AsyncFunction', check whether child[GEA_CREATED_PROMISE] is an instance of
Promise (i.e., child[GEA_CREATED_PROMISE] instanceof Promise); ensure
GEA_CREATED_PROMISE (the symbol used to capture created promises) is
imported/available in the module and update isAsyncCreated to return that
boolean test so it aligns with the documented Suspense detection logic.

---

Nitpick comments:
In `@PLAN.md`:
- Around line 643-655: Remove the unconditional new AbortController() creation
from the constructor and switch to lazy allocation: keep the declaration
abortSignal: AbortSignal and replace the constructor Object.defineProperty logic
with a getter implementation for abortSignal that checks (this as
any)[GEA_ABORT_CONTROLLER], creates and stores a new AbortController only when
absent, and returns its .signal; keep the dispose() change that calls (this as
any)[GEA_ABORT_CONTROLLER]?.abort() so abort is invoked only if the controller
was created. Ensure you reference GEA_ABORT_CONTROLLER, abortSignal, the
constructor initialization (where the current Object.defineProperty exists), and
dispose() when making the edits.
- Around line 106-255: The plan is too large for a single PR; split the work
into incremental PRs: create an "MVP" PR covering Phase 1–3 (implement
packages/gea-suspense/, src/types.ts, src/suspense.ts core fallback/resolve,
error handling and timing tests), an "Advanced" PR for Phase 4–5
(abort/staleWhileRefresh, triggers, abort.ts, updating src/types.ts and
src/suspense.ts), and a final "SSR" PR for Phase 6 (ssrStreamId changes in
src/types.ts and client hydration logic in src/suspense.ts); update PLAN.md to
show these three PR milestones, adjust the task checklists under each Phase, and
add a note in the confirmed decisions table indicating the chosen multi-PR
approach so reviewers know which files (packages/gea-suspense/, src/suspense.ts,
src/types.ts, src/abort.ts) will land in which PR.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0513db41-7523-469c-b92b-b25391426fa3

📥 Commits

Reviewing files that changed from the base of the PR and between c4ebce2 and e0db882.

📒 Files selected for processing (1)
  • PLAN.md

Comment thread PLAN.md Outdated
Comment thread PLAN.md Outdated
Comment thread PLAN.md
Comment thread PLAN.md
senrecep and others added 3 commits April 13, 2026 13:47
- Fix status line contradiction (planning complete)
- Add text language identifier to directory structure block (MD040)
- Clarify partial failure uses Promise.allSettled semantics
- Replace fragile AsyncFunction name check with symbol-based detection
- Switch AbortController to lazy getter allocation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…efinements

New decisions (Q14-Q21):
- progressive={true} prop for per-child progressive rendering
- error() accepts optional childIndex for per-child error UI
- staleWhileRefresh + error: error UI replaces stale content
- queueMicrotask batching for same-tick DOM updates
- suspense-entering/entered/leaving CSS lifecycle classes
- ssrStreamId compiler auto-generation from file+line hash
- onLoadStart callback for telemetry
- Updated SuspenseProps interface (Section 13)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Critical fixes:
- GAP 1: Document compiled path GEA_CREATED_PROMISE capture in generator.ts
  (without this fix, Suspense is broken for all production components)

Major fixes:
- GAP 2: GEA_SWAP_CHILD dispose() contract — Suspense must call dispose()
  on outgoing child before each swap to prevent observer/listener leaks
- GAP 3: Add Section 14 — complete progressive mode state machine with
  8 states, all transitions, callback matrix, retry semantics
- GAP 4: GEA_CREATED_PROMISE lifecycle — delete after allSettled(),
  re-capture on retry; retry() protocol fully specified
- GAP 5: SSR architecture — renderToStringAsync, boundary registration,
  fallback HTML generation, stream coordination, client hydration

Minor fixes:
- GAP 6: Fix abortSignal backwards compat table (lazy allocation clarified)
- GAP 7: Add timeout+minimumFallback timing interaction test case
- GAP 8: Trigger vs refresh contract — triggers are one-shot, refreshes
  driven by reactive prop changes via GEA_ON_PROP_CHANGE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@PLAN.md`:
- Line 900: The fenced state-machine block is missing a language identifier
(causing MD040); update the opening triple-backtick for that block to include a
language such as "text" so it reads ```text, e.g., for the state-machine block
containing "idle ──(trigger fires)──→ loading" and "refresh-error ──(retry()
called)──→ refreshing" so the linter recognizes the code fence language.
- Around line 943-949: The documentation has a contract conflict: the sentence
stating "onResolve() fires when ALL children have completed the refresh (success
or error)" conflicts with the callback matrix that routes refresh failures to
onError(...). Update the PLAN.md wording so the semantics are consistent—either
make onResolve() fire only when all children succeed and call out that any child
failure triggers onError(...), or explicitly state that onResolve() is called
after every child completes regardless of outcome and remove failures from
onError(...); ensure the final choice aligns with the callback matrix and adjust
the surrounding sentences (the bullet about progressive removal and the
onResolve() line) to match the chosen contract, referencing the onResolve() and
onError(...) callbacks for clarity.
- Around line 145-147: The plan text incorrectly prescribes a try/catch around
Promise.all(), which contradicts the partial-failure/retry semantics; update the
wording to replace Promise.all() with Promise.allSettled() and describe the
failure model: on each allSettled() result, for failed children call retry()
which disposes the old instance via dispose(), re-instantiate and call
created(), capture the new GEA_CREATED_PROMISE and run a new allSettled() cycle
while preserving already-resolved children DOM; mention allSettled() explicitly
as the mechanism that prevents short-circuiting and enables per-child retry
flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 464a715c-d3e1-4ed8-9df2-3dc1635fb616

📥 Commits

Reviewing files that changed from the base of the PR and between 726afa1 and 6d7e8f3.

📒 Files selected for processing (1)
  • PLAN.md

Comment thread PLAN.md Outdated
Comment thread PLAN.md

### Transitions

```
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add a language identifier to the fenced state-machine block.

This triggers MD040 at Line 900.

Proposed fix
-```
+```text
 idle ──(trigger fires)──→ loading
 ...
 refresh-error ──(retry() called)──→ refreshing
</details>

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion

🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 900-900: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@PLAN.md` at line 900, The fenced state-machine block is missing a language
identifier (causing MD040); update the opening triple-backtick for that block to
include a language such as "text" so it reads ```text, e.g., for the
state-machine block containing "idle ──(trigger fires)──→ loading" and
"refresh-error ──(retry() called)──→ refreshing" so the linter recognizes the
code fence language.

Comment thread PLAN.md
…, waterfall, re-fetch

New sections:
- §15 DOM Structure: comment marker pattern, CSS lifecycle classes, default error behavior
- §16 Deep Scan Algorithm & Waterfall: collectPendingPromises(), stop-at-inner-boundary rule,
  zero-allocation fast path, AsyncParent→AsyncChild waterfall protocol, IntersectionObserver pool
- §17 Reactive Re-fetch Contract: Suspense intercepts GEA_ON_PROP_CHANGE, cancel+restart
  concurrent refresh, prefetch='idle' vs trigger='idle' semantics

Updated sections:
- §8 Confirmed Decisions: Q22–Q31 added (31 total)
- §6 Changeset: @geajs/ssr patch added (renderToStringAsync)
- §13 SuspenseProps: revealOrder prop added
- §14 State Machine: refreshing→refreshing transition + revealOrder behavior table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (3)
PLAN.md (3)

916-916: ⚠️ Potential issue | 🟡 Minor

Add language identifier to state machine code block.

The fenced code block is missing a language identifier, triggering MD040 lint warning.

📝 Proposed fix
-```
+```text
 idle ──(trigger fires)──→ loading
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@PLAN.md` at line 916, The fenced code block containing the state-machine
snippet "idle ──(trigger fires)──→ loading" lacks a language identifier; update
the opening fence from ``` to include a language (e.g., ```text) so the block
becomes a fenced code block with a language identifier to satisfy MD040 linting.

145-145: ⚠️ Potential issue | 🟡 Minor

Replace Promise.all() with Promise.allSettled() to match documented semantics.

Line 145 still references try/catch around Promise.all(), but this conflicts with the partial-failure model defined throughout the document:

  • Line 117: Uses Promise.allSettled() for parallel resolution
  • Line 147: Retry protocol explicitly uses allSettled() cycles
  • Line 156: Describes "Promise.allSettled semantics" for partial failures
  • Line 368: Decision Q13 confirms "Partial render via Promise.allSettled()"

Promise.all() short-circuits on first rejection and prevents independent child error handling, breaking the retry flow.

📝 Proposed fix
-- `try/catch` around `Promise.all()`
+- `Promise.allSettled()` to collect all child outcomes; derive boundary state from settled results
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@PLAN.md` at line 145, The document still references wrapping Promise.all in a
try/catch which conflicts with the intended partial-failure semantics; update
the text and any example pseudocode that mentions Promise.all (and the try/catch
pattern) to use Promise.allSettled instead and describe handling of its results
(checking each result.status and performing per-item retry/cleanup as per the
retry protocol), and ensure any references to "Promise.all semantics" are
changed to "Promise.allSettled semantics" to match Decision Q13 and the retry
cycles.

972-980: ⚠️ Potential issue | 🟠 Major

Resolve callback contract conflict for refresh completion.

Line 979 states that onResolve() fires "when ALL children have completed the refresh (success or error)", but the callback matrix at lines 934-946 routes refresh failures to onError(...) (line 946: "refreshing → refresh-error" fires "onError(err, index?)"). These semantics are contradictory.

Choose one contract and update the document consistently:

  • Option A: onResolve() fires only when all children succeed; any child failure triggers onError(...) and transitions to refresh-error.
  • Option B: onResolve() fires after all children complete regardless of outcome; remove failures from the onError(...) trigger list.

The callback matrix suggests Option A is the intended design.

📝 Proposed fix (Option A)
-- `onResolve()` fires when ALL children have completed the refresh (success or error)
+- `onResolve()` fires only when refresh completes with all children successfully resolved
+- If any child fails during refresh, transition to `refresh-error` and fire `onError(err, index)`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@PLAN.md` around lines 972 - 980, The document currently contradicts itself
about when onResolve() fires for refresh: choose Option A (as the callback
matrix implies) and update the PLAN.md text so that in the "progressive=true +
staleWhileRefresh=true interaction" section the sentence about onResolve() is
changed to state that onResolve() fires only when ALL children succeed (any
child failure triggers onError(...) and a transition to refresh-error); then
make the same semantic change in the callback matrix entries (ensure the
"refreshing → refresh-error" row routes failures to onError(err, index?) and
remove any mention of onResolve firing on error), and verify any other
references to onResolve/onError in the file match this Option A contract.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@PLAN.md`:
- Line 916: The fenced code block containing the state-machine snippet "idle
──(trigger fires)──→ loading" lacks a language identifier; update the opening
fence from ``` to include a language (e.g., ```text) so the block becomes a
fenced code block with a language identifier to satisfy MD040 linting.
- Line 145: The document still references wrapping Promise.all in a try/catch
which conflicts with the intended partial-failure semantics; update the text and
any example pseudocode that mentions Promise.all (and the try/catch pattern) to
use Promise.allSettled instead and describe handling of its results (checking
each result.status and performing per-item retry/cleanup as per the retry
protocol), and ensure any references to "Promise.all semantics" are changed to
"Promise.allSettled semantics" to match Decision Q13 and the retry cycles.
- Around line 972-980: The document currently contradicts itself about when
onResolve() fires for refresh: choose Option A (as the callback matrix implies)
and update the PLAN.md text so that in the "progressive=true +
staleWhileRefresh=true interaction" section the sentence about onResolve() is
changed to state that onResolve() fires only when ALL children succeed (any
child failure triggers onError(...) and a transition to refresh-error); then
make the same semantic change in the callback matrix entries (ensure the
"refreshing → refresh-error" row routes failures to onError(err, index?) and
remove any mention of onResolve firing on error), and verify any other
references to onResolve/onError in the file match this Option A contract.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aceb289c-62db-4937-ba7c-1965857ae017

📥 Commits

Reviewing files that changed from the base of the PR and between 6d7e8f3 and 56faf67.

📒 Files selected for processing (1)
  • PLAN.md

…eview

Q41: onResolve() fires on all-settled (success or error) with PromiseSettledResult[]
Q42: waterfall detection via GEA_ON_CHILD_MOUNTED core lifecycle hook
Q43: partial → refreshing queues, waits for initial load to complete
Q44: minimumFallback is batch-only (progressive=true shows children immediately)
Q45: GEA_ON_CHILD_MOUNTED set recursively on full subtree
Q46: GEA_ON_CHILD_MOUNTED internal, not re-exported from @geajs/core
Q47: onResolve allocation guard (skip array if prop absent)
Q48: retry() in error prop retries failed children only
Q49: onLoadStart fires when idle callback starts (not on trigger)
Q50: revealOrder=forwards buffer unmount → drop and skip slot
Q51: onError index = DOM order (JSX child order)
Q52: IntersectionObserver pool key = threshold only
Q53: announceLabel = string only
Q54: zero children → dev warning + immediate resolved state
Q55: waterfall child GEA_CREATED_PROMISE deleted after allSettled
Q56: timeout + progressive=true → global timer, per-slot fallback
Q57: retry() in error render prop = single child (index-scoped)
Q58: refreshingOverlay prop added for overlay skeleton pattern
Q59: SuspenseProps.children type = ComponentClass[]
Q60: reactive fallback via internal GEA_UPDATE_FALLBACK symbol method
Q61: staleWhileRefresh = boolean | { delay: number }
Q62: updateFallback/updateError symbol-keyed, internal only
Q63: nested Suspense + staleWhileRefresh fully independent

Also fixes:
- onResolve() callback matrix updated (fires in all error cases too)
- Q19: suspense-leaving removed from CSS class list
- PR checklist: suspense-leaving reference corrected
- SuspenseProps: refreshingOverlay, staleWhileRefresh type, onResolve signature updated
- Section 16: GEA_ON_CHILD_MOUNTED scanAndHook() algorithm with N-level waterfall
- symbols.ts additions: GEA_ON_CHILD_MOUNTED, GEA_UPDATE_FALLBACK, GEA_UPDATE_ERROR

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant