Skip to content

Adopt rslib for shared-webapp libraries and decouple federated translations#912

Merged
tjementum merged 24 commits into
platformplatform:mainfrom
raix:upgrade-frontend-build-system
Jul 6, 2026
Merged

Adopt rslib for shared-webapp libraries and decouple federated translations#912
tjementum merged 24 commits into
platformplatform:mainfrom
raix:upgrade-frontend-build-system

Conversation

@raix

@raix raix commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Summary & Motivation

Build the shared-webapp libraries (@repo/ui, @repo/infrastructure, @repo/utils, @repo/build) with rslib as bundleless ESM consumed from dist, replacing the previous tsc + raw-source (pluginSourceBuild) setup, and rework the federated translation system so each self-contained system owns and self-registers its own catalog instead of the host enumerating a hardcoded remote list.

  • Migrate the four shared libraries to rslib (bundle: false, format: "esm", dts: true): route package exports at dist, drop the source conditions and pluginSourceBuild, and let rslib emit @repo/ui images (removing the hand-rolled copyImages step). Make the Node-side build plugins ESM-safe (import.meta.dirname, JSON.parse(fs.readFileSync(...))).
  • Share @lingui/core and @lingui/react as module-federation singletons so there is one i18n instance across remotes, then move translations to self-registration: a globalThis-backed catalog store merges each system's catalog in registration order and activates the shared i18n; remotes push their own catalog via withSystemTranslations and @repo/ui via a <RepoUiTranslation> provider. Adopt Lingui context for disambiguation.
  • Route the rsbuild HMR WebSocket through the AppGateway (and the BackOffice dev proxy) instead of pinning the dev-server port, using a per-system hmrPath so co-hosted remotes are distinguishable at the shared origin.
  • Persist the user's explicit locale choice reliably, resolve the initial locale before first paint (no flash of the default), and harden catalog loading against locale-switch races, transient load failures, and non-deterministic merge precedence.
  • Document the build system and translation contracts under .claude/rules/frontend/; TypeScript 7 and the Rust React Compiler are intentionally deferred (rationale captured in those rules).

Checklist

  • I have added tests, or done manual regression tests
  • I have updated the documentation, if necessary
Frontend build-system upgrade & migration guide (playbook for reproducing this change)

A step-by-step playbook for migrating the frontend build system to rslib (bundleless ESM libraries), decoupled self-registering translations, Lingui module-federation singletons, and gateway-routed HMR.

What This Migration Changes

Before After
Shared libs (@repo/ui, @repo/infrastructure, @repo/utils, @repo/build) built with tsc, consumed as raw TS source via pluginSourceBuild Built with rslib (ESM, bundleless, dts:true), consumed from dist
Host enumerates a hardcoded FEDERATED_TRANSLATION_REMOTES list and polls container.get() Each system self-registers its catalog into a globalThis store; host enumerates nothing
Only react/react-dom are MF shared singletons @lingui/core/@lingui/react are singletons too (one i18n instance across remotes)
HMR WebSocket pinned to the dev-server port, bypassing the gateway HMR WebSocket targets the page origin → routed through the AppGateway
copyImages.js hand-copies raster assets rslib emits images via output.distPath.image

Preconditions (verify first): Rsbuild/Rspack already on v2 (@rsbuild/core 2.x, @rspack/binding 2.x), Lingui already on v6, Node ≥ 22.19.

Intentionally deferred:

  • TypeScript 7 RC — its native compiler exposes no classic programmatic API, so openapi-typescript and rslib's dts generator both fail to load it. Stay on 6.x; typechecking stays on pluginTypeCheck(). Revisit ≈ TS 7.1.
  • Rust React Compiler — needs Rsbuild/Rspack 2.1 (repo is on 2.0). The federation singletons are the prerequisite; enablement is a follow-up.

Step 1 — Make shared-webapp build plugins ESM-safe

Everything becomes ESM-only, so Node-side build code can no longer use CJS constructs. In application/shared-webapp/build/plugin/*.ts and platformSettings.ts: require("./x.json")JSON.parse(fs.readFileSync(path, "utf-8")); __dirnameimport.meta.dirname.

Step 2 — Convert i18n.config.jsoni18n.config.ts

A bare JSON import breaks under ESM. Replace infrastructure/translations/i18n.config.json with a typed i18n.config.ts that exports the same shape, and update importers.

Step 3 — Adopt rslib for the shared libraries

3a. Tooling (application/package.json devDeps): add "@rslib/core": "0.22.1"; remove "@rsbuild/plugin-source-build". Do not bump typescript.

3b. Add rslib.config.ts to each library (ui, infrastructure, utils, build). Canonical shape:

export default defineConfig({
  source: { entry: { index: ["./**/*.tsx", "./**/*.ts", "!rslib.config.ts", "!node_modules/**", "!dist/**"] } },
  lib: [{ bundle: false, dts: true, format: "esm" }],
  output: { target: "web" },
  plugins: [pluginReact(), pluginTypeCheck()]
});

Per-package deltas: utils is .ts/.tsx only and omits pluginReact(); infrastructure adds "./**/*.json"; build adds .svg/.css; ui adds .svg/.css/.png/.webp, adds LinguiPlugin() (or <Trans>/t` ship untransformed → Trans is not defined at runtime), and emits raster assets unhashed via output: { distPath: { image: "images" }, filename: { image: "[name][ext]" } }. Keep pluginTypeCheck() everywhere (TS 7 deferred).

3c. Rewrite build scripts in all four package.jsons: "build": "rslib build", "dev": "rslib build --watch --no-clean", "dev:setup": "rslib build". Drop the old rimraf/copyImages/tsc chains.

3d. Exports/type/files cleanup: add "type": "module" + "files": ["dist"]; remove "source"/"main" and every "source" export condition; route exports at dist. Keep @repo/build's hand-written passthrough exports ("./react-env.d.ts", "./module-federation-types/*.d.ts").

3e. Delete ui/copyImages.js — rslib emits images now; verify they land in ui/dist/images.

3f. Remove pluginSourceBuild({ sourceField: "source" }) (and its import) from the three WebApp rsbuild.config.ts. Turbo's ^build ordering already builds libraries before the apps.

Step 4 — Share @lingui/core / @lingui/react as MF singletons

In ModuleFederationPlugin.ts add both to the shared map alongside react/react-dom with singleton: true, so there is one i18n instance across remotes — the prerequisite for Step 5 (and the deferred React Compiler).

Step 5 — Decouple federated translations via self-registration

  • Split the coordination logic in two: a globalThis-backed infrastructure/translations/catalogStore.ts (global because @repo/infrastructure is compiled once per remote) holds the imperative store — registerCatalog, loadAllAndActivate, ensureSystemActive (late-mount merge), isLoaded/hasFailed — and Translation.tsx keeps the React layer (Translation.create, setLocale, resolveInitialLocale, TranslationProvider, SystemTranslationGate, withSystemTranslations, createSystemTranslationProvider). catalogStore imports Locale/LocalLoaderFunction from Translation as types only so there is no runtime import cycle.
  • The store's loading contract (all four matter): merge every system in registration order (shared-ui < host < remotes) so precedence is deterministic regardless of import-resolution timing; guard loadAllAndActivate against a superseding switch (track the latest requested locale) so a slow earlier switch can't clobber a faster later one; keep a failed set so a transient load error degrades to untranslated (the gate renders children) yet stays retryable on the next switch; and set document.documentElement.lang inside loadAllAndActivate so <html lang> always tracks the active locale.
  • Delete createFederatedTranslation.ts (+ FEDERATED_TRANSLATION_REMOTES and the container.get(...) polling) — remotes push their own catalog; the host enumerates nothing.
  • Wire hosts (main/WebApp/bootstrap.tsx, account/BackOffice/main.tsx): Translation.create((locale) => import(...ownCatalog)). Wire remotes (account/WebApp): withSystemTranslations("account", ownLoader) applied to each federated export. Do not wrap a string-less pass-through guard that renders host children (e.g. TenantStateGuard rendering main's <Outlet/>) — the Suspense gate would block host content behind the account catalog.

Step 5b — Locale persistence & pre-render resolution

  • Always persist an explicit choice (unconditional localStorage.setItem). Do not clear the key when it equals the config default: an anonymous user's server-rendered locale comes from Accept-Language (UserInfo.GetValidLocale(browserLocale)), so the effective default is not the config default, and clearing would silently discard a real choice.
  • Resolve the initial locale before first paint in resolveInitialLocale: authenticated users follow the server <html lang>; anonymous users get their stored preference applied here (validated with isLocale), so there is no flash before useInitializeLocale runs post-mount.
  • Read the active locale for UI (switcher checkmark, preferences radio) from useLingui().i18n.locale, never a local useState mirror that can desync.

Step 6 — Make @repo/ui translations self-contained

Add ui/translations/loadUiTranslations.ts and infrastructure/translations/RepoUiTranslation.tsx = createSystemTranslationProvider("shared-ui", loadUiTranslations) (it lives in @repo/infrastructure, which owns the registry, because @repo/ui is below it in the dependency graph). Applications render <RepoUiTranslation> around their tree.

Step 7 — Adopt Lingui context for disambiguation

Use // lingui-set context="..." / // lingui-reset (supported by @lingui/swc-plugin@6.4.0) where the same source string needs different translations.

Step 8 — Route the HMR WebSocket through the AppGateway

  • In DevelopmentServerPlugin.ts, stop pinning dev.client.port. rsbuild's client builds the HMR URL as config.port || location.port; leaving it unset makes the browser use location.port — the gateway (page origin). Add an optional hmrPath.
  • Give each co-hosted system a distinct path: main keeps /rsbuild-hmr; the account remote sets hmrPath: "/account/rsbuild-hmr".
  • Add matching YARP routes in AppGateway/appsettings.json (main-hmr-wsmain-static, account-hmr-wsaccount-static). No production gating needed: in Azure the *-static clusters resolve to the API containers and the production SPA ships no HMR client, so the routes are dormant. BackOffice HMR already works via BackOfficeDevStaticProxy (which proxies /rsbuild-hmr).

Step 9 — Document the result

Update .claude/rules/frontend/build-system.md and translations.md, then run sync-ai-rules.

Pitfalls (real failures hit during this migration)

  • rspack persistent-cache lock (Transaction already in progress / State lock mismatch): a running dev server (rslib --watch) holds the cache lock. Stop the dev servers (an Aspire restart releases it) or clear node_modules/.cache/rspack before a foreground build.
  • Trans is not defined at runtime in @repo/ui: rslib pre-builds the library, so its own rslib.config.ts must run LinguiPlugin().
  • @lingui/swc-plugin ↔ rspack SWC coupling: the plugin API is not semver-stable and runs against rspack's embedded SWC. Pin an exact version and re-check compatibility whenever @rspack/binding or @lingui/swc-plugin is bumped.
  • ESM traps: __dirname/require are undefined; bare .json imports break (Steps 1–2).
  • Images missing from dist after removing copyImages: confirm the extension is in the ui entry glob and output.distPath.image/filename.image are set.
  • Stale hashed dist/index.html: running a production build before dev leaves a content-hashed index.html on disk that 404s until the dev server rewrites it (touch a source file).
  • Unset-on-default is a trap: clearing the stored locale when it equals the config default drops explicit choices for users whose browser locale differs from it (Step 5b).

Final Verification

build --frontend, then format/lint --frontend --no-build, backend test, and the e2e suite green. In-browser: locale switch en-US ↔ da-DK translates shared @repo/ui, the host's own strings, and the federated account strings; BackOffice standalone. Exactly one React and one Lingui i18n instance across the federation boundary. HMR hot-applies through the gateway for host and remote.

raix and others added 24 commits July 2, 2026 22:03
@tjementum

tjementum commented Jul 6, 2026

Copy link
Copy Markdown
Member

Disclaimer: Comment written by Claude Code:

Did a review pass over this branch and pushed a few small follow-up fixes to the build config in ff2207835. All four are config-only and verified green (frontend and backend build, backend tests 1000/1000, frontend and backend lint, format clean). Here is what changed and why.

1. shared-webapp/build/package.json - restore type-checking of environment/runtime.ts

The rslib migration stopped type-checking this file. rslib's pluginTypeCheck() uses build/tsconfig.json, which excludes the environment folder, so runtime.ts was still transpiled to dist/environment/runtime.js but never type-checked (the old build ran tsc -b . environment). Since runtime.js is injected as the first entry of every app build, losing that gate is a real coverage regression. The build script now chains tsc --noEmit -p environment/tsconfig.json, which type-checks the file against the environment folder's own DOM-lib tsconfig. Confirmed it catches a deliberately injected type error, and confirmed it does not double-emit (rslib still owns the emit, tsc runs with --noEmit).

2. shared-webapp/ui/rslib.config.ts - keep the root Tailwind sheets out of the entry glob

theme.css and tailwind.css are consumed via the package root exports (./theme.css, ./tailwind.css), not from dist, so the ./**/*.css glob was pulling them into rslib and emitting dead, shadowed dist/theme.css and dist/tailwind.css copies. Added !theme.css and !tailwind.css. The ./**/*.css glob stays for future component CSS.

3. shared-webapp/infrastructure/rslib.config.ts - drop the vestigial ./**/*.json glob

The former i18n.config.json is now i18n.config.ts, so there is no .json source left under infrastructure. The glob also spuriously matched package.json and tsconfig.json.

4. shared-webapp/ui/package.json and shared-webapp/infrastructure/package.json - remove the incorrect require export condition

Both packages are type: module (ESM only), so a "require": "./dist/*.js" condition pointing at ESM is wrong (a real Node require would throw ERR_REQUIRE_ESM). Nothing in the repo does a Node require() of these packages, and the apps resolve via the import condition, so this is a safe correctness cleanup.

@tjementum tjementum marked this pull request as ready for review July 6, 2026 10:50
@tjementum tjementum requested a review from a team as a code owner July 6, 2026 10:50
@tjementum tjementum added the Enhancement New feature or request label Jul 6, 2026
@tjementum tjementum self-assigned this Jul 6, 2026
@tjementum tjementum merged commit 77f82d1 into platformplatform:main Jul 6, 2026
25 of 27 checks passed
@github-project-automation github-project-automation Bot moved this to ✅ Done in Kanban board Jul 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Enhancement New feature or request

Projects

Status: ✅ Done

Development

Successfully merging this pull request may close these issues.

2 participants