Skip to content

Fix home-page SSR->CSR flicker#1287

Open
vidiecan wants to merge 4 commits into
dtq-devfrom
internal/fe-fix-home-page-flicker
Open

Fix home-page SSR->CSR flicker#1287
vidiecan wants to merge 4 commits into
dtq-devfrom
internal/fe-fix-home-page-flicker

Conversation

@vidiecan
Copy link
Copy Markdown

Summary

Eliminates the visible flicker on /home (reproducible on http://dev-5.pc:84/home; CLS 0.89 measured at t≈1.76s) that comes from Angular 15's lack of provideClientHydration — the whole SSR DOM is torn down and rebuilt on every browser load.

Two compounding causes, both addressed:

  1. CustomEagerThemeModule was commented out in src/themes/eager-themes.module.ts, so every custom-themed wrapper (footer, header, root, ...) was lazy-loaded via webpack code-splitting on the browser — stretching the gap. Re-enabled (the existing custom/eager-theme.module.ts already declares the right set). Bumps initial bundle by ~256 KB, so angular.json budget raised 5 MB → 8 MB.

  2. The fundamental no-hydration rebuild is masked by an inline pre-bootstrap script in src/index.html that:

    • Captures all <style ng-transition="dspace-angular"> blocks into <style data-dspace-ssr-keep> tags Angular won't strip (without this the moved SSR DOM renders unstyled — the original styles are wiped on bootstrap).
    • Moves (not clones) the SSR-rendered <ds-app> children into an absolute-positioned overlay so they keep every live DOM/style detail.
    • Hides the now-empty <ds-app> via a data-dspace-ssr-hidden attribute + CSS rule.
    • Exposes window.__dspaceRemoveSsrOverlay() for AppComponent.removeSsrOverlayWhenStable() to call once ApplicationRef.isStable fires (one rAF + 50 ms pad, then 150 ms fade).
    • 15 s safety fallback in case isStable never fires.

Bots / no-JS clients still get the original SSR <ds-app> (the overlay is JS-added). Real users see continuous SSR-rendered content while CSR rebuilds invisibly underneath, then a 150 ms fade reveals the CSR DOM in its final data-loaded state.

Files

  • src/themes/eager-themes.module.ts — enable CustomEagerThemeModule
  • src/index.html — inline overlay script + supporting <style>
  • src/app/app.component.tsremoveSsrOverlayWhenStable() hook
  • angular.json — bundle budget bump
  • scripts/dspace-deploy.bat + .claude/skills/dspace-deploy/SKILL.md — local dev helper (cmd, no PowerShell), multi-instance safe via the existing compose files
  • .gitignore — ignore the per-instance docker/.env.dspace-* / docker/.override.dspace-*.yml files the helper generates

Test plan

  • CI build passes
  • Local: scripts\dspace-deploy.bat 7 rebuild brings up FE+BE+DB+Solr; http://localhost:4007/home renders fully styled with no white flash on reload
  • Verify in browser DevTools that document.getElementById('__dspace_ssr_overlay') exists during boot and is removed after the app stabilises
  • Confirm view-source: of /home shows the inline <style id="__dspace-ssr-overlay-style"> and the bootstrap script — the page works without JS (overlay is JS-added, so bots / no-JS clients see plain SSR'd <ds-app>)
  • No regression on excluded SSR routes (/search, /browse/*, /admin/* etc) — the script early-returns when <ds-app> has no children

Angular 15 has no provideClientHydration; on every browser load Angular
tears down the entire SSR DOM and rebuilds the component tree from scratch.
Measured CLS = 0.89 at t=1.76s on /home (PerformanceObserver on dev-5).
The visible flicker is that ~600ms rebuild window between SSR view and
populated CSR view.

Two compounding causes, addressed in this PR:

1. CustomEagerThemeModule was commented out in src/themes/eager-themes.module.ts,
   so every custom-themed wrapper (footer, header, root, ...) was lazy-loaded via
   webpack code-splitting on the browser, stretching the gap. Re-enable it
   (the existing custom/eager-theme.module.ts already declares the right set).
   Bumps initial bundle by ~256KB; angular.json budget raised from 5MB to 8MB
   to accommodate.

2. The bigger cause - no hydration - is masked by an inline pre-bootstrap script
   in src/index.html that:
     - Captures all <style ng-transition="dspace-angular"> blocks into
       <style data-dspace-ssr-keep> tags Angular won't strip (Angular removes
       the originals on bootstrap, which is why a naive overlay renders
       unstyled).
     - Moves (not clones) the SSR-rendered <ds-app> children into an
       absolute-positioned overlay so they keep every live DOM/style detail.
     - Hides the now-empty <ds-app> via a data-attribute and CSS rule.
     - Exposes window.__dspaceRemoveSsrOverlay() for AppComponent to call
       once ApplicationRef.isStable fires (with one rAF + 50ms pad).
     - 15s safety fallback in case isStable never fires.

Bots and no-JS users still get the original SSR <ds-app> (the overlay is
JS-added). Real users see continuous SSR-rendered content while CSR rebuilds
invisibly underneath, then a 150ms fade reveals the CSR DOM in its final
data-loaded state.

Verified locally via Service Worker that suppresses the removal: overlay's
header height is 80px (proper styling preserved) versus 698px (the unstyled
fallback before this fix's style-preservation step).

Includes a small Windows cmd deploy helper at scripts/dspace-deploy.bat and
matching skill doc at .claude/skills/dspace-deploy/SKILL.md - multi-instance
safe local dev stack via the existing docker compose files.
Copilot AI review requested due to automatic review settings May 19, 2026 00:24
Copy link
Copy Markdown

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

Eliminates the visible SSR→CSR flicker on /home on this Angular 15 (pre-provideClientHydration) codebase. Achieved by (a) re-enabling the previously commented-out CustomEagerThemeModule so themed wrappers stop being lazy-chunked, and (b) installing an inline pre-bootstrap overlay in index.html that moves the SSR DOM into an absolute-positioned snapshot (with copies of <style ng-transition> blocks Angular would otherwise strip) while Angular invisibly rebuilds the real <ds-app>, then fades the overlay out from AppComponent when ApplicationRef.isStable settles. Adds a Windows-only multi-instance dev helper and bumps the production bundle budget to accommodate the eager-theme increase.

Changes:

  • Re-enable CustomEagerThemeModule in src/themes/eager-themes.module.ts and raise the initial bundle budget in angular.json (5MB → 8MB).
  • Add an inline SSR-mask overlay script + supporting CSS in src/index.html, and a matching removeSsrOverlayWhenStable() hook in AppComponent that runs outside Angular and waits on appRef.isStable.
  • Introduce a Windows cmd dev-helper (scripts/dspace-deploy.bat + .claude/skills/dspace-deploy/SKILL.md) and ignore its generated artifacts (plus various local investigation files) in .gitignore.

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/themes/eager-themes.module.ts Re-enables CustomEagerThemeModule so custom-theme wrappers ship in the main bundle instead of lazy chunks.
src/index.html Adds inline overlay CSS + pre-bootstrap script that snapshots SSR DOM + ng-transition styles and exposes __dspaceRemoveSsrOverlay.
src/app/app.component.ts Adds removeSsrOverlayWhenStable() that, after appRef.isStable, invokes the global overlay-removal callback.
angular.json Raises initial bundle budget warning to 6MB and error to 8MB.
scripts/dspace-deploy.bat New Windows cmd helper that generates env/override files and brings up a namespaced FE+BE+DB+Solr stack per INSTANCE digit.
.claude/skills/dspace-deploy/SKILL.md Documents the deploy helper, URL conventions, and multi-instance contract.
.gitignore Ignores per-instance docker/.env/override files and various local investigation artifacts.

Comment thread angular.json Outdated
Comment thread src/index.html
Comment thread src/index.html
Comment thread src/index.html
Comment thread src/app/app.component.ts Outdated
Comment thread .claude/skills/dspace-deploy/SKILL.md Outdated
Comment thread src/app/app.component.ts
jm added 2 commits May 19, 2026 02:31
- angular.json: tighten budget back to 5.5MB warn / 6MB error (was 8MB)
- index.html: re-entrancy guard on __dspaceRemoveSsrOverlay (null the
  pointer up-front so the isStable + 15s safety fallback can't double-fade)
- index.html: drop aria-hidden from overlay so screen-reader users get the
  SSR snapshot during boot (ds-app underneath has visibility:hidden which
  already excludes it from a11y tree)
- index.html: console.warn on the overlay-script catch so a silently broken
  flicker fix is at least diagnosable in DevTools
- typings.d.ts: typed Window.__dspaceRemoveSsrOverlay augmentation; drop
  the `as any` cast in AppComponent.removeSsrOverlayWhenStable
- app.component.spec.ts: cover removeSsrOverlayWhenStable (calls the
  global once on isStable=true; no-op when global absent)
- Drop scripts/dspace-deploy.bat + .claude/skills/dspace-deploy/SKILL.md
  from this PR per request (local dev tooling, will live elsewhere)
@vidiecan
Copy link
Copy Markdown
Author

Third take — apologies, the previous two were apples-to-apples but the wrong apples.

Both prior clips used the same FE bundle (this PR's): the BEFORE just had the overlay-installer script stripped at the edge. That isolates Fix B but leaves Fix A (the re-enabled CustomEagerThemeModule) in place — so the only "extra" gap was the lazy-import delay, which on a fast host is sub-100 ms and invisible.

This take is the real-world comparison:

  • BEFORE = http://dev-5.pc:84/home — what production looks like today. No CustomEagerThemeModule import, no overlay. CLS 0.89 at t≈1.76 s in the perf trace.
  • AFTER = http://localhost:4007/home — this PR's FE bundle. Both Fix A and Fix B applied.
  • Both throttled to 400 KB/s + 100 ms RTT (Chrome DevTools Network.emulateNetworkConditions via CDP) — same throttle for both, so any difference is the fix.

flicker-before-after

MP4 (better quality): https://raw.githubusercontent.com/dataquest-dev/dspace-angular/internal/fe-fix-home-page-flicker-evidence/flicker-comparison.mp4?v=3

On the left (BEFORE) you'll see: SSR'd LINDAT page paints → ~600 ms gap where the page goes to a stripped/half-built state as Angular tears down <ds-app> → final populated CSR repaints. On the right (AFTER): continuous content, the overlay masks the rebuild and 150 ms-fades when ApplicationRef.isStable fires.

Use ?v=3 on the URL above to dodge the CDN cache (was serving the old 526 KB GIF for me, the new one is 611 KB).

The overlay holds the SSR-rendered children alongside <ds-app>'s CSR-rendered
children during the masking window. Cypress's cy.get(selector) sees both
copies, so unique-id selectors return 2 elements and cy.click() fails. The
overlay is purely a UX smoothing layer (no behaviour to E2E-validate), so
short-circuit when window.Cypress is present. Browser users are unaffected.
@milanmajchrak
Copy link
Copy Markdown
Collaborator

PR Review Summary: Home Page SSR to CSR Flicker Fix

Problem This PR Solves

This PR fixes a visible flicker on the home page during app startup.

In simple terms, the page is rendered twice:

  1. The server sends a ready-looking page (SSR), so users see content fast.
  2. Angular in the browser then rebuilds that page (CSR) to make it fully interactive.
  3. During that rebuild, users can briefly see a bad transition:
    • complete page
    • blank or partially rebuilt page
    • complete page again

That visual jump is the issue this PR addresses.

How the PR Fixes It

The fix uses a temporary overlay to hide the rebuild phase.

  1. Early startup script creates an overlay from the already rendered SSR content.
  2. The real app container is hidden while Angular rebuilds in the background.
  3. Once Angular reports the app is stable, the overlay is removed.
  4. Removal happens with a short fade-out so the transition feels smooth.

Result: users keep seeing a stable page instead of a flicker.

Why the Timeouts Are There

The timeouts are intentional and part of the UX fix:

  • Short delay after stability signal:
    ensures the first stable paint is fully committed before removing the overlay.
  • Short delay for fade-out cleanup:
    allows the opacity transition to finish before removing elements.
  • Long fallback timeout:
    guarantees the overlay is removed even if the stable signal never arrives.

These are not test-only hacks. They are defensive UI timing controls to avoid flicker and prevent overlay lock-in.

Verdict

The approach is technically valid for Angular 15 environments where full hydration is not available.

The timeouts are justified and expected in this type of anti-flicker strategy.

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