Skip to content

Commit 8611fb3

Browse files
committed
feat: SPA build translations
1 parent 16e42cf commit 8611fb3

3 files changed

Lines changed: 79 additions & 13 deletions

File tree

cmp/compiler/src/plugin/unplugin.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,10 @@ export const lingoUnplugin = createUnplugin<LingoPluginOptions>((options) => {
4343
name: "lingo-compiler",
4444
enforce: "pre", // Run before other plugins (especially before React plugin)
4545

46-
// Start translation server on build start
46+
// Start translation server on build start (dev mode only)
4747
async buildStart() {
48-
// Start translation server if not already running
49-
if (!globalServer) {
48+
// Only start translation server in development mode
49+
if (isDev && !globalServer) {
5050
globalServer = await startTranslationServer({
5151
startPort,
5252
onError: (err) => {
@@ -144,9 +144,7 @@ export const cacheDir = ${JSON.stringify(cacheDir)};`;
144144

145145
await processBuildTranslations({
146146
config,
147-
// Note: publicOutputPath can be set by users in their config
148-
// For Vite, this might be dist/public or public/translations
149-
// For now, we don't set it here - users can handle it in their pipeline
147+
publicOutputPath: "public/translations",
150148
});
151149
} catch (error) {
152150
logger.error("Build-time translation processing failed:", error);

cmp/compiler/src/react/shared/TranslationContext.tsx

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -165,15 +165,64 @@ function TranslationProvider__Prod({
165165
const [cookieConfig] = useState(customCookieConfig || defaultCookieConfig);
166166
// TODO (AleksandrSl 01/12/2025): Correctly provide default locale.
167167
const [locale, setLocaleState] = useState(initialLocale ?? "en");
168+
const [translations, setTranslations] =
169+
useState<Record<string, string>>(initialTranslations);
170+
const [isLoading, setIsLoading] = useState(false);
168171

169172
logger.debug(
170173
`TranslationProvider initialized with locale: ${locale}`,
171174
initialTranslations,
172175
);
173176

174177
/**
175-
* Change locale - triggers server re-render via router.refresh()
176-
* Following next-intl pattern: locale changes reload the page with new translations from server
178+
* Load translations from public/translations/{locale}.json
179+
* Lazy loads on-demand for SPAs
180+
*/
181+
const loadTranslations = useCallback(
182+
async (targetLocale: string) => {
183+
// If we already have initialTranslations (Next.js SSR), don't fetch
184+
if (Object.keys(initialTranslations).length > 0) {
185+
return;
186+
}
187+
188+
setIsLoading(true);
189+
try {
190+
const response = await fetch(`/translations/${targetLocale}.json`);
191+
if (!response.ok) {
192+
throw new Error(
193+
`Failed to load translations for ${targetLocale}: ${response.statusText}`,
194+
);
195+
}
196+
197+
const data = await response.json();
198+
// Translation files have format: { version, locale, entries: {...} }
199+
setTranslations(data.entries || data);
200+
logger.debug(
201+
`Loaded translations for ${targetLocale}:`,
202+
Object.keys(data.entries || data).length,
203+
);
204+
} catch (error) {
205+
logger.error(`Failed to load translations for ${targetLocale}:`, error);
206+
// Fallback to empty translations
207+
setTranslations({});
208+
} finally {
209+
setIsLoading(false);
210+
}
211+
},
212+
[initialTranslations],
213+
);
214+
215+
// Load translations on mount if not provided via initialTranslations
216+
useEffect(() => {
217+
if (Object.keys(initialTranslations).length === 0) {
218+
loadTranslations(locale);
219+
}
220+
}, []); // Only run on mount
221+
222+
/**
223+
* Change locale
224+
* - For Next.js SSR: triggers server re-render via router.refresh()
225+
* - For SPAs: lazy loads translations from /translations/{locale}.json
177226
*/
178227
const setLocale = useCallback(
179228
async (newLocale: string) => {
@@ -183,23 +232,26 @@ function TranslationProvider__Prod({
183232
// 2. Update local state for immediate UI feedback
184233
setLocaleState(newLocale);
185234

186-
// 3. Trigger server re-render - Server Components will fetch new translations
187-
// and pass them as initialTranslations prop, causing this component to re-render
235+
// 3a. Next.js pattern: Trigger server re-render
188236
if (router) {
189237
router.refresh();
190238
}
239+
// 3b. SPA pattern: Lazy load translations
240+
else {
241+
await loadTranslations(newLocale);
242+
}
191243
},
192-
[cookieConfig, router],
244+
[cookieConfig, router, loadTranslations],
193245
);
194246

195247
return (
196248
<TranslationContext.Provider
197249
value={{
198250
locale,
199251
setLocale,
200-
translations: initialTranslations,
252+
translations,
201253
registerHashes: () => {}, // No-op in production
202-
isLoading: false, // No loading state - translations come from server
254+
isLoading,
203255
sourceLocale,
204256
}}
205257
>

cmp/compiler/src/translation-server/translation-server.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
*/
1313

1414
import http from "http";
15+
import type { Socket } from "net";
1516
import { URL } from "url";
1617
import type { MetadataSchema, TranslationMiddlewareConfig } from "../types";
1718
import { getLogger } from "./logger";
@@ -55,6 +56,7 @@ export class TranslationServer {
5556
private onErrorCallback?: (error: Error) => void;
5657
private translationService: TranslationService | null = null;
5758
private metadata: MetadataSchema | null = null;
59+
private connections: Set<Socket> = new Set();
5860

5961
constructor(options: TranslationServerOptions) {
6062
this.config = options.config;
@@ -118,6 +120,14 @@ export class TranslationServer {
118120

119121
this.logger.debug(`Starting translation server on port ${port}`);
120122

123+
// Track connections for graceful shutdown
124+
this.server.on("connection", (socket) => {
125+
this.connections.add(socket);
126+
socket.once("close", () => {
127+
this.connections.delete(socket);
128+
});
129+
});
130+
121131
this.server.on("error", (error: NodeJS.ErrnoException) => {
122132
if (error.code === "EADDRINUSE") {
123133
// Port is in use, try next one
@@ -147,6 +157,12 @@ export class TranslationServer {
147157
return;
148158
}
149159

160+
// Destroy all active connections to prevent hanging
161+
for (const socket of this.connections) {
162+
socket.destroy();
163+
}
164+
this.connections.clear();
165+
150166
return new Promise((resolve, reject) => {
151167
this.server!.close((error) => {
152168
if (error) {

0 commit comments

Comments
 (0)