mirror of https://github.com/sveltejs/svelte
parent
f0b01f76ca
commit
3c73cc703a
@ -0,0 +1,319 @@
|
||||
# Svelte Async SSR Hydration: Resolved Content Without Pending Snippets (Simplified Design)
|
||||
|
||||
This document outlines the implementation plan for hydrating boundaries when async SSR has already resolved the content on the server and no `pending` snippet exists. This uses a simplified approach that reuses existing hydration infrastructure and allows client-side async re-execution.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
With async SSR, we now have boundaries that can render in two states:
|
||||
|
||||
**Boundary with pending snippet:**
|
||||
|
||||
```svelte
|
||||
<svelte:boundary>
|
||||
<p>{await getData()}</p>
|
||||
{#snippet pending()}
|
||||
<p>Loading...</p>
|
||||
{/snippet}
|
||||
</svelte:boundary>
|
||||
```
|
||||
|
||||
- **With pending snippet**: Server always renders `<p>Loading...</p>`
|
||||
- **Without pending snippet**: Server waits for `getData()`, renders `<p>Resolved Data</p>`
|
||||
|
||||
**The hydration challenge**: How does the client know which content the server rendered?
|
||||
|
||||
## Simplified Design Approach
|
||||
|
||||
### Core Principles
|
||||
|
||||
1. **No promise value serialization** - Client async operations can re-execute if needed
|
||||
2. **Reuse existing markers** - Leverage the `else` block marker pattern (`<!--[!-->`)
|
||||
3. **Binary state model** - Either "pending rendered" or "resolved rendered"
|
||||
4. **Allow async re-execution** - If promises don't resolve immediately on client, let them run
|
||||
|
||||
### Existing Infrastructure to Reuse
|
||||
|
||||
Svelte already has a pattern for this with `{#if}` blocks:
|
||||
|
||||
```html
|
||||
<!-- If condition was true on server -->
|
||||
<!--[-->
|
||||
<div>if content</div>
|
||||
<!--]-->
|
||||
|
||||
<!-- If condition was false on server (else rendered) -->
|
||||
<!--[!-->
|
||||
<div>else content</div>
|
||||
<!--]-->
|
||||
```
|
||||
|
||||
We can apply the same pattern to boundaries, where pending is the "else" case:
|
||||
|
||||
```html
|
||||
<!-- Server rendered resolved content (normal case) -->
|
||||
<!--[-->
|
||||
<p>Resolved content</p>
|
||||
<!--]-->
|
||||
|
||||
<!-- Server rendered pending content (else case) -->
|
||||
<!--[!-->
|
||||
<p>Loading...</p>
|
||||
<!--]-->
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Server-Side Changes
|
||||
|
||||
#### 1.1 Server Boundary Rendering Logic
|
||||
|
||||
Modify `SvelteBoundary` server visitor to use existing marker pattern:
|
||||
|
||||
```javascript
|
||||
export function SvelteBoundary(node, context) {
|
||||
const pending_snippet = node.metadata.pending;
|
||||
|
||||
if (pending_snippet) {
|
||||
// Has pending snippet - render pending content with else marker
|
||||
context.state.template.push(b.literal(BLOCK_OPEN_ELSE)); // <!--[!-->
|
||||
|
||||
if (pending_snippet.type === 'Attribute') {
|
||||
const value = build_attribute_value(pending_snippet.value, context, false, true);
|
||||
context.state.template.push(b.call(value, b.id('$$payload')));
|
||||
} else if (pending_snippet.type === 'SnippetBlock') {
|
||||
context.state.template.push(context.visit(pending_snippet.body));
|
||||
}
|
||||
} else {
|
||||
// No pending snippet - render main content (may be async or sync)
|
||||
context.state.template.push(b.literal(BLOCK_OPEN)); // <!--[-->
|
||||
context.state.template.push(context.visit(node.fragment));
|
||||
}
|
||||
|
||||
context.state.template.push(b.literal(BLOCK_CLOSE)); // <!--]-->
|
||||
}
|
||||
```
|
||||
|
||||
**Key insight**: The server only cares about whether there's a pending snippet. If there is, render it with the else marker. If not, render the main content with the normal marker - the server will naturally wait for any async operations to resolve during rendering.
|
||||
|
||||
### Phase 2: Client-Side Hydration Changes
|
||||
|
||||
#### 2.1 Hydration State Detection
|
||||
|
||||
Extend boundary constructor to detect server rendering state:
|
||||
|
||||
```javascript
|
||||
constructor(node, props, children) {
|
||||
this.#anchor = node;
|
||||
this.#props = props;
|
||||
this.#children = children;
|
||||
this.#hydrate_open = hydrate_node;
|
||||
|
||||
// NEW: Detect what the server rendered
|
||||
this.#server_rendered_pending = this.#detect_server_state();
|
||||
|
||||
this.parent = active_effect.b;
|
||||
this.pending = !!this.#props.pending;
|
||||
|
||||
// Main effect logic...
|
||||
}
|
||||
|
||||
#detect_server_state() {
|
||||
if (!hydrating || !this.#hydrate_open) return false;
|
||||
|
||||
const comment = this.#hydrate_open;
|
||||
if (comment.nodeType === COMMENT_NODE) {
|
||||
// Check if server rendered pending content (else marker)
|
||||
return comment.data === HYDRATION_START_ELSE; // '[!'
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 Hydration Flow Logic
|
||||
|
||||
Modify the main boundary effect to handle both cases:
|
||||
|
||||
```javascript
|
||||
this.#effect = block(() => {
|
||||
active_effect.b = this;
|
||||
|
||||
if (hydrating) {
|
||||
hydrate_next();
|
||||
|
||||
if (this.#server_rendered_pending) {
|
||||
// Server rendered pending content - existing logic
|
||||
this.#hydrate_pending_content();
|
||||
} else {
|
||||
// Server rendered resolved content - new logic
|
||||
this.#hydrate_resolved_content();
|
||||
}
|
||||
} else {
|
||||
// Client-side rendering
|
||||
this.#render_client_content();
|
||||
}
|
||||
}, flags);
|
||||
```
|
||||
|
||||
#### 2.3 Resolved Content Hydration
|
||||
|
||||
Implement the resolved content hydration path:
|
||||
|
||||
```javascript
|
||||
#hydrate_resolved_content() {
|
||||
// Server already rendered resolved content, so hydrate it directly
|
||||
this.#main_effect = this.#run(() => {
|
||||
return branch(() => this.#children(this.#anchor));
|
||||
});
|
||||
|
||||
// Start in non-pending state since server rendered resolved content
|
||||
this.pending = false;
|
||||
|
||||
// Note: Even if client-side async operations are still running,
|
||||
// we never transition back to pending state. Users can use
|
||||
// $effect.pending() to track ongoing async work if needed.
|
||||
}
|
||||
|
||||
#hydrate_pending_content() {
|
||||
// Existing logic - server rendered pending content
|
||||
this.#pending_effect = branch(() => this.#props.pending(this.#anchor));
|
||||
|
||||
Batch.enqueue(() => {
|
||||
this.#main_effect = this.#run(() => {
|
||||
Batch.ensure();
|
||||
return branch(() => this.#children(this.#anchor));
|
||||
});
|
||||
|
||||
if (this.#pending_count > 0) {
|
||||
this.#show_pending_snippet();
|
||||
} else {
|
||||
pause_effect(this.#pending_effect, () => {
|
||||
this.#pending_effect = null;
|
||||
});
|
||||
this.pending = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: Compiler Integration
|
||||
|
||||
#### 4.1 Analysis Phase
|
||||
|
||||
The analysis already tracks `is_async` on boundaries. We just need to ensure it's set correctly:
|
||||
|
||||
```javascript
|
||||
// In AwaitExpression visitor - this already exists
|
||||
if (context.state.async_hoist_boundary && context.state.expression) {
|
||||
context.state.async_hoist_boundary.metadata.is_async = true;
|
||||
// ... existing logic
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2 Server Code Generation
|
||||
|
||||
The server visitor change is minimal - just use the else marker for pending content:
|
||||
|
||||
```javascript
|
||||
// In server SvelteBoundary visitor
|
||||
export function SvelteBoundary(node, context) {
|
||||
const pending_snippet = node.metadata.pending;
|
||||
|
||||
if (pending_snippet) {
|
||||
// Use else marker for pending content
|
||||
context.state.template.push(b.literal(BLOCK_OPEN_ELSE));
|
||||
// ... render pending content
|
||||
} else {
|
||||
// Use normal marker for main content (async or sync)
|
||||
context.state.template.push(b.literal(BLOCK_OPEN));
|
||||
// ... render main content
|
||||
}
|
||||
|
||||
context.state.template.push(b.literal(BLOCK_CLOSE));
|
||||
}
|
||||
```
|
||||
|
||||
## Edge Cases and Considerations
|
||||
|
||||
### Edge Case 1: Multiple Async Operations with Different Timing
|
||||
|
||||
```svelte
|
||||
<svelte:boundary>
|
||||
<p>{await fast()}</p>
|
||||
<p>{await slow()}</p>
|
||||
</svelte:boundary>
|
||||
```
|
||||
|
||||
If `fast()` resolves on server but `slow()` doesn't, the server still waits for both before rendering resolved content. On client, both may re-execute with different timing.
|
||||
|
||||
**Handling**: The boundary's `#pending_count` system already handles multiple async operations correctly.
|
||||
|
||||
### Edge Case 2: Conditional Async Content
|
||||
|
||||
```svelte
|
||||
<svelte:boundary>
|
||||
{#if condition}
|
||||
<p>{await getData()}</p>
|
||||
{:else}
|
||||
<p>No data needed</p>
|
||||
{/if}
|
||||
</svelte:boundary>
|
||||
```
|
||||
|
||||
**Handling**: The `is_async` flag is set if any path contains async operations. Server-side rendering will resolve the condition and any async operations in the taken path.
|
||||
|
||||
### Edge Case 3: Nested Boundaries
|
||||
|
||||
```svelte
|
||||
<svelte:boundary>
|
||||
<div>{await outer()}</div>
|
||||
<svelte:boundary><div>{await inner()}</div></svelte:boundary>
|
||||
</svelte:boundary>
|
||||
```
|
||||
|
||||
**Handling**: Each boundary is independent. Inner boundary can be resolved while outer is pending, or vice versa.
|
||||
|
||||
## Implementation Context
|
||||
|
||||
### Key Design Philosophy
|
||||
|
||||
- This is the **simplified** approach - we deliberately chose NOT to serialize promise values
|
||||
- We're reusing existing `if/else` block hydration markers rather than creating new ones
|
||||
- The server doesn't need to know about `is_async` - it just renders based on pending snippet presence
|
||||
|
||||
### Critical Semantic Understanding
|
||||
|
||||
- `<!--[!-->` = pending content (the "else" case when async hasn't resolved)
|
||||
- `<!--[-->` = resolved content (normal case)
|
||||
- This inversion makes semantic sense: pending is the fallback/else state
|
||||
|
||||
### Boundary State Rules
|
||||
|
||||
- Boundaries **never** transition back to pending once content is rendered
|
||||
- Use `$effect.pending()` for tracking ongoing async work, not boundary state
|
||||
- The `pending` property stays `false` once content is shown
|
||||
|
||||
### Server Logic Simplicity
|
||||
|
||||
- Server only checks: "Does this boundary have a pending snippet?"
|
||||
- If yes → render pending with `BLOCK_OPEN_ELSE`
|
||||
- If no → render main content with `BLOCK_OPEN` (async SSR waits naturally)
|
||||
|
||||
### Client Hydration Flow
|
||||
|
||||
- Detect marker type to know what server rendered
|
||||
- If `HYDRATION_START_ELSE` → server rendered pending, use existing logic
|
||||
- If normal marker → server rendered resolved, hydrate directly (no complex async handling)
|
||||
|
||||
### What We're NOT Doing
|
||||
|
||||
- No promise serialization/deserialization
|
||||
- No complex client-server async coordination
|
||||
- No `error` snippet handling (server never renders errors)
|
||||
- No distinction between async/sync resolved content
|
||||
|
||||
### Implementation Priority
|
||||
|
||||
The core change is surprisingly small - just swapping which marker the server uses for pending content. The rest leverages existing Svelte hydration infrastructure.
|
||||
|
||||
This approach prioritizes simplicity and reuse over complex optimization, which aligns with Svelte's philosophy of doing more with less code.
|
@ -0,0 +1,13 @@
|
||||
import { ok, test } from '../../test';
|
||||
|
||||
export default test({
|
||||
html: `
|
||||
<p>hello</p>
|
||||
`,
|
||||
|
||||
async test({ assert, target }) {
|
||||
const p = target.querySelector('p');
|
||||
ok(p);
|
||||
assert.htmlEqual(p.outerHTML, '<p>hello</p>');
|
||||
}
|
||||
});
|
@ -0,0 +1,2 @@
|
||||
|
||||
<p>{await Promise.resolve('hello')}</p>
|
Loading…
Reference in new issue