---
name: leptos-hydration-discipline
description: Canonical reference for all 12 Leptos hydration anti-patterns with grep detectors. Auto-triggers on any hydration mismatch, panic during hydration, blank content on first load, or before committing any .rs file that touches view! macros or #[server] functions.
---

# Leptos Hydration Discipline

## When to Use

This is the **highest-leverage** anti-pattern skill. Invoke before:
- Committing any file that contains `view!` macros.
- Investigating a hydration mismatch panic or blank content on first SSR load.
- Fixing a `Cannot find node with id` or `hydration key mismatch` error.
- Reviewing any component that conditionally renders different content server vs client.

This skill is the **canonical source** for `quality-gates` step 7 (Hydration Discipline Lint). The greps in step 7 are a subset of the detectors here.

## Core Patterns

### Pattern A — Correct Conditional Rendering

Use reactive signals or `Show`/`Suspense`/`ErrorBoundary` for conditional content. Server and client must emit identical HTML structure for the initial render.

```rust
// CORRECT: same HTML structure server and client; JS toggles visibility via signal
let is_authenticated = RwSignal::new(false);

// Hydrates consistently: server emits <div class="hidden">, client matches
view! {
    <div class:hidden=move || !is_authenticated.get()>
        <UserMenu />
    </div>
}
```

Anti-pattern — `cfg!()` branching in view:
```rust
// BAD: cfg!() is a compile-time check, NOT a runtime check.
// Both branches compile into the binary; the view tree differs between server build
// (non-wasm) and client (wasm) → DOM mismatch → hydration panic.
view! {
    {if cfg!(target_arch = "wasm32") {
        view! { <BrowserOnlyWidget /> }.into_any()
    } else {
        view! { <ServerWidget /> }.into_any()
    }}
}
// CORRECT: use Effect::new for browser-only code; use a signal for conditional rendering
```

### Pattern B — Structurally Valid HTML in view!

The browser auto-corrects invalid HTML (inserts `<tbody>`, closes open tags). Leptos SSR emits exactly what you wrote. The mismatch causes hydration to fail.

```rust
// CORRECT: explicit <tbody> matches browser-corrected DOM
view! {
    <table>
        <thead><tr><th>"Name"</th></tr></thead>
        <tbody>
            <For each=rows key=|r| r.id let:row>
                <tr><td>{row.name}</td></tr>
            </For>
        </tbody>
    </table>
}
```

Anti-pattern — `<table>` without `<tbody>`:
```rust
// BAD: browser inserts <tbody> automatically; SSR doesn't → DOM mismatch
view! {
    <table>
        <tr><td>"data"</td></tr>   // ← missing <tbody>
    </table>
}
```

## The 13 Forbidden Patterns

> **Note on Forbidden 0 (highest-impact under islands mode):** the #1 cause of "dead button / dead input / partially-interactive modal / hydration only activates after a random click" bugs is interactive markup declared as `#[component]`. See Forbidden 13 below and [[leptos-islands]] for the full ownership-boundary rules.


### Forbidden 1 — cfg!(target_arch) Inside view!

**Forbidden:** `cfg!(target_arch = "wasm32")` (or `"x86_64"` etc.) inside any `view!` block.

**Why:** Both branches exist in both builds. DOM structure diverges. Hydration fails.

**Fix:** Move browser-only logic to `Effect::new(|_| ...)`. Use a signal for toggle.

```bash
# Detector
grep -rn 'cfg!(target_arch' src/
```

---

### Forbidden 2 — `<table>` Without `<tbody>`

**Forbidden:** `<table>` element in `view!` without an explicit `<tbody>` child.

**Why:** Browser parser auto-inserts `<tbody>`; SSR output doesn't have it → node count mismatch.

**Fix:** Always add `<tbody>` around table row groups.

```bash
# Detector
grep -rn '<table' src/ | grep -v '<tbody'
```

---

### Forbidden 3 — Block Element Inside `<p>`

**Forbidden:** `<div>`, `<ul>`, `<ol>`, `<table>`, `<h1>`–`<h6>` inside a `<p>` element.

**Why:** Browser closes the `<p>` before the block element; SSR doesn't → mismatched nesting.

**Fix:** Use `<div>` or `<article>` as the container instead of `<p>`.

```bash
# Detector (heuristic)
grep -rn '<p>' src/ -A10 | grep -E '<div|<ul|<table|<h[1-6]'
```

---

### Forbidden 4 — Resource Read Inside Effect::new

**Forbidden:** Calling `some_resource.get()` inside an `Effect::new` closure.

**Why:** Effect runs after hydration; the Resource was already read during SSR. Double-fetch + hydration warning emitted.

**Fix:** Read Resources inside `<Suspense>` or `<Transition>`, or derive a `Memo` from the resource.

```bash
# Detector
grep -rn -A30 'Effect::new' src/ | grep '\.get()'
```

---

### Forbidden 5 — LocalResource for Primary Page Data

**Forbidden:** `LocalResource::new` for data that should be SSR-streamed (user lists, article content, product data).

**Why:** `LocalResource` skips SSR entirely → blank page on first load, no SEO, no streaming.

**Fix:** Use `Resource::new(source_fn, fetcher_fn)` for all primary page data.

**Exception:** OK for browser-only sources (Geolocation, IndexedDB, Web Bluetooth). Mark with `// @browser-only`.

```bash
# Detector
grep -rn 'LocalResource::new\|create_local_resource' src/ | grep -v '@browser-only'
```

---

### Forbidden 6 — RwSignal for Derived State

**Forbidden:** `RwSignal::new(some_signal.get() * 2)` or any `RwSignal` whose value is derived from another signal.

**Why:** Not reactive — goes stale when the source changes. SSR computes one value; client may compute another → mismatch.

**Fix:** Use `Memo::new(move |_| expr)` — reacts to dependencies, cached, consistent between SSR and client.

```bash
# Detector (heuristic: RwSignal initialized from a signal read)
grep -rn 'RwSignal::new(.*\.get()' src/
```

---

### Forbidden 7 — unwrap()/expect() in #[server] Bodies or Views

**Forbidden:** `.unwrap()` or `.expect(...)` inside any `#[server]` fn body or inside view closures that ship to both targets.

**Why:** Panics propagate as 500 errors on the server; client-side panics abort the WASM runtime. Neither is recoverable.

**Fix:** Return `Result<T, AppError>` and use `?`. Wrap view code in `<ErrorBoundary>`.

```bash
# Detector for #[server] bodies
grep -rn -A50 '#\[server\]' src/ | grep -E '\.unwrap\(\)|\.expect\(' | grep -v '//'

# Detector for view closures
grep -rn -A5 'view!' src/ | grep -E '\.unwrap\(\)|\.expect\(' | grep -v '//'
```

---

### Forbidden 8 — Non-Serialize/Deserialize Types in Server Fn Boundary

**Forbidden:** Server fn args or return type `T` where `T` does not implement `Serialize + Deserialize`.

**Why:** The client-side stub cannot deserialize the response → compile error or runtime panic.

**Fix:** Derive `#[derive(Serialize, Deserialize)]` on all types used in server fn signatures.

```bash
# Detector: types in Result that likely lack derives (heuristic)
grep -rn '#\[server\]' src/ -A5 | grep 'Result<'
# Then verify each named type has the derives:
grep -rn 'struct\|enum' src/ | grep -v 'Serialize\|Deserialize' | grep -v '^\s*#\|test'
```

---

### Forbidden 9 — Non-Send Types Across .await in Server Fns

**Forbidden:** Holding `Rc`, `RefCell`, raw `PgConnection` (direct, not via pool), or any `!Send` guard across an `.await` point inside a `#[server]` fn.

**Why:** Tokio's multi-threaded runtime requires `Send` futures. Violating this is a compile error, not a runtime bug — but it blocks the build.

**Fix:** Use `Arc` instead of `Rc`. Drop guards before `.await`. Use `sqlx::PgPool` (Send) not raw `PgConnection`.

```bash
# Detector
grep -rn 'Rc::new\|RefCell::new' src/ | grep -v '// @non-server\|#\[cfg(test'
```

---

### Forbidden 10 — spawn_local for View Data

**Forbidden:** `spawn_local(async { ... })` used to load data that should be rendered in a view.

**Why:** Bypasses SSR entirely. Data is never in the initial HTML. Clients with slow connections see blank content. No streaming.

**Fix:** Use `Resource::new` — SSR-streamed, reactive, cancels on component unmount.

```bash
# Detector
grep -rn 'spawn_local' src/ | grep -v '// @non-view\|#\[cfg(test\|Effect::new'
```

---

### Forbidden 11 — web_sys/gloo-net in SSR-Compiled Code

**Forbidden:** Calling `web_sys`, `gloo-net`, or any browser-only API in code that compiles for the SSR target.

**Why:** These APIs panic on the server because the browser runtime doesn't exist. Even with `#[cfg(target_arch = "wasm32")]` you must be sure the code path never runs server-side.

**Fix:** Use `reqwest` for HTTP in code that runs on both targets. Gate browser APIs inside `Effect::new` (client-only). Use compile-time `#[cfg(target_arch = "wasm32")]` at the module level, not inside `view!`.

```bash
# Detector
grep -rn 'web_sys::\|gloo_net\|gloo-net' src/ | grep -v '#\[cfg\|// @wasm-only'
```

---

### Forbidden 12 — cfg!(target_arch="wasm32") Runtime Branch in Views

(Distinct from Forbidden 1: this is the runtime boolean, not the macro form.)

**Forbidden:** `if cfg!(target_arch = "wasm32") { ... }` as a runtime conditional inside view closures.

**Why:** `cfg!()` evaluates at compile time. In a combined SSR+WASM codebase, both targets compile the binary — the "wasm32" branch is dead code in SSR but still exists, confusing the view tree.

**Fix:** Structure code so browser-only paths live inside `Effect::new` or behind a compile-time `#[cfg]` attribute at the item level, not inside `view!`.

```bash
# Detector (same as Forbidden 1 — catches both forms)
grep -rn 'cfg!(target_arch' src/
```

### Forbidden 13 — Interactive Markup Inside `#[component]` (Islands Mode)

**Forbidden:** A `#[component]` whose body contains `on:click`, `on:input`, `on:change`, `on:submit`, `RwSignal::new`, `Memo::new`, `Effect::new`, `ServerAction::<…>::new()`, or `<Portal>` with interactive children.

**Why:** Under `leptos/islands`, `#[component]` is server-only. Handlers never attach, signals never update, effects never run. Symptoms: dead buttons, dead inputs, modals that open but whose contents are inert, interactivity that appears to "wake up" only after an unrelated click triggers re-render. This is the dominant cause of hydration-related bugs in islands-mode codebases.

**Fix:** Change the attribute to `#[island]`. If multiple interactive sub-trees share signals/modal state/form state, they belong in the SAME island (see [[leptos-islands]] "Interactive Ownership Trees" and "Modals, Sheets, Dialogs").

```bash
# Detector — flag #[component] files that contain interactive primitives
for f in $(grep -rln '#\[component\]' src/); do
  if grep -qE 'on:(click|input|change|submit)|RwSignal::new|Memo::new|Effect::new|ServerAction::<' "$f"; then
    echo "$f: interactive markup inside #[component] — promote to #[island]"
  fi
done

# Detector — <Portal> rendered from a #[component] file (modal escapes island ownership)
for f in $(grep -rln '<Portal' src/); do
  grep -q '#\[component\]' "$f" && ! grep -q '#\[island\]' "$f" \
    && echo "$f: <Portal> in a #[component]-only file — mount portal from inside an island"
done
```

---

## Anti-Patterns to Block (Quick Reference)

- `cfg!(target_arch)` inside `view!` → `Effect::new` for browser code.
- `<table>` without `<tbody>` → always add `<tbody>`.
- Block element inside `<p>` → use `<div>` as container.
- `Resource.get()` inside `Effect::new` → read inside `<Suspense>`.
- `LocalResource` without `@browser-only` → use `Resource::new`.
- `RwSignal` for derived value → use `Memo`.
- `unwrap()`/`expect()` in server fn or view → use `?` + `<ErrorBoundary>`.
- Non-`Serialize`/`Deserialize` in server fn boundary → derive both.
- `Rc`/`RefCell` across `.await` in server fn → use `Arc`/drop before await.
- `spawn_local` for view data → use `Resource::new`.
- `web_sys`/`gloo-net` in SSR code → use `reqwest` or `Effect` gate.
- `cfg!(target_arch)` runtime branch in views → see Forbidden 1 and 12.
- `on:` handler / `RwSignal` / `Effect` / `ServerAction` inside `#[component]` (islands mode) → make it `#[island]` (Forbidden 13, [[leptos-islands]]).
- `<Portal>` mounted from a `#[component]` → move portal inside the island that owns the modal state ([[leptos-islands]] Anti-pattern 4).

## Verification Hooks

```bash
# Run all 13 detectors in one sweep
# F13 — interactive markup inside #[component] (islands mode; dominant bug source)
for f in $(grep -rln '#\[component\]' src/); do
  if grep -qE 'on:(click|input|change|submit)|RwSignal::new|Memo::new|Effect::new|ServerAction::<' "$f"; then
    echo "$f: F13 — promote to #[island]"
  fi
done

grep -rn 'cfg!(target_arch' src/
grep -rn '<table' src/ | grep -v '<tbody'
grep -rn '<p>' src/ -A10 | grep -E '<div|<ul|<table|<h[1-6]'
grep -rn -A30 'Effect::new' src/ | grep '\.get()'
grep -rn 'LocalResource::new\|create_local_resource' src/ | grep -v '@browser-only'
grep -rn 'RwSignal::new(.*\.get()' src/
grep -rn -A50 '#\[server\]' src/ | grep -E '\.unwrap\(\)|\.expect\(' | grep -v '//'
grep -rn 'Rc::new\|RefCell::new' src/ | grep -v '// @non-server\|#\[cfg(test'
grep -rn 'spawn_local' src/ | grep -v '// @non-view\|Effect::new'
grep -rn 'web_sys::\|gloo_net\|gloo-net' src/ | grep -v '#\[cfg\|// @wasm-only'

# Full hydration lint from quality-gates step 7
cargo clippy --no-default-features --features=ssr --all-targets -- -D warnings
cargo clippy --no-default-features --features=hydrate --target wasm32-unknown-unknown -- -D warnings 2>/dev/null || echo "WARN: wasm32 target not installed"
```

## References

- https://book.leptos.dev/ssr/24_hydration_bugs.html (§14.4 Hydration bugs — primary source)
- https://book.leptos.dev/async/10_resources.html (§6.1 Resources)
- https://book.leptos.dev/islands/ (§18 Islands)
- https://docs.rs/leptos/latest/leptos/attr.server.html
