diff --git a/.changeset/safe-languages-wash.md b/.changeset/safe-languages-wash.md new file mode 100644 index 00000000000..5805500304e --- /dev/null +++ b/.changeset/safe-languages-wash.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-devtools': patch +--- + +Sanitize invalid browser locale values before query devtools use them for locale-sensitive formatting. diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index ed8eed4534e..b4fe8df0164 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -15,12 +15,14 @@ import { clsx as cx } from 'clsx' import { TransitionGroup } from 'solid-transition-group' import { Key } from '@solid-primitives/keyed' import { createResizeObserver } from '@solid-primitives/resize-observer' -import { DropdownMenu, RadioGroup } from '@kobalte/core' +import { DropdownMenu, RadioGroup, useLocale } from '@kobalte/core' import { Portal } from 'solid-js/web' import { tokens } from './theme' import { convertRemToPixels, displayValue, + formatDateTime, + formatTime, getMutationStatusColor, getQueryStatusColor, getQueryStatusColorByLabel, @@ -675,6 +677,7 @@ export const ContentView: Component = (props) => { setupQueryCacheSubscription() setupMutationCacheSubscription() let containerRef!: HTMLDivElement + const locale = useLocale() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -778,7 +781,7 @@ export const ContentView: Component = (props) => { item.options.mutationKey ? JSON.stringify(item.options.mutationKey) + ' - ' : '' - }${new Date(item.state.submittedAt).toLocaleString()}` + }${formatDateTime(item.state.submittedAt, locale.locale())}` return rankItem(value, props.localStore.mutationFilter || '') .passed }) @@ -1501,6 +1504,7 @@ const QueryRow: Component<{ query: Query }> = (props) => { } const MutationRow: Component<{ mutation: Mutation }> = (props) => { + const locale = useLocale() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -1580,9 +1584,10 @@ const MutationRow: Component<{ mutation: Mutation }> = (props) => { styles().selectedQueryRow, 'tsqd-query-row', )} - aria-label={`Mutation submitted at ${new Date( + aria-label={`Mutation submitted at ${formatDateTime( props.mutation.state.submittedAt, - ).toLocaleString()}`} + locale.locale(), + )}`} >
= (props) => { {JSON.stringify(props.mutation.options.mutationKey)} -{' '} - {new Date(props.mutation.state.submittedAt).toLocaleString()} + {formatDateTime(props.mutation.state.submittedAt, locale.locale())} @@ -1857,6 +1862,7 @@ const QueryStatus: Component = (props) => { } const QueryDetails = () => { + const locale = useLocale() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -2049,7 +2055,7 @@ const QueryDetails = () => {
Last Updated: - {new Date(activeQueryState()!.dataUpdatedAt).toLocaleTimeString()} + {formatTime(activeQueryState()!.dataUpdatedAt, locale.locale())}
@@ -2391,6 +2397,7 @@ const QueryDetails = () => { } const MutationDetails = () => { + const locale = useLocale() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -2491,9 +2498,7 @@ const MutationDetails = () => {
Submitted At: - {new Date( - activeMutation()!.state.submittedAt, - ).toLocaleTimeString()} + {formatTime(activeMutation()!.state.submittedAt, locale.locale())}
diff --git a/packages/query-devtools/src/DevtoolsComponent.tsx b/packages/query-devtools/src/DevtoolsComponent.tsx index 069d79c87a2..aec92432d61 100644 --- a/packages/query-devtools/src/DevtoolsComponent.tsx +++ b/packages/query-devtools/src/DevtoolsComponent.tsx @@ -1,7 +1,8 @@ import { createLocalStorage } from '@solid-primitives/storage' +import { I18nProvider } from '@kobalte/core' import { createMemo } from 'solid-js' import { Devtools } from './Devtools' -import { getPreferredColorScheme } from './utils' +import { createSafeLocale, getPreferredColorScheme } from './utils' import { THEME_PREFERENCE } from './constants' import { PiPProvider, QueryDevtoolsContext, ThemeContext } from './contexts' import type { Theme } from './contexts' @@ -13,6 +14,7 @@ const DevtoolsComponent: DevtoolsComponentType = (props) => { }) const colorScheme = getPreferredColorScheme() + const locale = createSafeLocale() const theme = createMemo(() => { const preference = (props.theme || @@ -26,7 +28,9 @@ const DevtoolsComponent: DevtoolsComponentType = (props) => { - + + + diff --git a/packages/query-devtools/src/DevtoolsPanelComponent.tsx b/packages/query-devtools/src/DevtoolsPanelComponent.tsx index cae641b44ad..67a9deedd77 100644 --- a/packages/query-devtools/src/DevtoolsPanelComponent.tsx +++ b/packages/query-devtools/src/DevtoolsPanelComponent.tsx @@ -1,7 +1,8 @@ import { createLocalStorage } from '@solid-primitives/storage' +import { I18nProvider } from '@kobalte/core' import { createMemo } from 'solid-js' import { ContentView, ParentPanel } from './Devtools' -import { getPreferredColorScheme } from './utils' +import { createSafeLocale, getPreferredColorScheme } from './utils' import { THEME_PREFERENCE } from './constants' import { PiPProvider, QueryDevtoolsContext, ThemeContext } from './contexts' import type { Theme } from './contexts' @@ -13,6 +14,7 @@ const DevtoolsPanelComponent: DevtoolsComponentType = (props) => { }) const colorScheme = getPreferredColorScheme() + const locale = createSafeLocale() const theme = createMemo(() => { const preference = (props.theme || @@ -30,14 +32,16 @@ const DevtoolsPanelComponent: DevtoolsComponentType = (props) => { setLocalStore={setLocalStore} > - - - + + + + + diff --git a/packages/query-devtools/src/__tests__/locale.test.ts b/packages/query-devtools/src/__tests__/locale.test.ts new file mode 100644 index 00000000000..1ff7a6e0940 --- /dev/null +++ b/packages/query-devtools/src/__tests__/locale.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { + formatDateTime, + formatTime, + getPreferredLocale, + sanitizeLocale, +} from '../utils' + +describe('locale utils', () => { + it('falls back to en-US when navigator.language is an invalid string', () => { + expect( + getPreferredLocale({ + language: 'undefined', + userLanguage: 'undefined', + }), + ).toBe('en-US') + }) + + it('keeps valid locale tags unchanged', () => { + expect( + getPreferredLocale({ + language: 'fr-FR', + }), + ).toBe('fr-FR') + }) + + it('sanitizes invalid locales before formatting dates', () => { + const value = new Date('2024-01-02T03:04:05.000Z') + + expect(formatDateTime(value, 'undefined')).toBe( + value.toLocaleString('en-US'), + ) + expect(() => formatDateTime(value, 'undefined')).not.toThrow() + }) + + it('sanitizes invalid locales before formatting times', () => { + const value = new Date('2024-01-02T03:04:05.000Z') + + expect(formatTime(value, 'undefined')).toBe( + value.toLocaleTimeString('en-US'), + ) + expect(() => formatTime(value, 'undefined')).not.toThrow() + }) + + it('falls back for empty locale values', () => { + expect(sanitizeLocale('')).toBe('en-US') + expect(sanitizeLocale(' ')).toBe('en-US') + }) +}) diff --git a/packages/query-devtools/src/utils.tsx b/packages/query-devtools/src/utils.tsx index 9a826cd21e3..025b5c6df69 100644 --- a/packages/query-devtools/src/utils.tsx +++ b/packages/query-devtools/src/utils.tsx @@ -3,6 +3,74 @@ import { createSignal, onCleanup, onMount } from 'solid-js' import type { Mutation, Query } from '@tanstack/query-core' import type { DevtoolsPosition } from './contexts' +const DEFAULT_LOCALE = 'en-US' + +type NavigatorWithUserLanguage = { + language?: string + userLanguage?: string +} + +export function sanitizeLocale( + locale: string | undefined, + fallback: string = DEFAULT_LOCALE, +) { + if (typeof locale !== 'string') { + return fallback + } + + const normalizedLocale = locale.trim() + + if (!normalizedLocale) { + return fallback + } + + try { + return ( + Intl.DateTimeFormat.supportedLocalesOf([normalizedLocale])[0] || fallback + ) + } catch { + return fallback + } +} + +export function getPreferredLocale( + navigatorValue?: NavigatorWithUserLanguage, +) { + return sanitizeLocale( + navigatorValue?.language || navigatorValue?.userLanguage, + ) +} + +export function createSafeLocale() { + const getLocale = () => + getPreferredLocale( + typeof navigator === 'undefined' + ? undefined + : (navigator as NavigatorWithUserLanguage), + ) + + const [locale, setLocale] = createSignal(getLocale()) + + onMount(() => { + const updateLocale = () => { + setLocale(getLocale()) + } + + window.addEventListener('languagechange', updateLocale) + onCleanup(() => window.removeEventListener('languagechange', updateLocale)) + }) + + return locale +} + +export function formatDateTime(value: string | number | Date, locale?: string) { + return new Date(value).toLocaleString(sanitizeLocale(locale)) +} + +export function formatTime(value: string | number | Date, locale?: string) { + return new Date(value).toLocaleTimeString(sanitizeLocale(locale)) +} + export function getQueryStatusLabel(query: Query) { return query.state.fetchStatus === 'fetching' ? 'fetching'