From 5a40f03e0d6e92a338335bf00ffc291ea55e7099 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 00:23:36 +0000 Subject: [PATCH] fix(collaboration,console): two more dates read the display locale, not the UI language CommentThread's absolute date for comments seven or more days old, and the approvals inbox's date for timestamps 30 or more days old, formatted with the UI language. Both now take their tag from useDisplayLocale(), so a regional display locale (de-CH under an English UI) reaches them. The relative buckets stay on the translation function and the UI language. The machine-locale census keeps its CommentThread lit control: the bare fallback call in formatAbsoluteDate's catch is unchanged, and only its reason text now names the display locale its try passes. Refs objectui#10375. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C --- ...-comment-and-inbox-dates-display-locale.md | 19 ++ ...valsInboxPage.displayLocale-10375.test.tsx | 179 ++++++++++++++++++ .../src/pages/system/ApprovalsInboxPage.tsx | 9 +- packages/collaboration/src/CommentThread.tsx | 49 +++-- ...mment-thread-display-locale-10375.test.tsx | 136 +++++++++++++ ...mment-thread-no-provider-fallback.test.tsx | 46 ++++- .../machineLocaleCensus-9909.test.ts | 7 +- 7 files changed, 416 insertions(+), 29 deletions(-) create mode 100644 .changeset/10375-comment-and-inbox-dates-display-locale.md create mode 100644 apps/console/src/pages/system/ApprovalsInboxPage.displayLocale-10375.test.tsx create mode 100644 packages/collaboration/src/__tests__/comment-thread-display-locale-10375.test.tsx diff --git a/.changeset/10375-comment-and-inbox-dates-display-locale.md b/.changeset/10375-comment-and-inbox-dates-display-locale.md new file mode 100644 index 0000000000..af2e48f5ff --- /dev/null +++ b/.changeset/10375-comment-and-inbox-dates-display-locale.md @@ -0,0 +1,19 @@ +--- +'@object-ui/collaboration': patch +'@object-ui/console': patch +--- + +Two more dates now follow the display locale, not the UI language (objectui#10375). + +- `CommentThread` (`@object-ui/collaboration`): a comment seven or more days old shows an absolute + date, and that date now takes its locale from `useDisplayLocale()`. It used to format with the UI + language, so a regional display locale was ignored: an English UI with a `de-CH` display locale + read `3/4/2020` where it now reads `4.3.2020`. With no display locale declared, and with no + provider mounted at all, the date renders as before, because the display locale then falls back + to the UI language. A malformed display locale still falls back to the runtime's own date format, + never to the raw ISO string. +- The approvals inbox (`@object-ui/console`): once a submitted, completed or history timestamp is + 30 days old (rounded to the nearest day), the inbox shows a date instead of "Nd ago", and that + date now takes the page's display locale in the same way. +- The relative wording ("just now", "5m ago", "3d ago") is unchanged on both faces and stays in the + UI language. diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.displayLocale-10375.test.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.displayLocale-10375.test.tsx new file mode 100644 index 0000000000..572e5744e1 --- /dev/null +++ b/apps/console/src/pages/system/ApprovalsInboxPage.displayLocale-10375.test.tsx @@ -0,0 +1,179 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The approvals inbox's past-30-days date reads the DISPLAY locale, never the + * UI language (objectui#10375). + * + * `formatRelative` answers in words up to 30 days ("3d ago") and with a date + * past that. The date was `new Date(s).toLocaleDateString(language)`, with + * `language` from `useObjectTranslation()`, so a regional display locale + * (`de-CH` under an English UI) never reached it. It now takes the page's + * `useDisplayLocale()` tag. + * + * The real `I18nProvider` runs with an ENGLISH UI in every case, and the + * display locale is declared through `LocalizationProvider`, so the display + * locale is the only thing that differs between the `de-CH` pin and the + * `en-US` control. A page that still passed `language` renders the English + * form under `de-CH` and goes red. `consoleFaces.displayLocale-9909.test.tsx` + * could not see this: it stubs `useObjectTranslation` to `'en'` and declares + * `en` as its control locale, so its two readings of this cell were equal. + * + * Each row is read from its queue cell, found by the cell's `title` tooltip: + * that is `formatDate` in the display locale, a face this card does not touch. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import { isMachineLocale, recordLocaleArgumentsAsync } from '@object-ui/test-support'; + +/** Noon UTC, so the calendar date is the same in every zone (the suite pins `TZ=UTC`). */ +const { OLD, RECENT, approvalsApiStub, ADAPTER, AUTH } = vi.hoisted(() => { + const OLD = '2020-03-04T12:00:00.000Z'; + // Three days before the page's clock, which it reads at mount: squarely in + // the "Nd ago" bucket, far from both of its edges. + const RECENT = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(); + const row = (id: string, submittedAt: string): Record => ({ + id, + process_name: 'purchase_approval', + process_label: 'Purchase Approval', + object_name: 'showcase_purchase', + object_label: 'Purchase', + record_id: `po_${id}`, + record_title: `PO ${id}`, + status: 'pending', + pending_approvers: ['u_1'], + submitter_id: 'u_2', + submitter_name: 'Sam Submitter', + submitted_at: submittedAt, + }); + const ROWS = [row('old', OLD), row('recent', RECENT)]; + const approvalsApiStub = { + listRequests: async () => ({ data: ROWS, total: ROWS.length }), + getRequest: async () => ({ data: ROWS[0] }), + listActions: async () => ({ data: [] }), + approve: async () => ({ data: ROWS[0], finalized: true }), + reject: async () => ({ data: ROWS[0], finalized: true }), + }; + const ADAPTER = { + find: async () => ({ data: [{ id: 'po_old' }, { id: 'po_recent' }] }), + getObjectSchema: async () => ({ fields: {} }), + }; + const AUTH = { user: { id: 'u_1', email: 'approver@example.com' } }; + return { OLD, RECENT, approvalsApiStub, ADAPTER, AUTH }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => { + const authFetch = async () => new Response('{}', { status: 200 }); + return { + ...(await importOriginal>()), + useAuth: () => AUTH, + createAuthenticatedFetch: () => authFetch, + TokenStorage: { get: () => null }, + }; +}); + +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => ADAPTER, + useMetadata: () => ({ objects: [] }), + DeclaredActionsBar: () => null, + isViaOverrideRow: () => false, +})); + +vi.mock('../../services/approvalsApi', async (importOriginal) => ({ + ...(await importOriginal>()), + approvalsApi: approvalsApiStub, +})); + +import { ApprovalsInboxPage } from './ApprovalsInboxPage'; + +afterEach(() => cleanup()); + +function permissions(): MePermissionsResponse { + return { + authenticated: true, + userId: 'u_1', + tenantId: 't_1', + roles: [], + permissionSets: [], + objects: {}, + fields: {}, + systemPermissions: ['setup.access'], + }; +} + +function renderInbox(locale: string) { + return render( + + + + + + } /> + + + + + , + ); +} + +/** The queue cell for a row submitted at `submittedAt`, under `locale` as the display locale. */ +async function queueCell(locale: string, submittedAt: string): Promise { + renderInbox(locale); + await screen.findAllByText('PO old'); + const title = new Date(submittedAt).toLocaleString(locale); + const cells = [...document.querySelectorAll('td[title]')].filter((el) => el.getAttribute('title') === title); + expect(cells, `no queue cell titled ${title}`).toHaveLength(1); + const text = (cells[0].textContent ?? '').trim(); + cleanup(); + return text; +} + +describe('ApprovalsInboxPage — the past-30-days date follows the display locale (objectui#10375)', () => { + it('formats as de-CH under an English UI with a de-CH display locale', async () => { + const text = await queueCell('de-CH', OLD); + expect(text, `got: ${text}`).toBe('4.3.2020'); + expect(text).toBe(new Date(OLD).toLocaleDateString('de-CH')); + }); + + it('control: formats as en-US under an en-US display locale', async () => { + const text = await queueCell('en-US', OLD); + expect(text, `got: ${text}`).toBe('3/4/2020'); + expect(text).toBe(new Date(OLD).toLocaleDateString('en-US')); + }); + + /** ⭐ THE PIN: no runner locale can make both readings equal. */ + it('is a reading of the session, not of the machine', async () => { + expect(await queueCell('de-CH', OLD)).not.toBe(await queueCell('en-US', OLD)); + }); + + it('every date the page formats receives the declared tag, never the machine locale', async () => { + const calls = await recordLocaleArgumentsAsync(async () => { + await queueCell('de-CH', OLD); + }); + const dates = calls.filter((c) => c.api === 'Date.prototype.toLocaleDateString'); + expect(dates.length, `saw: ${JSON.stringify(calls).slice(0, 2000)}`).toBeGreaterThan(0); + expect(dates.every((c) => c.locale === 'de-CH'), `saw: ${JSON.stringify(dates)}`).toBe(true); + expect(calls.filter(isMachineLocale), 'these call sites formatted in the machine locale').toEqual([]); + }); + + /** + * The buckets up to 30 days are WORDS, so they stay on `tr` and the UI + * language: a three-day-old request under an English UI reads the English + * bucket even with a `de-CH` display locale — not a date, and not German. + */ + it('leaves the relative buckets on the UI language', async () => { + expect(await queueCell('de-CH', RECENT)).toBe('3d ago'); + }); +}); diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.tsx index 415bb6f0a9..7869008b00 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.tsx @@ -635,7 +635,7 @@ function InlineActions({ } export function ApprovalsInboxPage() { - const { t, language } = useObjectTranslation(); + const { t } = useObjectTranslation(); // Every date and number face on this page is formatted in the display // locale; the module helpers above take it as a required argument. const displayLocale = useDisplayLocale(); @@ -738,8 +738,11 @@ export function ApprovalsInboxPage() { if (hr < 24) return tr('hoursAgo', '{{count}}h ago', { count: hr }); const day = Math.round(hr / 24); if (day < 30) return tr('daysAgo', '{{count}}d ago', { count: day }); - try { return new Date(s).toLocaleDateString(language); } catch { return s; } - }, [tr, language, now]); + // Past 30 days the face is a DATE, not words, so it takes the display + // locale, never the UI language `tr` speaks: a regional locale (`de-CH` + // under an English UI) must reach it (objectui#10375). + try { return new Date(s).toLocaleDateString(displayLocale); } catch { return s; } + }, [tr, displayLocale, now]); const statusLabel = useCallback((status: string): string => { switch (status) { diff --git a/packages/collaboration/src/CommentThread.tsx b/packages/collaboration/src/CommentThread.tsx index 8198415930..b3733ea1a9 100644 --- a/packages/collaboration/src/CommentThread.tsx +++ b/packages/collaboration/src/CommentThread.tsx @@ -7,6 +7,7 @@ */ import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'; +import { useDisplayLocale } from '@object-ui/i18n'; import { useCollaborationTranslation, type CollaborationTranslate, @@ -52,14 +53,22 @@ export interface CommentThreadProps { } /** - * Absolute date for the >= 7d bucket, in the session language (objectui#3441). + * Absolute date for the >= 7d bucket, in the session's DISPLAY locale — the tag + * `useDisplayLocale()` resolves, which the component hands in (objectui#10375). + * + * objectui#3441 first localized this branch with the session LANGUAGE, before + * the display-locale contract existed. That tag is the UI language, so a + * regional display locale never reached the date: an English UI with a `de-CH` + * display locale read `3/4/2020`, not `4.3.2020`. The display locale falls back + * to the UI language when the session declares no regional locale, so such a + * session renders exactly as it did under objectui#3441. * * Has its OWN try/catch, deliberately not sharing `formatTimestamp`'s. The two * catches recover from different things and must recover differently: * * - `formatTimestamp`'s outer catch is for an input it cannot make sense of, * and its only honest fallback is to echo the raw `iso` back. - * - a throw from here says nothing about the *date* — it says the LANGUAGE TAG + * - a throw from here says nothing about the *date* — it says the LOCALE TAG * is malformed. `Date.prototype.toLocaleDateString(tag)` runs the tag through * `CanonicalizeLocaleList`, which raises `RangeError` for anything not * structurally well-formed per BCP 47 (`'en_US'`, `''`, `'zh CN'`). A @@ -71,23 +80,24 @@ export interface CommentThreadProps { * raw `2026-08-01T09:30:00.000Z`, i.e. WORSE than the un-localized date it * replaced. Falling back to the no-argument call restores exactly the previous * behaviour (the runtime's own locale) for that path, so the worst case of - * following the session language is the status quo, never a regression. + * following the display locale is the status quo, never a regression. * * No date library, and no month/weekday copy in the locale packs: `Intl` is * already in the runtime and owns the per-locale ordering and separators. */ -function formatAbsoluteDate(date: Date, language: string): string { +function formatAbsoluteDate(date: Date, locale: string): string { try { - return date.toLocaleDateString(language); + return date.toLocaleDateString(locale); } catch { return date.toLocaleDateString(); } } /** - * Relative age of a comment, in the session language. + * Relative age of a comment: the words in the session language, the absolute + * date in the display locale. * - * `t` and `language` are threaded in as parameters rather than read from a + * `t` and `locale` are threaded in as parameters rather than read from a * hook: this runs once per rendered comment from inside `renderComment`, and * the buckets are unchanged — only the words moved into the locale packs. * Counts are interpolated as STRINGS on purpose, so i18next skips its own @@ -95,13 +105,15 @@ function formatAbsoluteDate(date: Date, language: string): string { * cannot silently start looking for `_one`/`_other` variants this repo does not * ship. * - * The >= 7d bucket follows the session language too (objectui#3441) — a `zh` - * session used to read "6 天前" for a six-day-old comment and `8/1/2026` for an - * eight-day-old one, because that branch called `toLocaleDateString()` with no - * argument and got the *runtime's* locale. See {@link formatAbsoluteDate} for - * why the tag gets its own guard instead of being handed straight in. + * The >= 7d bucket is a DATE, not words, so it takes the display locale + * (objectui#10375), not the language `t` speaks. It was first localized under + * objectui#3441 — a `zh` session used to read "6 天前" for a six-day-old + * comment and `8/1/2026` for an eight-day-old one, because that branch called + * `toLocaleDateString()` with no argument and got the *runtime's* locale. See + * {@link formatAbsoluteDate} for why the tag gets its own guard instead of + * being handed straight in. */ -function formatTimestamp(iso: string, t: CollaborationTranslate, language: string): string { +function formatTimestamp(iso: string, t: CollaborationTranslate, locale: string): string { try { const date = new Date(iso); const now = new Date(); @@ -113,7 +125,7 @@ function formatTimestamp(iso: string, t: CollaborationTranslate, language: strin if (hours < 24) return t('collaboration.hoursAgo', { count: String(hours) }); const days = Math.floor(hours / 24); if (days < 7) return t('collaboration.daysAgo', { count: String(days) }); - return formatAbsoluteDate(date, language); + return formatAbsoluteDate(date, locale); } catch { return iso; } @@ -401,7 +413,12 @@ export function CommentThread({ const [mentionIndex, setMentionIndex] = useState(0); const [sortOrder, setSortOrder] = useState<'newest' | 'oldest'>('oldest'); const inputRef = useRef(null); - const { t, language } = useCollaborationTranslation(); + const { t } = useCollaborationTranslation(); + // The >= 7d timestamp is a date, so it reads the DISPLAY locale, never the + // UI language: a regional locale (`de-CH` under an English UI) must reach it + // (objectui#10375). Provider-safe like `t`: with no provider mounted the + // hook answers the same UI-language tag this branch used before. + const displayLocale = useDisplayLocale(); const filteredMentions = useMemo(() => { if (mentionQuery === null) return []; @@ -545,7 +562,7 @@ export function CommentThread({ // Header React.createElement('div', { style: styles.commentHeader }, React.createElement('span', { style: styles.authorName }, comment.author.name), - React.createElement('span', { style: styles.timestamp }, formatTimestamp(comment.createdAt, t, language)), + React.createElement('span', { style: styles.timestamp }, formatTimestamp(comment.createdAt, t, displayLocale)), comment.updatedAt ? React.createElement('span', { style: styles.timestamp }, t('collaboration.edited')) : null, diff --git a/packages/collaboration/src/__tests__/comment-thread-display-locale-10375.test.tsx b/packages/collaboration/src/__tests__/comment-thread-display-locale-10375.test.tsx new file mode 100644 index 0000000000..2aeb011c18 --- /dev/null +++ b/packages/collaboration/src/__tests__/comment-thread-display-locale-10375.test.tsx @@ -0,0 +1,136 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `CommentThread`'s >= 7d timestamp reads the DISPLAY locale, never the UI + * language (objectui#10375). + * + * `formatAbsoluteDate` used to take the tag `useCollaborationTranslation()` + * reports, which is the UI language, so a regional display locale (`de-CH` + * under an English UI) never reached the date. The component now reads + * `useDisplayLocale()` and hands that in. + * + * The real `I18nProvider` runs with an ENGLISH UI in every case, and the + * display locale is declared through `LocalizationProvider`, so the display + * locale is the only thing that differs between the `de-CH` pin and the + * `en-US` control. A thread that still passed `language` renders the English + * form under `de-CH` and goes red. + * + * The provider-less reading lives in `comment-thread-no-provider-fallback.test.tsx` + * and cannot live here: this file mounts `I18nProvider`, which registers + * react-i18next's module-global instance (that file's header says why). + */ + +import type { ComponentProps } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import { CommentThread, type Comment } from '../CommentThread'; + +const alice = { id: 'u_alice', name: 'Alice Chen' }; + +/** Noon UTC, so the calendar date is the same in every zone (the suite pins `TZ=UTC`). */ +const OLD = '2020-03-04T12:00:00.000Z'; + +const comment = (id: string, createdAt: string): Comment => ({ + id, + author: alice, + content: `Comment ${id}.`, + mentions: [], + createdAt, +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function renderThread(locale: string, overrides: Partial> = {}) { + return render( + + + {}} + {...overrides} + /> + + , + ); +} + +/** The timestamp beside the author's name on comment `id`. */ +function timestampOf(id: string): string { + const header = document.querySelector(`[data-comment-id="${id}"]`); + const spans = Array.from(header?.querySelectorAll('span') ?? []); + const author = spans.findIndex((n) => n.textContent === alice.name); + return spans[author + 1]?.textContent ?? ''; +} + +/** The old comment's date, under an ENGLISH UI with `locale` as the display locale. */ +function oldDate(locale: string): string { + renderThread(locale); + const text = timestampOf('old'); + cleanup(); + return text; +} + +describe('CommentThread — the >= 7d date follows the display locale (objectui#10375)', () => { + it('formats as de-CH under an English UI with a de-CH display locale', () => { + const text = oldDate('de-CH'); + expect(text, `got: ${text}`).toBe('4.3.2020'); + expect(text).toBe(new Date(OLD).toLocaleDateString('de-CH')); + }); + + it('control: formats as en-US under an en-US display locale', () => { + const text = oldDate('en-US'); + expect(text, `got: ${text}`).toBe('3/4/2020'); + expect(text).toBe(new Date(OLD).toLocaleDateString('en-US')); + }); + + /** ⭐ THE PIN: no runner locale can make both readings equal. */ + it('is a reading of the session, not of the machine', () => { + expect(oldDate('de-CH')).not.toBe(oldDate('en-US')); + }); + + it('the date formatter receives the declared tag and nothing else', () => { + const spy = vi.spyOn(Date.prototype, 'toLocaleDateString'); + renderThread('de-CH'); + expect(spy.mock.calls.length).toBeGreaterThan(0); + expect(spy.mock.calls, `saw: ${JSON.stringify(spy.mock.calls)}`).toEqual( + spy.mock.calls.map(() => ['de-CH']), + ); + }); + + /** + * The relative buckets are WORDS, so they stay on `t` and the UI language: + * a three-day-old comment under an English UI reads the English bucket even + * with a `de-CH` display locale — not a date, and not German. + */ + it('leaves the relative buckets on the UI language', () => { + const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(); + renderThread('de-CH', { comments: [comment('recent', threeDaysAgo)] }); + expect(timestampOf('recent')).toBe('3d ago'); + }); + + /** + * The declared fallback `machineLocaleCensus-9909.test.ts` keeps as its lit + * control, reached through the new channel: a structurally malformed display + * locale makes `toLocaleDateString` throw `RangeError`, and the inner catch + * restores the runtime's own date instead of letting the raw ISO string reach + * the reader. + */ + it('falls back to the runtime locale on a malformed display locale, never to raw ISO', () => { + const { container } = renderThread('de_CH'); + expect(timestampOf('old')).toBe(new Date(OLD).toLocaleDateString()); + expect(container.textContent).not.toContain(OLD); + expect(screen.getByText('Send')).toBeTruthy(); + }); +}); diff --git a/packages/collaboration/src/__tests__/comment-thread-no-provider-fallback.test.tsx b/packages/collaboration/src/__tests__/comment-thread-no-provider-fallback.test.tsx index ade2023259..8db2ba9753 100644 --- a/packages/collaboration/src/__tests__/comment-thread-no-provider-fallback.test.tsx +++ b/packages/collaboration/src/__tests__/comment-thread-no-provider-fallback.test.tsx @@ -52,7 +52,7 @@ * cheapest way to keep that true is to not import it at all.) */ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { render, screen, cleanup, fireEvent } from '@testing-library/react'; import { CommentThread, type Comment } from '../CommentThread'; import { COLLAB_DEFAULT_TRANSLATIONS } from '../useCollaborationTranslation'; @@ -91,7 +91,10 @@ function renderBare(overrides: Record = {}) { ); } -afterEach(() => cleanup()); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); describe('CommentThread with no I18nProvider — English fallback (objectstack#5506)', () => { it('renders the header, sort control and resolve button in English', () => { @@ -208,11 +211,14 @@ describe('CommentThread with no I18nProvider — English fallback (objectstack#5 /** * objectui#3441 — with no provider the session language is whatever - * react-i18next reports (in practice `'en'`), and the >= 7d branch now hands - * that to `toLocaleDateString`. What must hold on this path is narrower than - * under a provider but is the part a standalone host would notice: a real - * formatted date, never the raw ISO string `formatTimestamp`'s outer catch - * would produce if a bad tag reached `Intl`. + * react-i18next reports (in practice `'en'`). The >= 7d branch hands + * `toLocaleDateString` the DISPLAY locale (objectui#10375), and with no + * provider that is the same tag: no `LocalizationProvider` answers, so + * `useDisplayLocale` falls through to the session language. What must hold + * on this path is narrower than under a provider but is the part a + * standalone host would notice: a real formatted date, never the raw ISO + * string `formatTimestamp`'s outer catch would produce if a bad tag reached + * `Intl`. */ it('formats a week-old comment as a date, not a raw ISO string', () => { const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); @@ -233,6 +239,32 @@ describe('CommentThread with no I18nProvider — English fallback (objectstack#5 expect(screen.getByText(eightDaysAgo.toLocaleDateString('en'))).toBeTruthy(); }); + /** + * objectui#10375 — what `useDisplayLocale()` answers with no provider mounted, + * read off the argument the date formatter receives: `'en'`, passed + * explicitly. It is never the machine locale (no argument, or `undefined`), + * so a standalone host's date does not depend on the machine it runs on. + */ + it('hands the week-old date an explicit tag, never the machine locale', () => { + const spy = vi.spyOn(Date.prototype, 'toLocaleDateString'); + renderBare({ + comments: [ + { + id: 'old', + author: alice, + content: 'From last week.', + mentions: [], + createdAt: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(), + }, + ], + }); + + expect(spy.mock.calls.length).toBeGreaterThan(0); + expect(spy.mock.calls, `saw: ${JSON.stringify(spy.mock.calls)}`).toEqual( + spy.mock.calls.map(() => ['en']), + ); + }); + /** * The failure mode this whole file exists to catch: a key wired into the * component but absent from the defaults map renders as its own dotted name. diff --git a/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts b/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts index a676486c5c..6fc2e2b89a 100644 --- a/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts +++ b/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts @@ -234,9 +234,10 @@ const DECLARED: Exemption[] = [ count: 1, verdict: 'deliberate fallback', reason: - 'the `catch` of `formatAbsoluteDate`, whose `try` already passes the session language: it fires only on the ' + - '`RangeError` a structurally malformed tag raises, and restores the pre-localisation face instead of letting ' + - 'the raw ISO string reach the reader.', + 'the `catch` of `formatAbsoluteDate`, whose `try` already passes the display locale (`useDisplayLocale()`, ' + + 'objectui#10375; the session language before that): it fires only on the `RangeError` a structurally ' + + 'malformed tag raises, and restores the pre-localisation face instead of letting the raw ISO string reach the ' + + 'reader.', }, { file: 'packages/fields/src/currency.ts',