Frontend Build Timing Audit
The OpenOps frontend has hit production-only i18n regressions caused by a subtle interaction between Vite 8 + rolldown's chunk splitting and i18next's lazy initialization. This skill bundles the tooling we used to diagnose and prevent those bugs.
When to use
Trigger this skill proactively when modifying any of:
packages/react-ui/**(especiallymain.tsx,i18n.ts, routing/lazy loading)packages/ui-components/**(barrel exports insrc/index.ts,src/components/index.ts)packages/shared/**(the@openops/sharedbarrel)packages/react-ui/vite.config.ts- Any
package.jsonwith asideEffectsfield - Adding or removing top-level
import { t } from 'i18next'calls
Or reactively when a user reports:
- "Works in dev, broken in production / docker compose / staging"
- "i18n labels are missing / blank / undefined"
- "Toggle / button / nav item has no text"
- Empty strings appearing in the UI after deployment
The bug pattern (mental model)
main.tsx imports:
1) ./i18n.ts ← calls i18next.init() which sets this.translator
2) ./app/app ← the rest of the App
In dev, ./i18n.ts always evaluates before any App module, so a top-level
t('Foo') inside an App-tree module gets a working translator.
In production builds, rolldown can split the App tree into static-dependency
chunks of the entry chunk (import {...} from "./cloud-user-api-*.js"). Per
the ESM spec, those dependency chunks' module bodies evaluate before the
entry chunk body — which means before ./i18n.ts's init() call. Any
module-scope t('...') in those chunks now runs while i18next.translator is
undefined, and t() returns undefined. The "label" gets frozen as
undefined in a top-level array/object and the UI renders nothing.
The fix has two layers:
- Source layer: never call
t()at module top level. UseuseTranslation()(a hook) or a factory function instead. This is what PR #2292 did fortest-step-data-viewer.tsx. - Bundler layer: declare
"sideEffects": ["*.css", "*.scss"]onpackages/ui-components/package.jsonso rolldown stops emittingui-componentsmodules as static-dependency chunks and inlines them into the entry chunk body where./i18nalways wins. This is PR #2293.
For the full investigation with measurements, see reference.md.
Workflows
Workflow 1: Quick anti-pattern scan (always run when touching FE code)
The script scripts/find-toplevel-init-calls.mjs uses TypeScript's AST to
find every call to a configured init-dependent binding whose ancestor chain
contains no function/arrow/method — i.e. every module-scope invocation that
runs at module load.
The set of bindings to watch is declared in
scripts/anti-patterns.config.mjs (data-driven). The initial entry covers
the i18next t case that caused OPS-4318; adding a new pattern (Sentry,
dayjs locale, etc.) is a one-liner.
node .claude/skills/frontend-build-timing-audit/scripts/find-toplevel-init-calls.mjs
Output groups results by rule, one line per offender: <path>: lines 12, 19, 25. The scan runs in ~1 second across the whole repo. Pass --json to get
a machine-readable shape (used by snapshot.mjs).
Triage rule: every result is a latent OPS-4318-style regression. The risk of it manifesting today depends on which chunk the file lands in, which depends on rolldown's heuristics. Treat any hit as something to fix or explicitly justify.
When you find offenders, fix them by:
- For React components:
const { t } = useTranslation()inside the component, move the constant inside the function body or wrap it inuseMemo. - For non-component files (zod schemas, filter configs, route metadata): convert
the exported constant into a factory function
getFooFilter(t)or a hookuseFooFilter().
Workflow 2: Bundle-diff diagnostic (when actively triaging a regression)
The script scripts/bundle-diff.mjs runs two production builds (varying
working-tree state), then compares:
- Chunk count and entry chunk size
- Which chunks contain a given marker string (e.g. a missing label literal)
- Static-import dependency list at the top of the entry chunk
- Critical-path size (raw / gzipped / brotli) — what the user actually downloads before the app can boot
This takes ~30s per build configuration. Use it when:
- The AST scan is clean but a build-only bug is still suspected (maybe from a vendor module, dependency upgrade, or vite config change).
- Evaluating whether a proposed
sideEffectschange actually helps. - Comparing bundle behavior before/after a vite or rolldown upgrade.
Usage:
node .claude/skills/frontend-build-timing-audit/scripts/bundle-diff.mjs \
--label-a 'baseline (main)' --ref-a main \
--label-b 'feature branch' --ref-b HEAD \
--marker 'View input data'
--marker is the literal you expect to be missing or moved (e.g. a label
that disappeared). It will be located in each build's chunk graph so you can
see whether it's in the entry chunk (safe — runs after i18n.init()) or a
statically-imported sibling chunk (dangerous — runs before).
For multi-variable experiments (e.g. toggle two patches independently to
isolate which one fixes the bug), see the --patch-a / --patch-b flags
documented in the script's --help.
Workflow 3: Regression check vs committed baseline
scripts/baseline.json captures the current build's chunk count, entry-chunk
size, critical-path size (raw / gzipped / brotli), total JS size, and per-rule
anti-pattern counts. Two scripts work against it:
# After editing FE code, verify nothing regressed past thresholds.
node .claude/skills/frontend-build-timing-audit/scripts/check-regression.mjs
# Exit codes: 0 ok, 1 warn, 2 fail (suitable as a CI gate).
# Re-anchor the baseline after an intentional change (dependency upgrade,
# refactor that legitimately moves chunks).
node .claude/skills/frontend-build-timing-audit/scripts/update-baseline.mjs
# Prints the diff vs the previous baseline before writing.
Thresholds live inside baseline.json as percent deltas so routine growth
doesn't constantly require updates. Anti-pattern counts default to warnPct: 0 so any new offender (a new module-scope t(...), a new
module-scope Sentry.init(...), etc.) warns immediately.
Use this workflow:
- After landing FE changes locally to catch surprise bundle bloat.
- After a Vite, rolldown, or core dependency upgrade.
- Before approving a PR that touches barrels, lazy-loading boundaries, or
sideEffectsdeclarations.
If the verdict is warn or fail, drop into Workflow 2 (bundle-diff.mjs
against main) to see which chunks moved.
Workflow 4: Pre-commit verification for sideEffects changes
If editing a sideEffects field in any packages/*/package.json:
- Identify the package's real side effects (CSS/SCSS imports, global
registrations, polyfills). Grep for
import '*.css',customElements.define, module-scopewindow.*/global.*assignments. - If only CSS/SCSS, use
"sideEffects": ["*.css", "*.scss"](array form). - Run
node .../scripts/bundle-diff.mjsagainst the previous commit to confirm:- CSS files still in the build (xyflow, tailwind, app styles).
- Critical-path size hasn't grown.
- No expected runtime code was tree-shaken out.
Key facts to remember
timported fromi18nextis NOT reactive. It does not re-render components when language changes or resources load. UseuseTranslation()fromreact-i18nextin components.- Before
i18next.init()runs,t()returnsundefined, not the key. React rendersundefinedas nothing — that's why labels go blank rather than showing English fallback. - The repo's
translation.jsonis key=key, so once init has run, missing keys fall back to the English text by accident. This masks broken usage in most cases — but only after init. Module-scopet()may run before init. main.tsxorder matters../i18nMUST be imported beforeApp. Verify withhead -10 packages/react-ui/src/main.tsxwhen investigating.- Vite dev mode hides this bug entirely. Always verify fixes against
npx nx build react-uioutput, not the dev server.