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