Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
40 changes: 28 additions & 12 deletions apps/web/src/app/route-guards.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,44 @@
import { lazy } from "react";
import { use } from "react";
import type { ReactElement, ReactNode } from "react";
import { Navigate, useLocation } from "react-router-dom";

import { useTranslation } from "@/shared/i18n";

import { UploadRecoveryDialog } from "../features/files/upload-recovery/upload-recovery-dialog";
import type * as AppShell from "./app-shell";
import { useAppSession } from "./session-provider";

// The authenticated app shell (sidebar navigation, account/help menus, org
// chrome) only renders once a signed-in user clears the guards below. Loading
// it lazily keeps the whole shell subtree out of the entry chunk, so the
// public /login route — the cold-start page for first-time and
// logged-out visitors, where the shell never mounts — no longer pays to
// download it. Both wrappers pull the same "./app-shell" module, so they share
// one chunk and a signed-in visitor fetches it in parallel with the first route
// chunk (both are dynamic imports resolved after the same auth check).
const Layout = lazy(async () => {
const appShell = await import("./app-shell");
return { default: appShell.Layout };
});
const OrgLayout = lazy(async () => {
const appShell = await import("./app-shell");
return { default: appShell.OrgLayout };
});
// download it. Both wrappers share one cached "./app-shell" import, so switching
// between the App and Org layouts never suspends again after either shell loads.
type AppShellModule = typeof AppShell;

let loadedAppShell: AppShellModule | undefined;
let appShellPromise: Promise<AppShellModule> | undefined;

function loadAppShell(): Promise<AppShellModule> {
appShellPromise ??= import("./app-shell").then((appShell) => {
loadedAppShell = appShell;
return appShell;
});
return appShellPromise;
}

function Layout({ children }: RouteChildrenProps): ReactElement {
const appShell = loadedAppShell ?? use(loadAppShell());
const AppLayout = appShell.Layout;
return <AppLayout>{children}</AppLayout>;
}

function OrgLayout({ children }: RouteChildrenProps): ReactElement {
const appShell = loadedAppShell ?? use(loadAppShell());
const OrganizationLayout = appShell.OrgLayout;
return <OrganizationLayout>{children}</OrganizationLayout>;
}

interface RouteChildrenProps {
children: ReactNode;
Expand Down
81 changes: 76 additions & 5 deletions apps/web/src/app/route-registry.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { lazy } from "react";
import { createElement, use } from "react";
import type { ComponentType, ReactElement, ReactNode } from "react";
import { Navigate, useParams, useRoutes } from "react-router-dom";
import type { RouteObject } from "react-router-dom";
Expand All @@ -11,10 +11,20 @@ function lazyNamed<TName extends string>(
load: () => Promise<RouteModule<TName>>,
exportName: TName,
) {
return lazy(async () => {
const routeModule = await load();
return { default: routeModule[exportName] };
});
let loadedRoute: { default: ComponentType } | undefined;
let routePromise: Promise<{ default: ComponentType }> | undefined;
const preload = () => {
routePromise ??= load().then((routeModule) => {
loadedRoute = { default: routeModule[exportName] };
return loadedRoute;
});
return routePromise;
};
function PreloadableRoute(): ReactElement {
const route = loadedRoute ?? use(preload());
return createElement(route.default);
}
return Object.assign(PreloadableRoute, { preload });
}

function protectedRoute(element: ReactElement): ReactElement {
Expand Down Expand Up @@ -104,6 +114,67 @@ const OrgSettings = lazyNamed(
"OrgSettingsPage",
);

type PreloadableRoute = { preload(): Promise<unknown> };

// Intent prefetches include both nested layouts and redirect destinations so a
// subsequent navigation can commit without entering the top-level Suspense
// fallback. Dynamic parameter routes are resolved immediately below.
const staticRoutePreloads: Record<string, PreloadableRoute[]> = {
"/": [AppOverview],
"/agent": [AgentList],
"/app-settings": [AppSettingsLayout, AppSettingsGeneral],
"/app-settings/cost": [AppSettingsLayout, AppUsage],
"/app-settings/general": [AppSettingsLayout, AppSettingsGeneral],
"/app-settings/usage": [AppSettingsLayout, AppUsage],
"/apps": [AppsList],
"/cli-auth": [CliAuth],
"/cost": [AppSettingsLayout, AppUsage],
"/deployments": [AppOverview],
"/environment": [Environments],
"/environments": [Environments],
"/files": [Files],
"/integrations": [SkillsTabRoute],
"/integrations/mcp": [McpTabRoute],
"/integrations/mcp/oauth-complete": [McpOAuthComplete],
"/integrations/skills": [SkillsTabRoute],
"/login": [Login],
"/mcp": [McpTabRoute],
"/onboarding": [Onboarding],
"/org/settings": [OrgSettings],
"/profile": [SettingsLayout, SettingsProfile],
"/providers": [Providers],
"/settings": [SettingsLayout, SettingsProfile],
"/settings/access-tokens": [SettingsLayout, SettingsAccessTokens],
"/settings/app": [SettingsLayout, AppSettingsLayout, AppSettingsGeneral],
"/settings/cost": [SettingsLayout, AppSettingsLayout, AppUsage],
"/settings/environments": [SettingsLayout, Environments],
"/settings/profile": [SettingsLayout, SettingsProfile],
"/settings/usage": [SettingsLayout, AppSettingsLayout, AppUsage],
"/skill": [SkillsTabRoute],
"/skills": [SkillsTabRoute],
"/threads": [Threads],
"/usage": [AppSettingsLayout, AppUsage],
"/v0-deploy-preview": [V0DeployPreview],
};

function dynamicRoutePreloads(pathname: string): PreloadableRoute[] {
if (/^\/environments?\/[^/]+$/.test(pathname)) {
return [Environments];
}
if (/^\/agent\/[^/]+$/.test(pathname)) {
return [AgentDetail];
}
if (/^\/threads\/[^/]+$/.test(pathname)) {
return [Threads];
}
return [];
}

export async function preloadRoute(pathname: string): Promise<void> {
const routes = staticRoutePreloads[pathname] ?? dynamicRoutePreloads(pathname);
await Promise.all(routes.map(async (route) => route.preload()));
}

const appRoutes = [
{
element: (
Expand Down
57 changes: 55 additions & 2 deletions apps/web/src/app/router.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,70 @@
import { Suspense } from "react";
import { Suspense, useEffect, useLayoutEffect } from "react";
import { useLocation } from "react-router-dom";

import { DocumentTitle } from "./document-title";
import { AppLoading } from "./route-guards";
import { AppRoutes } from "./route-registry";
import { AppRoutes, preloadRoute } from "./route-registry";

const appLoadingFallback = <AppLoading />;
const ROUTE_READY_MARK_PREFIX = "mosoo:route-ready:";
const ROUTE_PREFETCHED_MARK_PREFIX = "mosoo:route-prefetched:";

function RouteIntentPrefetcher() {
useEffect(() => {
const prefetchedAnchors = new WeakMap<HTMLAnchorElement, string>();
const prefetch = (event: Event) => {
if (!(event.target instanceof Element)) {
return;
}
const anchor = event.target.closest("a[href]");
if (!(anchor instanceof HTMLAnchorElement)) {
return;
}
const url = new URL(anchor.href, globalThis.location.href);
if (
url.origin !== globalThis.location.origin ||
prefetchedAnchors.get(anchor) === url.pathname
) {
return;
}
prefetchedAnchors.set(anchor, url.pathname);
void preloadRoute(url.pathname).then(
() => performance.mark(`${ROUTE_PREFETCHED_MARK_PREFIX}${url.pathname}`),
() => {
prefetchedAnchors.delete(anchor);
},
);
};

document.addEventListener("focusin", prefetch);
document.addEventListener("pointerover", prefetch);
return () => {
document.removeEventListener("focusin", prefetch);
document.removeEventListener("pointerover", prefetch);
};
}, []);

return null;
}

function RouteReadyMarker() {
const location = useLocation();

useLayoutEffect(() => {
performance.mark(`${ROUTE_READY_MARK_PREFIX}${location.pathname}${location.search}`);
}, [location.pathname, location.search]);

return null;
}

export function App() {
return (
<>
<DocumentTitle />
<RouteIntentPrefetcher />
<Suspense fallback={appLoadingFallback}>
<AppRoutes />
<RouteReadyMarker />
</Suspense>
</>
);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ if (!container) {
}

// Initialise i18n before rendering so the first paint already uses the
// correct locale. The init promise resolves synchronously when resources
// are bundled (no async chunk load), so this does not delay the render.
// correct locale. English stays in the entry chunk; other locales load only
// their selected catalog instead of making every page download all catalogs.
void initI18n().then(() => {
createRoot(container).render(
<StrictMode>
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/shared/i18n/catalogs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { SupportedLocale } from "./locales";
import en from "./translations/en.json";

export type TranslationValue = string | Record<string, unknown>;
export type TranslationTree = Record<string, TranslationValue>;

const loadedCatalogs = new Map<SupportedLocale, TranslationTree>([["en", en]]);
const catalogLoaders: Record<Exclude<SupportedLocale, "en">, () => Promise<TranslationTree>> = {
ja: async () => (await import("./translations/ja.json")).default,
"zh-CN": async () => (await import("./translations/zh-CN.json")).default,
"zh-TW": async () => (await import("./translations/zh-TW.json")).default,
};

export function getTranslationCatalog(locale: SupportedLocale): TranslationTree {
return loadedCatalogs.get(locale) ?? en;
}

export async function loadTranslationCatalog(locale: SupportedLocale): Promise<void> {
if (loadedCatalogs.has(locale) || locale === "en") {
return;
}

const load = catalogLoaders[locale];
loadedCatalogs.set(locale, await load());
}
10 changes: 6 additions & 4 deletions apps/web/src/shared/i18n/init.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { loadTranslationCatalog } from "./catalogs";
import { DEFAULT_LOCALE, resolveLocale } from "./locales";
import type { SupportedLocale } from "./locales";

/**
* Prepare the bundled translation resources before the first render.
* Prepare only the selected translation resource before the first render.
*/
export function initI18n(): Promise<void> {
export async function initI18n(): Promise<void> {
const locale = getCurrentLocale();
await loadTranslationCatalog(locale);
if (typeof document !== "undefined") {
document.documentElement.lang = getCurrentLocale();
document.documentElement.lang = locale;
}
return Promise.resolve();
}

/**
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/shared/i18n/locale-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function LocaleSwitcher({
key={locale}
className={cn("cursor-pointer rounded-md", locale === current && "font-medium")}
onClick={() => {
i18n.changeLanguage(locale);
void i18n.changeLanguage(locale);
}}
>
{LOCALE_DISPLAY_NAMES[locale]}
Expand Down
33 changes: 15 additions & 18 deletions apps/web/src/shared/i18n/provider.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,17 @@
import { createContext, useContext, useMemo, useState } from "react";
import { createContext, useContext, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";

import { getTranslationCatalog, loadTranslationCatalog } from "./catalogs";
import type { TranslationTree, TranslationValue } from "./catalogs";
import { DEFAULT_LOCALE, resolveLocale } from "./locales";
import type { SupportedLocale } from "./locales";
import en from "./translations/en.json";
import ja from "./translations/ja.json";
import zhCN from "./translations/zh-CN.json";
import zhTW from "./translations/zh-TW.json";

type TranslationValue = string | Record<string, unknown>;
type TranslationTree = Record<string, TranslationValue>;

type I18nContextValue = {
language: SupportedLocale;
changeLanguage: (language: SupportedLocale) => void;
changeLanguage: (language: SupportedLocale) => Promise<void>;
t: (key: string, variables?: Record<string, string>) => string;
};

const resources: Record<SupportedLocale, TranslationTree> = {
en,
ja,
"zh-CN": zhCN,
"zh-TW": zhTW,
};
const I18nContext = createContext<I18nContextValue | null>(null);

function lookup(tree: TranslationTree, key: string): string {
Expand All @@ -40,17 +29,25 @@ function detectInitialLocale(): SupportedLocale {

export function I18nProvider({ children }: { children: ReactNode }) {
const [language, setLanguage] = useState<SupportedLocale>(detectInitialLocale);
const requestedLanguageRef = useRef(language);
const value = useMemo<I18nContextValue>(
() => ({
language,
changeLanguage(next) {
async changeLanguage(next) {
requestedLanguageRef.current = next;
await loadTranslationCatalog(next);
if (requestedLanguageRef.current !== next) {
return;
}
setLanguage(next);
localStorage.setItem("mosoo-locale", next);
document.documentElement.lang = next;
},
t(key, variables) {
let text = lookup(resources[language], key);
if (text === key && language !== DEFAULT_LOCALE) text = lookup(resources.en, key);
let text = lookup(getTranslationCatalog(language), key);
if (text === key && language !== DEFAULT_LOCALE) {
text = lookup(getTranslationCatalog(DEFAULT_LOCALE), key);
}
return Object.entries(variables ?? {}).reduce(
(result, [name, replacement]) => result.replaceAll(`{{${name}}}`, replacement),
text,
Expand Down
18 changes: 18 additions & 0 deletions apps/web/tests/i18n-catalog-loading.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test";

import { getTranslationCatalog, loadTranslationCatalog } from "../src/shared/i18n/catalogs";

describe("translation catalog loading", () => {
test("keeps English available without an asynchronous catalog load", () => {
expect(getTranslationCatalog("en")["common"]).toBeDefined();
});

test("loads a selected non-default catalog on demand", async () => {
const englishCommon = getTranslationCatalog("en")["common"];

await loadTranslationCatalog("ja");

expect(getTranslationCatalog("ja")["common"]).toBeDefined();
expect(getTranslationCatalog("ja")["common"]).not.toEqual(englishCommon);
});
});
Loading
Loading