Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
34 changes: 19 additions & 15 deletions client/app/components/HydrationIssue.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
<script setup lang="ts">
<script setup lang="ts" generic="Issue extends LocalHydrationMismatch | HydrationMismatchPayload">
import { codeToHtml } from 'shiki/bundle/web'
import type { ComponentInternalInstance, VNode } from 'vue'
import { diffLines, type ChangeObject } from 'diff'
import { transformerNotationDiff } from '@shikijs/transformers'
import type { HydrationMismatchPayload, LocalHydrationMismatch } from '../../../src/runtime/hydration/types'

const props = defineProps<{
issue: { instance: ComponentInternalInstance, vnode: VNode, htmlPreHydration: string | undefined, htmlPostHydration: string | undefined }
issue: Issue
}>()

type MaybeNamed = Partial<Record<'name' | '__name' | '__file', string>>
const componentName = computed(() => (props.issue.instance.type as MaybeNamed).name ?? (props.issue.instance.type as MaybeNamed).__name ?? 'AnonymousComponent')
const filePath = computed(() => (props.issue.instance.type as MaybeNamed).__file as string | undefined)
const rootTag = computed(() => (props.issue.instance.vnode.el as HTMLElement | null)?.tagName?.toLowerCase() || 'unknown')
const element = computed(() => props.issue.instance.vnode.el as HTMLElement | undefined)

const isLocalIssue = (issue: HydrationMismatchPayload | LocalHydrationMismatch): issue is LocalHydrationMismatch => {
return 'instance' in issue && 'vnode' in issue
}

const componentName = computed(() => props.issue.componentName ?? 'Unknown component')
const filePath = computed(() => isLocalIssue(props.issue)
? (props.issue.instance.type as MaybeNamed).name
?? (props.issue.instance.type as MaybeNamed).__name
?? (props.issue.instance.type as MaybeNamed).__file
?? 'Unknown component'
: (props.issue as HydrationMismatchPayload).fileLocation,
)

const element = computed(() => isLocalIssue(props.issue) ? props.issue.instance.vnode.el as HTMLElement | undefined : undefined)

const { highlightElement, inspectElementInEditor, clearHighlight } = useElementHighlighter()

Expand Down Expand Up @@ -66,17 +77,10 @@ function copy(text: string) {
<div class="text-xs text-neutral-500 truncate">
{{ filePath }}
</div>
<div class="mt-1 flex flex-wrap gap-2 text-[11px]">
<n-tip
size="small"
title="Root element tag where mismatch was detected."
>
root: {{ rootTag }}
</n-tip>
</div>
</div>
<div class="shrink-0 flex items-center gap-2">
<n-button
v-if="element"
size="small"
quaternary
title="Open in editor"
Expand Down
2 changes: 1 addition & 1 deletion client/app/composables/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function useHostHydration() {
return { hydration: host.__hints.hydration }
}

function useHostNuxt() {
export function useHostNuxt() {
const client = useDevtoolsClient().value

if (!client) {
Expand Down
2 changes: 1 addition & 1 deletion client/app/pages/hydration.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ definePageMeta({
title: 'Hydration',
})

const { hydration } = useHostHydration()
const hydration = useNuxtApp().$hydrationMismatches
</script>

<template>
Expand Down
4 changes: 2 additions & 2 deletions client/app/pages/index.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<script setup lang="ts">
const { allMetrics } = useHostWebVitals()
const { hydration } = useHostHydration()
const hydration = useNuxtApp().$hydrationMismatches

const hydrationCount = computed(() => hydration.length || 0)
const hydrationCount = computed(() => hydration.value.length)
</script>

<template>
Expand Down
26 changes: 26 additions & 0 deletions client/app/plugins/hydration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { HydrationMismatchPayload, HydrationMismatchResponse, LocalHydrationMismatch } from '../../../src/runtime/hydration/types'
import { defineNuxtPlugin, useHostNuxt, ref } from '#imports'

Check failure on line 2 in client/app/plugins/hydration.ts

View workflow job for this annotation

GitHub Actions / ci

Module '"#imports"' has no exported member 'useHostNuxt'.

export default defineNuxtPlugin(() => {
const host = useHostNuxt()

const hydrationMismatches = ref<(HydrationMismatchPayload | LocalHydrationMismatch)[]>([])

hydrationMismatches.value = [...host.__hints.hydration]

$fetch<HydrationMismatchResponse>(new URL('/__nuxt_hydration', window.location.origin).href).then((data: { mismatches: HydrationMismatchPayload[] }) => {

Check failure on line 11 in client/app/plugins/hydration.ts

View workflow job for this annotation

GitHub Actions / ci

Expected 0 type arguments, but got 1.
hydrationMismatches.value = [...hydrationMismatches.value, ...data.mismatches.filter(m => !hydrationMismatches.value.some(existing => existing.id === m.id))]
})

const eventSource = new EventSource(new URL('/__nuxt_hydration/sse', window.location.origin).href)
eventSource.addEventListener('message', (event) => {
const mismatch: HydrationMismatchPayload = JSON.parse(event.data)
hydrationMismatches.value.push(mismatch)
})

return {
provide: {
hydrationMismatches,
},
}
})
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 13 additions & 2 deletions src/module.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -19,6 +20,8 @@ export default defineNuxtModule<ModuleOptions>({
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)

Expand All @@ -28,12 +31,20 @@ export default defineNuxtModule<ModuleOptions>({
// 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'))
Expand Down
51 changes: 16 additions & 35 deletions src/runtime/hydration/composables.ts
Original file line number Diff line number Diff line change
@@ -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('<!')) {
formatted += '\n' + ' '.repeat(Math.max(0, indent)) + tag
indent++
}
else if (tag.startsWith('<')) {
formatted += '\n' + ' '.repeat(Math.max(0, indent)) + tag
}
else {
formatted += '\n' + ' '.repeat(Math.max(0, indent)) + tag.trim()
}
}

return formatted.trim()
}

import { HYDRATION_ROUTE, formatHTML } from './utils'
import type { HydrationMismatchPayload } from './types'
/**
* prefer implementing onMismatch hook after vue 3.6
* compare element
Expand All @@ -47,17 +18,27 @@ export function useHydrationCheck() {

if (!instance) return

const htmlPrehydration = formatHTML(instance.vnode.el?.outerHTML)
const htmlPreHydration = formatHTML(instance.vnode.el?.outerHTML)
const vnodePrehydration = instance.vnode

onMounted(() => {
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), {
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.`)
}
Expand Down
47 changes: 47 additions & 0 deletions src/runtime/hydration/handler.nitro.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { H3Event } from 'h3'
import { createError, defineEventHandler, readBody, setResponseStatus } from 'h3'
import type { HydrationMismatchPayload } from './types'
import { useNitroApp } from 'nitropack/runtime'

const hydrationMistmatches: HydrationMismatchPayload[] = []

export default defineEventHandler((event) => {
if (event.method === 'GET') {
console.log('called')
return getHandler()
}
else if (event.method === 'POST') {
return postHandler(event)
}

throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
})

function getHandler() {
return {
mismatches: hydrationMistmatches,
}
}

async function postHandler(event: H3Event) {
const body = await readBody<HydrationMismatchPayload>(event)
assertPayload(body)
const nitro = useNitroApp()
const payload = { id: body.id, htmlPreHydration: body.htmlPreHydration, htmlPostHydration: body.htmlPostHydration, componentName: body.componentName, fileLocation: body.fileLocation }
hydrationMistmatches.push(payload)
console.log('Received hydration mismatch:', hydrationMistmatches.length)
nitro.hooks.callHook('hints:hydration:mismatch', payload)
setResponseStatus(event, 201)
}

// 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')
) {
throw createError({ statusCode: 400, statusMessage: 'Invalid payload' })
}
}
18 changes: 18 additions & 0 deletions src/runtime/hydration/sse.nitro.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { createEventStream, defineEventHandler } from 'h3'
import { useNitroApp } from 'nitropack/runtime'

export default defineEventHandler((event) => {
const nitro = useNitroApp()
const eventStream = createEventStream(event)

const unsub = nitro.hooks.hook('hints:hydration:mismatch', (mismatch) => {
eventStream.push(JSON.stringify(mismatch))
})

eventStream.onClosed(async () => {
unsub()
await eventStream.close()
})

return eventStream.send()
})
19 changes: 19 additions & 0 deletions src/runtime/hydration/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
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[]
}
32 changes: 32 additions & 0 deletions src/runtime/hydration/utils.ts
Original file line number Diff line number Diff line change
@@ -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('<!')) {
formatted += '\n' + ' '.repeat(Math.max(0, indent)) + tag
indent++
}
else if (tag.startsWith('<')) {
formatted += '\n' + ' '.repeat(Math.max(0, indent)) + tag
}
else {
formatted += '\n' + ' '.repeat(Math.max(0, indent)) + tag.trim()
}
}

return formatted.trim()
}
12 changes: 10 additions & 2 deletions src/runtime/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ComponentInternalInstance, VNode, Ref } from 'vue'
import type { VNode, Ref } from 'vue'
import type { LCPMetricWithAttribution, INPMetricWithAttribution, CLSMetricWithAttribution } from 'web-vitals/attribution'
import type { HydrationMismatchPayload, LocalHydrationMismatch } from './hydration/types'

declare global {
interface Window {
Expand All @@ -19,6 +20,7 @@ declare global {
__vnode?: VNode
}
}

declare module '#app' {
interface RuntimeNuxtHooks {
'hints:scripts:added': (script: HTMLScriptElement) => void
Expand All @@ -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<LCPMetricWithAttribution[]>
inp: Ref<INPMetricWithAttribution[]>
Expand All @@ -45,4 +47,10 @@ declare module '#app' {
}
}

declare module 'nitropack' {
interface NitroRuntimeHooks {
'hints:hydration:mismatch': (payload: HydrationMismatchPayload) => void
}
}

export {}
Loading