Skip to content

Commit 3c73cc7

Browse files
feat: first hydrationgaa
1 parent f0b01f7 commit 3c73cc7

File tree

12 files changed

+453
-66
lines changed

12 files changed

+453
-66
lines changed

documentation/docs/98-reference/.generated/shared-errors.md

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,14 @@
33
### await_outside_boundary
44

55
```
6-
Cannot await outside a `<svelte:boundary>` with a `pending` snippet
6+
Cannot await outside a `<svelte:boundary>`.
77
```
88

9-
The `await` keyword can only appear in a `$derived(...)` or template expression, or at the top level of a component's `<script>` block, if it is inside a [`<svelte:boundary>`](/docs/svelte/svelte-boundary) that has a `pending` snippet:
9+
The `await` keyword can only appear in a `$derived(...)` or template expression, or at the top level of a component's `<script>` block, if it is inside a [`<svelte:boundary>`](/docs/svelte/svelte-boundary):
1010

1111
```svelte
1212
<svelte:boundary>
1313
<p>{await getData()}</p>
14-
15-
{#snippet pending()}
16-
<p>loading...</p>
17-
{/snippet}
1814
</svelte:boundary>
1915
```
2016

hydration-next.md

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
# Svelte Async SSR Hydration: Resolved Content Without Pending Snippets (Simplified Design)
2+
3+
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.
4+
5+
## Problem Statement
6+
7+
With async SSR, we now have boundaries that can render in two states:
8+
9+
**Boundary with pending snippet:**
10+
11+
```svelte
12+
<svelte:boundary>
13+
<p>{await getData()}</p>
14+
{#snippet pending()}
15+
<p>Loading...</p>
16+
{/snippet}
17+
</svelte:boundary>
18+
```
19+
20+
- **With pending snippet**: Server always renders `<p>Loading...</p>`
21+
- **Without pending snippet**: Server waits for `getData()`, renders `<p>Resolved Data</p>`
22+
23+
**The hydration challenge**: How does the client know which content the server rendered?
24+
25+
## Simplified Design Approach
26+
27+
### Core Principles
28+
29+
1. **No promise value serialization** - Client async operations can re-execute if needed
30+
2. **Reuse existing markers** - Leverage the `else` block marker pattern (`<!--[!-->`)
31+
3. **Binary state model** - Either "pending rendered" or "resolved rendered"
32+
4. **Allow async re-execution** - If promises don't resolve immediately on client, let them run
33+
34+
### Existing Infrastructure to Reuse
35+
36+
Svelte already has a pattern for this with `{#if}` blocks:
37+
38+
```html
39+
<!-- If condition was true on server -->
40+
<!--[-->
41+
<div>if content</div>
42+
<!--]-->
43+
44+
<!-- If condition was false on server (else rendered) -->
45+
<!--[!-->
46+
<div>else content</div>
47+
<!--]-->
48+
```
49+
50+
We can apply the same pattern to boundaries, where pending is the "else" case:
51+
52+
```html
53+
<!-- Server rendered resolved content (normal case) -->
54+
<!--[-->
55+
<p>Resolved content</p>
56+
<!--]-->
57+
58+
<!-- Server rendered pending content (else case) -->
59+
<!--[!-->
60+
<p>Loading...</p>
61+
<!--]-->
62+
```
63+
64+
## Implementation Plan
65+
66+
### Phase 1: Server-Side Changes
67+
68+
#### 1.1 Server Boundary Rendering Logic
69+
70+
Modify `SvelteBoundary` server visitor to use existing marker pattern:
71+
72+
```javascript
73+
export function SvelteBoundary(node, context) {
74+
const pending_snippet = node.metadata.pending;
75+
76+
if (pending_snippet) {
77+
// Has pending snippet - render pending content with else marker
78+
context.state.template.push(b.literal(BLOCK_OPEN_ELSE)); // <!--[!-->
79+
80+
if (pending_snippet.type === 'Attribute') {
81+
const value = build_attribute_value(pending_snippet.value, context, false, true);
82+
context.state.template.push(b.call(value, b.id('$$payload')));
83+
} else if (pending_snippet.type === 'SnippetBlock') {
84+
context.state.template.push(context.visit(pending_snippet.body));
85+
}
86+
} else {
87+
// No pending snippet - render main content (may be async or sync)
88+
context.state.template.push(b.literal(BLOCK_OPEN)); // <!--[-->
89+
context.state.template.push(context.visit(node.fragment));
90+
}
91+
92+
context.state.template.push(b.literal(BLOCK_CLOSE)); // <!--]-->
93+
}
94+
```
95+
96+
**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.
97+
98+
### Phase 2: Client-Side Hydration Changes
99+
100+
#### 2.1 Hydration State Detection
101+
102+
Extend boundary constructor to detect server rendering state:
103+
104+
```javascript
105+
constructor(node, props, children) {
106+
this.#anchor = node;
107+
this.#props = props;
108+
this.#children = children;
109+
this.#hydrate_open = hydrate_node;
110+
111+
// NEW: Detect what the server rendered
112+
this.#server_rendered_pending = this.#detect_server_state();
113+
114+
this.parent = active_effect.b;
115+
this.pending = !!this.#props.pending;
116+
117+
// Main effect logic...
118+
}
119+
120+
#detect_server_state() {
121+
if (!hydrating || !this.#hydrate_open) return false;
122+
123+
const comment = this.#hydrate_open;
124+
if (comment.nodeType === COMMENT_NODE) {
125+
// Check if server rendered pending content (else marker)
126+
return comment.data === HYDRATION_START_ELSE; // '[!'
127+
}
128+
129+
return false;
130+
}
131+
```
132+
133+
#### 2.2 Hydration Flow Logic
134+
135+
Modify the main boundary effect to handle both cases:
136+
137+
```javascript
138+
this.#effect = block(() => {
139+
active_effect.b = this;
140+
141+
if (hydrating) {
142+
hydrate_next();
143+
144+
if (this.#server_rendered_pending) {
145+
// Server rendered pending content - existing logic
146+
this.#hydrate_pending_content();
147+
} else {
148+
// Server rendered resolved content - new logic
149+
this.#hydrate_resolved_content();
150+
}
151+
} else {
152+
// Client-side rendering
153+
this.#render_client_content();
154+
}
155+
}, flags);
156+
```
157+
158+
#### 2.3 Resolved Content Hydration
159+
160+
Implement the resolved content hydration path:
161+
162+
```javascript
163+
#hydrate_resolved_content() {
164+
// Server already rendered resolved content, so hydrate it directly
165+
this.#main_effect = this.#run(() => {
166+
return branch(() => this.#children(this.#anchor));
167+
});
168+
169+
// Start in non-pending state since server rendered resolved content
170+
this.pending = false;
171+
172+
// Note: Even if client-side async operations are still running,
173+
// we never transition back to pending state. Users can use
174+
// $effect.pending() to track ongoing async work if needed.
175+
}
176+
177+
#hydrate_pending_content() {
178+
// Existing logic - server rendered pending content
179+
this.#pending_effect = branch(() => this.#props.pending(this.#anchor));
180+
181+
Batch.enqueue(() => {
182+
this.#main_effect = this.#run(() => {
183+
Batch.ensure();
184+
return branch(() => this.#children(this.#anchor));
185+
});
186+
187+
if (this.#pending_count > 0) {
188+
this.#show_pending_snippet();
189+
} else {
190+
pause_effect(this.#pending_effect, () => {
191+
this.#pending_effect = null;
192+
});
193+
this.pending = false;
194+
}
195+
});
196+
}
197+
```
198+
199+
### Phase 4: Compiler Integration
200+
201+
#### 4.1 Analysis Phase
202+
203+
The analysis already tracks `is_async` on boundaries. We just need to ensure it's set correctly:
204+
205+
```javascript
206+
// In AwaitExpression visitor - this already exists
207+
if (context.state.async_hoist_boundary && context.state.expression) {
208+
context.state.async_hoist_boundary.metadata.is_async = true;
209+
// ... existing logic
210+
}
211+
```
212+
213+
#### 4.2 Server Code Generation
214+
215+
The server visitor change is minimal - just use the else marker for pending content:
216+
217+
```javascript
218+
// In server SvelteBoundary visitor
219+
export function SvelteBoundary(node, context) {
220+
const pending_snippet = node.metadata.pending;
221+
222+
if (pending_snippet) {
223+
// Use else marker for pending content
224+
context.state.template.push(b.literal(BLOCK_OPEN_ELSE));
225+
// ... render pending content
226+
} else {
227+
// Use normal marker for main content (async or sync)
228+
context.state.template.push(b.literal(BLOCK_OPEN));
229+
// ... render main content
230+
}
231+
232+
context.state.template.push(b.literal(BLOCK_CLOSE));
233+
}
234+
```
235+
236+
## Edge Cases and Considerations
237+
238+
### Edge Case 1: Multiple Async Operations with Different Timing
239+
240+
```svelte
241+
<svelte:boundary>
242+
<p>{await fast()}</p>
243+
<p>{await slow()}</p>
244+
</svelte:boundary>
245+
```
246+
247+
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.
248+
249+
**Handling**: The boundary's `#pending_count` system already handles multiple async operations correctly.
250+
251+
### Edge Case 2: Conditional Async Content
252+
253+
```svelte
254+
<svelte:boundary>
255+
{#if condition}
256+
<p>{await getData()}</p>
257+
{:else}
258+
<p>No data needed</p>
259+
{/if}
260+
</svelte:boundary>
261+
```
262+
263+
**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.
264+
265+
### Edge Case 3: Nested Boundaries
266+
267+
```svelte
268+
<svelte:boundary>
269+
<div>{await outer()}</div>
270+
<svelte:boundary><div>{await inner()}</div></svelte:boundary>
271+
</svelte:boundary>
272+
```
273+
274+
**Handling**: Each boundary is independent. Inner boundary can be resolved while outer is pending, or vice versa.
275+
276+
## Implementation Context
277+
278+
### Key Design Philosophy
279+
280+
- This is the **simplified** approach - we deliberately chose NOT to serialize promise values
281+
- We're reusing existing `if/else` block hydration markers rather than creating new ones
282+
- The server doesn't need to know about `is_async` - it just renders based on pending snippet presence
283+
284+
### Critical Semantic Understanding
285+
286+
- `<!--[!-->` = pending content (the "else" case when async hasn't resolved)
287+
- `<!--[-->` = resolved content (normal case)
288+
- This inversion makes semantic sense: pending is the fallback/else state
289+
290+
### Boundary State Rules
291+
292+
- Boundaries **never** transition back to pending once content is rendered
293+
- Use `$effect.pending()` for tracking ongoing async work, not boundary state
294+
- The `pending` property stays `false` once content is shown
295+
296+
### Server Logic Simplicity
297+
298+
- Server only checks: "Does this boundary have a pending snippet?"
299+
- If yes → render pending with `BLOCK_OPEN_ELSE`
300+
- If no → render main content with `BLOCK_OPEN` (async SSR waits naturally)
301+
302+
### Client Hydration Flow
303+
304+
- Detect marker type to know what server rendered
305+
- If `HYDRATION_START_ELSE` → server rendered pending, use existing logic
306+
- If normal marker → server rendered resolved, hydrate directly (no complex async handling)
307+
308+
### What We're NOT Doing
309+
310+
- No promise serialization/deserialization
311+
- No complex client-server async coordination
312+
- No `error` snippet handling (server never renders errors)
313+
- No distinction between async/sync resolved content
314+
315+
### Implementation Priority
316+
317+
The core change is surprisingly small - just swapping which marker the server uses for pending content. The rest leverages existing Svelte hydration infrastructure.
318+
319+
This approach prioritizes simplicity and reuse over complex optimization, which aligns with Svelte's philosophy of doing more with less code.

packages/svelte/messages/shared-errors/errors.md

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
## await_outside_boundary
22

3-
> Cannot await outside a `<svelte:boundary>` with a `pending` snippet
3+
> Cannot await outside a `<svelte:boundary>`.
44
5-
The `await` keyword can only appear in a `$derived(...)` or template expression, or at the top level of a component's `<script>` block, if it is inside a [`<svelte:boundary>`](/docs/svelte/svelte-boundary) that has a `pending` snippet:
5+
The `await` keyword can only appear in a `$derived(...)` or template expression, or at the top level of a component's `<script>` block, if it is inside a [`<svelte:boundary>`](/docs/svelte/svelte-boundary):
66

77
```svelte
88
<svelte:boundary>
99
<p>{await getData()}</p>
10-
11-
{#snippet pending()}
12-
<p>loading...</p>
13-
{/snippet}
1410
</svelte:boundary>
1511
```
1612

packages/svelte/src/compiler/phases/1-parse/utils/create.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export function create_fragment(transparent = false) {
1212
transparent,
1313
dynamic: false,
1414
has_await: false,
15+
is_async: false,
1516
// name is added later, after we've done scope analysis
1617
hoisted_promises: { name: '', promises: [] }
1718
}

0 commit comments

Comments
 (0)