Files
remark42/frontend/CLAUDE.md
T
Dmitry VerkhoturovandGitHub a5b2fe3cfc Consolidate the frontend toolchain onto babel, and ship one bundle (#2178)
Four upgrades that were finished but never merged, the compiler collapse
they enable, and the dependency sweep that follows. Direct
devDependencies go from 78 to 60 and dependencies from 10 to 9.

Three were doing the same job: `ts-loader` stripped types in webpack,
`babel-loader` did everything else, and `@swc/jest` repeated both for the
tests with its own copy of the JSX settings. Babel is the one that
survives, because the `data-testid` stripper has no equivalent elsewhere.

`ts-loader` ran `transpileOnly: true`, so it only stripped types, which
`@babel/preset-typescript` does; `fork-ts-checker-webpack-plugin` was
already what type-checks. Jest runs `babel-jest` against the same
`.babelrc.js` the bundle uses, passed as `configFile` because a
file-relative babel config does not reach the `node_modules` packages in
`transformIgnorePatterns`, and `jest.config.mjs` is plain ESM because a
`.ts` config is compiled against `tsconfig.json`, whose
`verbatimModuleSyntax` rejects ESM syntax in a file the package has not
declared as a module.

That removes `ts-loader`, `@swc/jest` and `@swc/core`. The last was
pinned to 1.2.205 from 2022 with no way forward, because newer builds
emit non-configurable exports and break `jest.spyOn` across 13 suites.

Babel compiles a file at a time with no type information, so it cannot
tell a type-only import from a real one and keeps the module. One line,
`import { boundActions } from './connected-comment'`, pulled the whole
redux store into `last-comments.mjs` and doubled it. `verbatimModuleSyntax`
and `@typescript-eslint/consistent-type-imports` mark them properly; the
statement has to be a separate `import type`, since verbatim semantics
keep an inline `import { type X }` and load the module anyway.

The legacy and modern compilations produced the same bytes. Both read the
same browserslist query, `defaults, not IE 11, not samsung 12` resolves to
chrome 109 and up, and nothing in the source needs transforming for that
set, so 28 of the 29 output pairs were byte-identical.

That made the module/nomodule switch worse than redundant: it served the
`.js` file to browsers with no ES module support, and those files carried
`??`, `?.` and class fields, so the fallback handed its own audience a
syntax error. There is now one bundle, always loaded as a module, in the
five templates and in the seven `site/` documents integrators copy from.
A production build emits 29 files rather than 58, in about 3 seconds
rather than 17. Two of those documents did not work at all beforehand:
the SPA snippet could not parse, and the subdomain example had an
unterminated string.

`@babel/core` 8 declares `^22.18 || >=24.11` and `size-limit` 13 declares
`^22.18 || ^24 || >=26`, so 20 was below the floor of two things installed
here; pnpm only warns, which is why every build passed. All seven places
the frontend pins it move together. `site/` is untouched: it builds with
yarn and eleventy and installs neither.

`eslint --print-config` before and after gives 173 active rules on an
application file against 172, and 172 on a spec file and a plain JS file
against 171. What is gone is three `flowtype` rules with no Flow here,
`no-new-object` and `no-new-symbol` whose upstream replacements are on,
`react/forbid-foreign-prop-types` with no propTypes anywhere, and, on TS
only, `no-useless-constructor`, whose typescript-eslint version is on at
error. `@babel/core` is pinned to 8 across the workspace because
`@jest/transform` and `istanbul-lib-instrument` depend on 7 outright; a
second scoped override holds `eslint-config-preact` on 7, since its
`@babel/eslint-parser` loads babel 7 syntax plugins.

`fast-async` rewrote every async function into nodent promise chains,
calls babel's `transform` synchronously, which babel 8 removed, and every
browser in the target list runs async natively. `prefresh` blew its stack
on `createContext` under babel 8 with no newer release to move to, which
compiled `intl.tsx` and `store/context.tsx` into throwing stubs, so
`pnpm dev:app` could not run the widget at all. `core-js` is not injected
now that `useBuiltIns` is gone, `postcss-custom-properties` was reached
directly although nothing declared it and resolved only through pnpm's
private hoist directory, and `cssnano` ran in both postcss chains although
`CssMinimizerPlugin` already uses it.

`pnpm lint`, `pnpm test` and `pnpm build` now work from `frontend/` as
`CLAUDE.md` and the contributing guide have always said they do; the
workspace root defined none of them.
2026-08-21 19:13:25 -05:00

12 KiB

Frontend gotchas

Non-obvious constraints in the frontend toolchain and widget. Read before bumping dependencies or the node/pnpm versions, and before changing how the widget renders or how translations are extracted.

The node/pnpm version is pinned in many places, not one

CI staying green does not mean every pin is consistent — .nvmrc in particular is never read by CI, so it can silently drift. After changing the node or pnpm version, grep the whole repo and update every one of these, not just the ones CI exercises:

  • Dockerfile (production image) — FROM node:X-alpine and npm i -g pnpm@X.Y.Z
  • frontend/.nvmrc — not read by CI at all; only matters to a human running nvm use locally. This is the one that drifted unnoticed: it sat at 16 through the whole node-20 migration because nothing red ever pointed at it.
  • Every package.json's packageManager field (frontend/package.json, frontend/apps/remark42/package.json) and frontend/apps/remark42/package.json's engines block
  • pnpm/action-setup@vN blocks in .github/workflows/ci-frontend.yml (5) and release.yml (2) — pin version: to the exact patch (e.g. 10.10.0), matching packageManager, not just the major. A floating major here is silent in CI (it just resolves to whatever the latest patch is at run time) but breaks the "Dockerfile and CI use the same pnpm" guarantee.
  • node: matrices in .github/workflows/ci-frontend.yml (every entry, not just the first) and the node-version: values in release.yml

When bumping pnpm/node, also re-check frontend/apps/remark42/package.json's engines field — it's separate from packageManager and won't update itself.

engines.node states the major we support, currently >=24, which is the active LTS; 22 has dropped to maintenance. @babel/core 8 wants ^22.18 || >=24.11 and size-limit 13 wants ^22.18 || ^24 || >=26, so 22 was the floor rather than the target. Individual dev dependencies can be stricter within that major (undici wants >=20.18.1); do not chase those patch floors into engines or the docs, or every lockfile refresh becomes a documentation change.

pnpm 10's stricter node-linker layout needs explicit pins

One dep is pinned specifically because of pnpm 10's hoisting changes, not because of the dep itself:

  • @types/minimatch 5.1.2 — 6.x is an empty stub that the hoisted layout picks up instead of the real types

If a dependency bump mysteriously breaks types or module resolution only after a pnpm major bump, suspect the layout change before suspecting the dependency.

node's native fetch requires absolute URLs in tests

It rejects relative request URLs, and the failure is silent: requests simply never match. Any test harness that mocks fetch needs absolute base URLs and a jsdom base URL set.

Babel is the only thing that compiles

.babelrc.js drives both the bundle, through babel-loader, and the tests, which get it as babel-jest's configFile in jest.config.mjs. That indirection is not decoration: .babelrc.js is a file-relative config and would not reach node_modules, so the ESM-only packages in transformIgnorePatterns would arrive at jest untransformed.

Babel merges an env block over the root rather than replacing it, so anything the test run must not see has to be decided before the object is built rather than put in an env. jest.config.mjs sets BABEL_ENV itself and .babelrc.js reads it for two things: the compile targets, and the data-testid stripper, which the suites query by and the bundle must not carry. Verify with grep -c data-testid public/*.mjs after a production build; it must be 0. The plugin matches a literal JSXAttribute, which is every use in the tree; a spread such as {...{'data-testid': x}} would reach production, and that grep is what would catch it.

tsconfig.json types but never emits, so its target has no effect on output; browserslist in .babelrc.js decides that. Type checking runs out of band through fork-ts-checker-webpack-plugin.

Type-only imports have to say so

Babel compiles one file at a time with no type information, so it cannot tell that an import is types-only. Left unmarked, the module and everything it imports stay in the bundle, and a single type import of a store-connected component is enough to pull the whole redux graph into an entry that never uses it.

verbatimModuleSyntax in tsconfig.json and @typescript-eslint/consistent-type-imports are what enforce this, and the fix has to be a separate import type { ... } statement. Inline import { type X } does not work here: verbatim semantics keep the statement, so the module is still loaded. That is also why no-duplicate-imports runs with allowSeparateTypeImports, since a module legitimately appears twice.

JSX runs on the automatic runtime, in two places that must agree

preact 10.29 types a component's return as ComponentChildren, which only satisfies a JSX check on TypeScript 5.1+ via JSX.ElementType, and it scopes the JSX namespace to preact/jsx-runtime rather than declaring it globally. So the type layer has to use the automatic runtime:

  • tsconfig.json: jsx: react-jsx with jsxImportSource: preact
  • .babelrc.js: @babel/preset-react with runtime: 'automatic', importSource: 'preact'

Keep both in step. If babel were left on the classic pragma: 'h' transform, a new .tsx without import { h } would type-check and lint clean, then throw at runtime, because eslint-config-preact sets react/react-in-jsx-scope to 0 and the local config turns no-undef off.

@babel/core is pinned to 8 for the whole workspace

@jest/transform and istanbul-lib-instrument depend on @babel/core 7 outright, and a babel 8 preset loaded into a babel 7 core fails on the first enum it meets. The pnpm.overrides entry in frontend/package.json is what stops that. eslint-config-preact is the one consumer that cannot take it: its @babel/eslint-parser loads babel 7 syntax plugins that babel 8 rejects, so a second scoped override, eslint-config-preact>@babel/core, holds that subtree on 7.

Held-back majors

These were deliberately not bumped because each is a config-migration or bundle-changing major, not a drop-in update — don't bump them opportunistically inside an unrelated dependency PR:

  • eslint 10 (eslint-config-preact peers on ^9), typescript 7, redux 5 with redux-thunk 3, node-emoji 2 (renames the field search() returns), @formatjs/cli 6 (the translation extraction pipeline), and postcss-preset-env 11 with cssnano 8 and postcss-html 2, which change generated CSS
  • redux 4

/web has a second source

privacy.html, markdown-help.html and 400x400.jpeg live in backend/app/webassets/assets and are embedded in the Go binary. The frontend build wins for any name present in both, which is what lets an operator override one by dropping a file next to the frontend files in --web-root. That directory replaces the embedded frontend outright when it exists, so an override belongs in a populated one. These three sit outside this toolchain: prettier, stylelint and pnpm lint do not see them, and they are served unminified. devServer.static lists the build output first and that directory second, matching the backend's order, so links to them resolve on the dev port too.

eslint config resolves from the process cwd

eslint.config.mjs lives in apps/remark42, and eslint loads the config next to the directory it is run from rather than the one nearest the file being linted. Anything that invokes eslint has to have apps/remark42 as its working directory: the workspace-root lint script filters to the app, .husky/pre-commit cd's into it, and an IDE integration needs "eslint.workingDirectories": ["frontend/apps/remark42"].

Rules that only exist under a plugin's flat-config export are spread in explicitly; the block that re-enables project rules is the last entry in the array, because eslint-config-prettier (pulled in by prettierRecommended) switches several of them off and later entries win.

Verifying a build didn't regress

There's no automated build-output diff in CI. Before merging a dependency PR that touches the bundler/build tooling, manually diff the build output against a clean master checkout:

  • apps/remark42: expect webpack module-id numbers and css-module class tokens (e.g. .F_A.L_A) to differ — that's normal churn from a webpack/css-loader bump. HTML, CSS values, and translation content should be byte-identical.
  • site: asset URLs carry a content hash, so a stylesheet or script change moves the filename on every page referencing it. Anything else differing is a real regression.

Where the alerts actually were

When clearing Dependabot/audit alerts, check whether the flagged package is actually reachable from production code or only from the dev/test toolchain — pnpm audit does not distinguish. Several alerts here were in build-time-only tooling (webpack-dev-server, laravel-mix-equivalent dev deps) with no patched release available; those are lower-risk than a runtime dependency with the same severity label.

Don't import preact/compat

It installs hooks on preact's shared options that remap onFocus/onBlur to focusin/focusout on every element and make @testing-library/preact rewrite change to input, so one import changes event behaviour across the whole widget, and it adds about 3.8 kB gzipped.

forwardRef lives only there, so a component that needs a ref takes it as an ordinary prop; TextareaAutosize is the pattern. For a component type annotation use FunctionComponent from preact, not React.FC.

i18n is a hand-written binding whose export names are fixed by the extractor

app/common/intl.tsx provides IntlProvider, useIntl, createIntl, defineMessages, FormattedMessage and IntlShape. formatjs extract (translation:extract) finds messages by recognising the identifiers defineMessages, FormattedMessage and intl.formatMessage in the AST, not by import source, so those three names are fixed. Rename any of them and extraction returns nothing, with no error and a zero exit code.

The damage lands on the next step. translation:check compares locale keys against extracted keys and fails loudly, but tasks/generateDictionary.js calls removeAbandonedKeys, so translation:generate after a rename deletes the now-unextracted keys from all 24 files in app/locales/ and writes them back, after which the check passes. Extract-then-generate is the documented translator workflow, so the destructive order is the normal one.

After any rename or refactor touching those identifiers, verify the count rather than the exit code:

pnpm translation:extract
node -e "console.log(Object.keys(require('./extracted-messages/messages')).length)"

It must match the key count in app/locales/en.json, not drop to 0.

The binding implements {name} interpolation and paired <tag>text</tag> rich text, and nothing else of ICU: no plural, select or selectordinal, no typed arguments such as {n, number}, and no apostrophe quoting. A plural or select form has braces the placeholder rule rejects, so it falls back to English wherever values are passed and shows its raw text where they are not; '' simply stays as two apostrophes.

translation:check validates every translated value's markup and placeholders against the English string it translates, mirroring the binding's rule, and the catalogue sweep in app/common/intl.test.tsx additionally renders the two messages that carry a link. What neither catches is unsupported ICU syntax, since a plural form is well-formed text as far as both are concerned. A message the binding cannot resolve falls back to the English source rather than reaching the page, so without these checks a broken translation is invisible in the interface.