Skip to content

perf: load the figure lightbox only when it opens (#1146) - #1147

Open
frano-m wants to merge 1 commit into
mainfrom
frano-m/1146-lazy-figure-lightbox
Open

frano-m wants to merge 1 commit into
mainfrom
frano-m/1146-lazy-figure-lightbox

Conversation

@frano-m

@frano-m frano-m commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #1146.

FigureLightboxProvider no longer ships the lightbox with the page. yet-another-react-lightbox, its zoom plugin, its styles and findable-ui's lightbox components now sit in their own chunk. That chunk is fetched the first time a figure is hovered, focused or opened.

What changed

  • Loading: provider/utils.ts adds loadFigureLightbox(), a dynamic import("../figureLightbox"). The provider calls it from a new onPreload context action and from onOpen. It keeps the loaded component in state and mounts it from then on, so later opens behave exactly as before.
  • Preload: the figure's expand button calls onPreload on onPointerEnter and onFocus, so the chunk is usually loaded before the click. Touch fires pointerenter before the click too.
  • Failures: a failed load cancels the pending open, so a later successful load can't open the lightbox unprompted. The next hover, focus or open retries.
  • Navigation: useCloseOnRouteChange now takes an onClose callback instead of the controller ref. The lightbox passes its controller's close, which also clears inert, as before. The provider uses the same hook to cancel an open that is still waiting on the chunk when the route changes, because until then there is no lightbox to close itself.
  • Types: FigureLightboxContextActions gains an optional onPreload, so existing context values still type-check.

Why

Every page importing the provider or LightboxFigure downloaded the whole lightbox, even if no figure was ever opened. In brc-analytics, every Learn page (one catch-all route) paid for it, although only blog posts use it, and mounting the provider at _app level wasn't an option.

Measured on brc-analytics (production next build --webpack, published 57.2.0 vs this branch)

Before After
Blog page initial JS 2075.9 KB / 682.9 KB gzip 2027.2 KB / 665.9 KB gzip (−48.7 KB / −17.0 KB gzip)
Blog page initial CSS lightbox stylesheet, 5.2 KB none
Lightbox chunk 66.3 KB / 24.1 KB gzip, loaded with the page 50.1 KB / 18.1 KB gzip, loaded on demand
Home initial JS 1756.3 KB / 494.0 KB gzip 1757.6 KB / 494.4 KB gzip (+1.3 KB / +0.4 KB gzip)

The small increase on every page is webpack's runtime growing from 3.5 KB to 4.8 KB so it can load CSS for async chunks.

Open time: in a local static export, a first open with no hover took 32–42 ms and a reopen 22 ms. On brc main's dev server, the published version takes about 33 ms (first open) and 31 ms (reopen). A dev-server run with this branch measured 69 and 62 ms, but the machine was under heavy load during that run, so that comparison is unreliable.

Assumptions I made

  • No React.lazy / Suspense: I tried them first, and they made the first open take ~460 ms instead of ~40 ms. lazy suspends on its first render even when the module is already loaded, and React 19 then holds Suspense reveals back for up to 300 ms. Keeping the component in state avoids that. The reason is in the provider's JSDoc.
  • No loading indicator: on a cold click with nothing preloaded, the lightbox appears when the chunk arrives (tens of ms in production). I didn't add a spinner for that gap.
  • Once loaded, the closed lightbox stays mounted: this is the same as before, when it was always mounted.
  • Repeat calls are not guarded: later hovers call import() again. The bundler caches it, and React ignores the identical state update.
  • No consumer builds its own context value: I checked all consumers. Only brc-analytics uses the lightbox, and it only renders FigureLightboxProvider and LightboxFigure. onPreload is optional anyway.

How to verify

Definition of done from #1146:

  • Importing the provider or LightboxFigure doesn't pull the lightbox or its CSS into the page's chunk.
    1. In brc-analytics, run npm install --no-save <tarball of this branch> and npm run build-local:brc.
    2. In sites/brc-analytics/.next/build-manifest.json, the /learn/[...slug] entry should list no chunk containing yarl__ and no static/css file.
    3. The only yarl__ chunk under .next/static/chunks/ should be an async one (7985.*.js in my build).
  • The first click loads the chunk and opens the lightbox; zoom, pan, caption, previous / next and "Show in context" behave as before.
    1. Serve or run brc and open /learn/blog/genotyping-cyclospora-assessing-current-practices.
    2. Open DevTools → Network, reload, and check that no yarl chunk or lightbox CSS is fetched.
    3. Hover a figure: the chunk and CSS are fetched.
    4. Click: the lightbox opens at once.
    5. Check zoom, pan, caption, previous / next, "Show in context", and close on Escape.
    6. Reload and click a figure without hovering first (keyboard, or touch emulation): it still opens.
    7. Navigate to another blog post with the lightbox open: it closes.
  • A consumer can mount the provider high up (e.g. _app) without a bundle cost on pages with no figures. The provider's own module graph no longer reaches the library. On a page with no figures nothing triggers a load, so the only cost is the provider, the context and the webpack runtime increase above.
  • Storybook and tests pass.
    • npx storybook build succeeds, and in the FigureLightbox "Group" story the lightbox chunk loads on click and the lightbox opens and steps.
    • npm test passes (932). New tests cover:
      • nothing loads until a figure is used;
      • hover and focus preload without opening;
      • once loaded, the lightbox opens in the same render;
      • a failed load cancels the open and retries;
      • a pending open is cancelled on navigation;
      • reopening after a close.

🤖 Generated with Claude Code
🐦 Built with cc-claude-tools

the provider loads figurelightbox (yet-another-react-lightbox, its zoom
plugin and styles) with a dynamic import on a figure's first hover, focus
or open, keeps the loaded component in state and mounts it from then on.
react.lazy is not used: it suspends on first render even when the module
is loaded, and react 19 then holds the reveal back for up to 300ms.

a failed load cancels the pending open and is retried on the next hover,
focus or open. usecloseonroutechange now takes an onclose callback, so the
provider can also cancel an open still waiting on the load on navigation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: cc-claude-tools <cc-claude-tools@clevercanary.com>
Copilot AI balanced review requested due to automatic review settings October 23, 2026 07:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

The scoped loading change has no identified blocking issues, and added tests cover its principal lifecycle and navigation behaviors.

No open issues 🎉

What changed in this PR

Defers the figure lightbox, zoom plugin, and styles until a figure is hovered, focused, or opened, reducing initial downloads for pages using findable-ui’s figure components.

Changes:

  • Adds an on-demand loader and optional preload action while keeping the loaded lightbox mounted for subsequent opens.
  • Preserves navigation-based closing and cancels pending opens after navigation or loading failures.
  • Adds tests for deferred loading, preloading, failure recovery, navigation cancellation, and reopening.
File Description
tests/​figureLightbox.test.tsx Tests loading, preloading, cancellation, retries, and reopening.
src/​components/​common/​FigureLightbox/​provider/​utils.ts Adds the dynamic lightbox loader.
src/​components/​common/​FigureLightbox/​provider/​types.ts Adds an optional context preload action.
src/​components/​common/​FigureLightbox/​provider/​provider.tsx Loads and mounts the lightbox on demand; cancels pending opens.
src/​components/​common/​FigureLightbox/​hooks/​UseCloseOnRouteChange/​utils.ts Changes the navigation listener to accept a close callback.
src/​components/​common/​FigureLightbox/​hooks/​UseCloseOnRouteChange/​types.ts Defines the callback-based hook contract.
src/​components/​common/​FigureLightbox/​hooks/​UseCloseOnRouteChange/​hook.ts Subscribes the supplied close callback to navigation events.
src/​components/​common/​FigureLightbox/​figureLightbox.tsx Preserves controller-based closing through the updated hook.
src/​components/​common/​FigureLightbox/​components/​LightboxFigure/​hooks/​UseRegisterFigure/​types.ts Exposes an optional figure preload handler.
src/​components/​common/​FigureLightbox/​components/​LightboxFigure/​hooks/​UseRegisterFigure/​hook.ts Passes the provider’s preload action to figures.
src/​components/​common/​FigureLightbox/​components/​LightboxFigure/​components/​Figure/​figure.tsx Triggers preloading on pointer entry and focus.

Review effort: Balanced

@frano-m
frano-m marked this pull request as ready for review October 23, 2026 08:40
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.

Load the figure lightbox only when it opens

3 participants