Skip to content

Commit 7d89c16

Browse files
Copilothotlong
andcommitted
fix(i18n): add MemoryI18nPlugin to fix timing issue in server mode
Root cause: In server mode (`pnpm start`), the kernel bootstrap sequence runs plugin starts BEFORE validateSystemRequirements(). The memory i18n fallback service is auto-registered in validateSystemRequirements() — AFTER AppPlugin.start() → loadTranslations() has already tried (and failed) to find the i18n service, silently skipping translation loading. Fix: Add a MemoryI18nPlugin to objectstack.config.ts that registers the in-memory i18n service during the init phase (Phase 1). Since ALL inits complete before ANY starts, the service is available when AppPlugin.start() runs in Phase 2. The plugin's name ('com.objectstack.service.i18n') matches the CLI's dedup check, preventing conflicts. Also added a server-mode simulation test that validates the full flow: createMemoryI18n → loadTranslations (AppPlugin-style) → getTranslations. All 746 console tests pass. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/8eb7854e-6a7b-4b91-bcca-6d28bff373a3
1 parent 0bee120 commit 7d89c16

File tree

3 files changed

+69
-1
lines changed

3 files changed

+69
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717

1818
- **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).
1919

20-
- **i18n Translations Empty in Server Mode (`pnpm start`)** (`apps/console`): Fixed translations returning `{}` when running the real ObjectStack server on port 3000. The runtime's `AppPlugin.loadTranslations()` looks for translations in a top-level `translations` array in the config, but the console's `sharedConfig` only provided them in the custom `i18n.bundles` format used by `createKernel.ts`. Added a `translations` array in the spec-expected format (each entry maps `{ [locale]: { namespace: data } }`) to `objectstack.shared.ts` so that `AppPlugin` can load them into the kernel's in-memory i18n fallback. Also expanded the i18n service registration in `createKernel.ts` to implement `loadTranslations()`, `getLocales()`, `getDefaultLocale()`, and `setDefaultLocale()` — methods required by both `AppPlugin` and `HttpDispatcher`.
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 a **timing issue in the kernel bootstrap sequence**: `AppPlugin.start()``loadTranslations()` runs during Phase 2 (start), but the kernel's memory i18n fallback is only auto-registered in `validateSystemRequirements()` which runs AFTER Phase 2 — so `loadTranslations()` found no i18n service and silently skipped loading. Fixed by adding a `MemoryI18nPlugin` to `objectstack.config.ts` that registers the in-memory i18n service during Phase 1 (init), ensuring it's available when `AppPlugin.start()` runs. Also added a spec-format `translations` array to `objectstack.shared.ts` so `AppPlugin.loadTranslations()` can iterate and load the CRM locale bundles. The plugin's name (`com.objectstack.service.i18n`) matches the CLI's deduplication check, preventing conflicts with `@objectstack/service-i18n` if installed later.
2121

2222
- **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.
2323

apps/console/objectstack.config.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,47 @@ 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;
2426
const HonoServerPlugin = HonoServerPluginPkg.HonoServerPlugin || (HonoServerPluginPkg as any).default?.HonoServerPlugin || (HonoServerPluginPkg as any).default;
27+
const createMemoryI18n = CorePkg.createMemoryI18n || (CorePkg as any).default?.createMemoryI18n;
2528

2629
import { ConsolePlugin } from './plugin';
2730

31+
/**
32+
* Lightweight plugin that registers the in-memory i18n service during the
33+
* init phase. This is critical for server mode (`pnpm start`) because:
34+
*
35+
* 1. The CLI auto-registers AppPlugin(config) before config plugins.
36+
* 2. During bootstrap, all plugin inits run first, then all starts.
37+
* 3. AppPlugin.start() → loadTranslations() needs the i18n service.
38+
* 4. The kernel's own memory fallback is registered in
39+
* validateSystemRequirements() which runs AFTER all starts — too late.
40+
*
41+
* By providing the service during init, AppPlugin.start() finds it and
42+
* loads the spec-format `translations` array from the config.
43+
*
44+
* Name matches the check in CLI's serve command so it won't attempt to
45+
* duplicate-register I18nServicePlugin from @objectstack/service-i18n.
46+
*/
47+
class MemoryI18nPlugin {
48+
readonly name = 'com.objectstack.service.i18n';
49+
readonly version = '1.0.0';
50+
readonly type = 'service' as const;
51+
52+
init(ctx: any) {
53+
const svc = createMemoryI18n();
54+
ctx.registerService('i18n', svc);
55+
}
56+
}
57+
2858
const plugins: any[] = [
59+
new MemoryI18nPlugin(),
2960
new ObjectQLPlugin(),
3061
new DriverPlugin(new InMemoryDriver(), 'memory'),
3162
new HonoServerPlugin({ port: 3000 }),

apps/console/src/__tests__/i18n-translations.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,41 @@ describe('i18n translations pipeline', () => {
149149
expect(first.zh.crm.objects.account.label).toBe(EXPECTED_ZH_ACCOUNT_LABEL);
150150
expect(first.en.crm.objects.account.label).toBe(EXPECTED_EN_ACCOUNT_LABEL);
151151
});
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');
176+
expect(zh).toHaveProperty('crm');
177+
expect(zh.crm.objects.account.label).toBe(EXPECTED_ZH_ACCOUNT_LABEL);
178+
179+
const en = svc.getTranslations('en');
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+
});
152189
});

0 commit comments

Comments
 (0)