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