Skip to content

Commit 5ba8439

Browse files
authored
Merge pull request #1110 from objectstack-ai/copilot/fix-i18n-service-registration
2 parents 94c94c1 + b836c5c commit 5ba8439

File tree

6 files changed

+341
-4
lines changed

6 files changed

+341
-4
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515

1616
### Fixed
1717

18+
- **i18n Kernel Service `getTranslations` Returns Wrong Format** (`apps/console`): Fixed the `createKernel` i18n service registration where `getTranslations` returned a wrapped `{ locale, translations }` object instead of the flat `Record<string, any>` dictionary expected by the spec's `II18nService` interface. The HttpDispatcher wraps the service return value in `{ data: { locale, translations } }`, so the extra wrapping caused translations to be empty or double-nested in the API response (`/api/v1/i18n/translations/:lang`). The service now returns `resolveI18nTranslations(bundles, lang)` directly, aligning with the spec and making CRM business translations work correctly in all environments (MSW, server, dev).
19+
20+
- **i18n Translations Empty in Server Mode (`pnpm start`)** (`apps/console`): Fixed translations returning `{}` when running the real ObjectStack server on port 3000. The root cause was that the CLI's `isHostConfig()` function detects plugins with `init` methods in the config's `plugins` array and treats it as a "host config" — **skipping auto-registration of `AppPlugin`**. Without `AppPlugin`, the config's translations, objects, and seed data were never loaded. Additionally, the kernel's memory i18n fallback is only auto-registered in `validateSystemRequirements()` (after all plugin starts), too late for `AppPlugin.start()``loadTranslations()`. Fixed by: (1) explicitly adding `AppPlugin(sharedConfig)` to `objectstack.config.ts` plugins, and (2) adding `MemoryI18nPlugin` before it to register the i18n service during init phase. Also added a spec-format `translations` array to `objectstack.shared.ts` so `AppPlugin.loadTranslations()` can iterate and load the CRM locale bundles.
21+
22+
- **i18n Translations Empty in Root Dev Mode (`pnpm dev`)** (root `objectstack.config.ts`): Fixed translations returning `{}` when running `pnpm dev` from the monorepo root. The root config uses `objectstack dev``objectstack serve --dev` which loads the root `objectstack.config.ts` — but this config did not aggregate i18n bundles from the example stacks (CRM, Todo, etc.). The `composeStacks()` function doesn't handle the custom `i18n` field, so translation data was lost during composition. Fixed by: (1) aggregating i18n bundles from all plugin configs (same pattern as `objectstack.shared.ts`), (2) building a spec-format `translations` array passed to `AppPlugin(mergedApp)`, and (3) adding `MemoryI18nPlugin` to register the i18n service during init phase.
23+
1824
- **Duplicate Data in Calendar and Kanban Views** (`@object-ui/plugin-view`, `@object-ui/plugin-kanban`, `@object-ui/plugin-list`): Fixed a bug where Calendar and Kanban views displayed every record twice. The root cause was that `ObjectView` (plugin-view) unconditionally fetched data even when a `renderListView` callback was provided — causing parallel duplicate requests since `ListView` (plugin-list) independently fetches its own data. The duplicate fetch results triggered re-renders that destabilised child component rendering, leading to duplicate events in the calendar and duplicate cards on the kanban board. Additionally, `ObjectKanban` lacked proper external-data handling (`data`/`loading` props with `hasExternalData` guard), unlike `ObjectCalendar` which already had this pattern. Fixes: (1) `ObjectView` now skips its own fetch when `renderListView` is provided, (2) `ObjectKanban` now accepts explicit `data`/`loading` props and skips internal fetch when external data is supplied (matching `ObjectCalendar`'s pattern), (3) `ListView` now handles `{ value: [] }` OData response format consistently with `extractRecords` utility. Includes regression tests.
1925

2026
- **CI Build Fix: Replace dynamic `require()` with static imports** (`@object-ui/plugin-view`): Replaced module-level `require('@object-ui/react')` calls in `index.tsx` and `ObjectView.tsx` with static `import` statements. The dynamic `require()` pattern is not supported by Next.js Turbopack, causing the docs site build to fail during SSR prerendering of the `/docs/components/complex/view-switcher` page with `Error: dynamic usage of require is not supported`. Since `@object-ui/react` is already a declared dependency and other files in the package use static imports from it, replacing the `require()` with static imports is safe and eliminates the SSR compatibility issue.

apps/console/objectstack.config.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,64 @@ import * as HonoServerPluginPkg from '@objectstack/plugin-hono-server';
1616
import * as DriverMemoryPkg from '@objectstack/driver-memory';
1717
// @ts-ignore
1818
import * as RuntimePkg from '@objectstack/runtime';
19+
// @ts-ignore
20+
import * as CorePkg from '@objectstack/core';
1921

2022
const MSWPlugin = MSWPluginPkg.MSWPlugin || (MSWPluginPkg as any).default?.MSWPlugin || (MSWPluginPkg as any).default;
2123
const ObjectQLPlugin = ObjectQLPluginPkg.ObjectQLPlugin || (ObjectQLPluginPkg as any).default?.ObjectQLPlugin || (ObjectQLPluginPkg as any).default;
2224
const InMemoryDriver = DriverMemoryPkg.InMemoryDriver || (DriverMemoryPkg as any).default?.InMemoryDriver || (DriverMemoryPkg as any).default;
2325
const DriverPlugin = RuntimePkg.DriverPlugin || (RuntimePkg as any).default?.DriverPlugin || (RuntimePkg as any).default;
26+
const AppPlugin = RuntimePkg.AppPlugin || (RuntimePkg as any).default?.AppPlugin || (RuntimePkg as any).default;
2427
const HonoServerPlugin = HonoServerPluginPkg.HonoServerPlugin || (HonoServerPluginPkg as any).default?.HonoServerPlugin || (HonoServerPluginPkg as any).default;
28+
const createMemoryI18n = CorePkg.createMemoryI18n || (CorePkg as any).default?.createMemoryI18n;
2529

2630
import { ConsolePlugin } from './plugin';
2731

32+
/**
33+
* Lightweight plugin that registers the in-memory i18n service during the
34+
* init phase. This is critical for server mode (`pnpm start`) because:
35+
*
36+
* 1. AppPlugin.start() → loadTranslations() needs an i18n service.
37+
* 2. The kernel's own memory i18n fallback is auto-registered in
38+
* validateSystemRequirements() — which runs AFTER all plugin starts.
39+
* 3. Without an early-registered i18n service, loadTranslations() finds
40+
* nothing and silently skips — translations never get loaded.
41+
*
42+
* By registering the service during init (Phase 1), AppPlugin.start()
43+
* (Phase 2) finds it and loads the spec-format `translations` array.
44+
*
45+
* Name matches the CLI's dedup check so it won't attempt to also import
46+
* @objectstack/service-i18n.
47+
*/
48+
class MemoryI18nPlugin {
49+
readonly name = 'com.objectstack.service.i18n';
50+
readonly version = '1.0.0';
51+
readonly type = 'service' as const;
52+
53+
init(ctx: any) {
54+
const svc = createMemoryI18n();
55+
ctx.registerService('i18n', svc);
56+
}
57+
}
58+
59+
/**
60+
* Plugin ordering matters for server mode (`pnpm start`):
61+
*
62+
* The CLI's isHostConfig() detects that config.plugins contains objects with
63+
* init methods (ObjectQLPlugin, DriverPlugin, etc.) and treats this as a
64+
* "host config" — skipping auto-registration of AppPlugin.
65+
*
66+
* We therefore include AppPlugin explicitly so that:
67+
* - Objects/metadata are registered with the kernel
68+
* - Seed data is loaded into the in-memory driver
69+
* - Translations are loaded into the i18n service (via loadTranslations)
70+
*
71+
* MemoryI18nPlugin MUST come before AppPlugin so that the i18n service
72+
* exists when AppPlugin.start() → loadTranslations() runs.
73+
*/
2874
const plugins: any[] = [
75+
new MemoryI18nPlugin(),
76+
new AppPlugin(sharedConfig),
2977
new ObjectQLPlugin(),
3078
new DriverPlugin(new InMemoryDriver(), 'memory'),
3179
new HonoServerPlugin({ port: 3000 }),

apps/console/objectstack.shared.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,19 @@ const i18nBundles = allConfigs
3030
.map((c: any) => c.i18n)
3131
.filter((i: any) => i?.namespace && i?.translations);
3232

33+
// Build the spec `translations` array for the runtime's AppPlugin.
34+
// AppPlugin.loadTranslations expects `translations: Array<{ [locale]: data }>`.
35+
// Each locale's data is nested under the bundle's namespace so that
36+
// both the server-mode (AppPlugin → memory i18n) and MSW-mode (createKernel)
37+
// produce the same structure: `{ crm: { objects: { ... } } }`.
38+
const specTranslations: Record<string, any>[] = i18nBundles.map((bundle: any) => {
39+
const result: Record<string, any> = {};
40+
for (const [locale, data] of Object.entries(bundle.translations)) {
41+
result[locale] = { [bundle.namespace]: data };
42+
}
43+
return result;
44+
});
45+
3346
// Protocol-level composition via @objectstack/spec: handles object dedup,
3447
// array concatenation, actions→objects mapping, and manifest selection.
3548
const composed = composeStacks(allConfigs as any[], { objectConflict: 'override' }) as any;
@@ -97,7 +110,13 @@ export const sharedConfig = {
97110
},
98111
i18n: {
99112
bundles: i18nBundles,
113+
defaultLocale: 'en',
100114
},
115+
// Spec-format translations array consumed by AppPlugin.loadTranslations()
116+
// in real-server mode (pnpm start). Each entry maps locale → namespace-scoped
117+
// translation data so the runtime's memory i18n fallback serves the same
118+
// structure as the MSW mock handler.
119+
translations: specTranslations,
101120
plugins: [],
102121
datasources: [
103122
{
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/**
2+
* i18n Translations Pipeline Tests
3+
*
4+
* Validates that the i18n translation pipeline delivers CRM translations
5+
* correctly through all code paths:
6+
* - Kernel service: getTranslations returns flat dict (not wrapped)
7+
* - HttpDispatcher: wraps into standard spec envelope
8+
* - MSW handler: returns correct { data: { locale, translations } }
9+
*
10+
* Regression test for empty translations:{} response.
11+
*/
12+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
13+
import { setupServer } from 'msw/node';
14+
import { createKernel, type KernelResult } from '../mocks/createKernel';
15+
import { createAuthHandlers } from '../mocks/authHandlers';
16+
import appConfig from '../../objectstack.shared';
17+
import { crmLocales } from '@object-ui/example-crm';
18+
19+
// Expected values from the CRM i18n bundles — avoid hard-coding in assertions
20+
const EXPECTED_ZH_ACCOUNT_LABEL = crmLocales.zh.objects.account.label;
21+
const EXPECTED_EN_ACCOUNT_LABEL = crmLocales.en.objects.account.label;
22+
23+
describe('i18n translations pipeline', () => {
24+
let result: KernelResult;
25+
let server: ReturnType<typeof setupServer>;
26+
27+
beforeAll(async () => {
28+
result = await createKernel({
29+
appConfig,
30+
persistence: false,
31+
mswOptions: {
32+
enableBrowser: false,
33+
baseUrl: '/api/v1',
34+
logRequests: false,
35+
customHandlers: [
36+
...createAuthHandlers('/api/v1/auth'),
37+
],
38+
},
39+
});
40+
41+
const handlers = result.mswPlugin?.getHandlers() ?? [];
42+
server = setupServer(...handlers);
43+
server.listen({ onUnhandledRequest: 'bypass' });
44+
});
45+
46+
afterAll(() => {
47+
server?.close();
48+
});
49+
50+
// ── Kernel service layer ───────────────────────────────────────────
51+
52+
it('kernel i18n service returns flat translations dict (not wrapped)', () => {
53+
const i18nService = result.kernel.getService('i18n') as any;
54+
const translations = i18nService.getTranslations('zh');
55+
56+
// Must NOT have the { locale, translations } wrapper
57+
expect(translations).not.toHaveProperty('locale');
58+
expect(translations).toHaveProperty('crm');
59+
expect(translations.crm.objects.account.label).toBe(EXPECTED_ZH_ACCOUNT_LABEL);
60+
});
61+
62+
it('kernel i18n service returns English translations', () => {
63+
const i18nService = result.kernel.getService('i18n') as any;
64+
const translations = i18nService.getTranslations('en');
65+
66+
expect(translations.crm.objects.account.label).toBe(EXPECTED_EN_ACCOUNT_LABEL);
67+
});
68+
69+
it('kernel i18n service returns empty for unknown locale', () => {
70+
const i18nService = result.kernel.getService('i18n') as any;
71+
const translations = i18nService.getTranslations('xx');
72+
73+
expect(Object.keys(translations)).toHaveLength(0);
74+
});
75+
76+
// ── HttpDispatcher layer ───────────────────────────────────────────
77+
78+
it('HttpDispatcher returns populated translations in spec envelope', async () => {
79+
const { HttpDispatcher } = await import('@objectstack/runtime');
80+
const dispatcher = new HttpDispatcher(result.kernel);
81+
82+
const dispatchResult = await dispatcher.handleI18n('/translations/zh', 'GET', {}, {} as any);
83+
84+
expect(dispatchResult.handled).toBe(true);
85+
expect(dispatchResult.response?.status).toBe(200);
86+
const body = dispatchResult.response?.body;
87+
expect(body?.success).toBe(true);
88+
expect(body?.data?.locale).toBe('zh');
89+
expect(Object.keys(body?.data?.translations ?? {}).length).toBeGreaterThan(0);
90+
expect(body?.data?.translations?.crm?.objects?.account?.label).toBe(EXPECTED_ZH_ACCOUNT_LABEL);
91+
});
92+
93+
// ── MSW handler layer (fetch) ──────────────────────────────────────
94+
95+
it('GET /api/v1/i18n/translations/zh returns CRM zh translations', async () => {
96+
const res = await fetch('http://localhost/api/v1/i18n/translations/zh');
97+
expect(res.ok).toBe(true);
98+
99+
const json = await res.json();
100+
const translations = json?.data?.translations;
101+
102+
expect(translations).toBeDefined();
103+
expect(Object.keys(translations).length).toBeGreaterThan(0);
104+
expect(translations.crm).toBeDefined();
105+
expect(translations.crm.objects.account.label).toBe(EXPECTED_ZH_ACCOUNT_LABEL);
106+
});
107+
108+
it('GET /api/v1/i18n/translations/en returns CRM en translations', async () => {
109+
const res = await fetch('http://localhost/api/v1/i18n/translations/en');
110+
const json = await res.json();
111+
112+
expect(json?.data?.translations?.crm?.objects?.account?.label).toBe(EXPECTED_EN_ACCOUNT_LABEL);
113+
});
114+
115+
// ── Server-mode compatibility (AppPlugin.loadTranslations) ────────
116+
117+
it('kernel i18n service supports loadTranslations (AppPlugin compat)', () => {
118+
const i18nService = result.kernel.getService('i18n') as any;
119+
120+
// AppPlugin.loadTranslations calls these methods; they must exist
121+
expect(typeof i18nService.loadTranslations).toBe('function');
122+
expect(typeof i18nService.getLocales).toBe('function');
123+
expect(typeof i18nService.getDefaultLocale).toBe('function');
124+
expect(typeof i18nService.setDefaultLocale).toBe('function');
125+
});
126+
127+
it('kernel i18n service getLocales returns all CRM locales', () => {
128+
const i18nService = result.kernel.getService('i18n') as any;
129+
const locales = i18nService.getLocales();
130+
131+
// CRM declares 10 locales: en, zh, ja, ko, de, fr, es, pt, ru, ar
132+
expect(locales).toContain('en');
133+
expect(locales).toContain('zh');
134+
expect(locales.length).toBeGreaterThanOrEqual(10);
135+
});
136+
137+
it('appConfig.translations is spec-format array for AppPlugin', () => {
138+
const translations = (appConfig as any).translations;
139+
140+
expect(Array.isArray(translations)).toBe(true);
141+
expect(translations.length).toBeGreaterThan(0);
142+
143+
// Each entry maps locale → namespace-scoped data
144+
const first = translations[0];
145+
expect(first).toHaveProperty('zh');
146+
expect(first).toHaveProperty('en');
147+
// Data must be nested under namespace (e.g. 'crm')
148+
expect(first.zh).toHaveProperty('crm');
149+
expect(first.zh.crm.objects.account.label).toBe(EXPECTED_ZH_ACCOUNT_LABEL);
150+
expect(first.en.crm.objects.account.label).toBe(EXPECTED_EN_ACCOUNT_LABEL);
151+
});
152+
153+
// ── Server-mode flow simulation ───────────────────────────────────
154+
// Simulates the exact flow that `pnpm start` (CLI serve) uses:
155+
// 1. createMemoryI18n() registers the i18n service
156+
// 2. AppPlugin.loadTranslations() iterates config.translations
157+
// 3. HttpDispatcher.handleI18n() calls getTranslations(locale)
158+
159+
it('server-mode: memory i18n + AppPlugin loadTranslations produces populated response', async () => {
160+
// Import the same createMemoryI18n used by the MemoryI18nPlugin in objectstack.config.ts
161+
const { createMemoryI18n } = await import('@objectstack/core');
162+
const svc = createMemoryI18n();
163+
164+
// Simulate AppPlugin.loadTranslations() iterating the spec-format translations array
165+
const translations = (appConfig as any).translations;
166+
for (const bundle of translations) {
167+
for (const [locale, data] of Object.entries(bundle)) {
168+
if (data && typeof data === 'object') {
169+
svc.loadTranslations(locale, data as Record<string, unknown>);
170+
}
171+
}
172+
}
173+
174+
// After loading, getTranslations must return populated CRM data
175+
const zh = svc.getTranslations('zh') as any;
176+
expect(zh).toHaveProperty('crm');
177+
expect(zh.crm.objects.account.label).toBe(EXPECTED_ZH_ACCOUNT_LABEL);
178+
179+
const en = svc.getTranslations('en') as any;
180+
expect(en).toHaveProperty('crm');
181+
expect(en.crm.objects.account.label).toBe(EXPECTED_EN_ACCOUNT_LABEL);
182+
183+
// getLocales must list all loaded languages
184+
const locales = svc.getLocales();
185+
expect(locales).toContain('en');
186+
expect(locales).toContain('zh');
187+
expect(locales.length).toBeGreaterThanOrEqual(10);
188+
});
189+
});

apps/console/src/mocks/createKernel.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -332,11 +332,43 @@ export async function createKernel(options: KernelOptions): Promise<KernelResult
332332
const i18nBundles: I18nBundle[] = appConfig.i18n?.bundles ?? [];
333333

334334
if (i18nBundles.length > 0) {
335+
// Build a complete i18n service that satisfies both:
336+
// - HttpDispatcher.handleI18n (calls getTranslations, getLocales)
337+
// - AppPlugin.loadTranslations (calls loadTranslations, setDefaultLocale)
338+
// In MSW mode the custom handler serves translations directly, but the
339+
// kernel service is still needed for the broker shim and dispatcher paths.
340+
const appPluginTranslations = new Map<string, Record<string, unknown>>();
341+
let defaultLocale = 'en';
342+
335343
kernel.registerService('i18n', {
336-
getTranslations: (lang: string) => ({
337-
locale: lang,
338-
translations: resolveI18nTranslations(i18nBundles, lang),
339-
}),
344+
getTranslations: (lang: string) => {
345+
const resolved = resolveI18nTranslations(i18nBundles, lang);
346+
const extra = appPluginTranslations.get(lang);
347+
if (extra) {
348+
// Shallow merge: AppPlugin-loaded translations override bundle translations
349+
return { ...resolved, ...extra };
350+
}
351+
return resolved;
352+
},
353+
loadTranslations: (locale: string, data: Record<string, unknown>) => {
354+
const existing = appPluginTranslations.get(locale) ?? {};
355+
appPluginTranslations.set(locale, { ...existing, ...data });
356+
},
357+
getLocales: () => {
358+
// Collect all available locales from bundles + any loaded via loadTranslations
359+
const locales = new Set<string>();
360+
for (const bundle of i18nBundles) {
361+
for (const lang of Object.keys(bundle.translations)) {
362+
locales.add(lang);
363+
}
364+
}
365+
for (const lang of appPluginTranslations.keys()) {
366+
locales.add(lang);
367+
}
368+
return [...locales];
369+
},
370+
getDefaultLocale: () => defaultLocale,
371+
setDefaultLocale: (locale: string) => { defaultLocale = locale; },
340372
});
341373
}
342374

0 commit comments

Comments
 (0)