Hydration Error Handling and Optimization for Element Plus SSR
Overview
This guide covers comprehensive hydration error handling and optimization strategies for Element Plus applications in server-side rendering environments, including error detection, debugging techniques, and performance optimization.
Understanding Hydration in Element Plus SSR
Hydration Process Overview
typescript
// src/entry-client.ts
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import App from './App.vue'
import { routes } from './router'
import { createI18n } from 'vue-i18n'
// Hydration-aware app creation
const createClientApp = () => {
const app = createApp(App)
// Router setup with hydration considerations
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
// Prevent scroll during hydration
if (savedPosition && !window.__INITIAL_STATE__) {
return savedPosition
}
return { top: 0 }
}
})
// State management with SSR state
const pinia = createPinia()
// Restore SSR state
if (window.__INITIAL_STATE__) {
pinia.state.value = window.__INITIAL_STATE__.pinia
}
// I18n setup with SSR locale
const i18n = createI18n({
legacy: false,
locale: window.__INITIAL_STATE__?.locale || 'en',
messages: window.__INITIAL_STATE__?.messages || {}
})
// Element Plus with SSR-safe configuration
app.use(ElementPlus, {
// Disable auto-import during hydration
zIndex: 3000,
size: 'default',
// Prevent theme conflicts during hydration
namespace: 'el'
})
app.use(router)
app.use(pinia)
app.use(i18n)
return { app, router, pinia }
}
// Enhanced hydration with error handling
const hydrateApp = async () => {
const { app, router } = createClientApp()
try {
// Wait for router to be ready
await router.isReady()
// Hydration with error boundary
app.mount('#app', true)
// Clean up SSR state
delete window.__INITIAL_STATE__
console.log('✅ Hydration successful')
} catch (error) {
console.error('❌ Hydration failed:', error)
// Fallback to client-side rendering
handleHydrationFailure(error)
}
}
// Hydration failure recovery
const handleHydrationFailure = (error: Error) => {
// Log detailed error information
console.error('Hydration Error Details:', {
message: error.message,
stack: error.stack,
userAgent: navigator.userAgent,
url: window.location.href,
timestamp: new Date().toISOString()
})
// Send error to monitoring service
if (window.gtag) {
window.gtag('event', 'exception', {
description: `Hydration Error: ${error.message}`,
fatal: false
})
}
// Clear the DOM and restart with CSR
const appElement = document.getElementById('app')
if (appElement) {
appElement.innerHTML = ''
// Recreate app for client-side rendering
const { app } = createClientApp()
app.mount('#app')
}
}
// Start hydration
hydrateApp()
Hydration Error Detection
typescript
// src/utils/hydration-detector.ts
export class HydrationErrorDetector {
private errors: HydrationError[] = []
private isHydrating = true
private observer: MutationObserver | null = null
constructor() {
this.setupErrorHandlers()
this.setupMutationObserver()
this.setupHydrationCompleteDetection()
}
private setupErrorHandlers() {
// Catch Vue hydration warnings
const originalWarn = console.warn
console.warn = (...args) => {
const message = args.join(' ')
if (this.isHydrating && this.isHydrationError(message)) {
this.recordError({
type: 'hydration-mismatch',
message,
timestamp: Date.now(),
stack: new Error().stack
})
}
originalWarn.apply(console, args)
}
// Catch unhandled errors during hydration
window.addEventListener('error', (event) => {
if (this.isHydrating) {
this.recordError({
type: 'runtime-error',
message: event.error?.message || event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error?.stack,
timestamp: Date.now()
})
}
})
// Catch promise rejections during hydration
window.addEventListener('unhandledrejection', (event) => {
if (this.isHydrating) {
this.recordError({
type: 'promise-rejection',
message: event.reason?.message || String(event.reason),
stack: event.reason?.stack,
timestamp: Date.now()
})
}
})
}
private setupMutationObserver() {
this.observer = new MutationObserver((mutations) => {
if (!this.isHydrating) return
mutations.forEach((mutation) => {
// Detect unexpected DOM changes during hydration
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
const hasElementNodes = Array.from(mutation.addedNodes)
.some(node => node.nodeType === Node.ELEMENT_NODE)
if (hasElementNodes) {
this.recordError({
type: 'dom-mutation',
message: 'Unexpected DOM mutation during hydration',
target: (mutation.target as Element).tagName,
timestamp: Date.now()
})
}
}
})
})
this.observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeOldValue: true
})
}
private setupHydrationCompleteDetection() {
// Detect when hydration is complete
setTimeout(() => {
this.isHydrating = false
this.observer?.disconnect()
if (this.errors.length > 0) {
this.reportErrors()
}
}, 5000) // Assume hydration completes within 5 seconds
// Also listen for Vue app mounted event
document.addEventListener('vue:hydrated', () => {
this.isHydrating = false
this.observer?.disconnect()
})
}
private isHydrationError(message: string): boolean {
const hydrationKeywords = [
'hydration',
'mismatch',
'server-rendered',
'client-side',
'SSR',
'hydrating'
]
return hydrationKeywords.some(keyword =>
message.toLowerCase().includes(keyword.toLowerCase())
)
}
private recordError(error: HydrationError) {
this.errors.push(error)
// Immediate logging for debugging
console.error('🔥 Hydration Error Detected:', error)
}
private reportErrors() {
if (this.errors.length === 0) return
const report = {
timestamp: Date.now(),
url: window.location.href,
userAgent: navigator.userAgent,
errors: this.errors,
performance: this.getPerformanceMetrics()
}
// Send to monitoring service
this.sendToMonitoring(report)
// Store locally for debugging
localStorage.setItem('hydration-errors', JSON.stringify(report))
}
private getPerformanceMetrics() {
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
return {
domContentLoaded: navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart,
loadComplete: navigation.loadEventEnd - navigation.loadEventStart,
firstPaint: performance.getEntriesByName('first-paint')[0]?.startTime,
firstContentfulPaint: performance.getEntriesByName('first-contentful-paint')[0]?.startTime
}
}
private async sendToMonitoring(report: HydrationErrorReport) {
try {
await fetch('/api/monitoring/hydration-errors', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(report)
})
} catch (error) {
console.error('Failed to send hydration error report:', error)
}
}
public getErrors(): HydrationError[] {
return [...this.errors]
}
public clearErrors() {
this.errors = []
}
}
interface HydrationError {
type: 'hydration-mismatch' | 'runtime-error' | 'promise-rejection' | 'dom-mutation'
message: string
timestamp: number
stack?: string
filename?: string
lineno?: number
colno?: number
target?: string
}
interface HydrationErrorReport {
timestamp: number
url: string
userAgent: string
errors: HydrationError[]
performance: any
}
// Initialize detector
export const hydrationDetector = new HydrationErrorDetector()
Common Hydration Issues and Solutions
Element Plus Specific Issues
vue
<!-- src/components/SSRSafeElementPlus.vue -->
<template>
<div class="ssr-safe-wrapper">
<!-- Date/Time Components -->
<ClientOnly>
<el-date-picker
v-model="dateValue"
type="datetime"
placeholder="Select date and time"
:default-time="defaultTime"
/>
<template #fallback>
<el-input
v-model="dateDisplayValue"
placeholder="Loading date picker..."
readonly
/>
</template>
</ClientOnly>
<!-- Teleport Components -->
<el-dialog
v-model="dialogVisible"
title="SSR Safe Dialog"
:teleported="!isSSR"
:append-to-body="!isSSR"
>
<p>This dialog is SSR-safe</p>
</el-dialog>
<!-- Dynamic Content -->
<el-table
:data="tableData"
v-loading="loading"
element-loading-text="Loading..."
:element-loading-spinner="isSSR ? undefined : 'el-icon-loading'"
>
<el-table-column prop="name" label="Name" />
<el-table-column prop="value" label="Value" />
</el-table>
<!-- Theme-dependent Components -->
<div class="theme-wrapper" :class="themeClass">
<el-button
type="primary"
:class="{ 'ssr-safe': isSSR }"
@click="handleClick"
>
{{ buttonText }}
</el-button>
</div>
</div>
</template>
<script setup lang="ts"
import { ref, computed, onMounted } from 'vue'
import {
ElDatePicker,
ElInput,
ElDialog,
ElTable,
ElTableColumn,
ElButton
} from 'element-plus'
// SSR detection
const isSSR = ref(typeof window === 'undefined')
const isClient = ref(false)
// Reactive data
const dateValue = ref<Date | null>(null)
const dialogVisible = ref(false)
const loading = ref(true)
const tableData = ref([])
// Computed properties for SSR safety
const dateDisplayValue = computed(() => {
if (!dateValue.value) return ''
return dateValue.value.toLocaleDateString()
})
const defaultTime = computed(() => {
// Avoid timezone issues during hydration
return isSSR.value ? undefined : new Date(2000, 1, 1, 12, 0, 0)
})
const themeClass = computed(() => {
// Prevent theme mismatch during hydration
if (isSSR.value) return 'theme-default'
return localStorage.getItem('theme') || 'theme-default'
})
const buttonText = computed(() => {
return isClient.value ? 'Client Ready' : 'Loading...'
})
// Lifecycle hooks
onMounted(() => {
isSSR.value = false
isClient.value = true
// Load data after hydration
setTimeout(() => {
tableData.value = [
{ name: 'Item 1', value: 'Value 1' },
{ name: 'Item 2', value: 'Value 2' }
]
loading.value = false
}, 100)
})
// Event handlers
const handleClick = () => {
if (isClient.value) {
dialogVisible.value = true
}
}
</script>
<style scoped>
.ssr-safe-wrapper {
padding: 20px;
}
.theme-wrapper {
margin: 20px 0;
}
.theme-default {
/* Default theme styles */
}
.ssr-safe {
/* SSR-specific styles */
transition: none;
}
/* Prevent layout shift during hydration */
.el-date-picker,
.el-input {
min-height: 32px;
}
</style>
ClientOnly Component Implementation
vue
<!-- src/components/ClientOnly.vue -->
<template>
<div v-if="isMounted">
<slot />
</div>
<div v-else-if="$slots.fallback">
<slot name="fallback" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const isMounted = ref(false)
onMounted(() => {
isMounted.value = true
})
</script>
Hydration Performance Optimization
Lazy Hydration Strategy
typescript
// src/utils/lazy-hydration.ts
export class LazyHydrationManager {
private components = new Map<string, ComponentConfig>()
private observer: IntersectionObserver
private idleCallback: number | null = null
constructor() {
this.setupIntersectionObserver()
this.setupIdleHydration()
}
private setupIntersectionObserver() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const componentId = entry.target.getAttribute('data-component-id')
if (componentId) {
this.hydrateComponent(componentId)
}
}
})
},
{
rootMargin: '50px',
threshold: 0.1
}
)
}
private setupIdleHydration() {
if ('requestIdleCallback' in window) {
this.idleCallback = requestIdleCallback(() => {
this.hydrateRemainingComponents()
}, { timeout: 5000 })
} else {
setTimeout(() => {
this.hydrateRemainingComponents()
}, 5000)
}
}
public registerComponent(id: string, config: ComponentConfig) {
this.components.set(id, config)
const element = document.querySelector(`[data-component-id="${id}"]`)
if (element) {
if (config.strategy === 'visible') {
this.observer.observe(element)
} else if (config.strategy === 'immediate') {
this.hydrateComponent(id)
}
}
}
private async hydrateComponent(id: string) {
const config = this.components.get(id)
if (!config || config.hydrated) return
try {
const startTime = performance.now()
// Load component if needed
const component = await config.loader()
// Find the element
const element = document.querySelector(`[data-component-id="${id}"]`)
if (!element) return
// Create Vue app instance for this component
const app = createApp(component, config.props)
// Mount the component
app.mount(element)
// Mark as hydrated
config.hydrated = true
const endTime = performance.now()
console.log(`✅ Component ${id} hydrated in ${endTime - startTime}ms`)
// Remove from observer
this.observer.unobserve(element)
} catch (error) {
console.error(`❌ Failed to hydrate component ${id}:`, error)
}
}
private hydrateRemainingComponents() {
this.components.forEach((config, id) => {
if (!config.hydrated && config.strategy !== 'manual') {
this.hydrateComponent(id)
}
})
}
public destroy() {
this.observer.disconnect()
if (this.idleCallback) {
cancelIdleCallback(this.idleCallback)
}
}
}
interface ComponentConfig {
loader: () => Promise<any>
props?: Record<string, any>
strategy: 'immediate' | 'visible' | 'idle' | 'manual'
hydrated?: boolean
}
// Global instance
export const lazyHydrationManager = new LazyHydrationManager()
Progressive Hydration Directive
typescript
// src/directives/progressive-hydration.ts
import { Directive } from 'vue'
import { lazyHydrationManager } from '@/utils/lazy-hydration'
export const vProgressiveHydration: Directive = {
mounted(el, binding) {
const { value, modifiers } = binding
// Determine hydration strategy
let strategy: 'immediate' | 'visible' | 'idle' | 'manual' = 'visible'
if (modifiers.immediate) strategy = 'immediate'
else if (modifiers.idle) strategy = 'idle'
else if (modifiers.manual) strategy = 'manual'
// Generate unique component ID
const componentId = `component-${Math.random().toString(36).substr(2, 9)}`
el.setAttribute('data-component-id', componentId)
// Register with lazy hydration manager
lazyHydrationManager.registerComponent(componentId, {
loader: value.loader,
props: value.props,
strategy
})
},
unmounted(el) {
const componentId = el.getAttribute('data-component-id')
if (componentId) {
// Clean up if needed
el.removeAttribute('data-component-id')
}
}
}
Debugging Tools and Techniques
Hydration Debug Panel
vue
<!-- src/components/HydrationDebugPanel.vue -->
<template>
<div v-if="showDebugPanel" class="hydration-debug-panel">
<div class="debug-header">
<h3>Hydration Debug Panel</h3>
<el-button size="small" @click="togglePanel">Close</el-button>
</div>
<el-tabs v-model="activeTab">
<el-tab-pane label="Errors" name="errors">
<div class="error-list">
<div v-if="errors.length === 0" class="no-errors">
✅ No hydration errors detected
</div>
<div
v-for="(error, index) in errors"
:key="index"
class="error-item"
:class="`error-${error.type}`"
>
<div class="error-header">
<span class="error-type">{{ error.type }}</span>
<span class="error-time">{{ formatTime(error.timestamp) }}</span>
</div>
<div class="error-message">{{ error.message }}</div>
<div v-if="error.stack" class="error-stack">
<el-collapse>
<el-collapse-item title="Stack Trace">
<pre>{{ error.stack }}</pre>
</el-collapse-item>
</el-collapse>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="Performance" name="performance">
<div class="performance-metrics">
<div class="metric-item">
<span class="metric-label">Hydration Time:</span>
<span class="metric-value">{{ hydrationTime }}ms</span>
</div>
<div class="metric-item">
<span class="metric-label">DOM Ready:</span>
<span class="metric-value">{{ domReadyTime }}ms</span>
</div>
<div class="metric-item">
<span class="metric-label">First Paint:</span>
<span class="metric-value">{{ firstPaintTime }}ms</span>
</div>
<div class="metric-item">
<span class="metric-label">Memory Usage:</span>
<span class="metric-value">{{ memoryUsage }}MB</span>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="State" name="state">
<div class="state-inspector">
<h4>SSR State</h4>
<pre class="state-dump">{{ JSON.stringify(ssrState, null, 2) }}</pre>
<h4>Current State</h4>
<pre class="state-dump">{{ JSON.stringify(currentState, null, 2) }}</pre>
</div>
</el-tab-pane>
<el-tab-pane label="Actions" name="actions">
<div class="debug-actions">
<el-button @click="exportDebugData">Export Debug Data</el-button>
<el-button @click="clearErrors">Clear Errors</el-button>
<el-button @click="forceRehydration">Force Rehydration</el-button>
<el-button @click="simulateError">Simulate Error</el-button>
</div>
</el-tab-pane>
</el-tabs>
</div>
<!-- Debug Toggle Button -->
<el-button
v-if="!showDebugPanel && isDevelopment"
class="debug-toggle"
type="info"
size="small"
@click="togglePanel"
>
🔧 Debug
</el-button>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElTabs, ElTabPane, ElButton, ElCollapse, ElCollapseItem } from 'element-plus'
import { hydrationDetector } from '@/utils/hydration-detector'
import { useStore } from 'pinia'
const showDebugPanel = ref(false)
const activeTab = ref('errors')
const hydrationTime = ref(0)
const domReadyTime = ref(0)
const firstPaintTime = ref(0)
const memoryUsage = ref(0)
const isDevelopment = computed(() => {
return process.env.NODE_ENV === 'development'
})
const errors = computed(() => {
return hydrationDetector.getErrors()
})
const ssrState = computed(() => {
return window.__INITIAL_STATE__ || {}
})
const currentState = computed(() => {
const store = useStore()
return store.$state
})
const togglePanel = () => {
showDebugPanel.value = !showDebugPanel.value
}
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleTimeString()
}
const exportDebugData = () => {
const debugData = {
errors: errors.value,
performance: {
hydrationTime: hydrationTime.value,
domReadyTime: domReadyTime.value,
firstPaintTime: firstPaintTime.value,
memoryUsage: memoryUsage.value
},
state: {
ssr: ssrState.value,
current: currentState.value
},
timestamp: Date.now(),
url: window.location.href,
userAgent: navigator.userAgent
}
const blob = new Blob([JSON.stringify(debugData, null, 2)], {
type: 'application/json'
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `hydration-debug-${Date.now()}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const clearErrors = () => {
hydrationDetector.clearErrors()
}
const forceRehydration = () => {
window.location.reload()
}
const simulateError = () => {
// Simulate a hydration error for testing
console.warn('Simulated hydration mismatch: Test error for debugging')
}
const collectPerformanceMetrics = () => {
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
if (navigation) {
domReadyTime.value = Math.round(
navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart
)
}
const firstPaint = performance.getEntriesByName('first-paint')[0]
if (firstPaint) {
firstPaintTime.value = Math.round(firstPaint.startTime)
}
if ('memory' in performance) {
const memory = (performance as any).memory
memoryUsage.value = Math.round(memory.usedJSHeapSize / 1024 / 1024)
}
// Estimate hydration time
hydrationTime.value = Math.round(performance.now())
}
onMounted(() => {
collectPerformanceMetrics()
// Update memory usage periodically
setInterval(() => {
if ('memory' in performance) {
const memory = (performance as any).memory
memoryUsage.value = Math.round(memory.usedJSHeapSize / 1024 / 1024)
}
}, 5000)
})
</script>
<style scoped>
.hydration-debug-panel {
position: fixed;
top: 20px;
right: 20px;
width: 500px;
max-height: 80vh;
background: white;
border: 1px solid #dcdfe6;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9999;
overflow: hidden;
}
.debug-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #f5f7fa;
border-bottom: 1px solid #dcdfe6;
}
.debug-header h3 {
margin: 0;
font-size: 16px;
color: #303133;
}
.debug-toggle {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9998;
}
.error-list {
max-height: 300px;
overflow-y: auto;
padding: 16px;
}
.no-errors {
text-align: center;
color: #67c23a;
padding: 20px;
}
.error-item {
margin-bottom: 16px;
padding: 12px;
border-radius: 6px;
border-left: 4px solid;
}
.error-item.error-hydration-mismatch {
background: #fef0f0;
border-left-color: #f56c6c;
}
.error-item.error-runtime-error {
background: #fdf6ec;
border-left-color: #e6a23c;
}
.error-item.error-promise-rejection {
background: #f4f4f5;
border-left-color: #909399;
}
.error-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.error-type {
font-weight: 600;
text-transform: uppercase;
font-size: 12px;
}
.error-time {
font-size: 12px;
color: #909399;
}
.error-message {
font-size: 14px;
color: #303133;
margin-bottom: 8px;
}
.error-stack pre {
font-size: 12px;
color: #606266;
background: #f5f7fa;
padding: 8px;
border-radius: 4px;
overflow-x: auto;
}
.performance-metrics {
padding: 16px;
}
.metric-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #ebeef5;
}
.metric-label {
font-weight: 500;
color: #606266;
}
.metric-value {
font-weight: 600;
color: #303133;
}
.state-inspector {
padding: 16px;
}
.state-inspector h4 {
margin: 16px 0 8px 0;
color: #303133;
}
.state-dump {
background: #f5f7fa;
padding: 12px;
border-radius: 6px;
font-size: 12px;
max-height: 200px;
overflow: auto;
}
.debug-actions {
padding: 16px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>
This comprehensive guide covers hydration error handling and optimization for Element Plus SSR applications, including error detection, debugging tools, and performance optimization strategies.