Fix home-page SSR->CSR flicker#1287
Conversation
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.
There was a problem hiding this comment.
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
CustomEagerThemeModuleinsrc/themes/eager-themes.module.tsand raise theinitialbundle budget inangular.json(5MB → 8MB). - Add an inline SSR-mask overlay script + supporting CSS in
src/index.html, and a matchingremoveSsrOverlayWhenStable()hook inAppComponentthat runs outside Angular and waits onappRef.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. |
- 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)
|
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 This take is the real-world comparison:
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 Use |
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.
PR Review Summary: Home Page SSR to CSR Flicker FixProblem This PR SolvesThis PR fixes a visible flicker on the home page during app startup. In simple terms, the page is rendered twice:
That visual jump is the issue this PR addresses. How the PR Fixes ItThe fix uses a temporary overlay to hide the rebuild phase.
Result: users keep seeing a stable page instead of a flicker. Why the Timeouts Are ThereThe timeouts are intentional and part of the UX fix:
These are not test-only hacks. They are defensive UI timing controls to avoid flicker and prevent overlay lock-in. VerdictThe 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. |
Five small changes that make `docker/docker-compose.yml` + `docker-compose-rest.yml`
the same artifact for the pyinfra-driven production deploy AND for a developer
running the stack on their own machine. The prior gap was that compose's defaults
encoded production-only assumptions; local-dev had to override them in a separate
file, which was the friction this fixes.
Defaults preserve every existing production behaviour bit-for-bit (verified with
`docker compose -f ... -f ... config` against an INSTANCE-only env: same subnet,
same proxies trusted range, same host_ip 127.0.0.1, same dspace.ui.url default,
same CORS default that already matched dspace.ui.url implicitly).
Changes:
1. Dockerfile: also copy `docker/dspace-ui.json` to `/app/dspace-ui.json` so the
locally-built image matches Dockerfile.dist's layout. Compose's hardcoded
`pm2-runtime start dspace-ui.json` (no `docker/` prefix) was ENOENT-looping
on locally-built containers. The Dockerfile CMD now matches compose's
entrypoint too.
2. docker-compose.yml: add `extra_hosts: host.docker.internal:host-gateway` to
the FE service. No-op on production (BE is reached via dev-5.pc DNS, not
host.docker.internal). On local dev it lets the FE container's SSR reach a
BE port published on the host machine.
3. docker-compose-rest.yml: parameterise the dspacenet subnet via
`${DSPACE_SUBNET_PREFIX:-172.2${INSTANCE}}.0.0/16`. Default unchanged.
Local dev can move off the crowded 172.2X range with a one-line env override.
`proxies.trusted.ipranges` follows the same prefix automatically.
4. docker-compose-rest.yml: add explicit
`rest__P__cors__P__allowed__D__origins: ${REST_CORS_ALLOWED_ORIGINS:-${UI_URL:-...}}`
to the dspace service env. Default value matches the implicit
`${dspace.ui.url}` fallback that `dspace/config/modules/rest.cfg` already
computes, so production CORS is byte-identical. Local dev can extend with
multiple browser origins (localhost + host.docker.internal + LAN IP) so the
preflight succeeds regardless of which hostname resolves the BE first.
5. New `docker/.env.local.example`: documented starter for local-dev. Sets the
four env vars above (HOST_IP=0.0.0.0, DSPACE_HOST=host.docker.internal,
DSPACE_SUBNET_PREFIX, REST_CORS_ALLOWED_ORIGINS) plus image set and INSTANCE.
Production deploys do not use this file — pyinfra still templates its own
.env from devops/infra/pyinfra/assets/dspace/dspace-envs/v7/.env.j2.
Background: I wrote the diagnosis up in detail when investigating the home-page
flicker (separate PR #1287). The death-by-a-thousand-cuts of unstated assumptions
in the compose files is what made starting the stack locally a slog. This PR
removes the slog without touching the production deploy path.
…ckports Aligns ZCU-PUB's initial budget with the value the root fix (#1287) and the other customer backports use, so the budget block is identical across instances. The custom theme is already eager here, so this only widens the headroom; the build already passes under the previous 5mb error ceiling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…1318, #1321) The original overlay removal waited for ApplicationRef.isStable, which is held hostage by post-login/admin zone activity (page looks rendered but is not interactive - dspace-customers#725). Replace it with the final mechanism used across the customer backports: keep the SSR snapshot until the routed <ds-app> DOM has settled (MutationObserver + quiet window, content-height / #main-content check, 10s cap), and make the overlay a purely visual mask so the live app stays interactive underneath. index.html / app.component.ts / spec / typings are synced to the final version; the existing ngAfterViewInit delay(0) is preserved. Keeping the trunk on the final mechanism means new customer branches cut from dtq-dev start correct instead of re-inheriting the old isStable approach. Ref: #1318, #1321 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis PR introduces an SSR anti-flicker overlay: an inline script in index.html masks SSR content and exposes a global removal hook, typed in typings.d.ts, invoked by new AppComponent logic that detects DOM settle and auth/theme readiness before removing the overlay. It also increases build budgets, enables a custom eager theme module, adds tests, and updates .gitignore. ChangesSSR Overlay Feature
Estimated code review effort: 4 (Complex) | ~60 minutes Unrelated build/config/theme updates
Sequence Diagram(s)sequenceDiagram
participant Browser
participant IndexHtmlScript
participant AppComponent
participant Store as NgRx Store/ThemeService
Browser->>IndexHtmlScript: load page, run inline script
IndexHtmlScript->>IndexHtmlScript: clone SSR ds-app content into overlay
IndexHtmlScript->>Browser: mark ds-app data-dspace-ssr-hidden, define window.__dspaceRemoveSsrOverlay
AppComponent->>Store: subscribe to auth.blocking and isThemeLoading$
Store-->>AppComponent: emit readiness state
AppComponent->>AppComponent: dsAppDomSettled$ observes MutationObserver quiet window
AppComponent->>AppComponent: routedPageReadyToReveal$ combines readiness + DOM settle
AppComponent->>Browser: call window.__dspaceRemoveSsrOverlay() after next frame
Browser->>Browser: fade out and remove overlay + kept styles
Estimated code review effort: 4 (Complex) | ~60 minutes Suggested labels: enhancement, needs-review Suggested reviewers: dataquest-dev/dspace-angular maintainers familiar with SSR and AppComponent lifecycle 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/app/app.component.ts`:
- Around line 218-221: Add a short TypeDoc/JSDoc comment for the new public
lifecycle method ngOnDestroy in AppComponent. Update the method definition to
include a concise description of its cleanup responsibility, matching the
existing documentation style used in app.component.ts for public members.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 07cf77ff-fb89-4a99-b803-593edd3d347c
📒 Files selected for processing (7)
.gitignoreangular.jsonsrc/app/app.component.spec.tssrc/app/app.component.tssrc/index.htmlsrc/themes/eager-themes.module.tssrc/typings.d.ts
* Backport of Fix home-page SSR->CSR flicker * Fix: always unhide app when removing SSR anti-flicker overlay The overlay remover bailed out via `if (!el) return;` before unhiding <ds-app>, so if the overlay node went missing (browser extension, race, external script) the app stayed visibility:hidden forever -> blank page, plus the kept SSR styles leaked. Unhide the app and clean up the kept styles unconditionally, before checking for the overlay node. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Chore: drop accidentally committed build/spec logs _build.log and _spec.log are local deploy-tooling output that should never have been tracked. Remove them and gitignore /_*.log. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Test: isolate isStable override and cover the no-rAF overlay path Make the ApplicationRef.isStable override in the removeSsrOverlayWhenStable suite configurable and restore the original descriptor in afterEach, so the patched observable can't leak onto the shared TestBed instance. Add a test for the requestAnimationFrame-absent fallback branch of the remover. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Refactor: align eager-themes.module.ts with the other backport instances Bind the custom eager theme to the CustomEagerThemeModule alias used by the other customer backports so this file is byte-identical across instances. The same ./custom/eager-theme.module is still imported eagerly - no runtime, build, or bundle-size change; the custom theme stays eager (which also keeps the untyped-item theming working, ref DSpace#1897). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Build: raise initial bundle budget to 5.5mb/6mb to match the other backports Aligns ZCU-PUB's initial budget with the value the root fix (#1287) and the other customer backports use, so the budget block is identical across instances. The custom theme is already eager here, so this only widens the headroom; the build already passes under the previous 5mb error ceiling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Refactor: remove SSR overlay on content-visible instead of isStable Propagates VSB-TUO's fix #1317 to this instance. The overlay was removed when ApplicationRef.isStable settled, but isStable can be delayed for seconds by post-login admin zone activity (auth work, background polling, third-party scripts) - during which the live app stays hidden under the SSR mask and the page renders but is non-interactive (dataquest-dev/dspace-customers#725). Switch removal to the same condition root.component.html uses to show real content: !isAuthenticationBlocking && !isThemeLoading. Drop the now-unused ApplicationRef injection and the 50ms pad; keep the 15s hard fallback as a catastrophic safety net. Tests and the theme-service mock updated to match. Ref: #1317 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Backport final SSR-overlay mechanism from VSB-TUO (#1318, #1321) Supersedes the #1317 content-visible trigger backported earlier. That gate (!isAuthenticationBlocking && !isThemeLoading) still revealed a half-built page on hard reload, so VSB-TUO's #1318/#1321 keep the snapshot until the routed <ds-app> DOM has SETTLED (MutationObserver + quiet window, with a content height / #main-content check and a 10s cap). The overlay is now a purely visual mask, so the live app stays interactive underneath while it rebuilds (closes dspace-customers#725 - "looks rendered but not clickable"). index.html, app.component.ts, spec and typings are synced to VSB-TUO's final version; the VSB-only ngAfterViewInit delay(0) is omitted (these instances don't carry it). Ref: #1318, #1321 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

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 ofprovideClientHydration— the whole SSR DOM is torn down and rebuilt on every browser load.Two compounding causes, both addressed:
CustomEagerThemeModulewas commented out insrc/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 existingcustom/eager-theme.module.tsalready declares the right set). Bumps initial bundle by ~256 KB, soangular.jsonbudget raised 5 MB → 8 MB.The fundamental no-hydration rebuild is masked by an inline pre-bootstrap script in
src/index.htmlthat:<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).<ds-app>children into an absolute-positioned overlay so they keep every live DOM/style detail.<ds-app>via adata-dspace-ssr-hiddenattribute + CSS rule.window.__dspaceRemoveSsrOverlay()forAppComponent.removeSsrOverlayWhenStable()to call onceApplicationRef.isStablefires (onerAF+ 50 ms pad, then 150 ms fade).isStablenever 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— enableCustomEagerThemeModulesrc/index.html— inline overlay script + supporting<style>src/app/app.component.ts—removeSsrOverlayWhenStable()hookangular.json— bundle budget bumpscripts/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-instancedocker/.env.dspace-*/docker/.override.dspace-*.ymlfiles the helper generatesTest plan
scripts\dspace-deploy.bat 7 rebuildbrings up FE+BE+DB+Solr;http://localhost:4007/homerenders fully styled with no white flash on reloaddocument.getElementById('__dspace_ssr_overlay')exists during boot and is removed after the app stabilisesview-source:of/homeshows 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>)/search,/browse/*,/admin/*etc) — the script early-returns when<ds-app>has no childrenSummary by CodeRabbit
New Features
Bug Fixes
Tests