perf(reader): stop blocking first paint on scripts, images and fonts - #727
perf(reader): stop blocking first paint on scripts, images and fonts#727NagariaHussain wants to merge 6 commits into
Conversation
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>
Confidence Score: 4/5The PR is not safe to merge until private-file authorization is enforced before reading image metadata. Public markdown rendering still opens Files Needing Attention: wiki/wiki/markdown.py Reviews (4): Last reviewed commit: "perf(reader): delegate sidebar row navig..." | Re-trigger Greptile |
| # /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: |
There was a problem hiding this comment.
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)
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>
Lighthouse (before → after):
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/introductionthe hero image is the LCP element with a 5.8 s load delay, and on its own causes a 0.107 CLS.Solution
deferon the five body scripts — highlight.js alone is ~160 KB of parser blocking first paintwidth/height(read off disk for/files/and/private/files/),decoding="async", andloading="lazy"past the first one; the first is fetched eagerly at high priority as the likely LCP elementaria-labelon the mobile search and theme-toggle buttonsThe Alpine plugins stay non-deferred on purpose: they register on
alpine:initlike the inline store definitions do, so deferring them puts their listeners last and the stores callAlpine.$persistbefore it exists. Comment left in the template.Image dimensions are resolved with Pillow at render time, but
render_markdown_with_tocis 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/introductionhas zero<code>elements and still paid for it.code-blocks.jsnow reads the hashed URL off its owndata-highlight-srcand injects the script the first time it sees apre > 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_responsethen stamped itno-store, so the browser couldn't reuse it and the page was disqualified from the back/forward cache. In production that reads asx-from-cache: Falsewith a 1,020 ms TTFB on every hit.WikiDocumentRenderernow reads and writeswebsite_page::<route>, the same key format frappe's owncache_htmluses, sodelete_page_cacheandclear_cache(route)already drop these entries. A hit also setsprivate,max-age=300,stale-while-revalidate=10800, which opts the page out of theno-storedefault.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_contextbefore 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_cachedrops every cached page, since each one embeds a copy of the sidebar tree, and Wiki Settings does the same on save becausehead_html,javascriptand the TOC toggle are baked in.Two things worth flagging for review.
can_cache()refuses to cache underdeveloper_mode, so none of this is observable on a dev bench — the integration tests patch it on and drive the real HTTP client. Andlast_updatedis 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 thetitle/data-timestampattributes.Numbers
Lighthouse mobile, local reader page:
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/introductionthose 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-rowinmain.css, with dense/roomy geometry variants.@layer componentsrather than@utility, since these are multi-property component classes and utilities should still win where a caller overrides one;no-underlineandcursor-pointerkey off:where(a)/:where(button)instead of extra class names, and title size follows from the geometry class. The deadgroupclass went too — nothing in the reader usesgroup-hover.Then the active-row state. Every leaf carried two Alpine expressions — a
:classternary and an:aria-currentbinding — each re-evaluating the samecurrentRoute === '<this route>'comparison: ~2,800 reactive expressions per page across both trees, and 214 KB of markup to express them. The server now rendersaria-currenton 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
clickand onemouseoverlistener and readsdata-routeoff 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-linkwithdata-route.That last one needed a real fix, not just a move:
mouseoverfires far more often than themouseenterit replaces, soprefetchnow 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.navigationdrops 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:
Full request through the werkzeug test client — routing, session, render, response build — median of 12 rounds over 5 pages:
Invalidation cost, since
delete_page_cache()is a RedisKEYSscan 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
test_markdown.pycovering the image loading hints and the excerpt; full module is 76 passingtest_wiki_document.pyfor 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 passinge2e/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 onee2e/tests/sidebar-row-events.spec.ts: hovering a row prefetches exactly once, and a group toggle expands instead of navigatingsidebar,sidebar-reveal,toc-navigation,tab-navigation,external-link,page-actions-ai-url— the existing specs already assertaria-current="page"on load and after SPA navimage-viewer,public-pages,mermaid,sidebar,toc-navigation,search-modalpass. Twomobile-view.spec.tsfailures are pre-existing — they fail identically on a cleandevelopNot 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.woff2is 316 KB unsubsetted, with aunicode-rangecovering 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