feature: Performance improvements - #11
Conversation
Lazy-load all page components in App.tsx (565KB main chunk -> 200KB, per-page chunks fetched on nav). Register @fastify/compress globally on the BFF (gzip/brotli, SSE exempt via reply.hijack()). Cache hashed /assets/* for 1y immutable; index.html stays no-cache. Targets FCP regression vs the HTMX app (1.4s vs 0.6s). Signed-off-by: Gabriel Costa <gabrielcg@proton.me>
5c38bd3 to
a705dfa
Compare
marekdano
left a comment
There was a problem hiding this comment.
Frontend: route-level code splitting (src/App.tsx) — looks correct
Backend: compression (server/src/plugins/compress.ts) — correct
Backend: static caching — bug found, confirmed by test
server/src/plugins/static.ts:32-37 sets maxAge: "1y", immutable: true at the root @fastify/static registration (prefix: "/", covering all of server/public/), with the stated intent being to cache only Vite's content-hashed /assets/* files. The SPA-fallback 404 handler correctly overrides this back to maxAge: 0 for index.html — but that override only applies when a request falls through to the 404 handler (i.e. /app/* deep links). It does not apply when a file is served directly by @fastify/static's own route matching.
I verified this empirically with a throwaway Fastify harness against the real plugin:
GET /favicon.ico → cache-control: public, max-age=31536000, immutable
GET /index.html → cache-control: public, max-age=31536000, immutable (same file, direct request)
GET /app/login → cache-control: public, max-age=0 (SPA-fallback path — correct)
favicon.ico is a real, non-hashed file that Vite copies verbatim from public/ (vite.config.ts → outDir: "server/public"), and it's explicitly allowlisted as a known static path in the fallback handler's comment — so it's
a real, reachable route, not a hypothetical. Two concrete problems:
- If the favicon is ever updated, browsers/CDNs that fetched it once will keep serving the stale one for up to a year (
immutablealso skips conditional revalidation). index.htmlitself is only safe because nothing in this app currently links to/index.htmldirectly (/redirects server-side to/app/, and all client routes 404-fall-through to the explicit override). But that safety is incidental, not structural — any direct request, bot crawl, or CDN edge caching/index.htmlverbatim would cache the shell (and its script-tag references to the old hashed bundle) for a year, immutably, defeating the very cache-busting this PR is trying to add.
Fix: don't set maxAge/immutable at the plugin-registration level. Scope the long-cache header to /assets/* only, e.g. via @fastify/static's setHeaders(res, pathName) option:
setHeaders(res, pathName) {
if (pathName.includes(`${path.sep}assets${path.sep}`)) {
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
}
}That also lets you drop the now-unnecessary { maxAge: 0, immutable: false } override on the fallback sendFile call, since nothing outside /assets/* would get long-cached in the first place.
|
Also, please add tests for changes in |
Signed-off-by: Gabriel Costa <gabrielcg@proton.me>
| // Only /assets/* is content-hashed by Vite, so only it gets long-cached. | ||
| setHeaders(reply, pathName) { | ||
| if (pathName.includes(`${path.sep}assets${path.sep}`)) { | ||
| reply.header("Cache-Control", "public, max-age=31536000, immutable"); |
There was a problem hiding this comment.
server/src/plugins/static.ts:38
The 1-year immutable cache header is applied to the whole static root, not just the hashed /assets/* prefix. Direct requests to index.html (or any non-hashed file copied from public/) get cached immutably for a year, so after the next deploy (which wipes and regenerates hashed chunk names) clients can get stuck on a stale shell referencing chunks that no longer exist.
There was a problem hiding this comment.
Thanks for flagging this. I think it's actually already handled: the header is only applied when the resolved file path contains /assets/:
if (pathName.includes(`${path.sep}assets${path.sep}`)) {
reply.header("Cache-Control", "public, max-age=31536000, immutable");
}index.html and anything copied verbatim from public/ don't match that condition, so they fall through with no Cache-Control header set (i.e. @fastify/static's default, effectively no long-term caching). It's not being applied to the whole static root.
That said, you were right about the underlying risk: this exact bug (unconditional maxAge: "1y", immutable: true on the whole root) existed one commit back, before Fix caching issues replaced it with this scoped setHeaders check. Might be worth a re-review/resolve on this thread since it looks like the comment landed on the pre-fix version.
| // when its route is visited, instead of all ~25 pages riding in the initial | ||
| // bundle. Named exports need the `.then(m => ({ default: m.X }))` adapter | ||
| // since React.lazy only accepts a default export. | ||
| const Login = lazy(() => import("./pages/Login").then((m) => ({ default: m.Login }))); |
There was a problem hiding this comment.
src/App.tsx:13
All ~23 routes were switched to lazy-loaded chunks, but there's no ErrorBoundary anywhere in the app. If a client on a stale build tries to navigate to a route whose chunk was deleted by the last deploy, the dynamic import 404s and React unmounts the tree — blank/crashed page, no retry.
The .then(m => ({ default: m.X })) lazy-load adapter is duplicated 23 times instead of factored into one helper; copy-paste risk plus any future fix (e.g. adding chunk-load retry, which would also help with #2) needs to be repeated 23×.
|
|
||
| export default fp( | ||
| async function compressPlugin(fastify: FastifyInstance) { | ||
| await fastify.register(fastifyCompress, { global: true }); |
There was a problem hiding this comment.
server/src/plugins/compress.ts:17
@fastify/compress registered with only { global: true } leaves globalDecompression at its default true, so incoming request bodies with a Content-Encoding header
are now silently auto-decompressed across the whole API surface (including the SSE upstream route) — undocumented and untested side effect beyond the PR's stated intent (response compression).
Add ErrorBoundary around the route tree so a stale-build chunk 404
auto-reloads once instead of leaving a blank crashed page; factor the
23 duplicated lazy-import adapters into one lazyNamed() helper.
Set globalDecompression: false on @fastify/compress — { global: true }
alone also auto-decompressed request bodies fleet-wide, which was
undocumented and untested.
Signed-off-by: Gabriel Costa <gabrielcg@proton.me>
|
EDIT: Commented on wrong PR |
marekdano
left a comment
There was a problem hiding this comment.
7 findings, ranked most severe first.
Correctness bugs
1. src/App.tsx:115 — Reload guard is cleared before a broken chunk can fail again
The mount-time useEffect that clears the chunk-reload guard fires on App's own successful commit, which happens even while a lazy chunk import is still pending (Suspense fallback showing). So the guard gets cleared before a genuinely broken chunk has even had a chance to fail again.
Impact: For a persistently broken chunk (not just a one-time stale
deploy), this can cause an infinite reload loop instead of the "reload once, then show fallback" behavior the code's own comments promise.
2. src/components/ErrorBoundary.tsx:10 — Crashes on non-Error thrown values
isChunkLoadError() assumes the caught value is a real Error and reads error.name / error.message. If something throws null/undefined (e.g. an empty Promise.reject()), this throws inside componentDidCatch itself.
Impact: Crashes harder than if the boundary didn't exist at all — defeats the exact safety net this PR is adding.
3. server/src/plugins/static.ts:37 — Cache-Control immutable false positive
The new Cache-Control: immutable header is decided by checking whether the absolute filesystem path contains an assets segment, not the URL.
Impact: If the deployed PUBLIC_DIR ever sits under a directory containing "assets" anywhere in its path, non-hashed files — including index.html — get cached immutably for a year, permanently pinning users to a stale app shell after the next deploy.
4. server/src/plugins/static.ts:37 vs :53 — Two divergent asset-path checks
Two different techniques decide "is this an asset path" in the same file (filesystem substring match vs. URL-prefix match), with nothing keeping them in sync if the build layout changes.
Efficiency / cleanup
5. src/App.tsx:25 — Dashboard lazy-load introduces a fetch waterfall
Lazy-loading Dashboard (the default post-login page) turns its data fetch
into a JS-then-data waterfall with no preloading added, working against the
PR's own stated FCP goal.
6. src/App.tsx:22 — Trivial pages unnecessarily code-split
Placeholder pages (ForgotPassword, ResetPassword, ChangePassword) are
code-split into their own chunks even though they're a few lines of static
JSX — likely a net loss vs. just inlining them.
7. server/src/plugins/compress.ts:20 — No test coverage
The new global compression plugin has zero test coverage anywhere in the
repo, including on the SSE/streaming proxy paths it's riskiest for.
- ErrorBoundary: guard against non-Error throws (null/undefined/literal) crashing componentDidCatch/render instead of showing the fallback. - App: clear the chunk-reload guard from inside Suspense (post-mount), not App's own effect — was firing on the fallback commit, causing an infinite reload loop for a permanently-broken chunk. - static.ts: scope the immutable-cache header to PUBLIC_DIR/assets/ via prefix match instead of an absolute-path substring, which false- positived on index.html whenever PUBLIC_DIR had an "assets" ancestor dir; unify it with the 404 handler's asset check. - App: preload the Dashboard chunk in parallel with the auth check (default post-login route) and stop code-splitting the three placeholder auth pages, which cost more in request overhead than they save. - Add test coverage: compress.ts request/response behavior, cache-scope regression, and the two ErrorBoundary/App fixes above. Signed-off-by: Gabriel Costa <gabrielcg@proton.me>
Lazy-load all page components in App.tsx (565KB main chunk -> 200KB,
per-page chunks fetched on nav). Register @fastify/compress globally
on the BFF (gzip/brotli, SSE exempt via reply.hijack()). Cache hashed
/assets/* for 1y immutable; index.html stays no-cache.
Targets FCP regression vs the HTMX app (1.4s vs 0.6s).
Depends on #1