Skip to content

Tier 6: performance optimisations (lazy-load D3, WebP images, self-hosted fonts, CI size budget, cache headers)#70

Merged
felipebalbi merged 10 commits into
OpenDevicePartnership:mainfrom
felipebalbi:tier6-refactor
May 15, 2026
Merged

Tier 6: performance optimisations (lazy-load D3, WebP images, self-hosted fonts, CI size budget, cache headers)#70
felipebalbi merged 10 commits into
OpenDevicePartnership:mainfrom
felipebalbi:tier6-refactor

Conversation

@felipebalbi
Copy link
Copy Markdown
Contributor

Tier 6 — Performance optimisations

Standalone follow-up to #69 (Tier 5). Branched from main, no overlap. Each task is its own commit so reviewers can read them in order.

Headline numbers (first-time visitor to the landing page)

Asset Before After Δ
wasm (gz) 286 KB 178 KB −38%
D3 + repo_graph.js ~90 KB gz, eager 0, lazy on project pages −90 KB
repo_graph.css 0.5 KB gz, eager 0, lazy on project pages render-unblocked
Tailwind CSS 8.9 KB gz (unminified) 5.3 KB gz (minified) −40%
Geist font render-blocking Google Fonts (2 origins) self-hosted, preloaded, font-display swap no FOIT
Image payload ~14 MB ~300 KB −98%

Commits

20a9f44 t44: tighten release profile for smaller wasm payload
354427e t45: drop redundant web-sys feature declarations
be4fe77 t46a: drop unused patina_header.svg (-5.9 MB)
b1e56cc t46b: convert Patina project icons from SVG to WebP
9a90ff2 t46c: convert background and tile PNGs to WebP
171770d t47: lazy-load D3 + repo_graph.js only on project pages
efd7b6a t48: self-host Geist font with font-display: swap
f495f6a t51: enforce gzipped asset size budget in CI
b0c8ccd t52: add Cloudflare Pages _headers for long-immutable caching
478298a t53: minify CSS bundle and lazy-load repo_graph.css

Notes

  • No visual changes — verified at every step in the browser.
  • New CI step (Asset size budget) gzips the critical dist assets and fails the build if any exceeds budget. Update the budget alongside any change that legitimately exceeds it.
  • public/_headers is consumed by Cloudflare Pages only; the GH Pages workflow ignores the file. Returning visitors get zero-network wasm/CSS/JS for a year.

felipebalbi and others added 10 commits May 15, 2026 13:23
Cargo's defaults aren't tuned for wasm bundle size. Switching the
release profile to:

    opt-level    = "z"   # size over speed
    lto          = "fat" # cross-crate inlining + dead-code elim
    codegen-units = 1    # let LTO see the whole program
    panic        = "abort"

shrinks the shipped wasm from 786 KB -> 450 KB raw and 286 KB ->
182 KB gzipped, a ~37 % reduction over the wire. `strip = true`
remains so debug sections don't bloat the binary.

panic = "abort" is safe here because we never catch panics: the
top-level Leptos shell installs `console_error_panic_hook` which
runs before abort and surfaces the panic in the browser console
exactly as before.

Tests
-----

* `cargo test --release` -- 46 host tests pass.
* `cargo clippy --target wasm32-unknown-unknown --all-targets --no-deps -- -D warnings` -- clean.
* `trunk build --release` -- succeeds; wasm payload measured above.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The `web-sys` direct dependency declared
`features = ["Document", "Window", "console"]` for the small
amount of code in `components/repo_view.rs` that calls
`web_sys::window()`. Audit shows those features are already pulled
in transitively by `leptos` (via `leptos` -> `tachys` ->
`web-sys`), so the explicit list is redundant.

Drop the feature list and add a comment explaining why we still
depend on web-sys directly (the `js_sys` re-export and
`window()`).

Bundle impact: zero. LTO had already removed the unused symbols,
so the wasm payload is byte-identical (450 KB raw / 182 KB gz).
This is a clarity-only change.

Tests
-----

* `cargo test --release` -- 46 host tests pass.
* `cargo clippy --target wasm32-unknown-unknown --all-targets --no-deps -- -D warnings` -- clean.
* `trunk build --release` -- succeeds; wasm hash unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The file `public/images/patina_header.svg` weighed 5.9 MB (it
contains two large embedded base64 raster blobs wrapped in an SVG
container) and was bundled into `dist/images/` on every build, but
`grep -r patina_header` across `src/`, `index.html` and
`Trunk.toml` shows zero references. It's dead weight.

Delete it. Total image payload drops from ~20 MB to ~14 MB with no
code change and no visual change.

Tests
-----

* `trunk build --release` -- succeeds.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The 3 dark-mode Patina ProjectIcon SVGs were 1.4 MB each (CSS-styled
vector frames wrapping embedded base64 PNG glyphs). Rasterise to
204x204 WebP (~4-5 KB each) using Edge headless to preserve the CSS
class-based fills (the brand-colour rounded square border).

Also drop the 3 unused light-mode variants.

Total saved: ~8.5 MB on disk / wire.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The 6 project images (3 hero backgrounds + 3 landing/projects tiles)
were ~990 KB PNGs each (1080x900). Re-encode as WebP q82, dropping
total payload from ~5.8 MB to ~300 KB (95%). No markup changes
beyond the file extensions; image_button.rs and project_introduction.rs
already use plain <img> tags that browsers serve transparently.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
D3 v7 (~90 KB gz) and repo_graph.js were loaded eagerly via
<script defer> on every page, even though only the 3 project pages
ever instantiate a RepositoryGraph. Move both to on-demand:

* index.html: drop both <script> tags.
* repo_view.rs: ensure_graph_script() injects <script src=/repo_graph.js>
  exactly once, on first RepositoryGraph mount.
* repo_graph.js: ensureD3() injects the D3 CDN script on first render
  call. The IIFE also auto-renders on load if __odpGraphData is
  already set, closing the race the previous design avoided by
  eager-loading.

Subsequent project-page navigations are zero-network (publish data
+ call render synchronously).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Drop the render-blocking Google Fonts <link> in favour of two
self-hosted woff2 subsets (latin + latin-ext, ~46 KB total) that
serve both 400 and 600 weights from a single variable file each.
A <link rel="preload"> on the latin subset lets the browser fetch
the font in parallel with the wasm/CSS bundle.

Benefits:
* No DNS/TLS handshakes to fonts.googleapis.com + fonts.gstatic.com
  on the critical path.
* No third-party privacy hop on every page load.
* font-display: swap renders text immediately in the system fallback
  and re-paints once Geist arrives -- no FOIT.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a post-build step to .github/workflows/ci.yaml that gzips the
critical dist assets and fails the run if any exceeds its budget.
Current sizes (left) vs budget (right):

  wasm             178 KB    230 KB
  tailwind CSS       9 KB     15 KB
  repo_graph CSS     1 KB      2 KB
  wasm-bindgen JS    8 KB     20 KB

Budgets carry ~25% headroom over today's sizes so routine changes
don't trip them, but a careless dependency bump or stylesheet
bloat will. Update budgets alongside the change that exceeds them.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Trunk fingerprints CSS/JS/wasm with content hashes in the filename,
so every build emits new URLs and old assets become orphaned. That
makes them safe to cache forever, but the default Cloudflare Pages
cache is short. Add a public/_headers file (copied to dist root by
Trunk) that:

* Caches *.wasm, *.css, and the wasm-bindgen JS shim (odp-*.js) for
  one year, immutable.
* Caches /fonts/*.woff2 and /images/* for one week (unfingerprinted
  but slow-changing -- a one-week stale window is acceptable).
* Forces revalidation on /, /index.html, and the JSON the wasm
  bundle loads, so a fresh deploy is picked up immediately.
* Adds light security hardening: X-Content-Type-Options,
  Referrer-Policy, Permissions-Policy.

Only affects the Cloudflare Pages target (the GH Pages workflow
ignores the file). On a returning visitor, the wasm + CSS + JS no
longer hit the network at all.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Lighthouse on the landing page flagged tailwind.css and
repo_graph.css as render-blocking, and the tailwind output (40 KB
raw / 8.9 KB gz) was not actually being minified by trunk in
release. Two fixes:

* Trunk.toml: set minify = "always" so Trunk runs the standalone
  tailwindcss binary with --minify and also minifies the wasm-bindgen
  JS shim. Tailwind drops to 24 KB / 5.3 KB gz; odp-*.js drops to
  ~6 KB gz.
* repo_view.rs / index.html: stop letting Trunk auto-inject
  <link rel="stylesheet" href=".../repo_graph.css">. Switch the
  asset to copy-file (unhashed at /repo_graph.css) and inject the
  <link> tag from RepositoryGraph::Effect alongside the existing
  <script src="/repo_graph.js"> injection. Now neither asset
  appears on the landing page or any non-project route, so they
  no longer block first paint there.
* _headers: add explicit short-cache entries for the unhashed
  /repo_graph.{js,css} (the wildcard /*.css rule would otherwise
  mark them as immutable, which is wrong for files without a
  content hash in the name).
* ci.yaml: tighten the tailwind CSS budget to 10 KB gz.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@felipebalbi felipebalbi merged commit a12cef3 into OpenDevicePartnership:main May 15, 2026
7 checks passed
@felipebalbi felipebalbi deleted the tier6-refactor branch May 15, 2026 21:19
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.

1 participant