feat: more o11y for i18n pojo (#5148)

This commit is contained in:
Calum H.
2026-01-18 19:18:07 +00:00
committed by GitHub
parent 6efdfdf17e
commit a0e8c7f924
5 changed files with 76 additions and 36 deletions

View File

@@ -1,24 +0,0 @@
import { defineNuxtPlugin } from '#imports'
export default defineNuxtPlugin((nuxt) => {
if (import.meta.server) {
nuxt.hooks.hook('app:rendered', (ctx) => {
if (ctx.ssrContext?.payload?.data) {
const check = (obj: any, path = 'payload') => {
if (!obj || typeof obj !== 'object') return
if (
obj.constructor &&
obj.constructor.name !== 'Object' &&
obj.constructor.name !== 'Array'
) {
console.error(`Non-POJO at ${path}:`, obj.constructor.name)
}
for (const [k, v] of Object.entries(obj)) {
check(v, `${path}.${k}`)
}
}
check(ctx.ssrContext.payload.data)
}
})
}
})

View File

@@ -61,22 +61,26 @@ export default defineNuxtPlugin({
name: 'i18n', name: 'i18n',
enforce: 'pre', enforce: 'pre',
async setup(nuxtApp) { async setup(nuxtApp) {
// ONLY locale needs request-scoping (what language this request uses)
const locale = useState<string>('i18n-locale', () => DEFAULT_LOCALE) const locale = useState<string>('i18n-locale', () => DEFAULT_LOCALE)
function t(key: string, values?: Record<string, unknown>): string { function t(key: string, values?: Record<string, unknown>): string {
const msg = messageCache.get(locale.value)?.[key] ?? messageCache.get(DEFAULT_LOCALE)?.[key] const currentLocale = locale.value
const msg = messageCache.get(currentLocale)?.[key] ?? messageCache.get(DEFAULT_LOCALE)?.[key]
if (!msg) return key if (!msg) return key
if (!values || Object.keys(values).length === 0) return msg if (!values || Object.keys(values).length === 0) return msg
const cacheKey = `${locale.value}:${msg}` const cacheKey = `${currentLocale}:${msg}`
let formatter = formatterCache.get(cacheKey) let formatter = formatterCache.get(cacheKey)
if (!formatter) { if (!formatter) {
formatter = new IntlMessageFormat(msg, locale.value) formatter = new IntlMessageFormat(msg, currentLocale)
formatterCache.set(cacheKey, formatter) formatterCache.set(cacheKey, formatter)
} }
try { try {
return formatter.format(values) as string const result = formatter.format(values) as string
if (import.meta.dev && typeof result !== 'string') {
console.error('[i18n] t() returned non-string:', typeof result)
}
return result
} catch { } catch {
return msg return msg
} }

View File

@@ -0,0 +1,17 @@
export default definePayloadPlugin(() => {
definePayloadReducer('IntlMessageFormat', (value) => {
if (value?.constructor?.name === 'IntlMessageFormat' || value?._ast !== undefined) {
if (import.meta.dev) {
console.warn('[i18n] IntlMessageFormat instance leaked into payload - returning null')
console.warn('[i18n] This indicates a bug that should be fixed upstream')
console.warn('[i18n] Leaked value:', value)
}
return null
}
return false
})
definePayloadReviver('IntlMessageFormat', () => null)
})

View File

@@ -0,0 +1,37 @@
function findNonPOJOs(
obj: unknown,
path: string,
found: Array<{ path: string; type: string }> = [],
): Array<{ path: string; type: string }> {
if (obj === null || typeof obj !== 'object') return found
const proto = Object.getPrototypeOf(obj)
if (proto !== Object.prototype && proto !== null && !Array.isArray(obj)) {
found.push({ path, type: obj.constructor?.name ?? 'Unknown' })
}
for (const [key, value] of Object.entries(obj)) {
findNonPOJOs(value, `${path}.${key}`, found)
}
return found
}
export default defineNuxtPlugin((nuxtApp) => {
if (!import.meta.dev || !import.meta.server) return
nuxtApp.hooks.hook('app:rendered', () => {
try {
JSON.stringify(nuxtApp.payload)
} catch (e) {
console.error('[payload-debugger] Payload serialization would fail:', e)
const nonPOJOs = findNonPOJOs(nuxtApp.payload, 'payload')
if (nonPOJOs.length > 0) {
console.error('[payload-debugger] Non-POJO objects found in payload:')
for (const { path, type } of nonPOJOs) {
console.error(` - ${path}: ${type}`)
}
}
}
})
})

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import IntlMessageFormat, { type FormatXMLElementFn, type PrimitiveType } from 'intl-messageformat' import IntlMessageFormat, { type FormatXMLElementFn, type PrimitiveType } from 'intl-messageformat'
import { computed, useSlots, type VNode } from 'vue' import { computed, markRaw, useSlots, type VNode } from 'vue'
import type { MessageDescriptor } from '../../composables/i18n' import type { MessageDescriptor } from '../../composables/i18n'
import { injectI18n } from '../../providers/i18n' import { injectI18n } from '../../providers/i18n'
@@ -32,11 +32,13 @@ const formattedParts = computed(() => {
slotHandlers[normalizedName] = (chunks) => { slotHandlers[normalizedName] = (chunks) => {
const slot = slots[slotName] const slot = slots[slotName]
if (slot) { if (slot) {
return slot({ return markRaw(
children: chunks, slot({
}) children: chunks,
}),
) as VNode[]
} }
return chunks as VNode[] return markRaw(chunks) as VNode[]
} }
msg = msg.replace( msg = msg.replace(
@@ -52,10 +54,14 @@ const formattedParts = computed(() => {
...slotHandlers, ...slotHandlers,
}) })
// ensure result array items are marked as raw if they're VNodes
// prevents VNodes from entering the reactive system and SSR payload
if (Array.isArray(result)) { if (Array.isArray(result)) {
return result return result.map((part) =>
typeof part === 'object' && part !== null ? markRaw(part) : part,
)
} }
return [result] return [typeof result === 'object' && result !== null ? markRaw(result) : result]
} catch { } catch {
return [msg] return [msg]
} }