From 43fb507ab64be721f895f4c4a3c251557b845ed5 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Mon, 22 Jun 2026 09:19:17 +0200 Subject: [PATCH] fix(ssr-overlay): drop anti-flicker overlay after content paints (not on isStable or bare auth gate) The SSR anti-flicker overlay (PR #1288) is removed by AppComponent. Two prior approaches each fixed one symptom and reintroduced the other: - #1288 waited for ApplicationRef.isStable: no flicker, but admin sessions keep the zone busy (authz/widgets, polling, AAI scripts) so isStable fires many seconds late (or hits the 15s fallback), leaving the live, already-rendered page masked & non-interactive (dspace-customers#725). - #1317 switched to the loader-swap gate (!isAuthenticationBlocking && !isThemeLoading) + a single requestAnimationFrame: fast/interactive, but the gate only un-hides and one rAF runs before paint, so the snapshot is dropped over an empty -> the flicker returned. Neither signal means "the routed content is painted AND the app is interactive": isStable over-waits (couples removal to unrelated background async); the auth/theme gate under-waits (decoupled from actual content paint). This keeps #1317's decoupling from isStable but, after the gate opens, waits across animation frames until the real is actually laid out (height >= 200px AND its #main-content host present) before removing the overlay, capped at ~3s (MAX_FRAMES) so background async can never hold it open. The 15s fallback in index.html stays as the catastrophic-error net. Verified (DSpace 7.6.5 backend, CPU 4x, hard reload, admin session): - #1288: TTI 15963ms (page masked ~13s) #1317: ds-app height 0 at removal (217ms flash) - fix: TTI ~3.1-3.4s, ds-app height 5281px at removal, gap <= 0 (no flash), 3x deterministic; anon reload also no-flicker; 0 CORS and 0 SSR/hydration/NG0 console errors. Verification videos are linked in the PR description. Refs: dspace-customers#725, PR #1288, PR #1317 Co-Authored-By: Claude Opus 4.8 --- src/app/app.component.ts | 91 ++++++++++++++++++++++++++++++++-------- 1 file changed, 74 insertions(+), 17 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 9e6e0f150b5..9a64199ac32 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -93,14 +93,24 @@ export class AppComponent implements OnInit, AfterViewInit { } /** - * Drops the SSR mask overlay installed by the inline bootstrap script in src/index.html the - * moment the real CSR content is actually visible. We do NOT wait for ApplicationRef.isStable - * (which can be delayed many seconds by ongoing zone tasks, e.g. admin-only background HTTP - * polling, periodic timers, third-party AAI/discojuice scripts). Instead we react to the same - * condition root.component.html uses to swap the fullscreen loader for the real content: - * `!isAuthenticationBlocking && !isThemeLoading`. At that exact point the routed page is - * rendered, so removing the SSR snapshot does not produce flicker. One rAF delay lets the - * change-detection result commit to the DOM before the overlay fades. + * Drops the SSR mask overlay installed by the inline bootstrap script in src/index.html once the + * real CSR content has actually been painted. This trigger has to thread a needle between the two + * earlier approaches, each of which fixed one symptom and reintroduced the other: + * + * - PR #1288 waited for `ApplicationRef.isStable`. That guaranteed the content was painted (no + * flicker) but isStable is held hostage by ANY ongoing zone async — after an admin login the + * app keeps the zone busy (authz/widgets, periodic polling, AAI/discojuice scripts) so isStable + * fires many seconds late (or never, hitting the 15s fallback). The inert snapshot then masks + * the live, already-rendered page -> "looks rendered but not interactive" (issue #725). + * - PR #1317 switched to the loader-swap gate `!isAuthenticationBlocking && !isThemeLoading` plus + * a single requestAnimationFrame. That fires promptly (fixing #725) but the gate only un-hides + * ``; Angular has NOT yet rendered the routed page at that instant and one rAF + * runs before the browser paints, so the snapshot is dropped over an empty for a frame + * or two -> the flicker came back. + * + * We keep #1317's decoupling from isStable (so background async can never delay us) but, after the + * gate opens, we wait across animation frames until the real is actually laid out before + * removing the snapshot. See {@link removeSsrOverlayAfterContentPainted}. */ private removeSsrOverlayWhenContentVisible(): void { const w: Window | undefined = this._window?.nativeWindow; @@ -116,18 +126,65 @@ export class AppComponent implements OnInit, AfterViewInit { filter(([blocking, themeLoading]: [boolean, boolean]) => !blocking && !themeLoading), first(), ).subscribe(() => { - const remove = () => { - if (typeof w.__dspaceRemoveSsrOverlay === 'function') { - w.__dspaceRemoveSsrOverlay(); - } - }; - if (typeof w.requestAnimationFrame === 'function') { - w.requestAnimationFrame(remove); + this.removeSsrOverlayAfterContentPainted(w); + }); + }); + } + + /** + * Waits until the routed CSR view has been committed to the DOM and painted, then removes the SSR + * snapshot overlay. "Painted" is approximated by the real reaching a non-trivial height + * AND containing its `#main-content` host (i.e. it is no longer the empty shell the overlay script + * left behind). We poll this cheap layout signal once per animation frame, capped at MAX_FRAMES so + * that — unlike isStable in #1288 — nothing can hold the overlay open indefinitely; the 15s hard + * fallback in index.html stays as the catastrophic-error safety net. + */ + private removeSsrOverlayAfterContentPainted(w: Window): void { + const doc: Document = this.document; + const raf: ((cb: FrameRequestCallback) => number) | null = + typeof w.requestAnimationFrame === 'function' ? w.requestAnimationFrame.bind(w) : null; + const remove = () => { + if (typeof w.__dspaceRemoveSsrOverlay === 'function') { + w.__dspaceRemoveSsrOverlay(); + } + }; + const MAX_FRAMES = 180; // ~3s @60fps safety cap; the routed shell normally paints within a few frames + const MIN_CONTENT_HEIGHT = 200; // px: enough to prove the real is no longer the empty shell + let frames = 0; + const contentPainted = (): boolean => { + const app: Element | null = doc.querySelector('ds-app'); + if (!app) { + return false; + } + let height = 0; + try { + height = app.getBoundingClientRect().height; + } catch (e) { + height = 0; + } + return height >= MIN_CONTENT_HEIGHT && app.querySelector('#main-content') !== null; + }; + const tick = () => { + if (contentPainted() || ++frames >= MAX_FRAMES) { + // one more frame so the painted content is committed to screen before the snapshot fades + if (raf) { + raf(remove); } else { remove(); } - }); - }); + return; + } + if (raf) { + raf(tick); + } else { + setTimeout(tick, 16); + } + }; + if (raf) { + raf(tick); + } else { + setTimeout(tick, 16); + } } ngOnInit() {