diff --git a/src/apps/officer-web/lib/I18nBridge.tsx b/src/apps/officer-web/lib/I18nBridge.tsx index da9637fb..7c82d4be 100644 --- a/src/apps/officer-web/lib/I18nBridge.tsx +++ b/src/apps/officer-web/lib/I18nBridge.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import type { ReactNode } from 'react'; import type { TranslationMap } from 'i18n'; import { I18nProvider, localeDirection } from 'i18n'; +import { setNameRevision } from 'workspaces/react'; import { panelCatalogs } from 'officerdev'; import { uiCatalogs } from '@/components/locales'; import { useSettings } from 'state/useSettings'; @@ -40,6 +41,17 @@ export const I18nBridge = ({ children }: I18nBridgeProps) => { document.documentElement.dir = localeDirection(locale); }, [locale]); + /* + Panel names are thunks resolved during the framework's own render (AppRegistryEntry.name), and the + framework is not subscribed to this provider — it has no i18n dependency at all, deliberately. So + tell it that the words changed. It treats the value as opaque; that it happens to be a locale is + this file's business, not the framework's. + + Set during render rather than in an effect: an effect would leave the first paint after a locale + switch showing the previous language's titles. + */ + setNameRevision(locale); + return ( {children} diff --git a/src/workspaces/officerdev/src/locales/panelName.test.tsx b/src/workspaces/officerdev/src/locales/panelName.test.tsx index a07c6ec8..1f46d126 100644 --- a/src/workspaces/officerdev/src/locales/panelName.test.tsx +++ b/src/workspaces/officerdev/src/locales/panelName.test.tsx @@ -1,7 +1,8 @@ import { describe, expect, test } from 'bun:test'; -import { render, screen } from '@testing-library/react'; -import { I18nProvider, type TranslationMap } from 'i18n'; -import { resolveEntryName } from 'workspaces/react'; +import { act, render, screen } from '@testing-library/react'; +import { I18nProvider, activeLocale, type TranslationMap } from 'i18n'; +import { setActiveI18n } from 'i18n/standalone'; +import { resolveEntryName, setNameRevision, useNameRevision } from 'workspaces/react'; import { panelName } from './panelName'; const translations: TranslationMap = { @@ -9,8 +10,14 @@ const translations: TranslationMap = { 'pt-PT': { panel: { panelName: { fileBrowser: 'Explorador de ficheiros' } } }, }; -/** Stands in for AppPicker/PanelSlot: reads the name during ITS OWN render, exactly as they do. */ -const NameProbe = ({ entry }: { entry: { name: string | (() => string) } }) => {resolveEntryName(entry)}; +/** + * Stands in for AppPicker/PanelSlot: subscribes to the revision, then reads the name during its own + * render — exactly what those two do. + */ +const NameProbe = ({ entry }: { entry: { name: string | (() => string) } }) => { + useNameRevision(); + return {resolveEntryName(entry)}; +}; describe('panelName', () => { test('a plain string still works, so the 27 can migrate one at a time', () => { @@ -41,3 +48,31 @@ describe('panelName', () => { expect(screen.getByText('Explorador de ficheiros')).toBeDefined(); }); }); + +/** + * The gap the test above cannot see: it calls `rerender`, so it proves nothing is CACHED but not that + * anything asks for a new render. In the app, AppPicker and PanelSlot are not i18n consumers — a locale + * switch would update every string except the panel titles, which is the failure the revision closes. + * + * So: no rerender here. Only the revision moves, and the name has to follow on its own. + */ +describe('name revision', () => { + test('bumping the revision re-renders a name with no rerender from the test', () => { + setNameRevision('en-GB'); + const entry = { name: () => (activeLocale() === 'pt-PT' ? 'Explorador' : 'File Browser') }; + + render( + + + , + ); + expect(screen.getByText('File Browser')).toBeDefined(); + + // What I18nBridge does on a locale change, and nothing else. + act(() => { + setActiveI18n({ translations, locale: 'pt-PT', fallbackLocale: 'en-GB' }); + setNameRevision('pt-PT'); + }); + expect(screen.getByText('Explorador')).toBeDefined(); + }); +}); diff --git a/src/workspaces/workspaces/src/react/AppPicker.tsx b/src/workspaces/workspaces/src/react/AppPicker.tsx index 6d82b664..8752f82c 100644 --- a/src/workspaces/workspaces/src/react/AppPicker.tsx +++ b/src/workspaces/workspaces/src/react/AppPicker.tsx @@ -1,5 +1,6 @@ import type { AppRegistryMap } from './types'; import { resolveEntryName } from './types'; +import { useNameRevision } from './nameRevision'; type AppPickerProps = { registry: AppRegistryMap; @@ -7,6 +8,8 @@ type AppPickerProps = { }; export const AppPicker = ({ registry, onSelect }: AppPickerProps) => { + // Subscribes this component to the revision, so a name thunk is re-resolved when its words change. + useNameRevision(); const entries = Object.entries(registry).filter(([, entry]) => entry.availableOnPanel !== false); return ( diff --git a/src/workspaces/workspaces/src/react/PanelSlot.tsx b/src/workspaces/workspaces/src/react/PanelSlot.tsx index 92de9101..d94bb754 100644 --- a/src/workspaces/workspaces/src/react/PanelSlot.tsx +++ b/src/workspaces/workspaces/src/react/PanelSlot.tsx @@ -3,6 +3,7 @@ import { useCallback } from 'react'; import { ArrowLeftRight, ChevronLeft, X, Maximize2, Minimize2, ZoomIn, ZoomOut } from 'lucide-react'; import type { LayoutPanel, AppRegistryMap, PanelComponents, PanelComponentEntry } from './types'; import { resolveEntryName } from './types'; +import { useNameRevision } from './nameRevision'; import { useWorkspace } from './WorkspaceContext'; import { ZOOM_MIN, ZOOM_MAX, ZOOM_STEP } from '../core/layout'; import { Card } from './internal/Card'; @@ -312,6 +313,8 @@ export const PanelSlot = ({ onSplit, onRemove, }: PanelSlotProps) => { + // Subscribes this component to the revision, so a name thunk is re-resolved when its words change. + useNameRevision(); const { fullscreenPanelId, transitioningPanelId, isMobile, onMobileBack } = useWorkspace(); const isFullscreen = fullscreenPanelId === panel.id; diff --git a/src/workspaces/workspaces/src/react/index.ts b/src/workspaces/workspaces/src/react/index.ts index c4272fd8..365eac0c 100644 --- a/src/workspaces/workspaces/src/react/index.ts +++ b/src/workspaces/workspaces/src/react/index.ts @@ -13,6 +13,7 @@ export { WorkspaceLayout } from './WorkspaceLayout'; export { WorkspaceRenderer } from './WorkspaceRenderer'; export { PanelSlot } from './PanelSlot'; export { resolveEntryName } from './types'; +export { setNameRevision, useNameRevision } from './nameRevision'; export { WorkspaceProvider, useWorkspace, inertInteraction } from './WorkspaceContext'; export type { WorkspaceContextValue } from './WorkspaceContext'; export { usePanelConfig } from './usePanelConfig'; diff --git a/src/workspaces/workspaces/src/react/nameRevision.ts b/src/workspaces/workspaces/src/react/nameRevision.ts new file mode 100644 index 00000000..d20e7ab5 --- /dev/null +++ b/src/workspaces/workspaces/src/react/nameRevision.ts @@ -0,0 +1,42 @@ +import { useSyncExternalStore } from 'react'; + +/** + * An opaque revision, so a panel name re-renders when the thing that produced it changes. + * + * `AppRegistryEntry.name` may be a thunk, resolved during render rather than cached (see + * `resolveEntryName`). That is correct but not sufficient on its own: `AppPicker` and `PanelSlot` are + * not subscribed to whatever the thunk closes over, so nothing asks them to render again. A locale + * switch would update every string in the app except the panel titles. + * + * This package interprets the value in no way at all. It is a string that changed; the caller decides + * what changing means. officer-web sets it to the active locale, which is the only reason it exists — + * but the framework does not know that, and does not need a dependency on i18n to notice. + * + * A module-level store rather than a prop on `WorkspaceView`, deliberately: there are 28 call sites for + * that component, and a prop is a thing 28 places can forget. A forgotten one is a silently stale + * title, which is the exact failure this closes. + */ +let revision = ''; +const listeners = new Set<() => void>(); + +/** Call when the words behind a name thunk may have changed. Idempotent for an unchanged value. */ +export const setNameRevision = (next: string): void => { + if (next === revision) return; + revision = next; + for (const listener of listeners) listener(); +}; + +const subscribe = (listener: () => void): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +const read = (): string => revision; + +/** + * Subscribe to the revision. The return value is deliberately not useful — reading it is the point, + * because that is what makes the calling component re-render when it moves. + */ +export const useNameRevision = (): string => useSyncExternalStore(subscribe, read, read);