hydration-patterns
npx skills add https://github.com/farming-labs/fm-skills --skill hydration-patterns
Agent 安装分布
Skill 文档
Hydration Patterns
What is Hydration?
Hydration is the process of attaching JavaScript event handlers to server-rendered HTML, making static content interactive.
Server-Rendered HTML (static) â JavaScript Attaches â Interactive Application
"Dry HTML" â Hydration â "Hydrated App"
The Hydration Process
Step by Step
1. Server renders HTML with content
2. Browser displays HTML immediately (fast FCP)
3. Browser downloads JavaScript bundle
4. JavaScript parses and executes
5. Framework "hydrates":
- Walks the DOM
- Attaches event listeners
- Initializes state
- Makes components interactive
6. App is now fully interactive (TTI reached)
The Hydration Gap
The time between content visible (FCP) and interactive (TTI):
Timeline:
[------- Server Render -------][-- HTML Sent --][---- JS Download ----][-- Hydration --]
â â
Content Visible Interactive
[========= Hydration Gap =========]
(Page looks ready but isn't)
Hydration Challenges
1. Time to Interactive (TTI) Delay
Even though content is visible, buttons don’t work until hydration completes.
User sees "Buy Now" button â Clicks â Nothing happens â Frustrated
(hydration not complete)
2. Hydration Mismatch
If client render differs from server render:
// Server: <div>Today is Monday</div>
// Client (different timezone): <div>Today is Tuesday</div>
// Result: Error or UI flicker
3. Bundle Size
Full hydration requires downloading ALL component code:
Page uses: Header, Hero, ProductList, Footer, Cart, Reviews...
Bundle includes: ALL components (even if user never interacts)
4. CPU Cost
Hydration walks the entire DOM and attaches listeners:
Large page = More DOM nodes = Longer hydration = Worse TTI
Full Hydration
Traditional approach: hydrate the entire page.
// React example
import { hydrateRoot } from 'react-dom/client';
import App from './App';
// Hydrates entire app
hydrateRoot(document.getElementById('root'), <App />);
Pros and Cons
| Pros | Cons |
|---|---|
| Simple mental model | Large JS bundles |
| Full interactivity everywhere | Slow TTI on large pages |
| Framework handles everything | Wasted JS for static content |
Progressive Hydration
Hydrate components in priority order, deferring non-critical parts.
Strategy
1. Hydrate above-the-fold content first
2. Hydrate below-the-fold on scroll or idle
3. Hydrate low-priority on user interaction
Implementation Pattern
// Conceptual - defer hydration until visible
function LazyHydrate({ children, whenVisible }) {
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setHydrated(true);
}
});
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
if (!hydrated) return <div ref={ref}>{/* static HTML */}</div>;
return children;
}
Timeline
[Initial Hydration - Critical Components Only]
â
[Scroll/Idle - Hydrate More Components]
â
[Interaction - Hydrate On Demand]
Selective Hydration
Only hydrate components that need interactivity.
React 18+ with Suspense enables this:
<Suspense fallback={<Loading />}>
<Comments /> {/* Hydrates independently when ready */}
</Suspense>
How It Works
Server streams: Header (hydrated) â Content (hydrated) â Comments (pending)
Comments data arrives â Comments hydrate independently
User clicks comment before hydration â React prioritizes that subtree
Partial Hydration
Ship zero JavaScript for static components.
The Insight
Most pages have static and interactive parts:
âââââââââââââââââââââââââââââââââââââââ
â Header (static) â â No JS needed
âââââââââââââââââââââââââââââââââââââââ¤
â Hero Banner (static) â â No JS needed
âââââââââââââââââââââââââââââââââââââââ¤
â Product List (static) â â No JS needed
âââââââââââââââââââââââââââââââââââââââ¤
â Add to Cart Button â
â â NEEDS JS
âââââââââââââââââââââââââââââââââââââââ¤
â Reviews (static) â â No JS needed
âââââââââââââââââââââââââââââââââââââââ¤
â Newsletter Signup â
â â NEEDS JS
âââââââââââââââââââââââââââââââââââââââ¤
â Footer (static) â â No JS needed
âââââââââââââââââââââââââââââââââââââââ
With partial hydration: Only “Add to Cart” and “Newsletter” components ship JS.
Islands Architecture
Independent interactive components in a sea of static HTML.
Concept
âââââââââââââââââââââââââââââââââââââââââââââââââââ
â â
â Static HTML (server-rendered, no JS) â
â â
â âââââââââââââââ âââââââââââââââ â
â â Island â â Island â â
â â (Counter) â â (Search) â â
â â [JS: 2KB] â â [JS: 5KB] â â
â âââââââââââââââ âââââââââââââââ â
â â
â Static content continues... â
â â
â âââââââââââââââââââââââââââââââââââââââ â
â â Island (Comments) â â
â â [JS: 8KB] â â
â âââââââââââââââââââââââââââââââââââââââ â
â â
âââââââââââââââââââââââââââââââââââââââââââââââââââ
Total JS: 15KB (vs potentially 200KB+ for full hydration)
Key Characteristics
| Aspect | Full Hydration | Islands |
|---|---|---|
| JS shipped | All components | Only interactive |
| Hydration | Entire page | Per island |
| Bundle size | Large | Minimal |
| TTI | Slow | Fast |
| Coupling | Components connected | Islands independent |
Frameworks Using Islands
- Astro: First-class islands support
- Fresh (Deno): Islands by default
- Ãles: Islands for Vite
- Qwik: Resumability (related concept)
Island Definition Example (Astro)
---
// Static by default
import Header from './Header.astro';
import Counter from './Counter.jsx'; // React component
---
<Header /> <!-- No JS shipped -->
<Counter client:load /> <!-- JS shipped, hydrates on load -->
<Counter client:visible /> <!-- JS shipped, hydrates when visible -->
<Counter client:idle /> <!-- JS shipped, hydrates on browser idle -->
Resumability (Qwik’s Approach)
Skip hydration entirely by serializing application state.
Traditional Hydration
Server renders â Client downloads JS â Re-executes to rebuild state â Attaches listeners
Resumability
Server renders + serializes state â Client downloads minimal JS â Resumes exactly where server left off
Key Difference
// Hydration: Re-run component code to figure out handlers
// Resumability: Handlers serialized in HTML, just attach them
// Qwik serializes even closures:
<button on:click="./chunk.js#handleClick[0]">Click me</button>
Choosing a Hydration Strategy
| Scenario | Recommended Pattern |
|---|---|
| SPA/Dashboard | Full hydration |
| Content site with some interactivity | Islands |
| E-commerce product page | Partial/Progressive |
| Marketing landing page | Islands or SSG (no hydration) |
| Highly interactive app | Full hydration with code splitting |
| Performance critical, varied interactivity | Islands or Resumability |
Hydration Anti-Patterns
1. Hydration Mismatch
// Bad: Different output on server vs client
function Greeting() {
return <p>Hello at {new Date().toLocaleTimeString()}</p>;
}
// Good: Defer client-only values
function Greeting() {
const [time, setTime] = useState(null);
useEffect(() => setTime(new Date().toLocaleTimeString()), []);
return <p>Hello{time && ` at ${time}`}</p>;
}
2. Blocking Hydration on Data
// Bad: Hydration waits for fetch
useEffect(() => {
fetchData().then(setData); // Delays hydration
}, []);
// Good: Use server-provided data or streaming
3. Huge Hydration Bundles
// Bad: Import everything at top level
import { FullCalendar } from 'massive-calendar-lib';
// Good: Dynamic import for heavy components
const Calendar = lazy(() => import('./Calendar'));
Deep Dive: Understanding Hydration From First Principles
Why Hydration Exists: The Core Problem
Hydration exists because of a fundamental mismatch between two goals:
GOAL 1: Fast First Paint (show content quickly)
â Server rendering gives HTML immediately
â Browser displays without JavaScript
GOAL 2: Rich Interactivity (React/Vue/Svelte apps)
â Requires JavaScript
â Event handlers, state, reactivity
THE CONFLICT:
Server-rendered HTML is STATIC
Your framework needs to MANAGE that HTML
THE SOLUTION:
"Hydration" - attach framework to existing HTML
What Hydration Actually Does Internally
Let’s trace through React’s hydration process step by step:
// 1. Server rendered this HTML:
`<button id="counter">Count: 0</button>`
// 2. Client receives HTML and displays it (fast!)
// 3. JavaScript bundle loads and executes
// 4. React's hydrateRoot runs:
hydrateRoot(document.getElementById('root'), <App />);
// 5. React executes your component to get expected virtual DOM:
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
// Expected virtual DOM:
// { type: 'button', props: { onClick: fn }, children: ['Count: ', 0] }
// 6. React walks EXISTING DOM and COMPARES to virtual DOM:
const existingButton = document.querySelector('button');
// Does it match? Yes, same structure
// 7. React ATTACHES the onClick handler to existing button:
existingButton.addEventListener('click', () => setCount(c => c + 1));
// 8. React connects state management:
// - useState now tracks count
// - setCount will trigger re-renders
// - Component is now "live"
The key insight: Hydration doesn’t recreate the DOM. It walks the existing DOM and “wires up” the JavaScript.
The Hydration Mismatch Problem in Detail
When server HTML doesn’t match what client would render:
// Server renders at 11:59:59 PM December 31:
function NewYearCountdown() {
const now = new Date();
return <p>Current time: 11:59:59 PM</p>;
}
// Client hydrates at 12:00:01 AM January 1:
function NewYearCountdown() {
const now = new Date(); // Different time!
return <p>Current time: 12:00:01 AM</p>;
}
// React compares:
// Server HTML: "Current time: 11:59:59 PM"
// Client expected: "Current time: 12:00:01 AM"
// MISMATCH!
What happens on mismatch:
DEVELOPMENT MODE:
- Console warning: "Text content did not match"
- React shows the client version (visual jump)
PRODUCTION MODE:
- React silently patches to client version
- Can cause visual flicker
- SEO mismatch if crawled during gap
SEVERE MISMATCH:
- Different DOM structure, not just text
- React may throw errors
- App may break completely
The fix pattern:
function NewYearCountdown() {
const [time, setTime] = useState(null); // null on server
useEffect(() => {
// Only runs on client
setTime(new Date().toLocaleTimeString());
const interval = setInterval(() => {
setTime(new Date().toLocaleTimeString());
}, 1000);
return () => clearInterval(interval);
}, []);
return <p>Current time: {time ?? 'Loading...'}</p>;
}
// Server renders: "Current time: Loading..."
// Client hydrates to same: "Current time: Loading..."
// useEffect updates to real time (no mismatch)
Understanding the Hydration Performance Cost
Hydration is expensive. Let’s understand why:
// For a page with 1000 DOM nodes:
HYDRATION WORK:
1. Download JS bundle (network time)
2. Parse JavaScript (CPU time)
3. Execute all component code (CPU time)
4. Build virtual DOM tree (memory + CPU)
5. Walk real DOM tree (CPU time)
6. Compare virtual vs real (CPU time)
7. Attach all event listeners (memory)
8. Initialize all state (memory)
// For 1000 nodes, this might take 200-500ms on mobile
// During this time, clicks don't work!
Measuring hydration cost:
// React 18 provides timing
const startMark = performance.now();
hydrateRoot(container, <App />);
const endMark = performance.now();
console.log(`Hydration took ${endMark - startMark}ms`);
// More detailed with React Profiler
<Profiler id="App" onRender={(id, phase, duration) => {
if (phase === 'mount') {
// This includes hydration time
console.log(`${id} hydration: ${duration}ms`);
}
}}>
<App />
</Profiler>
Islands Architecture: The Mental Model
Traditional hydration thinks in pages. Islands thinks in components:
TRADITIONAL (Page-Level Hydration):
ââââââââââââââââââââââââââââââââââââââââââââââââ
â ââââââââââââââââââââââââââââââââââââââââ â
â â Page Component â â
â â ââââââââ ââââââââ ââââââââââââââââ â â
â â âHeaderâ â Nav â â Content â â â
â â ââââââââ ââââââââ ââââââââââââââââ â â
â â ââââââââ ââââââââââââââââââââââââ â â
â â âButtonâ â Comments â â â
â â ââââââââ ââââââââââââââââââââââââ â â
â ââââââââââââââââââââââââââââââââââââââââ â
â â
â ENTIRE TREE must be hydrated â
â One root, all components connected â
ââââââââââââââââââââââââââââââââââââââââââââââââ
ISLANDS (Component-Level Hydration):
ââââââââââââââââââââââââââââââââââââââââââââââââ
â â
â Static HTML (no JS required) â
â â
â ââââââââââââââââ ââââââââââââââââââââ â
â â Island: Nav â â Island: Search â â
â â (own JS, own â â (own JS, own â â
â â hydration) â â hydration) â â
â ââââââââââââââââ ââââââââââââââââââââ â
â â
â More static HTML... â
â â
â ââââââââââââââââââââââââââââââââââââââââ â
â â Island: Comments Widget â â
â â (loads only when visible) â â
â ââââââââââââââââââââââââââââââââââââââââ â
â â
â Each island is INDEPENDENT â
â Hydrate separately, fail separately â
ââââââââââââââââââââââââââââââââââââââââââââââââ
Islands technical implementation:
<!-- Astro compiles to something like this -->
<html>
<body>
<header>Static header content</header>
<!-- Island marker -->
<astro-island
component-url="/components/Counter.js"
client="visible"
>
<button>Count: 0</button>
</astro-island>
<main>Static main content</main>
<!-- Another island -->
<astro-island
component-url="/components/Comments.js"
client="idle"
props='{"postId":123}'
>
<div>Loading comments...</div>
</astro-island>
<footer>Static footer</footer>
</body>
</html>
<!-- The astro-island web component handles loading and hydration -->
Resumability: How Qwik Eliminates Hydration
Qwik takes a radically different approach. Instead of re-running code to figure out what event handlers should do, it serializes everything:
// TRADITIONAL HYDRATION:
// Server runs component, produces HTML
// Client runs SAME component code again to attach handlers
// Server:
function Counter() {
const [count, setCount] = useState(0);
const increment = () => setCount(c => c + 1);
return <button onClick={increment}>Count: {count}</button>;
}
// Output: <button>Count: 0</button>
// Client must run Counter() to know what onClick does
// QWIK RESUMABILITY:
// Server runs component, serializes EVERYTHING including handlers
// Server:
function Counter() {
const count = useSignal(0);
return <button onClick$={() => count.value++}>Count: {count.value}</button>;
}
// Output:
<button
on:click="counter_onclick_abc123.js#s0"
q:obj="0"
>Count: 0</button>
<script type="qwik/json">{"signals":{"0":0}}</script>
// Client sees: "When clicked, load counter_onclick_abc123.js and run s0"
// No need to run Counter() at all!
// Handler code downloaded ONLY when button is clicked
Why this matters:
HYDRATION TIMELINE:
[Page Loads] â [Download ALL JS] â [Execute ALL code] â [Interactive]
[====== Blocking, nothing works ======]
RESUMABILITY TIMELINE:
[Page Loads] â [Interactive immediately]
[Download handler only on first interaction]
// 100KB app with hydration: 500ms until interactive
// 100KB app with resumability: 0ms until interactive (JS loads on demand)
Partial Hydration: The Compiler’s Role
Frameworks like Astro use compilers to determine what needs JavaScript:
// Source code
---
import Header from './Header.astro'; // Astro component (static)
import Counter from './Counter.jsx'; // React component (interactive)
---
<Header />
<Counter client:load />
// COMPILER ANALYSIS:
// Header.astro:
// - No useState, no useEffect, no event handlers
// - Only template logic
// - RESULT: Compile to pure HTML, ship NO JavaScript
// Counter.jsx:
// - Has useState, has onClick
// - Needs interactivity
// - RESULT: Ship JavaScript, hydrate this component only
// BUILD OUTPUT:
// index.html: Full HTML with Header content + Counter placeholder
// counter.[hash].js: Only Counter component code (~2KB)
// NOT included: React runtime for static components
The Event Listener Memory Model
Understanding how event listeners work clarifies hydration cost:
// Without framework (vanilla JS):
button.addEventListener('click', () => console.log('clicked'));
// Browser stores: {element: button, event: 'click', handler: fn}
// Memory: ~100 bytes per listener
// With React (synthetic events):
<button onClick={() => console.log('clicked')}>
// React stores in its own system:
// - Element reference
// - Event type
// - Handler function
// - Handler closure scope (variables it captures)
// - Fiber node reference
// - Event priority
// Memory: ~500 bytes per listener + closure scope
// A page with 100 interactive elements:
// Vanilla: ~10KB in event system
// React: ~50KB+ in React's event system + component tree
This is why hydration is expensive – it’s rebuilding all these data structures.
Streaming Hydration: Out of Order
React 18’s streaming SSR allows components to hydrate out of order:
<Layout>
<Header /> {/* Hydrates first */}
<Suspense fallback={<Spinner />}>
<SlowComments /> {/* Hydrates when ready, even if Footer is waiting */}
</Suspense>
<Suspense fallback={<Spinner />}>
<SlowSidebar /> {/* Can hydrate before or after Comments */}
</Suspense>
<Footer /> {/* Hydrates independently */}
</Layout>
The streaming mechanism:
1. Server streams shell immediately:
<html><body><div id="header">...</div><div id="comments">Loading...</div>
2. As data arrives, server streams script tags:
<script>
$RC('comments', '<div class="comments">Real comments...</div>');
</script>
3. $RC (React's internal function) swaps content and triggers hydration
for just that subtree
4. User can interact with Header while Comments still loading
Real-World Hydration Optimization Strategies
1. Code Splitting by Route:
// Don't bundle everything together
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
// Only Dashboard code loads on /dashboard
// Settings code only loads when navigating to /settings
2. Deferred Hydration:
// Hydrate critical UI first
import { startTransition } from 'react';
// Critical path - hydrate immediately
hydrateRoot(headerContainer, <Header />);
// Non-critical - defer
startTransition(() => {
hydrateRoot(commentsContainer, <Comments />);
});
3. Intersection Observer Pattern:
// Only hydrate when scrolled into view
function useHydrateOnVisible(ref) {
const [shouldHydrate, setShouldHydrate] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setShouldHydrate(true);
observer.disconnect();
}
},
{ rootMargin: '200px' } // Start 200px before visible
);
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return shouldHydrate;
}
4. Static Extraction:
// Identify components that need no JS
function ProductDescription({ text }) {
// No state, no effects, no handlers
// This could be static HTML
return <p className="description">{text}</p>;
}
// vs
function AddToCartButton({ productId }) {
const [loading, setLoading] = useState(false);
const handleClick = () => { /* ... */ };
// This NEEDS hydration
return <button onClick={handleClick}>Add to Cart</button>;
}
For Framework Authors: Building Hydration Systems
Implementation Note: The patterns and code examples below represent one proven approach to building hydration systems. Hydration is one of the most complex areas in framework development with many valid strategiesâfrom React’s reconciliation-based approach to Qwik’s resumability to Astro’s islands. The direction shown here provides foundational concepts; adapt these patterns based on your framework’s rendering model, state management, and performance priorities.
Implementing Basic Hydration
// MINIMAL HYDRATION IMPLEMENTATION
function hydrateRoot(container, element) {
// Get existing DOM nodes
const existingNodes = Array.from(container.childNodes);
// Walk virtual tree and existing DOM in parallel
hydrateNode(element, existingNodes[0], container);
}
function hydrateNode(vnode, domNode, parent) {
if (typeof vnode === 'string' || typeof vnode === 'number') {
// Text node - validate match
if (domNode?.nodeType === Node.TEXT_NODE) {
if (domNode.textContent !== String(vnode)) {
console.warn('Hydration mismatch:', domNode.textContent, '!=', vnode);
domNode.textContent = vnode;
}
}
return domNode;
}
if (!vnode) return null;
const { type, props } = vnode;
// Function component - call and hydrate result
if (typeof type === 'function') {
const result = type(props);
return hydrateNode(result, domNode, parent);
}
// Element node - validate and attach listeners
if (domNode?.nodeName?.toLowerCase() !== type) {
console.error('Hydration mismatch: expected', type, 'got', domNode?.nodeName);
// Full re-render fallback
const newNode = render(vnode);
parent.replaceChild(newNode, domNode);
return newNode;
}
// Attach event listeners (not present in SSR HTML)
attachEventListeners(domNode, props);
// Hydrate children
const children = Array.isArray(props.children)
? props.children
: props.children ? [props.children] : [];
const domChildren = Array.from(domNode.childNodes);
children.forEach((child, i) => {
hydrateNode(child, domChildren[i], domNode);
});
return domNode;
}
function attachEventListeners(element, props) {
Object.entries(props).forEach(([key, value]) => {
if (key.startsWith('on') && typeof value === 'function') {
const eventName = key.slice(2).toLowerCase();
element.addEventListener(eventName, value);
}
});
}
Building a Hydration Scheduler
// PROGRESSIVE HYDRATION SCHEDULER
class HydrationScheduler {
constructor() {
this.queue = [];
this.isHydrating = false;
this.idleDeadline = 50; // ms per frame
}
// Add component to hydration queue
schedule(component, priority = 'normal') {
this.queue.push({ component, priority });
this.sortQueue();
this.processQueue();
}
sortQueue() {
const priorityOrder = { critical: 0, high: 1, normal: 2, low: 3 };
this.queue.sort((a, b) =>
priorityOrder[a.priority] - priorityOrder[b.priority]
);
}
processQueue() {
if (this.isHydrating || this.queue.length === 0) return;
this.isHydrating = true;
requestIdleCallback((deadline) => {
while (
this.queue.length > 0 &&
(deadline.timeRemaining() > 0 || deadline.didTimeout)
) {
const { component } = this.queue.shift();
this.hydrateComponent(component);
}
this.isHydrating = false;
if (this.queue.length > 0) {
this.processQueue();
}
}, { timeout: 1000 });
}
hydrateComponent(component) {
const element = document.querySelector(
`[data-hydrate="${component.id}"]`
);
if (element) {
hydrateRoot(element, component.vnode);
}
}
}
// Visibility-based hydration trigger
class VisibilityHydration {
constructor(scheduler) {
this.scheduler = scheduler;
this.observer = new IntersectionObserver(
(entries) => this.onIntersect(entries),
{ rootMargin: '200px' }
);
}
observe(element, component) {
element.__component = component;
this.observer.observe(element);
}
onIntersect(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.observer.unobserve(entry.target);
this.scheduler.schedule(entry.target.__component, 'high');
}
});
}
}
Implementing Resumability (Qwik-style)
// RESUMABILITY IMPLEMENTATION
// Server-side: Serialize component state and handlers
function serializeComponent(component, props) {
const handlers = {};
const state = {};
// Extract and serialize event handlers
Object.entries(props).forEach(([key, value]) => {
if (key.startsWith('on') && typeof value === 'function') {
// Serialize handler reference
const handlerId = generateHandlerId(value);
handlers[key] = handlerId;
// Store handler code location for lazy loading
registerHandler(handlerId, {
module: component.__module,
export: value.name,
});
} else {
state[key] = value;
}
});
return {
html: renderToString(component, props),
stateScript: `<script type="qwik/json">${JSON.stringify({
state,
handlers,
})}</script>`,
};
}
// Client-side: Resume without re-executing
function resume() {
// Parse serialized state
const stateScript = document.querySelector('script[type="qwik/json"]');
const { state, handlers } = JSON.parse(stateScript.textContent);
// Attach global event listener (event delegation)
document.addEventListener('click', async (event) => {
const target = event.target.closest('[on\\:click]');
if (!target) return;
const handlerId = target.getAttribute('on:click');
const handlerInfo = getHandler(handlerId);
// Lazy load handler module
const module = await import(handlerInfo.module);
const handler = module[handlerInfo.export];
// Execute with serialized state
handler(event, state);
});
}
// Emit resumable HTML
function renderResumable(component, props) {
const { html, stateScript } = serializeComponent(component, props);
return `
${html}
${stateScript}
<script src="/qwik-loader.js" async></script>
`;
}
Building Islands Architecture
// ISLANDS ARCHITECTURE IMPLEMENTATION
// Build-time: Analyze component for interactivity
function analyzeComponent(component, ast) {
return {
hasState: astContains(ast, 'useState', 'useSignal', 'createSignal'),
hasEffects: astContains(ast, 'useEffect', 'onMount'),
hasHandlers: astContains(ast, /^on[A-Z]/),
imports: extractImports(ast),
};
}
// Build-time: Mark islands in HTML
function markIslands(html, components) {
return html.replace(
/<([A-Z]\w+)([^>]*)client:(\w+)([^>]*)>/g,
(match, name, before, strategy, after) => {
const component = components[name];
const props = extractProps(before + after);
return `<astro-island
component-url="${component.url}"
component-export="${component.export}"
renderer-url="${component.renderer}"
props="${encodeProps(props)}"
client="${strategy}"
${before}${after}>`;
}
);
}
// Client-side: Island loader
class AstroIsland extends HTMLElement {
async connectedCallback() {
const strategy = this.getAttribute('client');
switch (strategy) {
case 'load':
await this.hydrate();
break;
case 'idle':
if ('requestIdleCallback' in window) {
requestIdleCallback(() => this.hydrate());
} else {
setTimeout(() => this.hydrate(), 200);
}
break;
case 'visible':
const observer = new IntersectionObserver(async ([entry]) => {
if (entry.isIntersecting) {
observer.disconnect();
await this.hydrate();
}
});
observer.observe(this);
break;
case 'media':
const query = this.getAttribute('client-media');
const mq = window.matchMedia(query);
if (mq.matches) {
await this.hydrate();
} else {
mq.addEventListener('change', () => this.hydrate(), { once: true });
}
break;
}
}
async hydrate() {
// Load component and renderer
const [Component, { default: render }] = await Promise.all([
import(this.getAttribute('component-url')),
import(this.getAttribute('renderer-url')),
]);
const props = JSON.parse(
decodeURIComponent(this.getAttribute('props'))
);
const exportName = this.getAttribute('component-export');
// Hydrate with framework-specific renderer
await render(
this,
Component[exportName],
props,
this.innerHTML
);
}
}
customElements.define('astro-island', AstroIsland);
State Serialization for Hydration
// STATE SERIALIZATION SYSTEM
class StateSerializer {
constructor() {
this.stateMap = new Map();
this.counter = 0;
}
// During SSR: capture state
captureState(componentId, state) {
this.stateMap.set(componentId, this.serialize(state));
}
serialize(value) {
if (value === null || value === undefined) return value;
if (typeof value === 'function') return undefined; // Can't serialize
if (value instanceof Date) return { __type: 'Date', value: value.toISOString() };
if (value instanceof Map) return { __type: 'Map', value: Array.from(value) };
if (value instanceof Set) return { __type: 'Set', value: Array.from(value) };
if (ArrayBuffer.isView(value)) {
return { __type: 'TypedArray', ctor: value.constructor.name, value: Array.from(value) };
}
if (Array.isArray(value)) return value.map(v => this.serialize(v));
if (typeof value === 'object') {
const result = {};
for (const [k, v] of Object.entries(value)) {
result[k] = this.serialize(v);
}
return result;
}
return value;
}
// Emit script tag with serialized state
emit() {
const data = Object.fromEntries(this.stateMap);
return `<script id="__HYDRATION_STATE__" type="application/json">${
JSON.stringify(data).replace(/</g, '\\u003c')
}</script>`;
}
}
// Client-side: restore state
function restoreState() {
const script = document.getElementById('__HYDRATION_STATE__');
if (!script) return new Map();
const data = JSON.parse(script.textContent);
const stateMap = new Map();
for (const [id, serialized] of Object.entries(data)) {
stateMap.set(id, deserialize(serialized));
}
return stateMap;
}
function deserialize(value) {
if (value === null || value === undefined) return value;
if (value?.__type === 'Date') return new Date(value.value);
if (value?.__type === 'Map') return new Map(value.value);
if (value?.__type === 'Set') return new Set(value.value);
if (value?.__type === 'TypedArray') {
const Ctor = globalThis[value.ctor];
return new Ctor(value.value);
}
if (Array.isArray(value)) return value.map(deserialize);
if (typeof value === 'object') {
const result = {};
for (const [k, v] of Object.entries(value)) {
result[k] = deserialize(v);
}
return result;
}
return value;
}
Related Skills
- See rendering-patterns for SSR/SSG context
- See web-app-architectures for SPA vs MPA
- See meta-frameworks-overview for framework hydration strategies