Adopt rslib for shared-webapp libraries and decouple federated translations#912
Conversation
…t Compiler upgrades
…s, and nondeterministic precedence
|
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 1. The rslib migration stopped type-checking this file. rslib's 2.
3. The former 4. Both packages are |
Summary & Motivation
Build the shared-webapp libraries (
@repo/ui,@repo/infrastructure,@repo/utils,@repo/build) with rslib as bundleless ESM consumed fromdist, replacing the previoustsc+ 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.bundle: false,format: "esm",dts: true): route packageexportsatdist, drop thesourceconditions andpluginSourceBuild, and let rslib emit@repo/uiimages (removing the hand-rolledcopyImagesstep). Make the Node-side build plugins ESM-safe (import.meta.dirname,JSON.parse(fs.readFileSync(...))).@lingui/coreand@lingui/reactas module-federation singletons so there is onei18ninstance across remotes, then move translations to self-registration: aglobalThis-backed catalog store merges each system's catalog in registration order and activates the sharedi18n; remotes push their own catalog viawithSystemTranslationsand@repo/uivia a<RepoUiTranslation>provider. Adopt Linguicontextfor disambiguation.hmrPathso co-hosted remotes are distinguishable at the shared origin..claude/rules/frontend/; TypeScript 7 and the Rust React Compiler are intentionally deferred (rationale captured in those rules).Checklist
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
@repo/ui,@repo/infrastructure,@repo/utils,@repo/build) built withtsc, consumed as raw TSsourceviapluginSourceBuilddts:true), consumed fromdistFEDERATED_TRANSLATION_REMOTESlist and pollscontainer.get()globalThisstore; host enumerates nothingreact/react-domare MFsharedsingletons@lingui/core/@lingui/reactare singletons too (onei18ninstance across remotes)copyImages.jshand-copies raster assetsoutput.distPath.imagePreconditions (verify first): Rsbuild/Rspack already on v2 (
@rsbuild/core2.x,@rspack/binding2.x), Lingui already on v6, Node ≥ 22.19.Intentionally deferred:
openapi-typescriptand rslib'sdtsgenerator both fail to load it. Stay on 6.x; typechecking stays onpluginTypeCheck(). Revisit ≈ TS 7.1.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/*.tsandplatformSettings.ts:require("./x.json")→JSON.parse(fs.readFileSync(path, "utf-8"));__dirname→import.meta.dirname.Step 2 — Convert
i18n.config.json→i18n.config.tsA bare JSON import breaks under ESM. Replace
infrastructure/translations/i18n.config.jsonwith a typedi18n.config.tsthat exports the same shape, and update importers.Step 3 — Adopt rslib for the shared libraries
3a. Tooling (
application/package.jsondevDeps): add"@rslib/core": "0.22.1"; remove"@rsbuild/plugin-source-build". Do not bumptypescript.3b. Add
rslib.config.tsto each library (ui,infrastructure,utils,build). Canonical shape:Per-package deltas:
utilsis.ts/.tsxonly and omitspluginReact();infrastructureadds"./**/*.json";buildadds.svg/.css;uiadds.svg/.css/.png/.webp, addsLinguiPlugin()(or<Trans>/t`ship untransformed →Trans is not definedat runtime), and emits raster assets unhashed viaoutput: { distPath: { image: "images" }, filename: { image: "[name][ext]" } }. KeeppluginTypeCheck()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 oldrimraf/copyImages/tscchains.3d. Exports/
type/filescleanup: add"type": "module"+"files": ["dist"]; remove"source"/"main"and every"source"export condition; routeexportsatdist. 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 inui/dist/images.3f. Remove
pluginSourceBuild({ sourceField: "source" })(and its import) from the three WebApprsbuild.config.ts. Turbo's^buildordering already builds libraries before the apps.Step 4 — Share
@lingui/core/@lingui/reactas MF singletonsIn
ModuleFederationPlugin.tsadd both to thesharedmap alongsidereact/react-domwithsingleton: true, so there is onei18ninstance across remotes — the prerequisite for Step 5 (and the deferred React Compiler).Step 5 — Decouple federated translations via self-registration
globalThis-backedinfrastructure/translations/catalogStore.ts(global because@repo/infrastructureis compiled once per remote) holds the imperative store —registerCatalog,loadAllAndActivate,ensureSystemActive(late-mount merge),isLoaded/hasFailed— andTranslation.tsxkeeps the React layer (Translation.create,setLocale,resolveInitialLocale,TranslationProvider,SystemTranslationGate,withSystemTranslations,createSystemTranslationProvider).catalogStoreimportsLocale/LocalLoaderFunctionfromTranslationas types only so there is no runtime import cycle.loadAllAndActivateagainst 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 setdocument.documentElement.langinsideloadAllAndActivateso<html lang>always tracks the active locale.createFederatedTranslation.ts(+FEDERATED_TRANSLATION_REMOTESand thecontainer.get(...)polling) — remotes push their own catalog; the host enumerates nothing.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.TenantStateGuardrendering main's<Outlet/>) — the Suspense gate would block host content behind the account catalog.Step 5b — Locale persistence & pre-render resolution
localStorage.setItem). Do not clear the key when it equals the config default: an anonymous user's server-rendered locale comes fromAccept-Language(UserInfo.GetValidLocale(browserLocale)), so the effective default is not the config default, and clearing would silently discard a real choice.resolveInitialLocale: authenticated users follow the server<html lang>; anonymous users get their stored preference applied here (validated withisLocale), so there is no flash beforeuseInitializeLocaleruns post-mount.useLingui().i18n.locale, never a localuseStatemirror that can desync.Step 6 — Make
@repo/uitranslations self-containedAdd
ui/translations/loadUiTranslations.tsandinfrastructure/translations/RepoUiTranslation.tsx=createSystemTranslationProvider("shared-ui", loadUiTranslations)(it lives in@repo/infrastructure, which owns the registry, because@repo/uiis 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
DevelopmentServerPlugin.ts, stop pinningdev.client.port. rsbuild's client builds the HMR URL asconfig.port || location.port; leaving it unset makes the browser uselocation.port— the gateway (page origin). Add an optionalhmrPath./rsbuild-hmr; the account remote setshmrPath: "/account/rsbuild-hmr".AppGateway/appsettings.json(main-hmr-ws→main-static,account-hmr-ws→account-static). No production gating needed: in Azure the*-staticclusters resolve to the API containers and the production SPA ships no HMR client, so the routes are dormant. BackOffice HMR already works viaBackOfficeDevStaticProxy(which proxies/rsbuild-hmr).Step 9 — Document the result
Update
.claude/rules/frontend/build-system.mdandtranslations.md, then runsync-ai-rules.Pitfalls (real failures hit during this migration)
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 clearnode_modules/.cache/rspackbefore a foreground build.Trans is not definedat runtime in@repo/ui: rslib pre-builds the library, so its ownrslib.config.tsmust runLinguiPlugin().@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/bindingor@lingui/swc-pluginis bumped.__dirname/requireare undefined; bare.jsonimports break (Steps 1–2).distafter removingcopyImages: confirm the extension is in theuientry glob andoutput.distPath.image/filename.imageare set.dist/index.html: running a productionbuildbeforedevleaves a content-hashedindex.htmlon disk that 404s until the dev server rewrites it (touch a source file).Final Verification
build --frontend, thenformat/lint --frontend --no-build, backendtest, and the e2e suite green. In-browser: locale switch en-US ↔ da-DK translates shared@repo/ui, the host's own strings, and the federatedaccountstrings; BackOffice standalone. Exactly one React and one Linguii18ninstance across the federation boundary. HMR hot-applies through the gateway for host and remote.