You've already forked AstralRinth
feat: more o11y for i18n pojo (#5148)
This commit is contained in:
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
17
apps/frontend/src/plugins/intl-payload-safety.ts
Normal file
17
apps/frontend/src/plugins/intl-payload-safety.ts
Normal 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)
|
||||||
|
})
|
||||||
37
apps/frontend/src/plugins/payload-debugger.ts
Normal file
37
apps/frontend/src/plugins/payload-debugger.ts
Normal 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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user