@@ -66,17 +85,10 @@ function copy(text: string) {
{{ filePath }}
-
-
- root: {{ rootTag }}
-
-
-
diff --git a/client/app/composables/host.ts b/client/app/composables/host.ts
index d2320ec..12b72d3 100644
--- a/client/app/composables/host.ts
+++ b/client/app/composables/host.ts
@@ -50,7 +50,7 @@ export function useHostHydration() {
return { hydration: host.__hints.hydration }
}
-function useHostNuxt() {
+export function useHostNuxt() {
const client = useDevtoolsClient().value
if (!client) {
diff --git a/client/app/pages/hydration.vue b/client/app/pages/hydration.vue
index bdb6a8f..4e9f94b 100644
--- a/client/app/pages/hydration.vue
+++ b/client/app/pages/hydration.vue
@@ -3,7 +3,7 @@ definePageMeta({
title: 'Hydration',
})
-const { hydration } = useHostHydration()
+const hydration = useNuxtApp().$hydrationMismatches
diff --git a/client/app/pages/index.vue b/client/app/pages/index.vue
index b2599fb..29bc6e9 100644
--- a/client/app/pages/index.vue
+++ b/client/app/pages/index.vue
@@ -1,8 +1,8 @@
diff --git a/client/app/plugins/hydration.ts b/client/app/plugins/hydration.ts
new file mode 100644
index 0000000..524c3d0
--- /dev/null
+++ b/client/app/plugins/hydration.ts
@@ -0,0 +1,33 @@
+import type { HydrationMismatchPayload, HydrationMismatchResponse, LocalHydrationMismatch } from '../../../src/runtime/hydration/types'
+import { defineNuxtPlugin, useHostNuxt, ref } from '#imports'
+import { HYDRATION_ROUTE, HYDRATION_SSE_ROUTE } from '../../../src/runtime/hydration/utils'
+
+export default defineNuxtPlugin(() => {
+ const host = useHostNuxt()
+
+ const hydrationMismatches = ref<(HydrationMismatchPayload | LocalHydrationMismatch)[]>([])
+
+ hydrationMismatches.value = [...host.__hints.hydration]
+
+ $fetch(new URL(HYDRATION_ROUTE, window.location.origin).href).then((data: { mismatches: HydrationMismatchPayload[] }) => {
+ hydrationMismatches.value = [...hydrationMismatches.value, ...data.mismatches.filter(m => !hydrationMismatches.value.some(existing => existing.id === m.id))]
+ })
+
+ const eventSource = new EventSource(new URL(HYDRATION_SSE_ROUTE, window.location.origin).href)
+ eventSource.addEventListener('hints:hydration:mismatch', (event) => {
+ const mismatch: HydrationMismatchPayload = JSON.parse(event.data)
+ if (!hydrationMismatches.value.some(existing => existing.id === mismatch.id)) {
+ hydrationMismatches.value.push(mismatch)
+ }
+ })
+ eventSource.addEventListener('hints:hydration:cleared', (event) => {
+ const clearedIds: string[] = JSON.parse(event.data)
+ hydrationMismatches.value = hydrationMismatches.value.filter(m => !clearedIds.includes(m.id))
+ })
+
+ return {
+ provide: {
+ hydrationMismatches,
+ },
+ }
+})
diff --git a/client/tsconfig.json b/client/tsconfig.json
index 735767f..5086b81 100644
--- a/client/tsconfig.json
+++ b/client/tsconfig.json
@@ -1,6 +1,9 @@
{
- "extends": "./.nuxt/tsconfig.json",
- "includes": [
- "../types.d.ts"
- ]
+ "files": [],
+ "references": [
+ { "path": "./.nuxt/tsconfig.app.json" },
+ { "path": "./.nuxt/tsconfig.server.json" },
+ { "path": "./.nuxt/tsconfig.shared.json" },
+ { "path": "./.nuxt/tsconfig.node.json" }
+ ]
}
\ No newline at end of file
diff --git a/package.json b/package.json
index 55e9d25..6133b1c 100644
--- a/package.json
+++ b/package.json
@@ -53,6 +53,7 @@
"@nuxt/icon": "^2.0.0",
"@nuxt/module-builder": "^1.0.0",
"@nuxt/schema": "^4.1.2",
+ "@nuxt/hints": "workspace:^",
"@nuxt/test-utils": "^3.19.2",
"@shikijs/transformers": "^3.15.0",
"@types/node": "^24.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4a14cda..c795e18 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -48,6 +48,9 @@ importers:
'@nuxt/eslint':
specifier: ^1.9.0
version: 1.11.0(@typescript-eslint/utils@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.25)(eslint@9.39.1(jiti@2.6.1))(magicast@0.5.1)(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(sass-embedded@1.93.3)(sass@1.93.3)(terser@5.44.1)(yaml@2.8.1))
+ '@nuxt/hints':
+ specifier: workspace:^
+ version: 'link:'
'@nuxt/icon':
specifier: ^2.0.0
version: 2.1.0(magicast@0.5.1)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(sass-embedded@1.93.3)(sass@1.93.3)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.25(typescript@5.9.3))
diff --git a/src/module.ts b/src/module.ts
index d54e1ee..ebc68ae 100644
--- a/src/module.ts
+++ b/src/module.ts
@@ -1,4 +1,5 @@
-import { defineNuxtModule, addPlugin, createResolver, addBuildPlugin, addComponent, addServerPlugin } from '@nuxt/kit'
+import { defineNuxtModule, addPlugin, createResolver, addBuildPlugin, addComponent, addServerPlugin, addServerHandler } from '@nuxt/kit'
+import { HYDRATION_ROUTE, HYDRATION_SSE_ROUTE } from './runtime/hydration/utils'
import { setupDevToolsUI } from './devtools'
import { InjectHydrationPlugin } from './plugins/hydration'
@@ -21,6 +22,8 @@ export default defineNuxtModule({
if (!nuxt.options.dev) {
return
}
+ nuxt.options.nitro.experimental = nuxt.options.nitro.experimental || {}
+ nuxt.options.nitro.experimental.websocket = true
const resolver = createResolver(import.meta.url)
@@ -30,12 +33,20 @@ export default defineNuxtModule({
// hydration
addPlugin(resolver.resolve('./runtime/hydration/plugin.client'))
addBuildPlugin(InjectHydrationPlugin)
+ addServerHandler({
+ route: HYDRATION_ROUTE,
+ handler: resolver.resolve('./runtime/hydration/handler.nitro'),
+ })
+ addServerHandler({
+ route: HYDRATION_SSE_ROUTE,
+ handler: resolver.resolve('./runtime/hydration/sse.nitro'),
+ })
+
addComponent({
name: 'NuxtIsland',
filePath: resolver.resolve('./runtime/core/components/nuxt-island'),
priority: 1000,
})
-
// third-party scripts
addPlugin(resolver.resolve('./runtime/third-party-scripts/plugin.client'))
addServerPlugin(resolver.resolve('./runtime/third-party-scripts/nitro.plugin'))
diff --git a/src/runtime/hydration/composables.ts b/src/runtime/hydration/composables.ts
index e2ba098..bdc75ed 100644
--- a/src/runtime/hydration/composables.ts
+++ b/src/runtime/hydration/composables.ts
@@ -1,36 +1,7 @@
import { getCurrentInstance, onMounted } from 'vue'
import { useNuxtApp } from '#imports'
-
-function formatHTML(html: string | undefined): string {
- if (!html) return ''
-
- // Simple HTML formatting function
- let formatted = ''
- let indent = 0
- const tags = html.split(/(<\/?[^>]+>)/g)
-
- for (const tag of tags) {
- if (!tag.trim()) continue
-
- if (tag.startsWith('')) {
- indent--
- formatted += '\n' + ' '.repeat(Math.max(0, indent)) + tag
- }
- else if (tag.startsWith('<') && !tag.endsWith('/>') && !tag.includes(' {
const htmlPostHydration = formatHTML(instance.vnode.el?.outerHTML)
- if (htmlPrehydration !== htmlPostHydration) {
+ if (htmlPreHydration !== htmlPostHydration) {
+ const payload: HydrationMismatchPayload = {
+ htmlPreHydration: htmlPreHydration,
+ htmlPostHydration: htmlPostHydration,
+ id: globalThis.crypto.randomUUID(),
+ componentName: instance.type.name ?? instance.type.displayName ?? instance.type.__name,
+ fileLocation: instance.type.__file ?? 'unknown',
+ }
nuxtApp.__hints.hydration.push({
+ ...payload,
instance,
vnode: vnodePrehydration,
- htmlPreHydration: htmlPrehydration,
- htmlPostHydration,
+ })
+ $fetch(new URL(HYDRATION_ROUTE, window.location.origin).href, {
+ method: 'POST',
+ body: payload,
})
console.warn(`[nuxt/hints:hydration] Component ${instance.type.name ?? instance.type.displayName ?? instance.type.__name ?? instance.type.__file} seems to have different html pre and post-hydration. Please make sure you don't have any hydration issue.`)
}
diff --git a/src/runtime/hydration/handler.nitro.ts b/src/runtime/hydration/handler.nitro.ts
new file mode 100644
index 0000000..f43c9cb
--- /dev/null
+++ b/src/runtime/hydration/handler.nitro.ts
@@ -0,0 +1,68 @@
+import type { H3Event } from 'h3'
+import { createError, defineEventHandler, readBody, setResponseStatus } from 'h3'
+import type { HydrationMismatchPayload } from './types'
+import { useNitroApp } from 'nitropack/runtime'
+
+const hydrationMismatches: HydrationMismatchPayload[] = []
+
+export default defineEventHandler((event) => {
+ switch (event.method) {
+ case 'GET':
+ return getHandler()
+ case 'POST':
+ return postHandler(event)
+ case 'DELETE':
+ return deleteHandler(event)
+ default:
+ throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
+ }
+})
+
+function getHandler() {
+ return {
+ mismatches: hydrationMismatches,
+ }
+}
+
+async function postHandler(event: H3Event) {
+ const body = await readBody(event)
+ assertPayload(body)
+ const nitro = useNitroApp()
+ const payload = { id: body.id, htmlPreHydration: body.htmlPreHydration, htmlPostHydration: body.htmlPostHydration, componentName: body.componentName, fileLocation: body.fileLocation }
+ hydrationMismatches.push(payload)
+ if (hydrationMismatches.length > 20) {
+ hydrationMismatches.shift()
+ }
+ nitro.hooks.callHook('hints:hydration:mismatch', payload)
+ setResponseStatus(event, 201)
+}
+
+async function deleteHandler(event: H3Event) {
+ const nitro = useNitroApp()
+ const body = await readBody<{ id: string[] }>(event)
+ if (!body || !Array.isArray(body.id)) {
+ throw createError({ statusCode: 400, statusMessage: 'Invalid payload' })
+ }
+ for (const id of body.id) {
+ const index = hydrationMismatches.findIndex(m => m.id === id)
+ if (index !== -1) {
+ hydrationMismatches.splice(index, 1)
+ }
+ }
+ nitro.hooks.callHook('hints:hydration:cleared', { id: body.id })
+ setResponseStatus(event, 204)
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function assertPayload(body: any): asserts body is HydrationMismatchPayload {
+ if (
+ typeof body !== 'object'
+ || typeof body.id !== 'string'
+ || (body.htmlPreHydration !== undefined && typeof body.htmlPreHydration !== 'string')
+ || (body.htmlPostHydration !== undefined && typeof body.htmlPostHydration !== 'string')
+ || typeof body.componentName !== 'string'
+ || typeof body.fileLocation !== 'string'
+ ) {
+ throw createError({ statusCode: 400, statusMessage: 'Invalid payload' })
+ }
+}
diff --git a/src/runtime/hydration/sse.nitro.ts b/src/runtime/hydration/sse.nitro.ts
new file mode 100644
index 0000000..bd666d2
--- /dev/null
+++ b/src/runtime/hydration/sse.nitro.ts
@@ -0,0 +1,26 @@
+import { createEventStream, defineEventHandler } from 'h3'
+import { useNitroApp } from 'nitropack/runtime'
+
+export default defineEventHandler((event) => {
+ const nitro = useNitroApp()
+ const eventStream = createEventStream(event)
+
+ const unsubs = [nitro.hooks.hook('hints:hydration:mismatch', (mismatch) => {
+ eventStream.push({
+ data: JSON.stringify(mismatch),
+ event: 'hints:hydration:mismatch',
+ })
+ }), nitro.hooks.hook('hints:hydration:cleared', async (payload) => {
+ eventStream.push({
+ data: JSON.stringify(payload.id),
+ event: 'hints:hydration:cleared',
+ })
+ })]
+
+ eventStream.onClosed(async () => {
+ unsubs.forEach(unsub => unsub())
+ await eventStream.close()
+ })
+
+ return eventStream.send()
+})
diff --git a/src/runtime/hydration/types.ts b/src/runtime/hydration/types.ts
new file mode 100644
index 0000000..a422268
--- /dev/null
+++ b/src/runtime/hydration/types.ts
@@ -0,0 +1,37 @@
+import type { EventStreamMessage } from 'h3'
+import type { ComponentInternalInstance, VNode } from 'vue'
+
+export interface HydrationMismatchPayload {
+ id: string
+ componentName?: string
+ fileLocation: string
+ htmlPreHydration: string
+ htmlPostHydration: string
+}
+
+export interface LocalHydrationMismatch extends HydrationMismatchPayload {
+ instance: ComponentInternalInstance
+ vnode: VNode
+}
+
+// prefer interface for extensibility
+export interface HydrationMismatchResponse {
+ mismatches: HydrationMismatchPayload[]
+}
+
+export interface HydrationDeleteSSE extends EventStreamMessage {
+ event: 'hydration:cleared'
+ // array of ids
+ data: string
+}
+
+export interface HydrationNewSSE extends EventStreamMessage {
+ event: 'hydration:mismatch'
+ /**
+ * Stringified HydrationMismatchPayload
+ * @see HydrationMismatchPayload
+ */
+ data: string
+}
+
+export type HydrationSSEPayload = HydrationDeleteSSE | HydrationNewSSE
diff --git a/src/runtime/hydration/utils.ts b/src/runtime/hydration/utils.ts
new file mode 100644
index 0000000..eb6d8a0
--- /dev/null
+++ b/src/runtime/hydration/utils.ts
@@ -0,0 +1,32 @@
+export const HYDRATION_ROUTE = '/__nuxt_hydration'
+export const HYDRATION_SSE_ROUTE = '/__nuxt_hydration/sse'
+
+export function formatHTML(html: string | undefined): string {
+ if (!html) return ''
+
+ // Simple HTML formatting function
+ let formatted = ''
+ let indent = 0
+ const tags = html.split(/(<\/?[^>]+>)/g)
+
+ for (const tag of tags) {
+ if (!tag.trim()) continue
+
+ if (tag.startsWith('')) {
+ indent--
+ formatted += '\n' + ' '.repeat(Math.max(0, indent)) + tag
+ }
+ else if (tag.startsWith('<') && !tag.endsWith('/>') && !tag.includes(' void
@@ -33,7 +35,7 @@ declare module '#app' {
interface NuxtApp {
__hints_tpc: Ref<{ element: HTMLScriptElement, loaded: boolean }[]>
__hints: {
- hydration: { instance: ComponentInternalInstance, vnode: VNode, htmlPreHydration: string | undefined, htmlPostHydration: string | undefined }[]
+ hydration: LocalHydrationMismatch[]
webvitals: {
lcp: Ref
inp: Ref
@@ -45,4 +47,11 @@ declare module '#app' {
}
}
+declare module 'nitropack' {
+ interface NitroRuntimeHooks {
+ 'hints:hydration:mismatch': (payload: HydrationMismatchPayload) => void
+ 'hints:hydration:cleared': (payload: { id: string[] }) => void
+ }
+}
+
export {}
diff --git a/tsconfig.json b/tsconfig.json
index 9683639..c26d3cf 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,6 +1,11 @@
{
- "extends": ["./.nuxt/tsconfig.json", "./playground/.nuxt/tsconfig.json"],
- "includes": [
- "./types.d.ts"
- ],
-}
+ // Remove "extends": "./.nuxt/tsconfig.json" if present
+ "files": [],
+ "references": [
+ { "path": "./.nuxt/tsconfig.app.json" },
+ { "path": "./.nuxt/tsconfig.server.json" },
+ { "path": "./.nuxt/tsconfig.shared.json" },
+ { "path": "./.nuxt/tsconfig.node.json" },
+ { "path": "./playground/.nuxt/tsconfig.json" }
+ ]
+}
\ No newline at end of file