feat: first hydrationgaa

pull/16762/head
S. Elliott Johnson 7 days ago
parent f0b01f76ca
commit 3c73cc703a

@ -3,18 +3,14 @@
### await_outside_boundary
```
Cannot await outside a `<svelte:boundary>` with a `pending` snippet
Cannot await outside a `<svelte:boundary>`.
```
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:
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):
```svelte
<svelte:boundary>
<p>{await getData()}</p>
{#snippet pending()}
<p>loading...</p>
{/snippet}
</svelte:boundary>
```

@ -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.

@ -1,16 +1,12 @@
## await_outside_boundary
> Cannot await outside a `<svelte:boundary>` with a `pending` snippet
> Cannot await outside a `<svelte:boundary>`.
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:
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):
```svelte
<svelte:boundary>
<p>{await getData()}</p>
{#snippet pending()}
<p>loading...</p>
{/snippet}
</svelte:boundary>
```

@ -12,6 +12,7 @@ export function create_fragment(transparent = false) {
transparent,
dynamic: false,
has_await: false,
is_async: false,
// name is added later, after we've done scope analysis
hoisted_promises: { name: '', promises: [] }
}

@ -1,7 +1,11 @@
/** @import { BlockStatement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import { BLOCK_CLOSE, BLOCK_OPEN } from '../../../../../internal/server/hydration.js';
import {
BLOCK_CLOSE,
BLOCK_OPEN,
BLOCK_OPEN_ELSE
} from '../../../../../internal/server/hydration.js';
import * as b from '#compiler/builders';
import { build_attribute_value } from './shared/utils.js';
@ -10,17 +14,22 @@ import { build_attribute_value } from './shared/utils.js';
* @param {ComponentContext} context
*/
export function SvelteBoundary(node, context) {
context.state.template.push(b.literal(BLOCK_OPEN));
const pending_snippet = node.metadata.pending;
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(
/** @type {BlockStatement} */ (context.visit(pending_snippet.body))
);
if (pending_snippet) {
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(
/** @type {BlockStatement} */ (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(/** @type {BlockStatement} */ (context.visit(node.fragment)));
}

@ -1,10 +1,11 @@
/** @import { Effect, Source, TemplateNode, } from '#client' */
import {
BOUNDARY_EFFECT,
COMMENT_NODE,
EFFECT_PRESERVED,
EFFECT_RAN,
EFFECT_TRANSPARENT
} from '#client/constants';
import { HYDRATION_START_ELSE } from '../../../../constants.js';
import { component_context, set_component_context } from '../../context.js';
import { handle_error, invoke_error_boundary } from '../../error-handling.js';
import { block, branch, destroy_effect, pause_effect } from '../../reactivity/effects.js';
@ -132,6 +133,8 @@ export class Boundary {
#cascading_pending_count = 0;
#is_creating_fallback = false;
/** @type {boolean} */
#server_rendered_pending = false;
/**
* A source containing the number of pending async deriveds/expressions.
@ -172,6 +175,8 @@ export class Boundary {
this.#hydrate_open = hydrate_node;
this.#server_rendered_pending = this.#detect_server_state();
this.parent = /** @type {Effect} */ (active_effect).b;
this.pending = !!this.#props.pending;
@ -183,34 +188,15 @@ export class Boundary {
hydrate_next();
}
const pending = this.#props.pending;
if (hydrating && pending) {
this.#pending_effect = branch(() => pending(this.#anchor));
// future work: when we have some form of async SSR, we will
// need to use hydration boundary comments to report whether
// the pending or main block was rendered for a given
// boundary, and hydrate accordingly
Batch.enqueue(() => {
this.#main_effect = this.#run(() => {
Batch.ensure();
return branch(() => this.#children(this.#anchor));
});
if (this.#cascading_pending_count > 0) {
this.#show_pending_snippet();
} else {
pause_effect(/** @type {Effect} */ (this.#pending_effect), () => {
this.#pending_effect = null;
});
this.pending = false;
}
});
if (hydrating) {
if (this.#server_rendered_pending) {
this.#hydrate_pending_content();
} else {
this.#hydrate_resolved_content();
}
} else {
try {
this.#main_effect = branch(() => children(this.#anchor));
this.#main_effect = branch(() => this.#children(this.#anchor));
} catch (error) {
this.error(error);
}
@ -228,6 +214,59 @@ export class Boundary {
}
}
#detect_server_state() {
if (!hydrating || !this.#hydrate_open) return false;
const comment = this.#hydrate_open;
if (comment.nodeType === COMMENT_NODE) {
return /** @type {Comment} */ (comment).data === HYDRATION_START_ELSE;
}
return false;
}
#hydrate_resolved_content() {
// Server already rendered resolved content, so hydrate it directly
try {
this.#main_effect = branch(() => this.#children(this.#anchor));
} catch (error) {
this.error(error);
}
// Since server rendered resolved content, we never show pending state
// Even if client-side async operations are still running, the content is already displayed
this.pending = false;
}
#hydrate_pending_content() {
const pending = this.#props.pending;
if (!pending) {
return;
}
this.#pending_effect = branch(() => pending(this.#anchor));
// future work: when we have some form of async SSR, we will
// need to use hydration boundary comments to report whether
// the pending or main block was rendered for a given
// boundary, and hydrate accordingly
Batch.enqueue(() => {
this.#main_effect = this.#run(() => {
Batch.ensure();
return branch(() => this.#children(this.#anchor));
});
if (this.#cascading_pending_count > 0) {
this.#show_pending_snippet();
} else {
pause_effect(/** @type {Effect} */ (this.#pending_effect), () => {
this.#pending_effect = null;
});
this.pending = false;
}
});
}
has_pending_snippet() {
return !!this.#props.pending;
}
@ -292,16 +331,17 @@ export class Boundary {
/**
* @param {number} d
* @param {boolean} safe
* @param {boolean} first
*/
update_pending_count(d, safe = false) {
this.#pending_count = Math.max(this.#pending_count + d, 0);
update_pending_count(d, safe = false, first = true) {
if (first) {
this.#pending_count = Math.max(this.#pending_count + d, 0);
}
if (this.has_pending_snippet()) {
this.#update_cascading_pending_count(d);
} else if (this.parent) {
this.parent.update_pending_count(d, safe);
} else if (this.parent === null && !safe) {
e.await_outside_boundary();
this.parent.update_pending_count(d, safe, false);
}
effect_pending_updates.add(this.#effect_pending_update);

@ -93,7 +93,7 @@ export function push_element(payload, tag, line, column) {
}
export function pop_element() {
parent = /** @type {Element} */ (parent).parent;
parent = /** @type {Element} */ (parent)?.parent;
}
/**

@ -3,12 +3,12 @@
import { DEV } from 'esm-env';
/**
* Cannot await outside a `<svelte:boundary>` with a `pending` snippet
* Cannot await outside a `<svelte:boundary>`.
* @returns {never}
*/
export function await_outside_boundary() {
if (DEV) {
const error = new Error(`await_outside_boundary\nCannot await outside a \`<svelte:boundary>\` with a \`pending\` snippet\nhttps://svelte.dev/e/await_outside_boundary`);
const error = new Error(`await_outside_boundary\nCannot await outside a \`<svelte:boundary>\`.\nhttps://svelte.dev/e/await_outside_boundary`);
error.name = 'Svelte error';

@ -4,7 +4,7 @@ import { globSync } from 'tinyglobby';
import { createClassComponent } from 'svelte/legacy';
import { proxy } from 'svelte/internal/client';
import { flushSync, hydrate, mount, unmount } from 'svelte';
import { render } from 'svelte/server';
import { render, renderAsync } from 'svelte/server';
import { afterAll, assert, beforeAll } from 'vitest';
import { async_mode, compile_directory, fragments } from '../helpers.js';
import { assert_html_equal, assert_html_equal_with_options } from '../html_equal.js';
@ -314,10 +314,16 @@ async function run_test_variant(
config.before_test?.();
// ssr into target
const SsrSvelteComponent = (await import(`${cwd}/_output/server/main.svelte.js`)).default;
const { html, head } = render(SsrSvelteComponent, {
props: config.server_props ?? config.props ?? {},
idPrefix: config.id_prefix
});
const rendered = async_mode
? await renderAsync(SsrSvelteComponent, {
props: config.server_props ?? config.props ?? {},
idPrefix: config.id_prefix
})
: render(SsrSvelteComponent, {
props: config.server_props ?? config.props ?? {},
idPrefix: config.id_prefix
});
const { html, head } = rendered;
fs.writeFileSync(`${cwd}/_output/rendered.html`, html);
target.innerHTML = html;

@ -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>');
}
});

@ -50,10 +50,15 @@ const { test, run } = suite<SSRTest>(async (config, test_dir) => {
const Component = (await import(`${test_dir}/_output/server/main.svelte.js`)).default;
const expected_html = try_read_file(`${test_dir}/_expected.html`);
const rendered = await (compile_options.experimental.async ? renderAsync : render)(Component, {
props: config.props || {},
idPrefix: config.id_prefix
});
const rendered = async_mode
? await renderAsync(Component, {
props: config.props || {},
idPrefix: config.id_prefix
})
: render(Component, {
props: config.props || {},
idPrefix: config.id_prefix
});
const { body, head } = rendered;
fs.writeFileSync(`${test_dir}/_output/rendered.html`, body);

Loading…
Cancel
Save