Last Updated: September 10, 2026
The Vue 3 + Vuetify 3 web UI for PhotoPrism. Built with webpack, tested with Vitest, and packaged into the Go binary as static assets.
Other frontend documentation lives next to this file:
frontend/AGENTS.mdβ agent quickstartfrontend/CODEMAP.mdβ module layout and responsibilitiesfrontend/src/common/README.mdβ dialog/focus patterns and shared helpersfrontend/tests/README.mdβ test layout
| Task | Command |
|---|---|
| Production build | make build-js |
| Watch (development) | make watch-js |
| Vitest unit tests | make test-js (sets TZ=UTC and BABEL_ENV=test) |
| Vitest watch | make vitest-watch |
| Coverage | make vitest-coverage |
| Lint and format | make fmt-js |
| Audit dependencies | make audit |
| List outdated deps | cd frontend && make dep-list |
| Refresh NOTICE | make notice |
Always invoke Vitest through
make test-jsornpm run test. Barenpx vitest runskips thecross-envwrapper that setsTZ=UTC BUILD_ENV=development NODE_ENV=development BABEL_ENV=test. Without those, ~50 component and TZ-sensitive tests fail spuriously.
Test pool is
forks, notvmForks.sanitize-html(used bycommon/util.js) depends onhtmlparser2, which is ESM-only from v11 onward β the version its2.17.6XSS fixes require. ThevmForksVM executor cannotrequire()an ES module, so it fails to loadhtmlparser2withCannot use import statement outside a module. Theforkspool runs each file in a real Node process (Node β₯ 22.12, which supportsrequire(ESM)), so the ESM dependency loads natively β no downgrade of the security-criticalsanitize-html/htmlparser2needed. Becauseforksexternalizesnode_modules, Vuetify is inlined (test.server.deps.inline: [/vuetify/]in the threevitest.config*.mjs) so Vite transforms its CSS imports instead of Node throwingUnknown file extension ".css". Trade-off: inlining Vuetify raises transform/setup CPU vs.vmForks(wall-clock is comparable with enough cores). Revisit if Vitest's VM pools gainrequire(ESM)support.
Vitest configs are
.mjs, not.js.frontend/package.jsonhas no"type": "module"(webpack, PostCSS, and gettext configs are CommonJS), so avitest.config.jswritten withimport/exportis only loadable through Vite's bundling config loader. Vite 8 warns thatconfigLoader: "native"becomes the default in a future major, at which point that file would fail outright β hencevitest.config.mjs,vitest.config.pro.mjs, andvitest.config.portal.mjs. The native loader runs them as real ES modules, so they useimport.meta.dirnamerather than the CommonJS__dirnamethe bundling loader used to inject. The--configflags inpro/Makefileandportal/Makefilename these files explicitly; keep them in sync with any rename.
Pins are intentional. When a version is locked without a caret (e.g., "axios": "1.19.0"), it is intentional. Before adjusting any pin, check the table below, the inline // comments at the top of package.json, and the git log (git log -p -- frontend/package.json | grep -B2 -A4 "<pkg>").
| Package | Pin | Reason |
|---|---|---|
webpack |
5.107.2 |
5.108.0 changed production scope-hoisting so the option builders in frontend/src/options/options.js (Languages, TimeZones, StartPages, MapsAnimate) threw Cannot read properties of undefined, breaking the login and Settings pages in all editions. The build itself succeeds β the failure only appears in the minified bundle at runtime, so make build-js is not a sufficient check. Reverted in 16eb3fb70 and pinned exactly (no caret) so a patch bump cannot reintroduce it. Before lifting the pin, run make build-js and load the login page plus Settings in a browser against the production bundle (not make watch-js, which builds unminified). |
vuetify |
3.12.2 |
3.12.3+ added an onFocusout handler to VAutocomplete/VSelect/VCombobox that closes long autocomplete/select dropdowns on open (#5538). Still unfixed in 3.12.5; upstream development moved to v4. See the long //vuetify comment in package.json and frontend/CODEMAP.md for retest steps. |
axios |
1.20.0 |
High-risk package. Originally pinned to 1.14.0 after the March 2026 supply-chain compromise (malicious 1.14.1/0.30.4 from a hijacked maintainer account). Quarantine was unwound on 2026-04-27 once OSV-Scanner came back clean; bumped to 1.17.0 on 2026-06-10, to 1.18.1 on 2026-06-22 (strips caller-supplied sensitive headers on cross-origin redirects, rejects malformed http/https URLs, tightens prototype-pollution defenses), then to 1.19.0 on 2026-08-05 (raises form-data to ^4.0.6 against its CRLF-injection advisory, fixes NO_PROXY matching for IPv4 and wildcard entries, and stops dispatching after a synchronous request-interceptor failure; same dependency set, no breaking changes, OSV-Scanner clean), then to 1.20.0 on 2026-08-27 (hardens configuration reads against shared and foreign prototype pollution, normalizes unsafe interceptor replacement objects, stops the interceptor handler array growing and tolerates a nullish handlers field, and keeps structural method-header buckets out of request headers; form-data, follow-redirects and proxy-from-env unchanged, OSV-Scanner clean over 1,188 packages). One behavior change to know: a request the browser cancels on navigation now rejects with ECONNABORTED instead of resolving with status 0, so it reaches the error interceptor in frontend/src/common/api.js rather than the success path; that path already treats a missing response as code 0. Keep an exact pin (no caret) per industry guidance for high-risk packages. |
frontend/package.json and root package.json declare matching overrides. Mirroring them keeps the npm workspace lockfile resolution consistent.
| Override | Reason |
|---|---|
"serialize-javascript": "^7.0.5" |
Closes the workbox-build β @rollup/plugin-terser β serialize-javascript RCE advisory (GHSA-5c6j-r48x-rmvq, GHSA-qj8w-gfj5-8c6v). |
When an upstream advisory is fully resolved, retire the override and rerun make audit plus a focused build/test pass before committing the cleanup.
Some major upgrades are blocked by config-file module style (the configs referenced below are CommonJS today and need an ESM migration first) or by a bundler-level incompatibility in the package itself. Track each as its own change:
| Package | Latest | Blocker |
|---|---|---|
postcss-preset-env 11.x |
ESM | frontend/postcss.config.js is CommonJS (module.exports = { plugins: [require("postcss-preset-env"), ...] }). |
webpack-manifest-plugin 6.x |
ESM | frontend/webpack.config.js is CommonJS (require("webpack-manifest-plugin")). Webpack accepts ESM configs, but the migration is non-trivial. |
vue3-gettext 4.x |
ESM | v4 is ESM-only and exports its extraction tooling from the same runtime entry: dist/index.js does an unconditional import PO from "pofile", and pofile calls require("fs"). The exports map has no runtime-only subpath and the package sets no sideEffects: false, so webpack cannot tree-shake it β make build-js fails with Can't resolve 'fs' in pofile/lib/po.js. The runtime API itself (createGettext({ translations, silent, defaultLanguage }), the $gettext/$ngettext/$pgettext/$npgettext globals, %{} interpolation) is compatible and the removed <translate> component / v-translate directive are unused here, so the only fix needed is a bundler workaround (e.g. resolve.fallback: { fs: false }, which ships dead extraction code), an upstream split of runtime vs. tooling exports, or a migration to Vite (Rollup externalizes fs instead of failing). |
vuetify 4.x |
β | See the vuetify row in Currently Pinned Packages; also a separate v3 β v4 migration project. |
vue-router 5.x |
β | Major release with breaking changes across frontend/src/app/routes.js and dynamic imports. Needs its own evaluation pass with TestCafe verification. |
- Babel 8: Requires Node.js
^22.18.0 || >=24.11.0and an ESM/tooling migration; retain Babel 7 with the current Node.js>=22.15.0baseline.babel-plugin-polyfill-corejs31.0.0 advertises Babel 7 compatibility, but its resolved@babel/helper-plugin-utils8 dependency requires@babel/core8 and leavesnpm lsinvalid. Keep 0.14.2 until the Babel toolchain moves together. - ESLint 10:
eslint-plugin-import2.32.0 declares support only through ESLint 9. Keepeslintand@eslint/json matching 9.x versions until the plugin stack supports the upgrade. - cssnano 9: ESM-only and requires Node.js
^22.22.3 || ^24.15.0 || >=26.0; the current CommonJS PostCSS config and declared Node.js baseline need a coordinated update first. - jsdom 30: Requires Node.js
^22.22.2 || ^24.15.0 || >=26.0.0, excluding supported Node.js versions. Keep 29.x until the baseline is deliberately raised.
webpack-cli7 works with the pinned webpack 5.107.2. Its--node-envoption was replaced by--config-node-envin the production, development, and analyzer scripts; keep this distinction from the webpack runtime pin.sass-loader17 supports CommonJS consumers and the existing Dart Sass implementation. Its defaultautoAPI selects the modern compiler when available; legacynode-sasssupport was removed.eslint-webpack-plugin6 supports ESLint 9, and this configuration does not use its removedthreadsoption.eslint-formatter-pretty7 continues to load through the existing dynamic import.- Vitest 5 and its matching V8 coverage provider use the existing
.mjsconfigs andforkspool. Its default mock-history clearing is compatible with the shared frontend suite. @testing-library/jest-dom7 adds a required@testing-library/dompeer, resolved automatically in the workspace lockfile.
MapLibre GL JS 6 renders Places, lightbox mini-maps, and the location editor. It requires a working WebGL2 context; the low-resolution style uses the same renderer and is not a WebGL1 fallback. When maps cannot initialize, a localized map-unavailable message leaves photo browsing, location information, coordinate entry, and location search available.
src/common/map.js probes WebGL2 and shares the lazy renderer import between concurrent map mounts. src/common/maplibregl.js uses namespace imports, configures the same-origin module worker, and preserves the language-label adapter's fluent setStyle contract. Arabic and bidirectional text use MapLibre's built-in shaping rather than a separate RTL plugin.
The webpack build emits maplibre-gl-worker.mjs and its sibling maplibre-gl-shared.mjs together under maplibre/<package-version>/. The version is read from the installed package, so the worker and its relative import stay aligned through upgrades. Both assets appear in the flat manifest and production precache. Keep the worker as a module asset; emitting it without its shared sibling leaves maps unable to load tiles. This setup does not require lifting the webpack 5.107.2 pin.
component/map.vue supplies missing style images through setMissingStyleImageResolver. Places waits for GeoJSONSource.setData() before reconciling markers. Verify style/language changes, clustering, marker clicks and dragging, globe/terrain controls, and the unavailable-WebGL2 path when updating the renderer.
Status: not started β tracked in #5679. The production bundle is built with webpack (frontend/webpack.config.js, run via make build-js); Vitest already runs on Vite (frontend/vitest.config.mjs), so a production migration would converge the two toolchains. This is its own project (related: #5659 test-side Vite v8, and the historical #838). It is not a prerequisite for any single dependency bump; treat the ESM-only unblock (e.g. vue3-gettext 4.x) as a side benefit, not the reason.
Why it helps the ESM-only blockers. Vite/Rollup externalize Node built-ins for the browser β a fs import resolves to a stub that only throws if actually called at runtime β instead of failing the build the way webpack 5 does. Verified locally: a minimal Vite build of vue3-gettext 4's createGettext succeeds with a Module "fs" has been externalized warning. Caveat: Vite does not tree-shake the dead extraction tooling either (the package sets no sideEffects: false), so the runtime bundle still carries the pofile/parser code β migrate for the modernization, not as a bundle slimmer.
The contract that must not break. The Go server reads a flat asset manifest: internal/config/client_assets.go (ClientAssets) unmarshals assets/static/build/assets.json keyed by logical name β app.js, app.css, share.js, share.css, splash.js, splash.css, plus named font/image entries (MaterialIcons-Regular.*, default-skin.svg, preloader.gif, β¦). That shape is webpack-manifest-plugin's output. Vite's default .vite/manifest.json is keyed by source path with nested objects and is not interchangeable, so the migration must either emit the flat assets.json from a small post-build step or teach ClientAssets.Load the Vite shape. All editions (plus/pro/portal) read the same struct, so whatever shape is chosen must hold for every edition build.
Feature-by-feature mapping (from webpack.config.js):
| webpack today | Vite equivalent |
|---|---|
3 entries app/share/splash |
build.rollupOptions.input (3 inputs) |
output β ../assets/static/build, [name].[contenthash].js, clean |
build.outDir + emptyOutDir + entryFileNames/chunkFileNames/assetFileNames with [name].[hash] |
flat assets.json via webpack-manifest-plugin |
build.manifest + post-build transform to the flat shape (see contract above) |
resolve.modules: [src] + preferRelative |
reuse the resolve.alias map already in vitest.config.mjs |
vue-loader, vue$ runtime alias, whitespace: "preserve" |
@vitejs/plugin-vue (already a dep) + alias + template.compilerOptions |
webpack-plugin-vuetify (auto-import) |
vite-plugin-vuetify (new dep) |
babel-loader + @babel/preset-env + core-js (targets the browserslist query in package.json) |
@vitejs/plugin-legacy (new dep; reads browserslist) β esbuild alone won't downlevel to the browserslist baseline |
workbox-webpack-plugin GenerateSW (sw.js, importScripts sw-scope-cleanup.js, modifyURLPrefix: static/build/, exclusions, 5 MiB cap) |
vite-plugin-pwa (new dep; workbox under the hood) with the same options |
CSS/PostCSS/Sass loader stack (mini-css-extract-plugin, css-loader, postcss-loader, resolve-url-loader, sass-loader) |
Vite native CSS (reads postcss.config.js; keep the sass dep) |
DefinePlugin Vue flags (__VUE_OPTIONS_API__, __VUE_PROD_DEVTOOLS__, __VUE_PROD_HYDRATION_MISMATCH_DETAILS__) |
Vite define |
EmitStaticFilePlugin (copies sw-scope-cleanup.js) |
publicDir or a small emit plugin |
hls.js β hls.light.min.js alias |
same resolve.alias entry |
png/jpg/svg/font asset/resource |
Vite asset pipeline β confirm the named manifest entries still land in assets.json |
CUSTOM_SRC/CUSTOM_NAME edition overlay |
env-driven resolve.alias/input |
webpack-bundle-analyzer (BUILD_ENV=analyze) |
rollup-plugin-visualizer |
make watch-js (webpack --watch) |
vite build --watch β not the dev server; Go serves the embedded build output, so HMR isn't wired into the templates |
postbuild scripts/precompress.js |
unchanged; it operates on the output dir, not the bundler |
Dependency churn. Retire webpack, webpack-cli, webpack-manifest-plugin, webpack-plugin-vuetify, workbox-webpack-plugin, eslint-webpack-plugin, webpack-bundle-analyzer, vue-loader, and the loader stack (babel-loader, css-loader, postcss-loader, resolve-url-loader, mini-css-extract-plugin, vue-style-loader, file-loader, url-loader, svg-url-loader). Add vite-plugin-vuetify, @vitejs/plugin-legacy, vite-plugin-pwa, and rollup-plugin-visualizer (vite and @vitejs/plugin-vue are already deps). Then make audit and make notice.
Validation. make build-js emits assets/static/build/assets.json in the flat shape and the Go server renders the app/share/splash entrypoints without cannot read assets.json; sw.js is generated and the precompressed .gz/.zst siblings appear; smoke-test the resolved browserslist baseline; the plus/pro/portal edition builds still produce valid manifests; finally make test-js and the acceptance suite.
Past migrations (e.g., easygettext β vue3-gettext, mocha β Vitest) have occasionally left top-level deps behind that no longer have any consumer. Before adding a new dep β and ideally as a periodic sweep β verify each candidate has a real consumer:
rg -nF "<pkg>" frontend \
--glob '!node_modules/**' --glob '!package-lock.json' --glob '!NOTICE'
cd frontend && npm ls <pkg> --allIf neither command surfaces a real source-level import or transitive consumer, the dep is a removal candidate (recent precedents: postcss-url, @vitejs/plugin-react, cheerio, @testing-library/react, vite-tsconfig-paths).
- Confirm the package has an active maintainer, scoped name, and a 2FA-protected publisher.
- Avoid packages that require
postinstall/installscripts. Installs default to--ignore-scripts. - Add to
frontend/package.json. From the repo root runnpm install --ignore-scripts --no-audit --no-fund --no-update-notifierso the workspace lockfile updates. make auditmust report zero advisories.- Run
make build-jsandmake test-js. make noticeto refreshNOTICEandfrontend/NOTICE.
- Confirm no source imports anywhere (use the
rgcommand in Known Unused or Legacy Dependencies). - Drop the line from
frontend/package.json. - Run
npm installfrom the repo root (refreshes the workspace lockfile). make audit,make build-js,make test-js,make notice.
- Check the table in Currently Pinned Packages; pinned packages need extra care.
- Check the table in Major-Version Upgrades β Known Blockers for ESM-only majors that would require a config rewrite.
- Edit the version in
frontend/package.json, thennpm installfrom repo root. - Run
make audit && make build-js && make test-js. For test runner or build tooling, also do an ad-hoc smoke test (e.g.,npm run build-analyzeforwebpack-bundle-analyzer). - Refresh
make noticeif the package count or licenses changed. - Update Currently Pinned Packages or this document if the rationale for an existing pin no longer applies.
Makefileandfrontend/Makefilefor build, test, and audit targets.frontend/package.jsonfor dependency declarations, overrides, and pin rationale comments.- Git log for the why behind any specific pin or removal β search with
git log -p -S "<pkg>" -- frontend/package.json.