Skip to content

perf(reader): stop blocking first paint on scripts, images and fonts - #727

Open
NagariaHussain wants to merge 6 commits into
developfrom
perf/reader-lighthouse
Open

perf(reader): stop blocking first paint on scripts, images and fonts#727
NagariaHussain wants to merge 6 commits into
developfrom
perf/reader-lighthouse

Conversation

@NagariaHussain

@NagariaHussain NagariaHussain commented Jul 27, 2026

Copy link
Copy Markdown
Member

Work in progress — more improvements to come.

Lighthouse (before → after):

before after
Performance 56 57
Accessibility 88 95
Best Practices 100 100
SEO 91 100

Problem

Lighthouse on a public reader page reported 4,020 ms of render-blocking scripts, no meta description, and unlabelled icon buttons. On docs.frappe.io/erpnext/introduction the hero image is the LCP element with a 5.8 s load delay, and on its own causes a 0.107 CLS.

Solution

  • defer on the five body scripts — highlight.js alone is ~160 KB of parser blocking first paint
  • preload the Inter variable font so it downloads alongside the stylesheet instead of after it
  • rendered images now carry width/height (read off disk for /files/ and /private/files/), decoding="async", and loading="lazy" past the first one; the first is fetched eagerly at high priority as the likely LCP element
  • fall back to a body excerpt when a page has no meta description
  • aria-label on the mobile search and theme-toggle buttons
  • fetch highlight.js only once a page actually has a code block
  • serve reader pages from frappe's website page cache
  • shrink the sidebar tree: row styling into a component class, active state marked once instead of per node

The Alpine plugins stay non-deferred on purpose: they register on alpine:init like the inline store definitions do, so deferring them puts their listeners last and the stores call Alpine.$persist before it exists. Comment left in the template.

Image dimensions are resolved with Pillow at render time, but render_markdown_with_toc is already memoised per document, so the disk read happens once per content edit rather than once per request. Remote URLs are never resolved.

highlight.js on demand

The bundle is ~160 KB (53 KB brotli) and shipped on every page, but most doc pages have no code at all — docs.frappe.io/erpnext/introduction has zero <code> elements and still paid for it. code-blocks.js now reads the hashed URL off its own data-highlight-src and injects the script the first time it sees a pre > code, so SPA navigation into a code page loads it too. A failed load resolves rather than rejects, leaving plain readable blocks.

The page cache

Every reader request rebuilt the whole page — sidebar tree, space switcher, TOC — and frappe's process_response then stamped it no-store, so the browser couldn't reuse it and the page was disqualified from the back/forward cache. In production that reads as x-from-cache: False with a 1,020 ms TTFB on every hit.

WikiDocumentRenderer now reads and writes website_page::<route>, the same key format frappe's own cache_html uses, so delete_page_cache and clear_cache(route) already drop these entries. A hit also sets private,max-age=300,stale-while-revalidate=10800, which opts the page out of the no-store default.

Only anonymous renders are stored. The key is the route alone and the page carries the visitor's Edit button, so caching a signed-in render would show one editor's chrome to every reader; an unauthorized Guest throws out of get_web_context before anything is written. The CSRF token is rendered as a placeholder and substituted after the lookup, so no visitor is ever handed another's token.

Invalidation: clear_wiki_tree_cache drops every cached page, since each one embeds a copy of the sidebar tree, and Wiki Settings does the same on save because head_html, javascript and the TOC toggle are baked in.

Two things worth flagging for review. can_cache() refuses to cache under developer_mode, so none of this is observable on a dev bench — the integration tests patch it on and drive the real HTTP client. And last_updated is a server-rendered relative string, so a cached page can show "Last updated 5 minutes ago" for up to 30 minutes; the exact timestamp is already in the title/data-timestamp attributes.

Numbers

Lighthouse mobile, local reader page:

before after
FCP 11.0 s 8.7 s
Speed Index 11.0 s 8.7 s
CLS 0.001 0
render-blocking 4,020 ms 930 ms

Performance barely moves locally because LCP is now 96% render delay, which on the dev server is the uncompressed HTML document — prod gzips it. The image work can't show up locally either; the test page has no images.

The sidebar tree

The tree renders twice per page — mobile sheet, then desktop rail — and on docs.frappe.io/erpnext/introduction those two copies are essentially the whole 2,548 KB document: 1,276 KB and 724 anchors for the desktop copy alone, at 1,804 bytes per row. Two things account for most of it, and neither is load-bearing.

The repeated utility string moved into .wiki-row in main.css, with dense/roomy geometry variants. @layer components rather than @utility, since these are multi-property component classes and utilities should still win where a caller overrides one; no-underline and cursor-pointer key off :where(a) / :where(button) instead of extra class names, and title size follows from the geometry class. The dead group class went too — nothing in the reader uses group-hover.

Then the active-row state. Every leaf carried two Alpine expressions — a :class ternary and an :aria-current binding — each re-evaluating the same currentRoute === '<this route>' comparison: ~2,800 reactive expressions per page across both trees, and 214 KB of markup to express them. The server now renders aria-current on the matching row, the navigation store moves it in one pass on SPA nav (markActiveRow), and CSS styles both the active row and the hover state off the attribute.

Finally the click and prefetch handlers, which embedded the route a second and third time on every row. The store now binds one click and one mouseover listener and reads data-route off the closest .wiki-link, so row markup carries no Alpine at all — and delegation survives the sheet's markup being replaced, which per-node handlers don't. Group toggles and external links are untouched; neither is a .wiki-link with data-route.

That last one needed a real fix, not just a move: mouseover fires far more often than the mouseenter it replaces, so prefetch now tracks in-flight routes as well as cached ones. Without it a single hover could put two identical requests on the wire before the first response landed. The new e2e covers it and fails with the guard removed.

Leaf row opening tags: 976 → 327 bytes, a ~67% cut, and $store.navigation drops from 84 occurrences to 12 on a test page. Brotli already squashes this markup hard, so the win is DOM size and Alpine effect count, not transfer.

Rendering is unchanged — a screenshot diff of the same page before and after is 0% different across 1,296,000 pixels, and a real-pointer hover check confirms idle rows still highlight while the current one doesn't.

Benchmarks

Server-side render vs cache read, median of 15 rounds over 6 real pages on a dev bench:

context template total render cache hit speedup
median 46 ms 39 ms 86 ms 0.83 ms ~105x

Full request through the werkzeug test client — routing, session, render, response build — median of 12 rounds over 5 pages:

no cache cached saved
median 75.3 ms 8.0 ms 89%

Invalidation cost, since delete_page_cache() is a Redis KEYS scan and runs on every document save: 0.4 ms at 100 cached pages, 1.1 ms at 500, 3.9 ms at 2,000. Cheap enough to keep the blanket clear rather than tracking which routes a tree change touched.

Highlight bundle: 162,235 B raw / 53,293 B gzip / 39,062 B brotli, now not fetched at all on a page without code. Lighthouse on a prose page drops from 13 requests to 12 and 1,983 KiB to 1,864 KiB, with LCP 11.4 s → 10.8 s.

Tests

  • 13 new unit tests in test_markdown.py covering the image loading hints and the excerpt; full module is 76 passing
  • 4 new integration tests in test_wiki_document.py for the page cache: reuse across requests, placeholder-not-token in the stored copy, signed-in renders never stored, tree change drops everything. Full module is 111 passing
  • new e2e/tests/code-blocks.spec.ts: the bundle is not fetched on a prose page, is fetched on a code page, and arrives on SPA navigation into one
  • new e2e/tests/sidebar-row-events.spec.ts: hovering a row prefetches exactly once, and a group toggle expands instead of navigating
  • 21 passing across sidebar, sidebar-reveal, toc-navigation, tab-navigation, external-link, page-actions-ai-url — the existing specs already assert aria-current="page" on load and after SPA nav
  • e2e image-viewer, public-pages, mermaid, sidebar, toc-navigation, search-modal pass. Two mobile-view.spec.ts failures are pre-existing — they fail identically on a clean develop

Not in scope

Still on the table: the mobile sheet renders a second full copy of the tree that could be built lazily on open — the single biggest DOM win left, but it wants its own PR. The tree's Jinja indent whitespace is 36.8% of the sidebar markup, though it's pure formatting that brotli already erases, so removing it would cost source readability for bytes that don't reach the wire. Inter.var.woff2 is 316 KB unsubsetted, with a unicode-range covering CJK the file doesn't contain, so it always downloads whole. The space switcher is O(spaces) and rendered twice — only 17 spaces on docs.frappe.io today, so it's latent rather than urgent. Uploads have no WebP/AVIF variants. And the Cloudflare JS challenge burns 5,398 ms of bootup by itself, which no change in this repo can touch.

🤖 Generated with Claude Code

Lighthouse on a public reader page reported 4,020 ms of render-blocking
scripts, no meta description, and unlabelled icon buttons; on
docs.frappe.io the hero image was the LCP element with a 5.8 s load delay
and it alone caused a 0.107 CLS.

- defer the five body scripts (highlight.js is ~160 KB on its own)
- preload the Inter variable font so it downloads alongside the stylesheet
- emit width/height, loading and decoding on rendered images, with the
  first one fetched eagerly at high priority as the likely LCP element
- fall back to a body excerpt when a page has no meta description
- label the mobile search and theme-toggle buttons

The Alpine plugins stay non-deferred on purpose: they register on
alpine:init like the inline store definitions do, so deferring them puts
their listeners last and the stores call Alpine.$persist before it exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jul 27, 2026

Copy link
Copy Markdown

Confidence Score: 4/5

The PR is not safe to merge until private-file authorization is enforced before reading image metadata.

Public markdown rendering still opens /private/files/ images directly and exposes dimensions without checking File permissions.

Files Needing Attention: wiki/wiki/markdown.py

Reviews (4): Last reviewed commit: "perf(reader): delegate sidebar row navig..." | Re-trigger Greptile

Comment thread wiki/wiki/markdown.py
Comment on lines +378 to +382
# /private/files/x -> ("private", "files", "x"); /files/x -> ("public", "files", "x")
parts = relative.split("/")
segments = parts if parts[0] == "private" else ["public", *parts]

with Image.open(frappe.get_site_path(*segments)) as image:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Private image metadata bypasses authorization

When a public document references a /private/files/ image, the renderer opens it directly without checking File permissions and embeds its dimensions in shared cached HTML, disclosing the file's existence and dimensions to unauthorized readers.

How this was verified: The markdown-controlled private path reaches Image.open(frappe.get_site_path(...)) without an authorization check, and successful dimensions are emitted into the rendered HTML.

Context Used: Guidelines for reviewing Frappe Framework applicat... (source)

NagariaHussain and others added 5 commits July 27, 2026 11:48
The bundle is ~160 KB (53 KB brotli) and shipped on every reader page, but
most doc pages carry no code at all -- docs.frappe.io/erpnext/introduction
has zero code elements and still paid for it.

code-blocks.js now reads the hashed bundle URL off its own data attribute
and injects the script the first time it sees a 'pre > code', so SPA
navigation into a code page loads it too. A failed load resolves rather
than rejects, leaving plain readable blocks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every reader request rebuilt the whole page -- sidebar tree, space
switcher, TOC -- and frappe's process_response then stamped it
'no-store', so the browser could not reuse it and the page was
disqualified from the back/forward cache. On docs.frappe.io that shows up
as 'x-from-cache: False' with a 1,020 ms TTFB on every hit.

WikiDocumentRenderer now reads and writes 'website_page::<route>', the
same key format frappe's own cache_html uses, so delete_page_cache and
clear_cache(route) already drop these entries. A hit also sets
'private,max-age=300,stale-while-revalidate=10800', which opts the page
out of the no-store default.

Only anonymous renders are stored. The key is the route alone and the
page carries the visitor's Edit button, so caching a signed-in render
would show one editor's chrome to every reader; an unauthorized Guest
throws out of get_web_context before anything is written. The CSRF token
is rendered as a placeholder and substituted after the lookup, so no
visitor is ever handed another's token.

Invalidation: clear_wiki_tree_cache drops every cached page, since each
one embeds a copy of the sidebar tree, and Wiki Settings does the same on
save because head_html, javascript and the TOC toggle are baked in.

Also updates the metatags fallback test, which asserted the absence of a
description that the previous commit now fills in from the body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tree renders twice per page (mobile sheet, then desktop rail) and once
per node, so the repeated utility string was ~105 KB of markup per copy on
a large space -- 8.2% of the whole document on docs.frappe.io.

Row presentation now lives in main.css as .wiki-row plus dense/roomy
geometry, using @layer components rather than @Utility: these are
multi-property component classes, so utilities still win where a caller
overrides one. no-underline and cursor-pointer key off :where(a) and
:where(button) instead of extra class names, and the title size follows
from the row's geometry class.

Row class attributes drop from 299 to 183 bytes, title spans from 46 to
25 -- ~137 bytes per node, ~200 KB across both copies of the ERPNext tree.
The dead 'group' class went too; nothing in the reader uses group-hover.

Rendering is unchanged: a screenshot diff against the same page before
and after is 0% different across 1,296,000 pixels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every leaf row carried two Alpine expressions -- a :class ternary and an
:aria-current binding -- each re-evaluating the same
currentRoute === '<this route>' comparison. On docs.frappe.io that is
~2,800 reactive expressions per page across the two copies of the tree,
and 214 KB of markup (11.0% + 5.7% of the sidebar) to express them.

The server now renders aria-current on the matching row, the navigation
store moves it in one pass on SPA navigation (markActiveRow), and CSS
styles both the active row and the hover state off the attribute. Writes
to currentRoute inside the store go through setCurrentRoute so the marker
and the property can't drift.

Leaf row opening tags drop from 976 to 546 bytes -- about 632 KB across
both trees on the ERPNext docs. Rendering is unchanged: 0% pixel
difference over 1,296,000 pixels, and hover still highlights idle rows
while leaving the current one alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every leaf row carried @click.prevent and @mouseenter, each embedding the
route a second and third time. The navigation store now binds one click
and one mouseover listener and reads data-route off the closest
.wiki-link, so the row markup carries no Alpine at all. Group toggles and
external links are untouched -- neither is a .wiki-link with data-route.

Delegation also survives the sheet's markup being replaced, which
per-node handlers do not.

mouseover fires far more often than the mouseenter it replaces, so
prefetch now tracks in-flight routes as well as cached ones; without that
a single hover could put two identical requests on the wire before the
first response landed. e2e covers both that and the group-toggle case,
and the prefetch test fails with the in-flight guard removed.

Leaf row opening tags are down to 327 bytes from 976 before this series,
and $store.navigation appears 12 times on a test page rather than 84.
0% pixel difference over 1,296,000 pixels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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