Skip to content

Add stateful ImageLoader component to eliminate layout shifts - #60

Merged
Sparkier merged 7 commits into
mainfrom
jules/feat/stateful-image-skeleton-js1-1ece5eac-e8c8-4efc-a616-c2b2bb22581a
Aug 1, 2026
Merged

Add stateful ImageLoader component to eliminate layout shifts#60
Sparkier merged 7 commits into
mainfrom
jules/feat/stateful-image-skeleton-js1-1ece5eac-e8c8-4efc-a616-c2b2bb22581a

Conversation

@google-labs-jules

Copy link
Copy Markdown
Contributor

Context & Rationale

Currently, publication preview images on index and detail pages lack defined dimensions. Consequently, they collapse to a height of 0px before loading, causing jarring Cumulative Layout Shift (CLS) when they finally render. This visual instability degrades the user experience and impacts our SEO metrics.

To solve this, this PR introduces a stateful, reusable <ImageLoader> component. By extending our project metadata to include image dimensions, we can dynamically calculate and reserve the exact aspect ratio on page load using active skeleton placeholders. Additionally, we normalized asset paths within this component to prevent broken resource paths on nested detail pages, and added a smooth fade-in transition to eliminate abrupt loading pops.


Key Decisions

  • Explicit Aspect Ratio Reservation: Rather than hardcoding image sizes, we extended the metadata schema to fetch dimensions dynamically. This allows us to apply Svelte/Tailwind-driven aspect ratios, reserving precise layout boxes before the actual assets finish downloading.
  • Path Normalization: Resolved nested route failures by handling asset path resolution within the reusable loader, ensuring stability regardless of how deep the subroute is.
  • Perceived Performance: Replaced empty shifting spaces with a pulsing Tailwind placeholder (bg-primary/10 animate-pulse) and a smooth fade-in transition to improve perceived application responsiveness.

Detailed Changes

1. Schema & Data Pipeline

  • src/lib/types.ts: Extended Project and ResearchProject classes to accept an optional imageDimensions: { width: number; height: number } parameter without breaking backwards compatibility.
  • src/lib/helpers/projectsProvider.ts: Generated and injected an asset dimension map to automatically populate imageDimensions for research and fun projects before they are served to the frontend.

2. Component Implementation

  • src/lib/components/ImageLoader.svelte: Created the stateful image loader component. It:
    • Calculates the CSS aspect-ratio dynamically.
    • Renders a Tailwind-animated pulsing skeleton loader.
    • Resolves image paths safely to prevent broken assets on nested detail routes.
    • Fades in the loaded image over a brief, smooth transition window.

3. View Migration

  • src/routes/publications/+page.svelte: Replaced standard layout-unstable <img> tags with <ImageLoader> on the publication index page.
  • src/routes/publications/[paper]/+page.svelte: Updated the detail view hero image to use <ImageLoader>.

Verification & Testing

  • Verified that the Cumulative Layout Shift (CLS) score drops to 0 on both list and detail views.
  • Confirmed that nested detail page routing resolves image paths correctly without regressions.
  • Ran compiler validations and verified successful local and production builds.

@google-labs-jules

Copy link
Copy Markdown
Contributor Author

[CI/CD Fix Attempt 1]
Fixed CI failure by removing the unused onMount import in src/lib/components/ImageLoader.svelte and running Prettier formatting over src/lib/types.ts, src/lib/helpers/projectsProvider.ts, and src/lib/components/ImageLoader.svelte to resolve lint errors.

Jules and others added 3 commits August 1, 2026 11:40
Replace the hand-maintained 127-line image dimensions map with a small
Vite plugin that reads intrinsic dimensions from static/images at build
time and exposes them via a `virtual:image-dimensions` module. New images
are picked up automatically and dimensions can't drift out of sync with
the assets.

This also fixes vispositions.svg, whose dimensions were hardcoded as
1000x500 but are actually 100x100 (a 1:1 icon), so its reserved aspect
ratio was wrong.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Sparkier
Sparkier force-pushed the jules/feat/stateful-image-skeleton-js1-1ece5eac-e8c8-4efc-a616-c2b2bb22581a branch from 538ae21 to 828dc3d Compare August 1, 2026 09:42
@google-labs-jules

Copy link
Copy Markdown
Contributor Author

[CI/CD Fix Attempt 2]
Identified and resolved the CI test failure where Vitest was failing to resolve the virtual:image-dimensions module. Added the custom imageDimensions plugin to vitest.config.ts plugins, and updated both vite.config.ts and vitest.config.ts import paths to include the explicit .ts extension to resolve Vitest configuration warnings. All local tests and builds are now passing cleanly.

@google-labs-jules

Copy link
Copy Markdown
Contributor Author

[CI/CD Fix Attempt 3]
Fixed CI type-check failure by enabling "allowImportingTsExtensions": true in the compiler options of tsconfig.json. This resolves the TypeScript compilation error regarding explicit .ts file extensions in vite.config.ts and vitest.config.ts (An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled.), while keeping the explicit extension to satisfy Vitest's configLoader: 'native' and prevent loader compatibility warnings.

Verified that:

  • bun run check passes with 0 errors and warnings.
  • bun run test runs and passes successfully.
  • bun run lint is clean.
  • bun run build completes cleanly without issue.

Sparkier and others added 2 commits August 1, 2026 12:41
The ImageLoader faded the image in only once a `loaded` flag flipped, but
for cached/server-rendered images the `load` event fires before the
listener attaches, so `loaded` stayed false and every publication
thumbnail rendered at opacity 0 (invisible) despite loading fine.

Stop gating the image's visibility on JS entirely: the image is always
rendered, with the skeleton behind it as a best-effort placeholder that
the image covers once it paints. This removes the fragile load-timing
dependency (and the fade transition that relied on it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Sparkier
Sparkier merged commit 31d90c7 into main Aug 1, 2026
1 check passed
@Sparkier
Sparkier deleted the jules/feat/stateful-image-skeleton-js1-1ece5eac-e8c8-4efc-a616-c2b2bb22581a branch August 1, 2026 10:45
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