Skip to content

build: replace Create React App with Vite - #1567

Open
xantorres wants to merge 41 commits into
apache:mainfrom
xantorres:build/replace-cra-with-vite
Open

build: replace Create React App with Vite#1567
xantorres wants to merge 41 commits into
apache:mainfrom
xantorres:build/replace-cra-with-vite

Conversation

@xantorres

Copy link
Copy Markdown

Replaces react-scripts and react-app-rewired with Vite.

The configuration preserves the two output contracts the server depends on: the build directory stays at ui/build, which ui/static.go embeds, and emitted assets stay under static/, which internal/router/ui.go serves as a route. Files keep the static/js, static/css and static/media grouping, because both .gitignore and the analyze script match on that layout. Production sourcemaps stay on, and the REACT_APP_ prefix is retained so ui/scripts/env.js remains the single source of truth for configuration shared with the server. That same script's public_url value now also drives Vite's base configuration, and the manifest link in ui/index.html uses Vite's %BASE_URL% macro instead of a hardcoded path, so a non-root deployment keeps every asset and manifest reference prefixed the way it did under the previous toolchain's PUBLIC_URL wiring.

Agentic tooling did the mechanical work in this migration; every change was reviewed by a human before being committed.

Server-side changes, and why they were unavoidable

Three parts of the Go side had one bundler's output format encoded into them. A frontend-only migration was attempted first and is not possible.

GetStyle() matched a literal tag shape. It scraped index.html with regexes requiring classic scripts with defer first and stylesheet links with href before rel. Any other shape returned nothing, and server-rendered pages would load with no JavaScript and no stylesheet while every build step still reported success. The tags are now read from the parsed document, so attribute order, attribute set and quoting no longer matter, and the next bundler change cannot reintroduce this.

header.html re-emitted those paths as classic scripts. An ES module loaded through a classic script tag fails on its first import, so fixing the parsing alone would still have shipped broken server-rendered pages. The tag is now declared as a module. Rewriting the built index.html instead was not an option, because internal/router/ui.go serves that same file to boot the SPA and it cannot misdeclare its own script type.

GetStyle() returned a single stylesheet. The previous build emitted exactly one entry stylesheet; the current one emits two, so pages loaded partially unstyled. It is now a list, mirroring how script paths were already collected and prefixed.

Route code splitting

Routes loaded pages with lazy(() => import(`@/pages/${pagePath}`)). That shape cannot be statically analyzed, so no page received its own chunk and the specifier reached the browser untransformed, leaving every lazily routed page unable to load. Pages are now enumerated with a bounded glob covering the three directory depths routes actually use, excluding component subtrees so their own index files do not become route chunks.

A page path with no matching module now rejects with the requested path and the list of known keys, surfacing through the existing route error boundary.

Three behaviour changes, called out deliberately

The markdown editor now renders its themed background. src/components/Editor/index.scss read var(-bs-body-bg) with a single leading dash. That is not a valid custom property reference, so the declaration was discarded and the editor never received the background it asks for. The previous CSS minifier accepted the invalid value; the current one rejects it, which is how it surfaced. Fixing it changes rendering.

The custom stylesheet link now derives its href from the build's own base, not a separately computed value. PageTags built the /custom.css link from process.env.PUBLIC_URL, which the previous toolchain exposed with its trailing slash already stripped; at the default configuration that value was an empty string, resolving to /custom.css. import.meta.env.BASE_URL, the direct equivalent under the new toolchain, keeps the trailing slash, so substituting it in the same place would resolve the same default configuration to //custom.css instead. The trailing slash is stripped explicitly before the substitution, reproducing the previous output at the default configuration and staying correct away from it. The output here is unchanged; only the mechanism it depends on is.

Two routes were dead ends; one now renders, and the other now fails loudly instead of silently. pages/403 and pages/Admin/UserOverview both referenced modules that did not exist in the tree, and both failed the same way, silently rendering blank. pages/403 has been repointed at pages/404/403, an existing component, and now renders correctly; that route is no longer dead. pages/Admin/UserOverview still references a module that does not exist; it now reports the missing path through the route error boundary instead of rendering blank. Neither is a regression: the pages/403 fix restores a working page, and the pages/Admin/UserOverview change only makes an existing gap visible. Creating that missing page file is a separate, content decision.

Dependency removals

react-scripts, react-app-rewired, customize-cra and config-overrides.js are gone, and yaml-loader is replaced by the equivalent Vite plugin, pinned to the schema the previous loader used so bare dates and merge keys keep parsing the same way they did before.

Three removed packages were already inert before this migration. Both purgecss packages were declared but wired nowhere: no postcss config exists, the overrides file never referenced them, and no script invoked them. buffer was aliased and provided as a global, but no application source uses it, and the one dependency requiring it declares buffer: false in its own browser field.

sass and @types/node are raised to the versions the toolchain requires; the previous sass predates the async compiler API it now calls. sass is pinned below the release that begins deprecating @import, which this project uses across 30 files. Migrating those to @use is a separate concern. Bootstrap's own Sass internals print dozens of dependency deprecation warnings on every build, unrelated to anything in this project's own styles; vite.config.mts sets css.preprocessorOptions.scss.quietDeps: true to silence those specifically while still surfacing warnings from this project's own stylesheets.

The eslint config no longer extends react-app/jest, which shipped inside react-scripts and configured rules for a test suite this project does not have.

A third failure, found by running the built application

With the build green, the two checks above passing and the dev server working, the built application did not boot. React never mounted, the page showed its loading spinner indefinitely, and the browser console was empty.

i18next attaches its resource-store methods to the instance inside init(). The builtin plugins register their translations while their modules are being evaluated. Whether that happens before or after init depends on how the bundler groups and orders chunks, so the previously working order was incidental rather than guaranteed. When it inverts, the registration throws while the entry module is still evaluating, which takes the application down before it mounts and produces no console output.

Registration now happens immediately only when there is an initialised instance to register into, and otherwise falls to the initialized handler the code already installed, which is correct in either order.

Review hardening

A closer pass after the initial migration found several places where the new toolchain worked but was not yet equivalent to the old one.

Bootstrap icon fonts. The bootstrap-icons stylesheet points at font files under a path the bundler could not resolve on the first migration pass, so the build silently emitted zero font files and every icon rendered as a missing-glyph box. The stylesheet now overrides the package's font-directory variable to a path Vite can resolve; the fonts are emitted, and the browser boot test described below confirms they are served.

Type checking. pnpm build now also runs tsc --noEmit before the bundler runs, and is clean today. The previous toolchain ran its checker in the dev server (blocking) and during builds (downgraded to warnings by this project's TSC_COMPILE_ON_ERROR setting); none of that carried over when the bundler changed, so until this fix a type error shipped with no signal at all.

Yaml parsing. The Vite yaml plugin defaults to js-yaml's more permissive schema, which resolves bare dates to JS Date objects and enables merge keys; the previous loader's schema kept both as plain strings. The plugin is now pinned to that same schema.

Module script tag shape. header.html's module script tag now carries crossorigin, matching the tag Vite's own build emits for the equivalent client-rendered entry point. This does not create a new requirement: a type=module script already fetches in CORS mode, so any CDN origin serving these files already needs to send the matching CORS headers, with or without crossorigin present.

Declared Node range. The bundler's declared support range is ^20.19.0 || >=22.12.0. ui/package.json's own engines.node allowed >=20, which admits versions below that floor, and now matches it exactly. The release workflow's pinned Node, which also sat below the floor, is raised to 20.19.0 to match. That version bump is the only change this branch makes anywhere under .github/; it does not add a job.

Guard checks now fail closed. All three make check-ui checks preset their result to failure and only report success once they have proved it. The two Node scripts, check-locale-resolution.js and check-plugin-i18n-order.js, bound every server-creation, listen and close call with its own timeout and add an unreferenced whole-run watchdog, so a hang cannot silently drain the event loop into an accidental success instead of a reported failure. check-built-assets.sh gained a preflight that fails if the Go test it depends on is ever renamed or removed, plus a fourth self-check fixture confirming a non-stylesheet <link rel="manifest"> is never miscounted as a stylesheet.

Typed environment variables. import.meta.env.REACT_APP_* previously typed as any, since no ImportMetaEnv augmentation existed; a typo'd key would compile clean and only surface as a missing value at runtime. The two keys the app reads this way, REACT_APP_API_URL and REACT_APP_BASE_URL, are now declared, with strictImportMetaEnv enabled so an undeclared key is a type error instead of a silent any.

Verification

  • pnpm build completes with no errors and exactly two warnings: front-matter, a dependency unrelated to this project's own pinned js-yaml@^4.1.0, pulls in a legacy js-yaml@3.x copy whose buffer import gets externalized for browser compatibility, and one chunk exceeds the default size threshold; see the note on chunking below. tsc --noEmit, which now runs as part of this build, is clean.
  • make check-ui passes, covering three behaviours a successful build does not demonstrate: that the server can still find the built asset paths, that a non-default language resolves and is fetchable at runtime, and that plugin translations register regardless of module evaluation order, verified through both the deferred and the immediate registration paths. All three checks fail closed: each presets its result to failure, bounds every step that can hang with its own timeout, and only reports success once it has proved it.
  • Every check was observed failing before being relied on. ./script/check-built-assets.sh --self-check rewrites the built index.html with four shapes the server cannot parse, including a non-stylesheet manifest link, and asserts the check fails on each, so a check that quietly stopped asserting anything is distinguishable from a passing one. The same script also fails if the Go test it depends on is ever renamed or removed, instead of silently matching nothing.
  • On the Go side: go build ./... succeeds, so the embed still resolves; go vet ./... reports no issues; TestGetStyleResolvesBuiltAssets passes; and the license header check (script/check-asf-header.sh) passes.
  • The built application was booted against sqlite3 and loaded in a browser. /, /questions, /tags and /users render with the client mounted, every asset and font loads, and the console is empty.

This project has no test runner and no unit tests, before or after. These are three targeted regression checks, not a test suite.

Measurements

Same machine, same Node and package manager versions, clean tree and clean install on both sides, five runs per timing metric.

Metric Before After
Cold production build 19.87s 4.77s
Warm build 8.75s 4.87s
Dev server time to ready 7104ms 359ms
HMR latency 421ms 139ms
Bundle JS, raw / gzip 3477.60KB / 1198.75KB 3010.49KB / 1035.57KB
Bundle total, raw / gzip 4290.39KB / 1652.24KB 3763.97KB / 1474.34KB
Direct dependencies 75 65
Packages installed 1578 689
Audit findings, critical/high/moderate/low 4/78/63/13 1/50/38/6

Five things worth stating rather than leaving to be inferred:

  • Cold and warm builds are within noise of each other, because the new build has no meaningful persistent cache to warm and now also runs a type check on every build, cold or warm alike. The old toolchain had a real warm cache, which is why its two figures differ so much. The comparison to draw is cold against cold.
  • Dev server time-to-ready is wall clock on both sides, including process spawn. The new tool self-reports a much smaller number that excludes that, and using it would compare two different quantities.
  • Chunk boundaries differ structurally between the two bundlers, so the bundle rows compare total shipped bytes rather than like-for-like chunks.
  • HMR latency was measured once, at commit 7200ca3, on both toolchains, and carried forward from there: none of the commits since touch the hot-update path.
  • All figures were captured at the branch head, d06b623, with a clean working tree on both sides.

Not included

This PR adds no CI job for the frontend. The project runs no frontend job today, and a build on every push is a cost a maintainer should choose to take on, not one this migration should impose. One constraint carries forward for whoever wires that job later: a bare go test ./... reports ok while asserting nothing about the built asset paths, because TestGetStyleResolvesBuiltAssets skips when no frontend build is embedded. Any future CI job needs to build the frontend first.

The dev server now binds to loopback only by default; reaching it from another device on the network needs an explicit --host flag.

Create React App's SVG-as-component imports (import { ReactComponent as X } from './x.svg') are not carried over to this configuration. Nothing in this codebase used them.

xantorres added 30 commits July 31, 2026 22:55
Two things the server depends on are invisible to the build. Both fail
silently, so a green build and a working dev server actively disguise them.

The server parses the script and stylesheet paths out of the built
index.html and reuses them on every server-rendered page. That parse is
coupled to the exact attribute set and attribute order the frontend build
writes into those tags. A build that emits a different tag shape still
succeeds, the dev server still works, the binary still compiles, and the
pages simply render with no scripts and no stylesheet.
TestGetStyleResolvesBuiltAssets asserts the parse still finds them.

Languages other than the default one are loaded with a template-literal
dynamic import through an alias that points outside the frontend root. A
bundler that cannot enumerate that pattern still builds and still serves a
working app; the resources never arrive, and only for non-default
languages, so a smoke test in the default language misses it.
check-locale-resolution.js bundles that same import with the project's own
configuration, runs it, and requires two languages to resolve to distinct
translated content.

Both run through make check-ui. check-built-assets.sh --self-check
confirms the asset check still fails on tag shapes the parser cannot read,
so a check that quietly stopped asserting anything is distinguishable from
a passing one.
Replaces the bundling adapter in the locale check with one that drives the
project's dev server directly, now that the dev server is the thing serving
languages.

The check now asserts two separate properties, because only one of them is
about resolution:

  1. The module graph resolves the alias and parses the file. Covered by
     importing the probe through the server's module runner.
  2. A browser is allowed to fetch it. The languages live outside the
     frontend root, so they are reachable only if the dev server's
     filesystem allow-list covers their directory.

Checking only the first passes while every language 403s in a browser.

Order is load-bearing and is commented as such. Once a module is in the
graph the dev server answers from the transform pipeline instead of reading
the file, and the request stops passing through the allow-list. Measured:
403 for a cold request, 200 for the same request after the module has been
loaded. So the reachability check resolves the path without loading it and
asks before anything else touches a language.

Observed failing on each of: alias removed, yaml plugin removed, allow-list
narrowed to the frontend root, and the application's import shape changed
out from under the probe.
The previous rules ignored /ui/build/*/*/*, which encoded the directory
depth of the old asset layout: build/static/js/main.js sits three levels
under build, so it matched. Assets emitted directly into build/static are
two levels down and matched nothing, leaving 229 build artifacts totalling
9.9 MB staged for commit.

Ignore everything under build and re-include the one file that is tracked
there, so the rules stop depending on how the build tool happens to nest
its output.
react-scripts and react-app-rewired are replaced by Vite. The configuration
preserves the two output contracts the Go server depends on: the build
directory stays at ui/build, which static.go embeds, and emitted assets stay
under static/, which internal/router/ui.go serves as a route. Emitted files
keep the static/js, static/css and static/media grouping, because both the
repository's .gitignore and the analyze script match on that layout.

Production sourcemaps stay enabled to match the previous build, and the
REACT_APP_ prefix is retained so scripts/env.js remains the single source of
truth for configuration shared with the server.

sass and @types/node are raised to the versions Vite requires; the previous
sass predates the async compiler API the current toolchain calls.
Values still come from .env files generated by scripts/env.js under the
REACT_APP_ prefix, so no configuration keys change.

The public URL needs care rather than a direct substitution. The previous
toolchain stripped the trailing slash before exposing the value, so it read as
an empty string at the site root. import.meta.env.BASE_URL keeps the slash, and
substituting it directly produced a protocol-relative //custom.css. The trailing
slash is stripped here to preserve the previous behaviour at the root and to
stay correct if the site is ever served from a sub-path.
require() in application source relied on the previous bundler's CommonJS
interop. Each call is replaced with the import form matching how the package
actually publishes itself: a namespace import for diff, which ships ESM with
named exports and no default, and named imports for semver, whose single
static export object is statically analyzable.
The tilde prefix is a webpack resolver convention with no equivalent elsewhere.
bootstrap-icons is given its explicit entry path rather than relying on the
package's sass field being consulted during bare-specifier resolution.
The declaration read var(-bs-body-bg) with a single leading dash, which is not
a valid custom property reference, so the background-color was discarded and
the editor never received the themed background it asks for. The previous CSS
minifier accepted the invalid value and passed it through; the current one
rejects it outright, which is how it surfaced.

This changes rendering: the editor now takes the themed background it was
always meant to have.
Routes loaded pages with a dynamic import built from an aliased template
literal. That shape cannot be analyzed, so no page received its own chunk and
the specifier reached the browser untransformed, leaving every lazily routed
page unable to load. The entry bundle absorbed the pages it should have split
out, growing to over a megabyte.

Pages are now enumerated with a bounded glob covering the three directory
depths routes actually use, excluding component subtrees so their own index
files do not become route chunks.

A page path with no matching module now rejects with the requested path and the
list of known keys, surfacing through the existing route error boundary. Two
routes are already in that state, pages/403 and pages/Admin/UserOverview,
neither of which has a module in the tree; both failed the same way before this
change, silently.
GetStyle scraped index.html with regexes matching one exact tag shape:
classic scripts with defer first, and stylesheet links with href before rel.
Any bundler emitting a different shape returned nothing, and server-rendered
pages would load with no JavaScript and no stylesheet while every build step
still reported success.

The tags are now read from the parsed document, so attribute order, attribute
set and quoting no longer matter.

header.html emits the scraped paths as script tags itself, and those were
classic scripts. A module bundle loaded that way fails on its first import, so
fixing only the parsing would have left server-rendered pages broken; the tag
is now declared as a module.

The self-check fixtures are replaced. The previous two asserted failure on
module scripts and on rel-before-href, both of which the parser now accepts, so
they would have inverted into false alarms. The replacements cover a stylesheet
with no script, a script with no stylesheet, and an inline script with no src.

golang.org/x/net moves to a direct requirement, matching its use here.
react-scripts, react-app-rewired, customize-cra and config-overrides.js are no
longer reachable now that the build runs on Vite. yaml-loader is replaced by the
equivalent Vite plugin.

Three of the removed packages were already inert before this migration began.
Both purgecss packages were declared but wired nowhere: no postcss config exists
in the repository, config-overrides.js never referenced them, and no script
invoked them. buffer was aliased and provided as a global, but no application
source uses it, and the one dependency that requires it declares buffer as false
in its browser field, so bundlers stub it out.

The eslint config no longer extends react-app/jest, which shipped inside
react-scripts and configured rules for a test suite this project does not have.
GetStyle returned a single stylesheet path because the previous build emitted
exactly one. The current build emits two, so server-rendered pages loaded
partially unstyled while every build step still reported success.

The stylesheet is now a list, mirroring how script paths are already collected
and prefixed, and the template renders one link per entry.

This is the same assumption as the tag-shape one fixed earlier: the server
encoded a property of one bundler's output, here that there is exactly one
entry stylesheet.
Plugin i18n modules call initI18nResource while they are being evaluated.
i18next only attaches its resource-store methods to the instance during
init, so if a plugin module evaluates before i18next.init has run, the
immediate addResourceBundle call throws.

Whether that happens is decided by the bundler: it is a function of how
modules are grouped into chunks and in what order those chunks evaluate.
Nothing in the application controls it and nothing reports it. The build
succeeds, the dev server works, the page returns 200 with the
server-rendered markup present, and the console stays empty, because the
throw happens while the entry module is still evaluating and takes the
whole application down before it mounts. The user gets the loading spinner
forever.

Register immediately only when there is an initialised instance to
register into, and otherwise let the existing initialized handler do it.
That makes the call correct in either order rather than correct in the one
order the current chunking happens to produce.
Plugin i18n modules register their translations while they are being
evaluated, and i18next only attaches its resource-store methods during
init. Which of the two happens first is decided by how the bundler groups
and orders chunks, so it has to hold in both orders.

The check loads the plugin helper without touching the application's i18n
bootstrap, so i18next is guaranteed uninitialised, registers a bundle, and
then initialises. Two assertions, because either one alone can pass while
the feature is broken: registering must not throw, and the translations
must actually be present afterwards. A fix that swallowed the error would
satisfy the first and leave every plugin string untranslated.

The check also asserts that exactly one i18next module is loaded. Without
that, the helper and the check can each resolve their own copy and every
later assertion silently measures an object nothing under test wrote to.

Observed failing on the unguarded call with the same error the browser
reports: addResourceBundle is not a function.
None of these are referenced any more, verified by searching the source,
the scripts, the workflows, the Makefile, the Dockerfile and the shell
scripts:

- postcss, declared but wired to nothing; no postcss config exists, and the
  build tooling pulls its own copy
- @testing-library/dom, already an indirect dependency of
  @testing-library/react
- @testing-library/user-event, imported nowhere

The ignore file still listed a config file that no longer exists, and two
comments pointed at an index.html path that moved.

The env generator also emitted three variables the current build never
reads: two compiler flags belonging to the old toolchain, and a bare
PUBLIC_URL that bypassed the REACT_APP_ prefix. The prefix itself stays,
since the build config reads exactly that prefix and the generator remains
the single source of truth for both sides.
The config is written in ES module syntax but was being loaded as
CommonJS, which the build tool warns about on every run and has announced
it will stop supporting. The warning printed on every check run too, which
is noise in output that is meant to be read.

Renaming to .mts makes it load as an ES module, where __dirname does not
exist, so derive the directory from import.meta.url instead. Using
fileURLToPath rather than import.meta.dirname keeps it working on the
whole Node range the package declares support for.
Module scripts are deferred by definition, so defer has no effect on them.
Leaving it there reads as though it controls load behaviour.
The linter configured for this repository flags the slice-building form,
so make lint fails on it.
src/App.test.tsx is the test that ships with a fresh Create React App
project. It asserts the text "learn react", which appears nowhere in this
application, so it would fail if it ran, and it cannot run: no test runner
is installed, and there is no test script, runner config or setup file.

It was the only reason three devDependencies and a tsconfig include
pointing into node_modules were still present. Verified before removing
that it is the only test file, that nothing else imports the testing
library, that no plugin workspace package depends on it, and that the
project still type-checks with the include gone.

The commit that removed the rest of that toolchain missed this file.
Asserting that the parsed stylesheet list is non-empty leaves the exact
regression this repository already hit uncovered: a parser that stops at
the first stylesheet returns a one element list, satisfies every existing
assertion, and silently drops the rest of the page's CSS.

Count the declarations again by a cruder method than the parser uses and
require the two to agree, so the parser has to be checked against
something other than itself. The count is a lower bound: a build that
quotes attributes differently drives it to zero and it stops constraining,
which is why it supplements the shape-independent assertions rather than
replacing them.

Verified by reintroducing the truncation and watching this fail.
bootstrap-icons.scss builds its @font-face url()s from
$bootstrap-icons-font-dir, which defaults to "./fonts", a path meant
to be relative to the partial's own location inside node_modules.
Dart Sass does not rebase url()s during @import, so that relative
path survived unchanged into the compiled CSS. Vite then had no way
to resolve "./fonts/bootstrap-icons.woff2" from the entry stylesheet,
so the production CSS shipped the literal unresolved path and the
build emitted zero font files. Every icon in the app rendered as a
missing-glyph box.

Setting the font-dir variable to a package-resolvable specifier
before importing the partial lets Vite's asset pipeline resolve and
emit the woff/woff2 files under static/media and rewrite the url()s
to point at them.
configs/config.yaml's ui.public_url used to flow into the old CRA
build's PUBLIC_URL, which set webpack's public path and was read
directly by PageTags for the custom.css href. The Vite migration
dropped that bare PUBLIC_URL variable as apparently unused, but
nothing replaced its job: vite.config.mts never set base, so
import.meta.env.BASE_URL stayed at the default "/" regardless of
config, and index.html's manifest link had been hardcoded to a
root-relative path during the migration. A deployment served from a
non-root public_url would silently lose asset and manifest
prefixing.

Derive base from the same REACT_APP_PUBLIC_URL value scripts/env.js
already generates from config.yaml, normalizing the trailing slash
Vite requires without double-slashing the root case. Let the
manifest link use Vite's %BASE_URL% html macro instead of a
hardcoded path, so it resolves the same way the rest of the built
asset tags do. PageTags' existing custom.css href logic already
derives correctly from BASE_URL once it carries the real value, so
it needed no change.

Verified with a build against a non-root public_url: the emitted
index.html's script src, stylesheet hrefs, modulepreload links and
manifest link all came out prefixed with the configured path, and
the default root config still builds byte-identical manifest and
asset paths to before.
CRA's webpack build surfaced type errors through its fork-ts-checker
plugin, so a broken type would fail the build. Vite's build has no
equivalent checker wired in, and the migration dropped type checking
from the build entirely with nothing else covering it. Run tsc in
check mode before the bundler so the build fails again on type
errors. tsc --noEmit is clean today and takes about 4 seconds.
Every build prints dozens of Sass deprecation warnings (color
function renames, mixed-decls) that originate entirely inside
bootstrap 5.3.3's own scss files. There is nothing to act on here,
and the volume buries any warning that points at our own code.
quietDeps only silences warnings whose source sass file is loaded
as a dependency, so warnings from our own stylesheets still show.
border-bottom was declared after the &:hover nested rule. Current
Sass hoists trailing declarations above nested rules, so output is
unaffected today, but a future Sass release will stop doing that and
emit border-bottom last, changing rule order. Move it above the
nested rule so source order already matches CSS order. Verified the
compiled css is byte-for-byte identical before and after (same
filenames, same content, same sha256 across every emitted css file).
yaml@2.6.1's core schema kept bare dates as strings and left merge
keys unresolved. @modyfi/vite-plugin-yaml defaults to js-yaml's
DEFAULT_SCHEMA, which resolves bare YYYY-MM-DD scalars to JS Date
objects and turns on merge key support. Nothing in the current i18n
or plugin yaml corpus hits either case, so this is a guard against a
future translator adding one and silently changing a string field
into a Date. Pin CORE_SCHEMA, which the plugin accepts directly as a
js-yaml Schema value. js-yaml is already a direct devDependency used
by scripts/env.js and scripts/loadPlugins.js, so this adds nothing
new to package.json.

Verified with a standalone parse of every file under i18n/*.yaml and
ui/src against both schemas: all 54 files parse identically, so the
pin changes nothing for the real corpus. A synthetic bare-date and a
synthetic merge-key fixture each parse differently under the two
schemas, confirming the pin has real effect where it would matter.
Module scripts fetch in CORS mode, so the tag the bundler emits for
this entry carries the crossorigin attribute. The server-rendered
template was missing it, which meant the two render paths for the
same script produced different tag shapes for no functional reason.

Adding crossorigin here brings the template in line with the built
output. Note that this attribute does not itself impose a new
requirement: any CDN origin serving these files already needs to
send Access-Control-Allow-Origin for module scripts, because that
follows from type="module" regardless of whether crossorigin is
present on the tag.
The 403 route pointed at pages/403, one directory above the module
that actually implements it. The real page lives at
pages/404/403/index.tsx, a nested sibling of the generic 404 page,
matching the same depth-two pattern already used by routes such as
Admin/Mcp and Legal/Tos. Because the referenced module never existed,
the route always fell through to the router's generic error boundary
instead of rendering the real 403 content.

Before the migration to Vite, a lookup miss like this failed silently
and produced a blank or default fallback. The new router surfaces the
mismatch as a visible error boundary, so the same wrong path now
breaks loudly instead of quietly. Pointing it at the module that
actually exists fixes both behaviors.
go test -run with a pattern matching zero tests exits 0, not an
error. If TEST_NAME in this script ever went stale, because the
underlying test got renamed or deleted, the check would keep
reporting success while testing nothing, forever, silently.

Add a guard that lists the package for the exact test name before
the first real check runs. If the name is not found, the script now
fails loudly and explains why, instead of quietly turning into a
no-op.
fail() in check-locale-resolution.js and check-plugin-i18n-order.js
called process.exit(1) synchronously on an assertion failure. That
skips any pending finally block, so the dev server each script starts
was never closed once fail() ran. It only looked fine because killing
the process tears down the listening socket anyway, but it left no
real cleanup path, and it would have silently broken the moment
anything meaningful was added after the finally in the future.

fail() now throws instead, and is caught once at the top level, so the
finally that closes the dev server always runs before the process
exits. The top-level catch sets process.exitCode instead of calling
process.exit, so the event loop can drain naturally rather than being
killed mid-async-work.

Both scripts also bound their dev-server-start and module-runner-import
calls with a 30 second timeout, so a hang in either one fails the check
instead of blocking it forever.

Confirmed the fix by perturbing a working copy to force a failure: the
new code prints the failure and exits 1, but only after the finally
block's cleanup step actually runs; forcing the same failure through
the old process.exit(1) shape prints the failure and exits 1 without
ever reaching that cleanup step.
The check only ever registered a plugin's translations before
i18next.init ran, which exercises the deferred branch in
initI18nResource: the listener registered for the 'initialized' event.
The immediate branch, which fires synchronously when i18next is
already initialised, had no coverage at all.

Add a second registration right after init and read the resource
bundle back with no i18next event and no other statement in between.
The 'initialized' event has already fired for the first scenario at
that point, so the new translations only land if the immediate branch
actually runs. Without it, this second registration would never appear
in the bundle, and every plugin whose module evaluates after
i18next.init would render untranslated.
The three existing self-check fixtures all under-count: one drops
the script, one drops the stylesheet, and one has a script tag with
no src. None of them would catch a parser that over-matches,
counting any link tag as a stylesheet regardless of its rel
attribute.

Add a fixture with a real script (has a src) and a single
non-stylesheet link (rel="manifest") and nothing else. Only a parser
that keys off rel="stylesheet" specifically, rather than just the
presence of a link tag, correctly rejects this page.
The release pipeline builds the frontend with a pinned Node version
before running goreleaser. Vite, the bundler this project now uses,
declares an engines floor of node ^20.19.0 || >=22.12.0, and the
pinned 20.18.1 sits below that floor. Bump the pin to 20.19.0 so the
release build runs on a Node version Vite actually supports.

Checked every other workflow file under .github/workflows: none of
them pin a Node version for a frontend build. The Docker image
workflows only invoke docker buildx against the root Dockerfile,
which installs Node from an unpinned Alpine package, a separate
concern not touched here.
withTimeout() wrapped server.listen() but ran before either script
could reach its own cleanup path. In check-locale-resolution.js a
listen failure threw out of openProbe() before it returned the
closable probe, so main()'s try/finally never started and the
server openProbe() had already created was never closed. In
check-plugin-i18n-order.js the same call sat above the try/finally
entirely, so a listen failure skipped past the close() in finally.

Vite creates the watcher and websocket server before listen() runs,
so either shape leaves the process alive forever on a timeout: the
event loop never drains and nothing closes the sockets.

Moved the listen call inside a try that always reaches the close:
an inline try/catch around openProbe()'s listen for the first file,
and the existing try/finally extended to cover listen for the
second. A scratch repro mirroring both shapes with a listen that
never settles confirms the process now exits on its own once the
timeout fires, instead of hanging until something else kills it.
Vite's resolveBaseUrl keeps an absolute external base exactly as
configured only when the command is build. For dev and preview
(both command=serve) it silently reduces the same base to its
pathname, dropping scheme and host. Confirmed by reading
resolveBaseUrl in the installed vite package: the external branch
only survives when isBuild is true.

Our base wiring passes REACT_APP_PUBLIC_URL straight through with
no mode check, and that is correct: only vite build output ever
reaches the Go server, which is the only thing actually deployed.
Local dev and preview serving a reduced base is a difference in a
throwaway artifact, not a bug.

Recorded the constraint next to the code so a future change does
not "fix" serve mode into breaking the one output that matters.
The declared floor was >=20, which admits Node 20.0 through 20.18,
21.x, and 22.0 through 22.11, all of which vite 8 warns about on
every invocation: its own supported range is ^20.19.0 || >=22.12.0.
Declare that exact range so the engines field and the tool agree on
which Node versions are expected to work. pnpm reports a mismatch as
a warning either way, since engine-strict is not set.
No ImportMetaEnv augmentation existed, so import.meta.env.REACT_APP_*
fell back to vite/client's index signature and typed as any. A typo
in one of those keys compiled clean and would only surface at
runtime as a missing value.

Added the strictImportMetaEnv marker interface vite/client checks
for, which drops that fallback, plus an ImportMetaEnv augmentation
for the keys actually read that way. Grepped import.meta.env.REACT_APP_
across src and vite.config.mts first: only REACT_APP_API_URL
(request.ts) and REACT_APP_BASE_URL (App.tsx, router/alias.ts)
are read through import.meta.env, so only those two are declared.
REACT_APP_PUBLIC_URL exists too, but vite.config.mts reads it
through loadEnv, never through import.meta.env, so it stays out.

Verified with a throwaway probe referencing
import.meta.env.REACT_APP_TYPO: tsc failed on it naming the probe
file, then passed again once the probe was deleted. vite/client's
own BASE_URL, MODE, DEV, PROD and SSR keys still type-check
unchanged, since strictImportMetaEnv only removes the fallback for
keys nothing declares.
creating the dev server can hang before listen is ever called, and a
hang there leaves live handles that keep the process alive even after
the timeout error is reported; bound creation with the same timeout,
exit hard once the final catch has reported (all cleanup has run by
then), and stop a failing close from replacing the error that
actually caused the failure. close() was still unbounded: it awaits
plugin buildEnd and closeBundle hooks with no timeout of its own, so a
hung close blocked the failure path's own exit, and on the success
path could hang the process after the OK line had already printed.
Bound every close() call the same way, and exit explicitly on both
success and failure, since a leaked handle from any bounded step,
close included, must not keep a finished check alive.
the earlier commits bound every known-hangable step; any await this or a
future change leaves unbounded could still hang the run, or worse, drain
the event loop and exit 0 silently. Preset the exit code to failure, add
an unreferenced whole-run watchdog that forces a verdict through any
held-alive hang, and keep both explicit exits, so every termination mode
ends with the code that matches what was proven.
@mingcheng

Copy link
Copy Markdown
Member

This is a HUGE pull request, I don't recommend merging it without any purpose or dicusssion.

@xantorres

Copy link
Copy Markdown
Author

@mingcheng Agreed: discussion first.

The purpose: react-scripts is unmaintained and officially sunset; it's behind most of ui/'s audit findings and blocks dependency upgrades. Staying on it is the riskier default.

The PR just makes the discussion concrete: the server-side coupling is mapped and the risky parts are already worked out. Happy to move to a Discussion, an issue, or the dev list, and to split this into reviewable stages. Your call, no rush.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants