perf: speed up landing-page numbers and /specs plots load#7694
Merged
Conversation
User report (anyplot.ai): the language / library count under the hero and the plots on /specifications "sometimes take really long to land". Four contributors fixed: 1. Layout.tsx wrapped the metadata fetches (/stats, /libraries, /languages, /specs) in requestIdleCallback with a 2 s timeout to "give /plots/filter bandwidth priority". The NumbersStrip is above-the-fold on the landing page, so this stalled the visible counts by up to 2 s on Chrome (Safari, lacking rIC, fell back to setTimeout(_, 1) and was already fast — explaining the "manchmal"). Now fired immediately, with AbortController on unmount. 2. SpecsListPage's spec-row thumbnails (300+ images, no pagination) lacked loading="lazy", so the browser eagerly fetched every plot PNG on page open. Added the attribute (matches LandingPage and ImageCard). 3. /stats loaded every Spec (selectinload(Impl)+library) and every Library row just to take len() over them. Replaced with three aggregate queries on the repositories: - SpecRepository.count_with_impls() - ImplRepository.count_all() - LibraryRepository.count_with_languages() -> (lib_count, lang_count) 4. /plots/filter recomputed `counts` from filtered_images even when no filter groups were supplied — but in that case filtered_images == all_images and counts == globalCounts. The /specs page and homepage both hit this "filter:all" path; short-circuit it to skip the O(images x categories) recomputation and the empty or_counts loop. Tests: stats and plots unit tests updated for the new aggregate-mock shape; new integration tests cover the three count helpers (populated and empty database). All 1554 Python and 507 frontend tests pass.
CI: Lint failed on the previous commit because ruff format wanted the new StatsResponse(...) call and count_with_impls() session.execute(...) to stay on a single line. Applied `ruff format` to both files; ruff check, ruff format --check, and mypy all clean.
Contributor
There was a problem hiding this comment.
Pull request overview
Improves perceived and actual load performance for above-the-fold landing-page stats and the /specifications experience by removing avoidable client-side delays and reducing backend work for common unfiltered requests.
Changes:
- Frontend: load shared metadata immediately (with abort cleanup) and lazy-load spec thumbnails on the specs list.
- Backend: refactor
/statsto use aggregate COUNT/DISTINCT queries via new repository helpers. - Backend: add a fast path in
/plots/filterfor the empty-filter case to skip redundant recomputation; update/add tests accordingly.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| app/src/components/Layout.tsx | Removes idle-callback delay; fetches shared metadata immediately with AbortController. |
| app/src/pages/SpecsListPage.tsx | Adds loading="lazy" to spec thumbnails to reduce eager network load. |
| api/routers/stats.py | Extracts _compute_stats and switches /stats to repository aggregate counts. |
| core/database/repositories.py | Adds count_with_impls, count_with_languages, and count_all aggregate helpers. |
| api/routers/plots.py | Adds empty-filter fast path to avoid unnecessary count recalculation for filter:all. |
| tests/unit/api/test_stats.py | Updates mocks to the new aggregate-based stats computation. |
| tests/unit/api/test_routers.py | Updates /stats router tests to use aggregate repo methods. |
| tests/integration/test_repositories.py | Adds integration coverage for new repository count helpers (incl. empty DB cases). |
| // in requestIdleCallback to "give /plots/filter bandwidth priority", but that | ||
| // delays the NumbersStrip ("languages / libraries / specs" counts under the | ||
| // hero) by up to the 2 s timeout on Chrome while Safari (no rIC) ran fast — | ||
| // explaining the user-reported "manchmal echt lange". HTTP/2 multiplexes |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
codecov/patch/frontend flagged 0% coverage on the 11 changed lines in
Layout.tsx because there was no Layout test before. Added a focused
test suite that exercises the new behavior:
1. Happy path: all four endpoints (/specs, /libraries, /languages,
/stats) are hit and the context is populated — locks in the
"fire immediately on mount" semantics (previously rIC-deferred).
2. Specs envelope: both bare-array and {specs: [...]} response shapes.
3. Error path: rejected fetch logs a warn and leaves empty defaults.
4. Cleanup: unmounting aborts in-flight fetches without surfacing a
warn for the AbortError rejection.
511 frontend tests pass, lint clean.
User feedback (same browser, repeat visits): the NumbersStrip and the /specs page were still "sometimes" slow. The remaining bottleneck is per-instance cache: the cache lives in the FastAPI process, so every Cloud Run instance brought up by autoscale or a cold start serves its first request straight from the database. Add a lifespan prewarm hook in api/main.py that fires the four refresh factories the frontend's AppDataProvider needs on every page load — _refresh_stats, _refresh_libraries, _refresh_languages, and the newly extracted _refresh_specs_list (specs.py previously defined this inline). Each result is written to the cache before lifespan startup yields, so the first user request on any new instance hits a warm cache. Failures are logged and swallowed so prewarm can never block the app from coming up — a failed prewarm just falls back to lazy load on the first user request, matching today's behavior. Tests: TestPrewarmCache covers the happy path (all four cached) and the resilience path (one factory raises, the other three still populate). 1556 Python tests pass.
Comment on lines
+25
to
+48
| beforeEach(() => { | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| describe('AppDataProvider', () => { | ||
| it('fetches /specs, /libraries, /languages, /stats and exposes them via useAppData', async () => { | ||
| const specsBody = [{ id: 'bar-grouped', title: 'Grouped Bar Chart' }]; | ||
| const libsBody = { libraries: [{ id: 'matplotlib', name: 'Matplotlib', language: 'python' }] }; | ||
| const langsBody = { languages: [{ id: 'python', name: 'Python', file_extension: '.py' }] }; | ||
| const statsBody = { specs: 7, plots: 42, libraries: 11, languages: 3 }; | ||
|
|
||
| const fetchMock = vi.fn().mockImplementation((url: string) => { | ||
| if (url.endsWith('/specs')) return Promise.resolve(jsonResponse(specsBody)); | ||
| if (url.endsWith('/libraries')) return Promise.resolve(jsonResponse(libsBody)); | ||
| if (url.endsWith('/languages')) return Promise.resolve(jsonResponse(langsBody)); | ||
| if (url.endsWith('/stats')) return Promise.resolve(jsonResponse(statsBody)); | ||
| throw new Error(`unexpected fetch: ${url}`); | ||
| }); | ||
| global.fetch = fetchMock; | ||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A user reported on https://anyplot.ai/ that the language/library counts under the hero and the plots on
/specifications"sometimes take really long to land — das darf nicht sein". The word sometimes is the clue: Safari was already fast, Chrome was slow — meaning the bottleneck was a code path that branched on browser features. I traced four contributors and fixed each.1.
Layout.tsx— droprequestIdleCallbackfor above-the-fold metadataThe shared
/stats,/libraries,/languages,/specsfetches were wrapped inrequestIdleCallbackwith a 2 s timeout to "give/plots/filterbandwidth priority". But theNumbersStrip("languages / libraries / specifications / implementations" counts) is above the fold on the landing page, which never loads/plots/filter— so this just stalled the visible counts by up to 2 s on Chrome. Safari has norequestIdleCallbackand used thesetTimeout(_, 1)fallback, which is why the report says "manchmal" (sometimes). Now fired immediately on mount, withAbortControllerfor cleanup.2.
SpecsListPage.tsx—loading="lazy"on spec thumbnailsThe
/specspage renders ~300 spec rows with no pagination. The thumbnail<img>had noloading="lazy", so the browser eagerly fetched every plot PNG on page open — saturating the connection and blocking the first viewport. Other thumbnail components (LandingPageFeaturedThumb,ImageCard) already use lazy loading; added it here for consistency.3.
/stats— aggregate COUNT queries instead of loading every row_fetchloaded every Spec (withselectinload(Impl)andselectinload(Impl.library)) and every Library row just to takelen()and{lib.language_id for lib in libraries}over them. Cold-cache/statswas therefore one of the slowest reads on the site. Replaced with three lightweight aggregate methods on the repositories:SpecRepository.count_with_impls()→SELECT COUNT(DISTINCT impls.spec_id)ImplRepository.count_all()→SELECT COUNT(*) FROM implsLibraryRepository.count_with_languages()→ returns(library_count, distinct_language_count)in one round-trip4.
/plots/filter— fast path when no filtersThe unfiltered request (
filter:all, hit by/specsand the initial/plotsload) was running:_filter_images(...)over every image — but with no filter groups, it returns the input unchanged._calculate_contextual_counts(filtered_images, ...)— produces the same result asglobalCountswhen nothing is filtered out._calculate_or_counts(filter_groups=[], ...)— its outerfor group in filter_groupsloop never executes, so this is always[].Short-circuited the empty-filter case to skip both O(images × categories) recomputations.
Files changed
app/src/components/Layout.tsxrequestIdleCallback, fire metadata fetches immediately withAbortControllerapp/src/pages/SpecsListPage.tsxloading="lazy"to spec-row imageapi/routers/stats.py_compute_statsshared by sync + refresh paths, use aggregate countscore/database/repositories.pycount_with_impls/count_all/count_with_languagesapi/routers/plots.pyfilter_groupstests/...Test plan
pytest tests/unit tests/integration— 1554 passedyarn test— 507 passedyarn lint— clean (the 2 warnings inMapPage.tsx/test-utils.tsxare pre-existing)/statsand/specscold-cache latency, NumbersStrip paint time on landing pageCloses the user feedback about slow loading of the languages/libraries counts and
/specificationsplots.Generated by Claude Code