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() {