Skip to content

perf: speed up landing-page numbers and /specs plots load#7694

Merged
MarkusNeusinger merged 5 commits into
mainfrom
claude/anyplot-slow-loading-perf-bZJjV
May 24, 2026
Merged

perf: speed up landing-page numbers and /specs plots load#7694
MarkusNeusinger merged 5 commits into
mainfrom
claude/anyplot-slow-loading-perf-bZJjV

Conversation

@MarkusNeusinger
Copy link
Copy Markdown
Owner

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 — drop requestIdleCallback for above-the-fold metadata

The shared /stats, /libraries, /languages, /specs fetches were wrapped in requestIdleCallback with a 2 s timeout to "give /plots/filter bandwidth priority". But the NumbersStrip ("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 no requestIdleCallback and used the setTimeout(_, 1) fallback, which is why the report says "manchmal" (sometimes). Now fired immediately on mount, with AbortController for cleanup.

2. SpecsListPage.tsxloading="lazy" on spec thumbnails

The /specs page renders ~300 spec rows with no pagination. The thumbnail <img> had no loading="lazy", so the browser eagerly fetched every plot PNG on page open — saturating the connection and blocking the first viewport. Other thumbnail components (LandingPage FeaturedThumb, ImageCard) already use lazy loading; added it here for consistency.

3. /stats — aggregate COUNT queries instead of loading every row

_fetch loaded every Spec (with selectinload(Impl) and selectinload(Impl.library)) and every Library row just to take len() and {lib.language_id for lib in libraries} over them. Cold-cache /stats was 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 impls
  • LibraryRepository.count_with_languages() → returns (library_count, distinct_language_count) in one round-trip

4. /plots/filter — fast path when no filters

The unfiltered request (filter:all, hit by /specs and the initial /plots load) 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 as globalCounts when nothing is filtered out.
  • _calculate_or_counts(filter_groups=[], ...) — its outer for group in filter_groups loop never executes, so this is always [].

Short-circuited the empty-filter case to skip both O(images × categories) recomputations.

Files changed

File Change
app/src/components/Layout.tsx Drop requestIdleCallback, fire metadata fetches immediately with AbortController
app/src/pages/SpecsListPage.tsx Add loading="lazy" to spec-row image
api/routers/stats.py Extract _compute_stats shared by sync + refresh paths, use aggregate counts
core/database/repositories.py Add count_with_impls / count_all / count_with_languages
api/routers/plots.py Fast path for empty filter_groups
tests/... Update stats mocks to new aggregate shape; integration tests for the new count helpers

Test plan

  • pytest tests/unit tests/integration1554 passed
  • yarn test507 passed
  • yarn lint — clean (the 2 warnings in MapPage.tsx / test-utils.tsx are pre-existing)
  • New integration tests cover both populated and empty database for each new repo helper
  • Manual verify on staging: /stats and /specs cold-cache latency, NumbersStrip paint time on landing page

Closes the user feedback about slow loading of the languages/libraries counts and /specifications plots.


Generated by Claude Code

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.
Copilot AI review requested due to automatic review settings May 24, 2026 07:26
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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 /stats to use aggregate COUNT/DISTINCT queries via new repository helpers.
  • Backend: add a fast path in /plots/filter for 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
Copy link
Copy Markdown

codecov Bot commented May 24, 2026

Codecov Report

❌ Patch coverage is 91.37931% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
api/routers/specs.py 33.33% 4 Missing ⚠️
api/main.py 93.33% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

claude added 2 commits May 24, 2026 07:33
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.
Copilot AI review requested due to automatic review settings May 24, 2026 09:04
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

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;

@MarkusNeusinger MarkusNeusinger enabled auto-merge (squash) May 24, 2026 09:23
@MarkusNeusinger MarkusNeusinger merged commit 5ceac02 into main May 24, 2026
9 checks passed
@MarkusNeusinger MarkusNeusinger deleted the claude/anyplot-slow-loading-perf-bZJjV branch May 24, 2026 09:29
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.

3 participants