Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions apps/docs/docs/cdk/hydration-tracker/hydration-tracker.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
---
sidebar_label: 'Hydration Tracker'
sidebar_position: 2
title: 'Hydration Tracker'
hide_title: true
---

# @rx-angular/cdk/ssr — Hydration Tracker

> A high-performance utility to track Angular application hydration status using a MutationObserver, with a configurable timeout safety net.

`HydrationTracker` helps you know when your server-rendered app has finished hydrating in the browser. It exposes a **signal** and an **observable** that turn `true` once all components are hydrated or after a configurable timeout.

**Note:** The service only runs in the browser and is not available on the server.

## Key features

- ✅ Reactive `isFullyHydrated` signal and `isFullyHydrated$` observable
- ✅ Configurable timeout (default: 10 seconds) as a safety net
- ✅ Optional logging for debugging
- ✅ Runs outside Angular zone for minimal overhead

## Install

```bash
npm install --save @rx-angular/cdk
# or
yarn add @rx-angular/cdk
```

## Motivation

With Angular’s non-destructive hydration, the server sends HTML that already has the right structure. The client then “hydrates” that HTML by attaching event listeners and making components interactive. During this phase, Angular marks hydrated nodes by removing the `ngh` attribute.

Knowing when hydration is complete is useful to:

- Defer heavy or non-critical work until after hydration
- Show loading or placeholders until the app is interactive
- Avoid layout shifts or flicker by coordinating UI with hydration

`HydrationTracker` observes the DOM for removal of `ngh` attributes and exposes a reactive “fully hydrated” state, with an optional timeout so the app never waits indefinitely.

## Setup

### 1. Provide the tracker (optional config)

Configure the tracker in your app config. If you don’t provide any config, defaults are used (timeout: 10s, logging: false).

```typescript
import { ApplicationConfig } from '@angular/core';
import { provideHydrationTracker } from '@rx-angular/cdk/ssr';

export const appConfig: ApplicationConfig = {
providers: [
provideHydrationTracker({
timeout: 5000, // ms after which we consider hydration done (default: 10000)
logging: false, // set true to log hydration events (default: false)
}),
],
};
```

### 2. Inject and use HydrationTracker

The service is `providedIn: 'root'`. Inject it where you need to react to hydration completion.

```typescript
import { Component } from '@angular/core';
import { HydrationTracker } from '@rx-angular/cdk/ssr';

@Component({
selector: 'app-root',
template: `
@if (hydrationTracker.isFullyHydrated()) {
<p>App is fully hydrated and interactive.</p>
} @else {
<p>Hydrating...</p>
}
`,
})
export class AppComponent {
protected readonly hydrationTracker = inject(HydrationTracker);
}
```

## API

### HydrationTracker

| Member | Type | Description |
| ------------------ | --------------------- | ---------------------------------------------------------------- |
| `isFullyHydrated` | `Signal<boolean>` | Signal that is `true` when the app is considered fully hydrated. |
| `isFullyHydrated$` | `Observable<boolean>` | Observable that emits `true` when the app is fully hydrated. |

### Configuration: `HydrationTrackerConfig`

| Property | Type | Default | Description |
| --------- | --------- | ------- | ---------------------------------------------------------------------------------------- |
| `timeout` | `number` | `10000` | Time in ms after which hydration is considered complete even if some `ngh` nodes remain. |
| `logging` | `boolean` | `false` | When `true`, logs hydration completion (and timeout) to the console. |

### Provider

- **`provideHydrationTracker(config?)`** — Call in `ApplicationConfig.providers` to supply `HydrationTrackerConfig`. Config is optional; defaults apply if omitted.

## Usage examples

### Using the signal in a template

```typescript
import { Component, inject } from '@angular/core';
import { HydrationTracker } from '@rx-angular/cdk/ssr';

@Component({
selector: 'app-shell',
template: `
@if (tracker.isFullyHydrated()) {
<app-heavy-chart />
}
`,
})
export class ShellComponent {
protected readonly tracker = inject(HydrationTracker);
}
```

### Using the observable in a component

```typescript
import { Component, inject } from '@angular/core';
import { HydrationTracker } from '@rx-angular/cdk/ssr';
import { filter, take } from 'rxjs';

@Component({
/* ... */
})
export class AnalyticsComponent {
private readonly hydrationTracker = inject(HydrationTracker);

ngOnInit() {
this.hydrationTracker.isFullyHydrated$.pipe(filter(Boolean), take(1)).subscribe(() => {
// Run analytics or other post-hydration logic once
});
}
}
```

### With custom config and logging

```typescript
// app.config.ts
import { provideHydrationTracker } from '@rx-angular/cdk/ssr';

export const appConfig: ApplicationConfig = {
providers: [
provideHydrationTracker({
timeout: 5000,
logging: true, // console logs when hydration completes or times out
}),
],
};
```
102 changes: 99 additions & 3 deletions apps/docs/docs/template/virtual-view-directive.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,96 @@ onVisibilityChanged(event: { content: boolean; placeholder: boolean }) {
}
```

## Using RxVirtualView with hydration (SSR)

When you use Angular with server-side rendering (SSR) or client hydration, the server sends HTML with full content. If the virtual view were active immediately on the client, it could replace that content with placeholders as soon as the IntersectionObserver runs, causing a flash and destroying components that were just hydrated.

To avoid that, you can **disable** the virtual view until hydration is complete, then optionally **enable** it so that virtual behavior (placeholders when out of view) applies only after the app is interactive.

### 1. Disable virtual behavior until hydrated (`enabled`)

Use the global config option **`enabled`** (a `boolean` or a **`Signal<boolean>`**). When `enabled` is `false`:

- The directive renders **content** synchronously (no IntersectionObserver).
- No placeholders are shown; everything behaves like normal, non-virtual content.

That way, on the server and during hydration the user sees the full content. Once you set `enabled` to `true` (e.g. when hydration is done), the directive can start observing visibility.

**Example: enable virtual view only after hydration**

Use [HydrationTracker](https://rx-angular.io/docs/cdk/hydration-tracker) from `@rx-angular/cdk/ssr` and pass its signal as `enabled`:

```typescript
// app.config.ts
import { ApplicationConfig, inject } from '@angular/core';
import { provideVirtualViewConfig } from '@rx-angular/template/virtual-view';

export const appConfig: ApplicationConfig = {
providers: [
provideVirtualViewConfig(() => {
const hydrationTracker = inject(HydrationTracker);
return {
enabled: hydrationTracker.isFullyHydrated,
};
}),
],
};
```

Until `isFullyHydrated` is `true`, the virtual view stays disabled and shows content everywhere. After hydration, `enabled` becomes `true` and the directive can start virtualizing.

### 2. Control behavior after hydration (`enableAfterHydration`)

When the directive **starts disabled** (e.g. `enabled` is `false` during SSR/hydration) and later becomes **enabled**, one question is: should it start observing visibility and swap visible content for placeholders when elements scroll out of view?

That’s what **`enableAfterHydration`** controls (provider-level config only).

| Value | Behavior |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`true`** (default) | After `enabled` turns `true`, the directive registers the IntersectionObserver. Elements that scroll out of view will show placeholders; virtual behavior is fully active. |
| **`false`** | After `enabled` turns `true`, the directive **does not** register the observer. Hydrated content stays as-is and is never replaced by placeholders. Use this to avoid destroying components that were just hydrated. |

**When to use `enableAfterHydration: false`**

- You want the **first paint** to match the server (no placeholders) and you’re okay with **not** virtualizing that page after hydration (e.g. long, mostly-static landing pages).
- You want to avoid any risk of destroying freshly hydrated components or causing layout shifts right after hydration.

**When to keep `enableAfterHydration: true` (default)**

- You want **full virtual behavior** after hydration: once the app is interactive, elements that leave the viewport should show placeholders again to save DOM and work.

**Example: keep hydrated content, no virtualizing after**

```typescript
provideVirtualViewConfig(() => {
const hydrationTracker = inject(HydrationTracker);
return {
enabled: hydrationTracker.isFullyHydrated,
enableAfterHydration: false, // hydrated nodes stay content; no placeholders later
};
});
```

**Example: full virtual behavior after hydration (default)**

```typescript
provideVirtualViewConfig(() => {
const hydrationTracker = inject(HydrationTracker);
return {
enabled: hydrationTracker.isFullyHydrated,
enableAfterHydration: true, // after hydration, virtualize as usual (default)
};
});
```

### Summary

| Config | Purpose |
| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled: false` or `enabled: signalThatBecomesTrueAfterHydration` | Turn off virtual behavior on the server and during hydration; turn it on only when appropriate (e.g. after `HydrationTracker.isFullyHydrated`). |
| `enableAfterHydration: true` | Once `enabled` becomes true, start observing and show placeholders when elements leave the viewport. |
| `enableAfterHydration: false` | Once `enabled` becomes true, do **not** start observing; keep hydrated content and never replace it with placeholders. |

## Configuration & Inputs

### RxVirtualViewObserver Inputs
Expand Down Expand Up @@ -190,13 +280,17 @@ Defines an interface representing all configuration that can be adjusted on prov

```typescript
export interface RxVirtualViewConfig {
/** Whether the virtual view is active. Can be a boolean or a signal (e.g. from HydrationTracker). When false, content is rendered synchronously (useful for SSR/hydration). */
enabled: boolean | Signal<boolean>;
keepLastKnownSize: boolean;
useContentVisibility: boolean;
useContainment: boolean;
placeholderStrategy: RxStrategyNames<string>;
contentStrategy: RxStrategyNames<string>;
cacheEnabled: boolean;
startWithPlaceholderAsap: boolean;
/** When the directive starts disabled and later becomes enabled: if true (default), register the visibility observer and virtualize; if false, keep showing content and never swap to placeholders. See [Using RxVirtualView with hydration](#using-rxvirtualview-with-hydration-ssr). */
enableAfterHydration: boolean;
cache: {
/**
* The maximum number of contents that can be stored in the cache.
Expand Down Expand Up @@ -235,19 +329,21 @@ const appConfig: ApplicationConfig = {
This is the default configuration which will be used when no other config was provided.

```typescript

{
enabled: true,
keepLastKnownSize: false,
useContentVisibility: false,
useContainment: true,
placeholderStrategy: 'low',
contentStrategy: 'normal',
startWithPlaceholderAsap: false,
enableAfterHydration: true,
cacheEnabled: true,
cache: {
contentCacheSize: 20,
placeholderCacheSize: 20,
},
};

}
```

**Hydration:** For SSR/hydration, set `enabled` to a signal that becomes `true` after hydration (e.g. `HydrationTracker.isFullyHydrated`) and optionally set `enableAfterHydration` to control behavior once virtual view is enabled. See [Using RxVirtualView with hydration (SSR)](#using-rxvirtualview-with-hydration-ssr).
1 change: 1 addition & 0 deletions apps/ssr-hydration/src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
<app-vv-hydration-demo />
<app-hydration-demo />
3 changes: 2 additions & 1 deletion apps/ssr-hydration/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Component } from '@angular/core';
import { HydrationDemo } from './hydration-demo';
import { VVHydrationDemo } from './vv-demo';

@Component({
imports: [HydrationDemo],
imports: [VVHydrationDemo, HydrationDemo],
selector: 'app-root',
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
Expand Down
12 changes: 10 additions & 2 deletions apps/ssr-hydration/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import {
provideRxRenderStrategies,
RX_CONCURRENT_STRATEGIES,
} from '@rx-angular/cdk/render-strategies';
import { HydrationTracker } from '@rx-angular/cdk/ssr';
import { provideVirtualViewConfig } from '@rx-angular/template/virtual-view';
import { switchMap, tap } from 'rxjs';
import { HydrationTrackerService } from './hydration-tracker';

export const appConfig: ApplicationConfig = {
providers: [
Expand All @@ -22,7 +23,7 @@ export const appConfig: ApplicationConfig = {
provideZoneChangeDetection({ eventCoalescing: true }),

provideRxRenderStrategies(() => {
const hydrationTracker = inject(HydrationTrackerService);
const hydrationTracker = inject(HydrationTracker);
const strategyFactory = (name: string) => {
return {
[name]: {
Expand Down Expand Up @@ -60,5 +61,12 @@ export const appConfig: ApplicationConfig = {
},
};
}),

provideVirtualViewConfig(() => {
const hydrationTracker = inject(HydrationTracker);
return {
enabled: hydrationTracker.isFullyHydrated,
};
}),
],
};
8 changes: 4 additions & 4 deletions apps/ssr-hydration/src/app/hydration-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {
ViewEncapsulation,
} from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { HydrationTracker } from '@rx-angular/cdk/ssr';
import { RxFor } from '@rx-angular/template/for';
import { HydrationTrackerService } from './hydration-tracker';

interface User {
id: number;
Expand Down Expand Up @@ -85,8 +85,8 @@ function doWork() {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

h1 {
Expand Down Expand Up @@ -207,7 +207,7 @@ export class HydrationDemo {
loadAfterHydration = signal(false);

constructor() {
const s = inject(HydrationTrackerService);
const s = inject(HydrationTracker);
effect(() => {
const isHydrated = s.isFullyHydrated();

Expand Down
Loading
Loading