From 26512ba1abe930988c209305e3c9d0da194aa3ee Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 11 Nov 2026 01:53:32 +0100 Subject: [PATCH 1/3] fix(i18n): ship the per-tab catalog sync in @simple-module-py/i18n and use it in the scaffold (#871) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- CHANGELOG.md | 1 + docs/framework/i18n.md | 4 +- .../2026-11-11-i18n-client-sync-design.md | 27 +++++++ .../templates/host/client_app/app.tsx | 36 +++------ host/client_app/app.tsx | 6 +- host/client_app/i18n.ts | 58 -------------- packages/i18n/README.md | 14 ++-- packages/i18n/package.json | 4 + .../i18n/src/inertia.test.ts | 33 ++++---- packages/i18n/src/inertia.ts | 76 +++++++++++++++++++ 10 files changed, 146 insertions(+), 113 deletions(-) create mode 100644 docs/superpowers/specs/2026-11-11-i18n-client-sync-design.md delete mode 100644 host/client_app/i18n.ts rename host/client_app/i18n.test.ts => packages/i18n/src/inertia.test.ts (83%) create mode 100644 packages/i18n/src/inertia.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 56985ef2d..d0873587d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to this project are documented in this file. The format is b ### Fixed +- Apps scaffolded with `smpy new` now keep a separate i18n catalog per tab, as this repo's host has done since #788: every Inertia visit sends `X-SM-I18n`. The logic moved from `host/client_app/i18n.ts` into `@simple-module-py/i18n/inertia` (`bootI18nFromInitialPage`, `subscribeI18nToNavigation(router)`), and the scaffold's `client_app/app.tsx` uses it. An existing app can replace its `router.on('success')` catalog handler with those two calls; without the header the server still falls back to the per-session markers (#871). - `host/alembic.ini`, both this repo's and the one `smpy new` scaffolds, sets `path_separator = os`. Alembic then splits `prepend_sys_path` and `version_locations` on the OS path separator. Without the setting, an app that adds either option gets a `DeprecationWarning` on every migration command, and a checkout path with a space is split in two. An existing app on Alembic 1.16 or newer can add the same line under `[alembic]` (#872). - The `simple-module-creating` and `simple-module-conventions` skills, which ship inside `simple_module_cli`, linked the module layout standard as `../../docs/module-layout.md`. That path does not exist in an installed CLI. They now link https://docs.py.simplemodule.dev/module-layout, and a test fails if a shipped skill links a file outside `skills/`. `docs/module-layout.md` gains a "Downstream modules" section on copying this repo's root-layout test (`ALLOWED_ROOT`, `DOMAIN_AREAS`) into an app or a standalone module repo (#875). - `smpy new` warns when the CLI running it is older than the latest `simple_module_cli` on PyPI. A new app pins the framework to the CLI's own version, and `uvx --from simple_module_cli` quietly reuses a copy installed with `uv tool install`, so an old tool used to scaffold an outdated app without saying so. The warning names both fixes: `uv tool upgrade simple_module_cli`, or `uvx --from simple_module_cli@latest`. The check is best effort, with a 3-second timeout; offline or on any PyPI error it is skipped, and it never blocks the scaffold. A CLI that is itself a pre-release, dev or local build is never warned. The README, the CLI and hosting READMEs and the landing page's copy command now use `uvx --from simple_module_cli@latest`, and the installation and quickstart guides, the docs home and the make-commands reference say how to upgrade an installed CLI, with `pipx upgrade` as the alternative (#899). diff --git a/docs/framework/i18n.md b/docs/framework/i18n.md index 013382094..97f8670d6 100644 --- a/docs/framework/i18n.md +++ b/docs/framework/i18n.md @@ -335,7 +335,9 @@ The `LocaleMiddleware` resolves and the shared props carry the right bundle to t ## Catalog caching across tabs Each tab sends `X-SM-I18n: :` on Inertia visits, describing the -catalog it actually holds. The `i18n` block includes `audience` (`public` or +catalog it actually holds. The client side ships in `@simple-module-py/i18n/inertia` +(`bootI18nFromInitialPage`, `subscribeI18nToNavigation(router)`); this repo's host +and every `smpy new` app use it. The `i18n` block includes `audience` (`public` or `full`); messages are omitted only when the tab reports the current pair. Full document loads always receive messages. Header-bearing requests never write the legacy session markers. Clients without the header retain session diff --git a/docs/superpowers/specs/2026-11-11-i18n-client-sync-design.md b/docs/superpowers/specs/2026-11-11-i18n-client-sync-design.md new file mode 100644 index 000000000..890765fbb --- /dev/null +++ b/docs/superpowers/specs/2026-11-11-i18n-client-sync-design.md @@ -0,0 +1,27 @@ +# Scaffolded apps send `X-SM-I18n` (#871) + +#788 made the server choose whether to send the i18n catalog per tab, from the +`X-SM-I18n: :` header. Only this repo's `host/client_app/i18n.ts` sent +it. The scaffold's `app.tsx` had its own `router.on('success')` handler with no header, +so `smpy new` apps fell back to the per-session markers. + +## Design + +- Move the logic into `packages/i18n/src/inertia.ts`, exported as + `@simple-module-py/i18n/inertia` (a subpath export, so the main entry stays + framework-agnostic). It exports `bootI18nFromInitialPage(i18n)` and + `subscribeI18nToNavigation(router)`. +- The router is a parameter with a structural `I18nRouter` type, so the package does + not depend on `@inertiajs/react`. +- This repo's `host/client_app/app.tsx` and the scaffold template call it. + `host/client_app/i18n.ts` and its test are removed, and the test moves to + `packages/i18n/src/inertia.test.ts` with a fake router. +- Published apps pin `@simple-module-py/i18n` to the CLI version, which ships in + lockstep, so the subpath exists wherever the new template does. + +## Verification + +- The package tests (`vitest`), `make ci-js-typecheck` and Biome pass. +- In a scaffolded app with the new template and package: `tsc` passes. In the browser, + clicking a sidebar link sends `x-sm-i18n: en:full`, and the response carries + `"messages": null`, so the tab keeps the catalog it holds. diff --git a/framework/cli/simple_module_cli/templates/host/client_app/app.tsx b/framework/cli/simple_module_cli/templates/host/client_app/app.tsx index 1859fdc92..4d5668b1d 100644 --- a/framework/cli/simple_module_cli/templates/host/client_app/app.tsx +++ b/framework/cli/simple_module_cli/templates/host/client_app/app.tsx @@ -1,5 +1,9 @@ import { createInertiaApp, router } from '@inertiajs/react'; -import { configureI18n, updateI18n } from '@simple-module-py/i18n'; +import { + bootI18nFromInitialPage, + type I18nPageBlock, + subscribeI18nToNavigation, +} from '@simple-module-py/i18n/inertia'; import { formatTitle, setTitleAppName } from '@simple-module-py/ui/lib/app-title'; import { resolveServerHead } from '@simple-module-py/ui/lib/server-head'; import { @@ -11,12 +15,6 @@ import { initTheme } from '@simple-module-py/ui/lib/theme'; import { createRoot } from 'react-dom/client'; import { resolvePage } from './pages'; -type I18nBlock = { - locale: string; - supportedLocales?: string[]; - messages: Record | null; -}; - createInertiaApp({ title: (title) => formatTitle(title), // Re-emits the `_head` prop (server-rendered SEO tags) on client visits. @@ -26,30 +24,16 @@ createInertiaApp({ }, setup({ el, App, props }) { const pageProps = props.initialPage.props as { - i18n?: I18nBlock; + i18n?: I18nPageBlock; branding?: { appName?: string | null }; }; setTitleAppName(pageProps.branding?.appName); // Before the first render, so a dark-theme user never sees a light frame. initTheme(); - const initial = pageProps.i18n; - configureI18n({ - locale: initial?.locale ?? 'en', - messages: initial?.messages ?? {}, - }); - router.on('success', (event) => { - const block = (event.detail.page.props as { i18n?: I18nBlock }).i18n; - // A non-null `messages` payload IS the server's signal that the client - // needs it — the backend sends `null` whenever the catalog the client - // already holds is still good. Gating on a locale change instead drops - // the catalog that arrives when the *audience* changes: signing in swaps - // the anonymous snapshot for one including admin-only modules, at the - // same locale, so every admin screen rendered raw keys - // ("dashboard.home.title") until a hard refresh. - if (block?.messages) { - updateI18n({ locale: block.locale, messages: block.messages }); - } - }); + // Reports this tab's catalog in `X-SM-I18n` on every visit and adopts the + // catalog the server sends back, so each tab keeps its own (#788, #871). + bootI18nFromInitialPage(pageProps.i18n); + subscribeI18nToNavigation(router); // Authored content (pagebuilder widgets, markdown and rich-text fields) // renders author-entered URLs as plain , which the browser would // follow with a full document load. This app is client-rendered, so that diff --git a/host/client_app/app.tsx b/host/client_app/app.tsx index f11cb759d..b1b5cc4de 100644 --- a/host/client_app/app.tsx +++ b/host/client_app/app.tsx @@ -1,4 +1,5 @@ import { createInertiaApp, router } from '@inertiajs/react'; +import { bootI18nFromInitialPage, subscribeI18nToNavigation } from '@simple-module-py/i18n/inertia'; import { ErrorBoundary } from '@simple-module-py/ui/components/ErrorBoundary'; import { OfflineBanner } from '@simple-module-py/ui/components/OfflineBanner'; import { formatTitle, setTitleAppName } from '@simple-module-py/ui/lib/app-title'; @@ -11,7 +12,6 @@ import { import { initTheme } from '@simple-module-py/ui/lib/theme'; import { useEffect, useRef } from 'react'; import { createRoot } from 'react-dom/client'; -import { bootI18nFromInitialPage, subscribeI18nToNavigation } from './i18n'; import { resolvePage } from './pages'; // The configured app name is held in the app-title module (Inertia's title @@ -31,7 +31,7 @@ createInertiaApp({ const branding = (props.initialPage.props as { branding?: { appName?: string | null } }) .branding; setTitleAppName(branding?.appName); - bootI18nFromInitialPage(props.initialPage.props); + bootI18nFromInitialPage(props.initialPage.props.i18n); // Before the first render, so a dark-theme user never sees a light frame // flash. The returned unsubscribe is deliberately dropped: the listener // keeps `system` following the OS for the life of the document. @@ -42,7 +42,7 @@ createInertiaApp({ useEffect(() => { const stopReset = router.on('navigate', () => boundaryRef.current?.reset()); - const stopI18n = subscribeI18nToNavigation(); + const stopI18n = subscribeI18nToNavigation(router); // Authored content renders author-entered URLs as plain , which // the browser follows with a full document load — a blank page until // this client-rendered app boots again. Route them through Inertia. diff --git a/host/client_app/i18n.ts b/host/client_app/i18n.ts deleted file mode 100644 index dc6e18700..000000000 --- a/host/client_app/i18n.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Initial wiring for @simple-module-py/i18n inside the Inertia app. - * - * Reads {locale, messages} from Inertia shared props and calls - * configureI18n on boot; on every successful navigation, adopts whatever - * catalog the server chose to send (see `subscribeI18nToNavigation`). - */ - -import { router } from '@inertiajs/react'; -import { activeLocale, configureI18n, updateI18n } from '@simple-module-py/i18n'; -import type { SharedProps } from '@simple-module-py/ui/types'; - -let cachedCatalog: string | undefined; - -/** `:` of a catalog the server actually sent, else undefined. */ -function catalogKey(i18n: SharedProps['i18n'] | undefined): string | undefined { - return i18n?.messages != null && i18n.audience ? `${i18n.locale}:${i18n.audience}` : undefined; -} - -export function bootI18nFromInitialPage(props: Partial): void { - const i18n = props.i18n; - cachedCatalog = catalogKey(i18n); - if (!i18n) { - configureI18n({ locale: 'en', messages: {} }); - return; - } - configureI18n({ locale: i18n.locale, messages: i18n.messages ?? {} }); -} - -export function subscribeI18nToNavigation(): () => void { - let reloading = false; - const stopBefore = router.on('before', (event) => { - if (cachedCatalog !== undefined || reloading) { - event.detail.visit.headers['X-SM-I18n'] = cachedCatalog ?? ''; - } - }); - const stopSuccess = router.on('success', (event) => { - const i18n = event.detail.page.props.i18n; - if (!i18n) return; - if (i18n.messages != null) { - updateI18n({ locale: i18n.locale, messages: i18n.messages }); - cachedCatalog = catalogKey(i18n); - reloading = false; - } else if (i18n.locale !== activeLocale() && !reloading) { - reloading = true; - router.reload({ - only: ['i18n'], - onFinish: () => { - reloading = false; - }, - }); - } - }); - return () => { - stopBefore(); - stopSuccess(); - }; -} diff --git a/packages/i18n/README.md b/packages/i18n/README.md index 971f28d95..5835fc13c 100644 --- a/packages/i18n/README.md +++ b/packages/i18n/README.md @@ -17,21 +17,23 @@ Peer-depends on React 19. Runtime deps `i18next` and `react-i18next` install aut - `useT()` — re-exported `react-i18next` `useTranslation` hook; returns `{ t }`. - `t` — non-hook translation accessor (re-exported from `i18next`) for use in schemas or utilities. - `keys` — generated, typed key tree (`keys..<...>`) you pass to `t()`. +- `@simple-module-py/i18n/inertia` — `bootI18nFromInitialPage(i18n)` and `subscribeI18nToNavigation(router)`: keep each tab's catalog in step with the server. Every Inertia visit reports the catalog the tab holds in `X-SM-I18n`, adopts the catalog the server sends back, and reloads `i18n` once when the locale changed without one. ## Usage Root setup (in `client_app`): ```tsx -import { configureI18n, updateI18n } from "@simple-module-py/i18n"; +import { router } from "@inertiajs/react"; +import { bootI18nFromInitialPage, subscribeI18nToNavigation } from "@simple-module-py/i18n/inertia"; -// On boot, from Inertia shared props (provided by InertiaLayoutDataMiddleware): -configureI18n({ locale: props.i18n.locale, messages: props.i18n.messages ?? {} }); - -// On every Inertia navigation that brings a new locale: -updateI18n({ locale: nextLocale, messages: nextMessages }); +// In createInertiaApp's setup(), from the initial page's shared props: +bootI18nFromInitialPage(props.initialPage.props.i18n); +subscribeI18nToNavigation(router); ``` +`configureI18n` / `updateI18n` remain for an app that wires navigation itself. + In a module page: ```tsx diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 222daebe7..73761e316 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -25,6 +25,10 @@ ".": { "types": "./src/index.ts", "default": "./src/index.ts" + }, + "./inertia": { + "types": "./src/inertia.ts", + "default": "./src/inertia.ts" } }, "files": [ diff --git a/host/client_app/i18n.test.ts b/packages/i18n/src/inertia.test.ts similarity index 83% rename from host/client_app/i18n.test.ts rename to packages/i18n/src/inertia.test.ts index 7884694e9..91ee959f5 100644 --- a/host/client_app/i18n.test.ts +++ b/packages/i18n/src/inertia.test.ts @@ -1,7 +1,7 @@ import { beforeEach, expect, it, vi } from 'vitest'; -const { handlers, reload, language, update } = vi.hoisted(() => ({ - handlers: {} as { +const { handlers, reload, language, update, router } = vi.hoisted(() => { + const handlers = {} as { before: (event: { detail: { visit: { headers: Record } } }) => void; success: (event: { detail: { @@ -16,35 +16,30 @@ const { handlers, reload, language, update } = vi.hoisted(() => ({ }; }; }) => void; - }, - reload: vi.fn(), - language: { value: 'en' }, - update: vi.fn(), -})); -vi.mock('@inertiajs/react', () => ({ - router: { - on: vi.fn((name, handler) => { + }; + const reload = vi.fn(); + const router = { + on: vi.fn((name: 'before' | 'success', handler: never) => { handlers[name] = handler; return vi.fn(); }), reload, - }, -})); -vi.mock('@simple-module-py/i18n', () => ({ + }; + return { handlers, reload, language: { value: 'en' }, update: vi.fn(), router }; +}); +vi.mock('./index', () => ({ configureI18n: vi.fn(), activeLocale: () => language.value, updateI18n: update, })); -import { bootI18nFromInitialPage, subscribeI18nToNavigation } from './i18n'; +import { bootI18nFromInitialPage, type I18nRouter, subscribeI18nToNavigation } from './inertia'; beforeEach(() => { vi.clearAllMocks(); language.value = 'en'; - bootI18nFromInitialPage({ - i18n: { locale: 'en', audience: 'public', supportedLocales: ['en'], messages: {} }, - }); - subscribeI18nToNavigation(); + bootI18nFromInitialPage({ locale: 'en', audience: 'public', messages: {} }); + subscribeI18nToNavigation(router as unknown as I18nRouter); }); it('sends the catalog held by this tab and updates its audience after login', () => { @@ -96,7 +91,7 @@ it('resets the retry guard after receiving a catalog', () => { }); it('forces catalog delivery on recovery when the initial page had no audience', () => { - bootI18nFromInitialPage({}); + bootI18nFromInitialPage(undefined); const visit = { headers: {} as Record }; handlers.before({ detail: { visit } }); expect(visit.headers).toEqual({}); diff --git a/packages/i18n/src/inertia.ts b/packages/i18n/src/inertia.ts new file mode 100644 index 000000000..2f4e7f598 --- /dev/null +++ b/packages/i18n/src/inertia.ts @@ -0,0 +1,76 @@ +/** + * Keeps this tab's catalog in step with the server across Inertia visits. + * + * Every visit reports the catalog the tab holds in `X-SM-I18n` + * (`:`), so the server sends `messages` only when this tab + * needs them, per tab rather than per browser session (#788). A visit that + * changes locale but arrives without a catalog reloads `i18n` once. + * + * Shipped here rather than in each host's `client_app` so a scaffolded app + * gets it from the package (#871). The router is passed in, which keeps + * `@inertiajs/react` out of this package's dependencies. + */ + +import { activeLocale, configureI18n, updateI18n } from './index'; + +/** The `i18n` shared prop the backend sends (`build_i18n_block`). */ +export interface I18nPageBlock { + locale: string; + audience?: string; + messages: Record | null; +} + +/** The slice of Inertia's `router` this module uses. */ +export interface I18nRouter { + on( + event: 'before', + callback: (event: { detail: { visit: { headers: Record } } }) => void, + ): () => void; + on( + event: 'success', + callback: (event: { detail: { page: { props: Record } } }) => void, + ): () => void; + reload(options: { only: string[]; onFinish: () => void }): void; +} + +let cachedCatalog: string | undefined; + +/** `:` of a catalog the server actually sent, else undefined. */ +function catalogKey(i18n: I18nPageBlock | undefined): string | undefined { + return i18n?.messages != null && i18n.audience ? `${i18n.locale}:${i18n.audience}` : undefined; +} + +export function bootI18nFromInitialPage(i18n: I18nPageBlock | undefined): void { + cachedCatalog = catalogKey(i18n); + configureI18n({ locale: i18n?.locale ?? 'en', messages: i18n?.messages ?? {} }); +} + +export function subscribeI18nToNavigation(router: I18nRouter): () => void { + let reloading = false; + const stopBefore = router.on('before', (event) => { + if (cachedCatalog !== undefined || reloading) { + event.detail.visit.headers['X-SM-I18n'] = cachedCatalog ?? ''; + } + }); + const stopSuccess = router.on('success', (event) => { + const i18n = event.detail.page.props.i18n as I18nPageBlock | undefined; + if (!i18n) return; + if (i18n.messages != null) { + updateI18n({ locale: i18n.locale, messages: i18n.messages }); + cachedCatalog = catalogKey(i18n); + reloading = false; + } else if (i18n.locale !== activeLocale() && !reloading) { + reloading = true; + router.reload({ + only: ['i18n'], + onFinish: () => { + reloading = false; + }, + }); + } + }); + return () => { + stopBefore(); + stopSuccess(); + }; +} From c4f1aa345b081e1c943c08cbc1da0865d379c901 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 11 Nov 2026 01:56:47 +0100 Subject: [PATCH 2/3] fix: address code review findings (round 1, pass 1) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- docs/frontend/performance.md | 4 ++-- docs/frontend/shared-props.md | 3 +++ scripts/tests/test_check_no_double_cast.py | 2 +- scripts/tests/test_check_no_raw_fetch.py | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/frontend/performance.md b/docs/frontend/performance.md index 5f9672651..196f32cc0 100644 --- a/docs/frontend/performance.md +++ b/docs/frontend/performance.md @@ -199,8 +199,8 @@ returns goes into every Inertia response (`docs/frontend/shared-props.md`). Keep providers cheap, and return callables for anything that costs a query, so a partial reload that does not name them skips them. The framework's own (`auth`, `menus`, `i18n`) come to about 3 KB, and the translation catalog is sent once per tab: -the client echoes the catalog it holds in `X-SM-I18n`, so later visits come -back without it. +the client echoes the catalog it holds in `X-SM-I18n` (wired by +`@simple-module-py/i18n/inertia`), so later visits come back without it. ### Link to the canonical URL diff --git a/docs/frontend/shared-props.md b/docs/frontend/shared-props.md index 410fcdadb..2eadabfc7 100644 --- a/docs/frontend/shared-props.md +++ b/docs/frontend/shared-props.md @@ -170,6 +170,9 @@ t("orders.items", { count: orders.length }); // pluralization Each tab advertises its cached locale/audience via `X-SM-I18n`. A matching Inertia visit receives `messages: null`; otherwise the server resends the catalog. The client reloads `i18n` once if it receives a changed locale without messages. +The client side is `bootI18nFromInitialPage` and `subscribeI18nToNavigation(router)` from +`@simple-module-py/i18n/inertia`; call both in `createInertiaApp`'s `setup()` (this repo's host +and `smpy new` apps do). The `i18n.messages` block is the **resolved locale's** translations merged across all modules. Switching the locale (via ``, which POSTs to `/i18n/set-locale`) triggers a full-page navigation so the messages are re-fetched. The active locale and supported locales are on `usePage().props.i18n` (`locale`, `supportedLocales`). diff --git a/scripts/tests/test_check_no_double_cast.py b/scripts/tests/test_check_no_double_cast.py index 6ca1be05c..5bfd3f371 100644 --- a/scripts/tests/test_check_no_double_cast.py +++ b/scripts/tests/test_check_no_double_cast.py @@ -37,7 +37,7 @@ def test_is_checked_directories(): assert is_checked(Path("modules/users/users/auth_local/components/AuthIntro.tsx")) assert is_checked(Path("modules/users/users/lib/format.ts")) assert is_checked(Path("packages/ui/src/layouts/SidebarLayout.tsx")) - assert is_checked(Path("host/client_app/i18n.ts")) + assert is_checked(Path("host/client_app/app.tsx")) def test_is_checked_excludes_tests_and_other_dirs(): diff --git a/scripts/tests/test_check_no_raw_fetch.py b/scripts/tests/test_check_no_raw_fetch.py index bf57f1bf8..ba64e1eeb 100644 --- a/scripts/tests/test_check_no_raw_fetch.py +++ b/scripts/tests/test_check_no_raw_fetch.py @@ -35,7 +35,7 @@ def test_is_checked_directories(): assert is_checked(Path("modules/users/users/auth_local/components/AuthIntro.tsx")) assert is_checked(Path("modules/users/users/index.ts")) assert is_checked(Path("packages/ui/src/layouts/SidebarLayout.tsx")) - assert is_checked(Path("host/client_app/i18n.ts")) + assert is_checked(Path("host/client_app/app.tsx")) assert is_checked(Path("framework/hosting/simple_module_hosting/setup_wizard/Wizard.tsx")) From cb45677775216bf69b7b9ef0f1717b3460ef83a7 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 11 Nov 2026 02:00:06 +0100 Subject: [PATCH 3/3] test(cli): scaffold regression test checks the shared i18n catalog sync (Codex review) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .../cli/tests/test_cli_new_regressions.py | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/framework/cli/tests/test_cli_new_regressions.py b/framework/cli/tests/test_cli_new_regressions.py index 5b48ccb7a..46a8c9883 100644 --- a/framework/cli/tests/test_cli_new_regressions.py +++ b/framework/cli/tests/test_cli_new_regressions.py @@ -213,23 +213,22 @@ def test_sm_new_rejects_mixed_separators(tmp_path: Path) -> None: assert result.exit_code != 0 -def test_scaffold_adopts_every_locale_catalog_the_server_sends(tmp_path: Path) -> None: - """Issue #321: the scaffold gated ``updateI18n`` on a locale *change*, which - drops the catalog that arrives when the audience changes instead. Signing in - swaps the anonymous snapshot for one including admin-only modules at the same - locale, so every admin screen rendered raw keys ("dashboard.home.title") - until a hard refresh. A non-null ``messages`` payload is itself the server's - signal that the client needs it.""" +def test_scaffold_wires_the_shared_i18n_catalog_sync(tmp_path: Path) -> None: + """The scaffold uses ``@simple-module-py/i18n/inertia`` (GH #871), which sends + ``X-SM-I18n`` per tab (#788) and adopts every non-null catalog the server + sends. A same-locale catalog after sign-in is covered there + (``packages/i18n/src/inertia.test.ts``, #321), so the scaffold must not keep + a handler of its own that could gate it on a locale change again.""" from simple_module_cli.scaffolding import create_host dest = tmp_path / "demo" create_host(dest, name="demo-host", modules=["Dashboard", "Auth"]) app_tsx = (dest / "client_app" / "app.tsx").read_text() - assert "updateI18n" in app_tsx - assert "activeLocale" not in app_tsx, ( - "the locale gate is back — a same-locale catalog would be dropped again" - ) + assert "from '@simple-module-py/i18n/inertia'" in app_tsx + assert "bootI18nFromInitialPage(" in app_tsx + assert "subscribeI18nToNavigation(router)" in app_tsx + assert "router.on('success'" not in app_tsx def test_sm_new_host_app_tsx_wires_theme_and_title(tmp_path: Path) -> None: