feat(suspense): add @geajs/suspense package — declarative async rendering boundaries#66
feat(suspense): add @geajs/suspense package — declarative async rendering boundaries#66senrecep wants to merge 8 commits intodashersw:mainfrom
Conversation
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>
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughDocuments a complete design and phased rollout for a new Changes
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (2)
PLAN.md (2)
643-655: Consider lazy allocation of AbortController for better performance.The plan creates an
AbortControllerunconditionally in every component constructor (line 646), even for components that never usethis.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:
- MVP: Phases 1-3 (core functionality)
- Advanced: Phases 4-5 (stale-while-refresh + triggers)
- 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
- 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>
There was a problem hiding this comment.
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
|
|
||
| ### Transitions | ||
|
|
||
| ``` |
There was a problem hiding this comment.
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.
…, 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>
There was a problem hiding this comment.
♻️ Duplicate comments (3)
PLAN.md (3)
916-916:⚠️ Potential issue | 🟡 MinorAdd 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 | 🟡 MinorReplace
Promise.all()withPromise.allSettled()to match documented semantics.Line 145 still references
try/catcharoundPromise.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 | 🟠 MajorResolve 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 toonError(...)(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 triggersonError(...)and transitions torefresh-error.- Option B:
onResolve()fires after all children complete regardless of outcome; remove failures from theonError(...)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.
…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>
Summary
This PR introduces
@geajs/suspense, a new official package that adds declarative async rendering boundaries to Gea — addressing the gap noted indocs/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 SSRdeferreds. Zero new concepts for users.Why a separate package?
Following the
@geajs/ssrprecedent:@geajs/coreWhat'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
Complete `SuspenseProps` Interface
Key Design Decisions (63 confirmed — updated from 40)
Core semantics:
results: PromiseSettledResult<void>[]passed; caller inspects.status;onError()still fires per failed child independentlyqueueMicrotaskbatching — 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 independentlyminimumFallbackbatch-only (Q44) — in progressive mode, children show immediately without waitingtimeoutglobal in progressive mode (Q56) — single timer; still-loading slots get per-slot fallback when timer firesWaterfall elimination (Q42, Q45):
GEA_ON_CHILD_MOUNTEDcore hook — new internal symbol called bycomponent.tsxwhen a child is pushed tochildComponents; Suspense hooks recursively on full subtree for N-level waterfall detectionscanAndHook()algorithm — sets hook on every node in subtree, stopping at nested Suspense boundaries; newly mounted children also receive the hook; cleaned up indispose()Stale-while-refresh (Q61, Q58):
staleWhileRefresh: boolean | { delay: number }—delaywithholds CSS class andrefreshingOverlayfor fast refreshes; no visual indicator if refresh completes before delayrefreshingOverlayprop — new prop for skeleton/shimmer overlay on top of stale content (position: absolute)refreshingError handling:
retry()in error render prop = single child (Q57) — retries only the specific failed child at that index;onError index = DOM order(Q51)State machine — new transitions:
partial → refreshingqueues (Q43) — prop change during initial load waits for allSettled to complete before refreshingidle → loading → partial / resolved / error / partial-error → refreshing → refresh-errorReactive fallback (Q60):
fallback/error/refreshingJSX are observed; compiler generates__observe()calls that invoke internal[GEA_UPDATE_FALLBACK](html)/[GEA_UPDATE_ERROR](html)symbol-keyed methodsCSS / DOM:
<!-- suspense-{id}-content -->anchorsuspense-entering/suspense-enteredonly —suspense-leavingremoved (Q32/Q19);GEA_SWAP_CHILDis synchronousdispose()before each swap — prevents observer/listener leaks in all state transitionsAccessibility:
aria-busyauto on fallback container during loadingannounceLabel: string(Q53) — opt-in; static string; compiler can inline it; enablesaria-live=\"polite\"Performance:
IntersectionObserverpool — keyed by threshold only (Q52); O(1) per-boundaryGEA_CREATED_PROMISEcleared after allSettled — including waterfall-added children (Q55)onResolveallocation guard (Q47) — results array only allocated whenonResolveprop presentGEA_ON_CHILD_MOUNTED/GEA_UPDATE_FALLBACK/GEA_UPDATE_ERRORinternal — NOT re-exported from@geajs/core(Q46, Q62)Critical Implementation Notes
Compiled path fix (both packages must change together):
@geajs/coreconstructor capturesasync created()return value intoGEA_CREATED_PROMISE@geajs/vite-plugingenerator.tsmust also capture the promise — without this, Suspense is broken for all production compiled componentsSSR architecture:
renderToStringAsyncin@geajs/ssrawaits all async children before callingtemplate()DeferredChunkentries linked byssrStreamIdcreateSSRStreamstreams<script>replacements as deferred data resolvesMinimal Core Changes (
@geajs/core)Five new internal symbols — backwards compatible, public API unchanged:
GEA_CREATED_PROMISE— tracks pendingasync created()promise; cleared after resolutionGEA_ABORT_CONTROLLER— lazy lifecycle-bound abort controllerGEA_ON_CHILD_MOUNTED— waterfall detection hook; called when child added tochildComponentsGEA_UPDATE_FALLBACK— internal Suspense method key for reactive fallback updatesGEA_UPDATE_ERROR— internal Suspense method key for reactive error content updatesCompiler Changes (
@geajs/vite-plugin)isBuiltInComponentTagrecognizes<Suspense>fallback,error,refreshing,refreshingOverlaygen-observer-wiring.ts) for store refs in render-prop JSXssrStreamIdfrom\${file}-\${line}-\${key ?? ''}(list-safe)generator.tscapturesasync created()return value (critical fix)React Problems This Solves
Promise.allSettled()+ deep scan +GEA_ON_CHILD_MOUNTEDwaterfall hookasync created(), no exception abuseerrorprop built-in, no separate<ErrorBoundary>FALLBACK_THROTTLE_MS— configurabletimeout+minimumFallbackper boundaryAbortController,dispose()before every swapprogressive={true}shows children as they resolverevealOrder='forwards'/'backwards'with independent partial-error handlingerror(err, retry, childIndex?)with index-scoped retrykey)GEA_CREATED_PROMISEcleared afterallSettled()staleWhileRefresh+refreshingOverlay+ delay thresholdIntersectionObserverpool (threshold key); O(1) per-boundaryrefreshingaria-busyauto;announceLabelopt-in foraria-livestaleWhileRefresh={{ delay: ms }}withholds overlay for sub-threshold refreshesTesting Strategy
node:test+jsdom(consistent with@geajs/core)generator.tsregressionqueueMicrotaskbatch efficiency, memory leak verification, observer pool costexamples/suspense-demo/(kitchen-sink)Checklist
generator.tscompiled-path fixrenderToStringAsync, deferred registration)GEA_ON_CHILD_MOUNTED,scanAndHook())boolean | { delay },refreshingOverlayproponResolvesemantics: all-settled withPromiseSettledResult[]packages/gea-suspense/)@geajs/suspenseminor +@geajs/ssrpatch)docs/philosophy.mdupdated@geajs/suspensewrittenReferences
@defer(triggers), React SuspenseList (revealOrder), Qwik (AbortController cleanup)