Repository navigation
Restore landing page performance and serve the site over HTTP/2 - #1836
Open
David Pine (IEvangelist) wants to merge 8 commits into
Open
David Pine (IEvangelist) wants to merge 8 commits into
David Pine (IEvangelist) wants to merge 8 commits into
Conversation
David Pine (IEvangelist)
force-pushed
the
ievangelist-landing-page-performance
branch
from
October 8, 2026 04:47
e75368d to
4e85c4d
Compare
The banner shipped `hidden` and a bundled script revealed it after first paint, shifting the whole page down. It was the largest layout shift on the landing page (CLS 0.09 in Lighthouse mobile). Render it visible and hide it with a tiny pre-paint script when the reader has dismissed it or its sunset or auto-dismiss window has passed. `data-astro-rerun` re-applies the decision to the incoming page on client-side navigation. A unit test runs the emitted script against `resolveBannerVisibility` for dismiss, sunset, auto-dismiss and malformed-storage cases so the two cannot drift, and a Playwright spec with scripts blocked covers the first-paint state. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Poppins is declared in site.css, so the browser only discovers it after that stylesheet has downloaded and parsed. First paint waited on it, and text re-flowed when it swapped in (CLS 0.08 on the landing page). Preload latin 400, 600 and 700, the weights the first viewport renders. They are the same files the @font-face rules use, so each is still fetched once. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
David Pine (IEvangelist)
force-pushed
the
ievangelist-landing-page-performance
branch
from
October 8, 2026 06:15
4e85c4d to
d746799
Compare
The landing page redesign set `loading="eager"` on 35 icons thousands of pixels below the fold. Over HTTP/1.1 they competed with the render-blocking CSS and fonts for six connections: 88 requests, or 61 without them. Use Astro's default lazy loading again. Native lazy loading never starts for `display: none` content, so `loadLazyImagesNearViewport` loads the icons in hidden environment panels and the inactive AppHost language as their section nears the viewport. Panel and language switches stay instant without adding those icons to the initial page load. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
AsciinemaPlayer imported its stylesheet from a bundled <script>. Astro follows dynamic importers when it attaches CSS, so that import landed on the whole docs route and every page shipped the player's 15 KB of CSS, including the landing page, which has no player. Import it in the component frontmatter instead, which attaches it only to the pages that render the component. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
David Pine (IEvangelist)
force-pushed
the
ievangelist-landing-page-performance
branch
from
October 8, 2026 06:49
d746799 to
5f41bd3
Compare
The carousel's ~23 KB script was `is:inline`, so it was re-sent inside every homepage response. Bundling it takes the document from 45.6 to 40.4 KiB transferred, under the ~43 KB a cold connection delivers in two round trips, which shortens the simulated document download by one round trip (1.05 s to 0.90 s) and leaves the script cacheable. In the lab that reaches FCP in some runs and not others (2.25 s in 4 of 6 batches with the script bundled, 0 of 3 with it inline), so the benefit is modest and sits on a TCP-window boundary. A bundled <script> is TypeScript, and editors check it under the project's strict settings, where the untyped body reported 108 errors. Type it with erasable syntax only: generics on querySelector, parameter and variable annotations, and `!` where TypeScript drops narrowing inside hoisted function declarations. Stripping the types gives JavaScript identical to the previous inline body, and the built chunk is byte-identical with and without them. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Two mistakes quietly added render-blocking CSS and early requests to every first view of the site. Both are invisible in review and only show up in a Lighthouse run, so check them statically: - a component importing CSS from a bundled <script> instead of its frontmatter (attached to the whole docs route, so every page pays for it); - `loading="eager"` outside the few components that render first-viewport images. Each check also has tests that prove it flags the mistake it exists for and ignores the near-misses (frontmatter imports, `is:inline` scripts, property assignments in client scripts). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
aspire.dev negotiates HTTP/1.1 today. The apex resolves to an App Service front end: an unknown host sent to that IP gets the "Microsoft Azure Web App - Error 404" page with the *.azurewebsites.net certificate, and responses carry no Front Door headers. Its TLS handshake only selects http/1.1, while github.com and learn.microsoft.com select h2 from the same client. App Service leaves HTTP/2 off unless `siteConfig.http20Enabled` is set, so browsers are limited to six connections per origin and the landing page's 60-90 requests queue behind them. Set `SiteConfig.IsHttp20Enabled` in the AppHost. Publishing the AppHost before and after this change shows `http20Enabled: true` in aspiredev.bicep as the only difference in the generated infrastructure. Lighthouse lab, mobile, five cold runs, identical bytes served over HTTP/1.1 versus HTTP/2: the original page scores 78 -> 90 and the page with the frontend fixes 86 -> 93. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
David Pine (IEvangelist)
force-pushed
the
ievangelist-landing-page-performance
branch
from
October 8, 2026 08:01
5f41bd3 to
fff220c
Compare
David Pine (IEvangelist)
marked this pull request as ready for review
October 8, 2026 12:41
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
The pre-paint banner script skips absolute expiry when local storage is unavailable, allowing a renewed layout shift.
1 open finding
What changed in this PR
Restores landing-page performance by reducing layout shifts and unnecessary requests, while enabling HTTP/2 for production deployment.
Changes:
- Preloads fonts, lazy-loads below-fold icons, and scopes player CSS.
- Prevents banner CLS and bundles the dashboard carousel.
- Adds performance guardrails, browser tests, and HTTP/2 configuration.
| File | Description |
|---|---|
src/frontend/tests/unit/page-weight-guardrails.vitest.test.ts |
Adds static performance guardrails. |
src/frontend/tests/unit/lazy-images.vitest.test.ts |
Tests deferred image loading. |
src/frontend/tests/unit/banner-render.vitest.test.ts |
Tests banner rendering and visibility parity. |
src/frontend/tests/e2e/homepage.spec.ts |
Verifies hidden icons preload near view. |
src/frontend/tests/e2e/banner.spec.ts |
Covers banner first-load visibility states. |
src/frontend/src/utils/lazy-images.ts |
Adds near-viewport image loading. |
src/frontend/src/components/starlight/Head.astro |
Preloads first-viewport fonts. |
src/frontend/src/components/starlight/Banner.astro |
Moves banner visibility decisions before paint. |
src/frontend/src/components/home/HomePage.astro |
Defers model-story icon requests. |
src/frontend/src/components/home/HomeEnvironment.astro |
Defers environment icon requests. |
src/frontend/src/components/DashboardCarousel.astro |
Bundles and types carousel logic. |
src/frontend/src/components/AsciinemaPlayer.astro |
Scopes player CSS to rendered pages. |
src/apphost/Aspire.Dev.AppHost/AppHost.cs |
Enables App Service HTTP/2. |
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
David Pine (IEvangelist)
enabled auto-merge (squash)
October 8, 2026 15:43
| * | ||
| * Returns a function that stops waiting for `root` to approach the viewport. | ||
| */ | ||
| export function loadLazyImagesNearViewport(root: Element, rootMargin = '1500px 0px'): () => void { |
There was a problem hiding this comment.
is the default of 0px correct?
| */ | ||
| export function loadLazyImagesNearViewport(root: Element, rootMargin = '1500px 0px'): () => void { | ||
| const loadImages = () => { | ||
| root.querySelectorAll<HTMLImageElement>('img[loading="lazy"]').forEach((image) => { |
There was a problem hiding this comment.
Doesn't this load all images under root? Is that what we want?
Eric Erhardt (eerhardt)
approved these changes
Oct 8, 2026
David Pine (IEvangelist)
disabled auto-merge
October 8, 2026 17:10
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
The landing page's Lighthouse score slipped from the high 80s to the high 70s (mobile, simulated slow 4G), and PageSpeed Insights reported 70. Rather than guess, I built a production-like lab (the production build output served over HTTPS + HTTP/1.1 + Brotli with the same cache headers, because that is what
aspire.devserves today), ran Lighthouse 13 with the mobile preset (simulated Slow 4G, 4× CPU) in a fresh browser profile per run, and bisected the page across the commits that changed it. Every number below is the median of five cold runs. There is one commit per cause, so any of them can be reverted on its own.Where it regressed
0b54dce5)main(a097c7e7)Serving the same bytes over HTTP/2:
main90 → this PR 93.Causes and fixes
I measured each lever on
mainin isolation before changing any code.hiddenand a bundled script reveals it after first paint, shifting the whole page down (CLS 0.09).resolveBannerVisibilityso the two cannot drift.site.css, so it is only discovered after that stylesheet downloads; text re-flows when it swaps in (CLS 0.08).loading="eager"on 35 icons thousands of pixels below the fold; on HTTP/1.1 they compete with CSS and fonts for six connections.display: nonecontent, so a small helper (loadLazyImagesNearViewport) loads the icons in hidden environment panels and the inactive AppHost language as their section nears the viewport, and switching panels or language stays instant.AsciinemaPlayerimported its CSS from a bundled<script>. Astro attaches that to the whole docs route, so every page shipped it./get-started/ai-coding-agents/,/reference/overview/and/reference/cli/commands/aspire-agent-init/).is:inline, so it was re-sent in every homepage response.<script>is TypeScript and editors check it under the project's strict settings (the untyped body reported 108 errors), so the script is typed with erasable syntax only; the built chunk is byte-identical with and without the annotations.aspire.devis served over HTTP/1.1. The apex resolves to an App Service front end (an unknown host sent to that IP gets "Microsoft Azure Web App – Error 404" with the*.azurewebsites.netcertificate, and responses carry no Front Door headers). Its TLS handshake only offershttp/1.1, whilegithub.comandlearn.microsoft.comselect h2 from the same client. App Service leaves HTTP/2 off unlesshttp20Enabledis set.SiteConfig.IsHttp20Enabledin the AppHost.aspire publishbefore and after differs by exactlyhttp20Enabled: true.Together the frontend changes take CLS from 0.105 to 0.001. I also added guardrail tests for the two mistakes behind this (CSS imported from a bundled script, stray eager images), e2e coverage for the banner's first-paint state, and e2e plus unit coverage for icons that sit behind tabs and language toggles.
Measured and deliberately not changed
page-ssr.css, 100 KB raw, 21 KiB brotli, ~0.5% used on the homepage). Removing it entirely was worth at most one point, and every way to scope it per page needs custom build plumbing.load. About +1 point and it is compliance-sensitive, so I left it alone.What this does not reach
LCP is still 3.77 s over HTTP/1.1 (3.15 s over HTTP/2) against the 2.5 s goal. In the final build the simulated LCP chain ends in the third-party consent script (52 KiB) and the module-script waterfall, not in anything this PR can remove cleanly.
The lab is a proxy: it scored the original page 78 where PageSpeed Insights reported 70, so expect PSI to read lower than the numbers here. The HTTP/2 commit only takes effect after the next deployment and I could not test it against production, so check the negotiated protocol (
ALPNshould selecth2) after deploying. It is a separate commit so it can be dropped or applied through your deployment pipeline instead.Third-party links and affiliations
None. This pull request does not add or change third-party links.
Validation
pnpm test:unit: 79 files, 1000 passed, 1 skipped.pnpm lint: passed.pnpm exec playwright test tests/e2e/homepage.spec.ts tests/e2e/ui-regressions.spec.ts tests/e2e/banner.spec.ts -g "loads icons hidden|keeps the environment frame stable|carousel|announcement banner|finite motion|dashboard screenshot" --workers=4on the desktop, tablet and mobile projects against the dev server: 25 passed, 2 skipped (the carousel client-navigation test only runs on desktop by design). With the default worker count (10 on this machine) the dev server cannot keep up and unrelated image and navigation tests time out intermittently, a different few each run; they pass serially and with four workers. The new hidden-icons test waits up to 60 s for that reason.CI=1,astro previewof a production build pruned to the homepage and the pages that render the player, two workers, no retries): 25 passed, 2 skipped, in 41 s. All ofhomepage.spec.tsandbanner.spec.tsagainst that build: 136 passed, 10 skipped, 4 failed. The four failures navigate to pages the pruned build does not contain (/hub/, and the ~90 links requested by "serves every internal homepage link"), so they only mean something in the full CI build.>=to>(a one-millisecond shift) fails the parity test at "exactly at the auto-dismiss boundary"; a lazy-image helper that matches no images fails the hidden-icons spec on desktop, tablet and mobile.dotnet test tests/Aspire.Dev.AppHost.Tests: 4 passed.aspire publishbefore and after: the only difference ishttp20Enabled: trueinaspiredev.bicep./get-started/ai-coding-agents/,/reference/overview/and/reference/cli/commands/aspire-agent-init/; the banner renders withouthidden; the hero is the only eager image; the homepage document is 40.4 KiB transferred./fr/and a reference page the three preloaded fonts are each fetched exactly once and are the faces the page uses.tscwith the project's strict settings (astro/tsconfigs/strict) over the extracted script reported 108 errors before and 0 after, and theHomePage,HomeEnvironmentandAsciinemaPlayerscripts touched here report 0. Stripping the types gives JavaScript identical to the previous inline body (22,835 characters, byte for byte), and the production build emits the same carousel chunk with and without the annotations (same content hash, size and SHA-256; the same comparison flags a one-token logic change). All carousel e2e tests pass against the typed script (11 passed, 4 skipped by viewport).fff220c5): all 19 checks passed, including the full production build, all six e2e shards (desktop, tablet and mobile: 1346 tests, no failures, retries or flaky tests, including the 15 new banner and hidden-icon results), validation, the AppHost build, CodeQL and the CI gate.