Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
23473bd
MSD: Refactor interim omnibar to hooks-based data flow
StevenDufresne Apr 8, 2026
379d40a
MSD: Trim historical comments from interim omnibar container
StevenDufresne Apr 8, 2026
ca9e729
MSD: Add tests for useInterimOmnibarData
StevenDufresne Apr 8, 2026
a658691
MSD: Tighten interim omnibar tests
StevenDufresne Apr 8, 2026
f2a4eaf
MSD: Extract useInterimOmnibarData into its own file
StevenDufresne Apr 8, 2026
b64e6e0
Revert "Temporary turn on omnibar."
StevenDufresne Apr 8, 2026
b03137c
MSD: Remove interim omnibar data hook tests
StevenDufresne Apr 9, 2026
c2d9a2f
MSD: Server-render the interim omnibar in the user's language
StevenDufresne Apr 8, 2026
d000f5e
MSD: Share a single locale fetch between SSR and hydration
StevenDufresne Apr 9, 2026
7d46df5
MSD: Reset defaultI18n on switch to English in loadUserLocale
StevenDufresne Apr 9, 2026
7275998
MSD: Use resetLocaleData in loadUserLocale so switches replace cleanly
StevenDufresne Apr 9, 2026
93d0b03
MSD: Convert dashboard-i18n middleware to TypeScript
StevenDufresne Apr 9, 2026
3dcba1b
MSD: Match server's locale derivation in getUserLanguage
StevenDufresne Apr 9, 2026
77a9cc0
MSD: Reset defaultI18n and clear cache on failed locale fetch
StevenDufresne Apr 9, 2026
a069dd7
MSD: Replace defaultI18n contents per-request in dashboard-i18n middl…
StevenDufresne Apr 9, 2026
e6fde86
MSD: Split loadUserLocale into pure fetch + caller-owned apply
StevenDufresne Apr 9, 2026
3c4021c
MSD: Collapse redundant resetLocaleData branching
StevenDufresne Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 16 additions & 49 deletions client/dashboard/app/i18n.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,8 @@
import { defaultI18n, type I18n, type LocaleData } from '@wordpress/i18n';
import { defaultI18n, type I18n } from '@wordpress/i18n';
import { I18nProvider as WPI18nProvider } from '@wordpress/react-i18n';
import { useEffect, useState, type PropsWithChildren } from 'react';
import { useAuth } from './auth';

async function fetchLocaleData(
language: string,
signal: AbortSignal
): Promise< [ string, LocaleData | undefined ] > {
if ( language === 'en' ) {
return [ language, undefined ];
}

try {
const response = await fetch(
`https://widgets.wp.com/languages/calypso/${ language }-v1.1.json`,
{ signal }
);

return [ language, await response.json() ];
} catch ( error ) {
// Only fall back to `en` when the error is not an abort
if ( error instanceof Error && error.name === 'AbortError' ) {
throw error;
}

// Fall back to `en` when fetching the language data fails. Without this
// the i18n provider would be stuck forever in a non-loaded state.
return [ 'en', undefined ];
}
}
import { getUserLanguage, loadUserLocaleData } from './shared-locale-loader';

function getHtmlLangAttribute( i18n: I18n, fallback: string ) {
// translation of this string contains the desired HTML attribute value
Expand Down Expand Up @@ -145,12 +119,7 @@ async function switchWebpackCSS( isRTL: boolean ) {
// slug in the route path.
function useLocaleSlug() {
const { user } = useAuth();
type ComputedAttributes = {
localeSlug?: string;
localeVariant?: string;
};
const u = user as typeof user & ComputedAttributes;
return u.localeVariant || u.localeSlug || user.locale_variant || user.language || 'en';
return getUserLanguage( user );
}

export function I18nProvider( { children }: PropsWithChildren ) {
Expand All @@ -160,23 +129,21 @@ export function I18nProvider( { children }: PropsWithChildren ) {
const i18n = defaultI18n;

useEffect( () => {
const abortController = new AbortController();

fetchLocaleData( language, abortController.signal )
.then( ( [ realLanguage, data ] ) => {
i18n.resetLocaleData( data );
// `realLanguage` can be different from `language` when loading language data fails
// and it falls back to `en`.
setLoadedLocale( realLanguage );
setLocaleInDOM( i18n, realLanguage );
switchWebpackCSS( i18n.isRTL() );
} )
.catch( () => {
// Ignore abort errors as they are expected during cleanup
} );
let cancelled = false;

loadUserLocaleData( language ).then( ( data ) => {
if ( cancelled ) {
return;
}
i18n.resetLocaleData( data );
const realLanguage = data ? language : 'en';
setLoadedLocale( realLanguage );
setLocaleInDOM( i18n, realLanguage );
switchWebpackCSS( i18n.isRTL() );
} );

return () => {
abortController.abort();
cancelled = true;
};
}, [ i18n, language ] );

Expand Down
65 changes: 13 additions & 52 deletions client/dashboard/app/interim-omnibar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import {
queryClient,
rawUserPreferencesQuery,
siteByIdQuery,
userPreferenceQuery,
} from '@automattic/api-queries';
import { QueryObserver } from '@tanstack/react-query';
import { defaultI18n } from '@wordpress/i18n';
import { hydrateRoot } from 'react-dom/client';
import { AUTH_QUERY_KEY, initializeCurrentUser } from '../auth';
import { getUserLanguage, loadUserLocaleData } from '../shared-locale-loader';
import type { OmnibarEvents } from './click-handlers';
import type { UserPreferences } from '@automattic/api-core';

export default async function loadOmnibar( events: OmnibarEvents ) {
const container = document.getElementById( 'wpcom-omnibar' );
Expand All @@ -34,51 +27,19 @@ export default async function loadOmnibar( events: OmnibarEvents ) {
events.linkClick.emit( { href, event } );
} );

const [ { InterimOmnibar }, user ] = await Promise.all( [
import( './interim-omnibar' ),
queryClient.fetchQuery( { queryKey: AUTH_QUERY_KEY, queryFn: initializeCurrentUser } ),
// Apply the user's locale to `defaultI18n` before hydrating so the first
// client render matches the SSR-translated HTML. `getUserLanguage` mirrors
// the server's `setUpLoggedInRoute` derivation so both sides agree on the
// effective locale.
const [ { InterimOmnibarContainer }, localeData ] = await Promise.all( [
import( './interim-omnibar-container' ),
loadUserLocaleData( getUserLanguage( window.currentUser ?? null ) ),
] );

// Hydrate matching the SSR output: user when bootstrapped, null when not.
const root = hydrateRoot(
defaultI18n.resetLocaleData( localeData );

hydrateRoot(
container,
<InterimOmnibar
user={ window.currentUser ?? null }
site={ null }
currentRoute={ window.location.pathname }
/>
<InterimOmnibarContainer initialUser={ window.currentUser ?? null } events={ events } />
);

const handleToggleMenu = () => events.mobileMenu.emit();
const handleToggleNotifications = () => events.notifications.emit();

async function renderWithSiteId( siteId: number | undefined ) {
const site = siteId ? await queryClient.ensureQueryData( siteByIdQuery( siteId ) ) : null;
root.render(
<InterimOmnibar
user={ user }
site={ site }
currentRoute={ window.location.pathname }
onToggleMenu={ handleToggleMenu }
onToggleNotifications={ handleToggleNotifications }
/>
);
}

// Render with the initial recent site (or primary blog as fallback).
const recentSites = queryClient.getQueryData< UserPreferences >(
rawUserPreferencesQuery().queryKey
)?.recentSites;
const initialSiteId = recentSites?.[ 0 ] || user.primary_blog;
renderWithSiteId( initialSiteId );

// Re-render whenever recentSites changes (e.g. user navigates to a different site).
let currentSiteId = initialSiteId;
new QueryObserver( queryClient, userPreferenceQuery( 'recentSites' ) ).subscribe( ( result ) => {
const siteId = result.data?.[ 0 ] || user.primary_blog;
if ( siteId !== currentSiteId ) {
currentSiteId = siteId;
renderWithSiteId( siteId );
}
} );
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { queryClient } from '@automattic/api-queries';
import { QueryClientProvider } from '@tanstack/react-query';
import { InterimOmnibar } from './interim-omnibar';
import { useInterimOmnibarData } from './use-interim-omnibar-data';
import type { OmnibarEvents } from './click-handlers';
import type { User } from '@automattic/api-core';

interface InterimOmnibarContainerProps {
initialUser: User | null;
events: OmnibarEvents;
}

function InterimOmnibarDataProvider( props: InterimOmnibarContainerProps ) {
const data = useInterimOmnibarData( props );
return <InterimOmnibar { ...data } />;
}

export function InterimOmnibarContainer( props: InterimOmnibarContainerProps ) {
return (
<QueryClientProvider client={ queryClient }>
<InterimOmnibarDataProvider { ...props } />
</QueryClientProvider>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { siteByIdQuery, userPreferenceQuery } from '@automattic/api-queries';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useState } from 'react';
import { AUTH_QUERY_KEY, initializeCurrentUser } from '../auth';
import type { OmnibarEvents } from './click-handlers';
import type { Site, User } from '@automattic/api-core';

interface UseInterimOmnibarDataOptions {
initialUser: User | null;
events: OmnibarEvents;
}

export interface InterimOmnibarData {
user: User | null;
site: Site | null;
currentRoute: string;
onToggleMenu?: () => void;
onToggleNotifications?: () => void;
}

/**
* Provides the props for `InterimOmnibar`. The first render mirrors the SSR
* output exactly (`user = initialUser`, `site = null`, no callbacks) so
* hydration succeeds; after hydration commits, the hook switches to
* query-driven data.
*/
export function useInterimOmnibarData( {
initialUser,
events,
}: UseInterimOmnibarDataOptions ): InterimOmnibarData {
const [ hydrated, setHydrated ] = useState( false );
useEffect( () => {
setHydrated( true );
}, [] );

const { data: user } = useQuery( {
queryKey: AUTH_QUERY_KEY,
queryFn: initializeCurrentUser,
initialData: initialUser ?? undefined,
enabled: hydrated,
} );

const { data: recentSites } = useQuery( {
...userPreferenceQuery( 'recentSites' ),
enabled: hydrated,
} );

const siteId = recentSites?.[ 0 ] || user?.primary_blog;

const { data: site = null } = useQuery( {
...siteByIdQuery( siteId ?? 0 ),
enabled: hydrated && !! siteId,
} );

const onToggleMenu = useCallback( () => events.mobileMenu.emit(), [ events ] );
const onToggleNotifications = useCallback( () => events.notifications.emit(), [ events ] );

if ( ! hydrated ) {
return {
user: initialUser,
site: null,
currentRoute: window.location.pathname,
};
}

return {
user: user ?? null,
site,
currentRoute: window.location.pathname,
onToggleMenu,
onToggleNotifications,
};
}
71 changes: 71 additions & 0 deletions client/dashboard/app/shared-locale-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { isTranslatedIncompletely } from '@automattic/i18n-utils';
import { type LocaleData } from '@wordpress/i18n';
import type { User } from '@automattic/api-core';

const dataPromises = new Map< string, Promise< LocaleData > >();

/**
* Derives the effective locale slug for a user. Mirrors the server's
* `setUpLoggedInRoute` logic so SSR and client produce the same value:
*
* - Falls back to English when the user has
* `use_fallback_for_incomplete_languages` enabled and their language
* is not fully translated.
* - Otherwise returns the bootstrap-provided `localeSlug` (preferred),
* or the REST `locale_variant` / `language` for non-bootstrapped
* sessions.
*
* The server's incompleteness check uses `localeVariant || localeSlug`,
* so we mirror that here.
*/
export function getUserLanguage( user: User | null | undefined ): string {
if ( ! user ) {
return 'en';
}

const slug = user.localeSlug || user.locale_variant || user.language;
if ( ! slug ) {
return 'en';
}

const checkAgainst = user.localeVariant || slug;
if ( user.use_fallback_for_incomplete_languages && isTranslatedIncompletely( checkAgainst ) ) {
return 'en';
}

return slug;
}

/**
* Fetches the user's locale JSON from the Calypso CDN and returns a cached
* promise per language, so concurrent callers share a single network
* request. Returns `undefined` for English (nothing to load) or on error.
*
* Pure cache + fetch — no singleton mutation. Callers are responsible for
* applying the result to `defaultI18n` (typically via `resetLocaleData`),
* gated on their own cancellation state.
*/
export function loadUserLocaleData( language: string ): Promise< LocaleData | undefined > {
if ( ! language || language === 'en' ) {
return Promise.resolve( undefined );
}

let dataPromise = dataPromises.get( language );
if ( ! dataPromise ) {
dataPromise = fetch( `https://widgets.wp.com/languages/calypso/${ language }-v1.1.json` ).then(
( response ) => {
if ( ! response.ok ) {
throw new Error( `Failed to load locale data for ${ language }` );
}
return response.json() as Promise< LocaleData >;
}
);
dataPromises.set( language, dataPromise );
}

return dataPromise.catch( () => {
// Drop the cached rejection so a later call can retry.
dataPromises.delete( language );
return undefined;
} );
}
48 changes: 48 additions & 0 deletions client/server/dashboard-i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// eslint-disable-next-line import/no-nodejs-modules
import { readFile } from 'fs/promises';
import { defaultI18n, type LocaleData } from '@wordpress/i18n';
import { type Request, type RequestHandler } from 'express';
import getAssetFilePath from 'calypso/lib/get-asset-file-path';
import { getLanguageFile } from 'calypso/lib/i18n-utils/switch-locale';

const localeDataCache = new Map< string, LocaleData >();

type CalypsoRequest = Request & { context?: { lang?: string } };

/**
* Populates `defaultI18n` with the bootstrapped user's locale for the duration
* of the request's render, so the server-side render of the interim omnibar
* emits translated strings. Loads from `public/languages/` when available and
* falls back to the Calypso CDN otherwise.
*/
export const loadDashboardLocaleData: RequestHandler = ( req, res, next ) => {
const language = ( req as CalypsoRequest ).context?.lang;
if ( ! language || language === 'en' ) {
defaultI18n.resetLocaleData();
next();
return;
}

const apply = ( data: LocaleData ) => {
defaultI18n.resetLocaleData( data );
next();
};

const cached = localeDataCache.get( language );
if ( cached ) {
apply( cached );
return;
}

readFile( getAssetFilePath( `languages/${ language }-v1.1.json` ), 'utf-8' )
.then( ( raw ) => JSON.parse( raw ) as LocaleData )
.catch( () => getLanguageFile( language ) as Promise< LocaleData > )
.then( ( data ) => {
localeDataCache.set( language, data );
apply( data );
} )
.catch( () => {
defaultI18n.resetLocaleData();
next();
} );
};
Loading
Loading