Skip to content
Open
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
55 changes: 39 additions & 16 deletions client/app/components/HydrationIssue.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
<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'
import { HYDRATION_ROUTE } from '../../../src/runtime/hydration/utils'

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).__file
: (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 @@ -51,6 +60,16 @@
function copy(text: string) {
navigator.clipboard?.writeText(text).catch(() => { })
}

function removeSelf() {
fetch(HYDRATION_ROUTE, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ id: [props.issue.id] }),
})
}
</script>

<template>
Expand All @@ -66,17 +85,10 @@
<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">
<div class="flex gap-2">
<n-button
v-if="element"
size="small"
quaternary
title="Open in editor"
Expand Down Expand Up @@ -111,12 +123,23 @@
/>
<span class="ml-1">Copy post</span>
</n-button>
<n-button
title="Remove"
size="small"
quaternary
@click="removeSelf"
>
<Icon
name="material-symbols:delete-outline"
class="text-lg"
/>
</n-button>
</div>
</div>

<div
class="w-full mt-3 overflow-auto rounded-lg"
v-html="diffHtml"

Check warning on line 142 in client/app/components/HydrationIssue.vue

View workflow job for this annotation

GitHub Actions / ci

'v-html' directive can lead to XSS attack
/>
</n-card>
</template>
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
33 changes: 33 additions & 0 deletions client/app/plugins/hydration.ts
Original file line number Diff line number Diff line change
@@ -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<HydrationMismatchResponse>(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,
},
}
})
11 changes: 7 additions & 4 deletions client/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
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 @@ -21,6 +22,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 @@ -30,12 +33,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).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.`)
}
Expand Down
68 changes: 68 additions & 0 deletions src/runtime/hydration/handler.nitro.ts
Original file line number Diff line number Diff line change
@@ -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<HydrationMismatchPayload>(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' })
}
}
26 changes: 26 additions & 0 deletions src/runtime/hydration/sse.nitro.ts
Original file line number Diff line number Diff line change
@@ -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()
})
Loading
Loading