Skip to content

Commit dce52f0

Browse files
fix(ssr-overlay): drop anti-flicker overlay after content paints (not on isStable or bare auth gate) (#1318)
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 <router-outlet> and one rAF runs before paint, so the snapshot is dropped over an empty <ds-app> -> 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 <ds-app> 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 <noreply@anthropic.com>
1 parent 96ab415 commit dce52f0

1 file changed

Lines changed: 74 additions & 17 deletions

File tree

src/app/app.component.ts

Lines changed: 74 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,24 @@ export class AppComponent implements OnInit, AfterViewInit {
9393
}
9494

9595
/**
96-
* Drops the SSR mask overlay installed by the inline bootstrap script in src/index.html the
97-
* moment the real CSR content is actually visible. We do NOT wait for ApplicationRef.isStable
98-
* (which can be delayed many seconds by ongoing zone tasks, e.g. admin-only background HTTP
99-
* polling, periodic timers, third-party AAI/discojuice scripts). Instead we react to the same
100-
* condition root.component.html uses to swap the fullscreen loader for the real content:
101-
* `!isAuthenticationBlocking && !isThemeLoading`. At that exact point the routed page is
102-
* rendered, so removing the SSR snapshot does not produce flicker. One rAF delay lets the
103-
* change-detection result commit to the DOM before the overlay fades.
96+
* Drops the SSR mask overlay installed by the inline bootstrap script in src/index.html once the
97+
* real CSR content has actually been painted. This trigger has to thread a needle between the two
98+
* earlier approaches, each of which fixed one symptom and reintroduced the other:
99+
*
100+
* - PR #1288 waited for `ApplicationRef.isStable`. That guaranteed the content was painted (no
101+
* flicker) but isStable is held hostage by ANY ongoing zone async — after an admin login the
102+
* app keeps the zone busy (authz/widgets, periodic polling, AAI/discojuice scripts) so isStable
103+
* fires many seconds late (or never, hitting the 15s fallback). The inert snapshot then masks
104+
* the live, already-rendered page -> "looks rendered but not interactive" (issue #725).
105+
* - PR #1317 switched to the loader-swap gate `!isAuthenticationBlocking && !isThemeLoading` plus
106+
* a single requestAnimationFrame. That fires promptly (fixing #725) but the gate only un-hides
107+
* `<router-outlet>`; Angular has NOT yet rendered the routed page at that instant and one rAF
108+
* runs before the browser paints, so the snapshot is dropped over an empty <ds-app> for a frame
109+
* or two -> the flicker came back.
110+
*
111+
* We keep #1317's decoupling from isStable (so background async can never delay us) but, after the
112+
* gate opens, we wait across animation frames until the real <ds-app> is actually laid out before
113+
* removing the snapshot. See {@link removeSsrOverlayAfterContentPainted}.
104114
*/
105115
private removeSsrOverlayWhenContentVisible(): void {
106116
const w: Window | undefined = this._window?.nativeWindow;
@@ -116,18 +126,65 @@ export class AppComponent implements OnInit, AfterViewInit {
116126
filter(([blocking, themeLoading]: [boolean, boolean]) => !blocking && !themeLoading),
117127
first(),
118128
).subscribe(() => {
119-
const remove = () => {
120-
if (typeof w.__dspaceRemoveSsrOverlay === 'function') {
121-
w.__dspaceRemoveSsrOverlay();
122-
}
123-
};
124-
if (typeof w.requestAnimationFrame === 'function') {
125-
w.requestAnimationFrame(remove);
129+
this.removeSsrOverlayAfterContentPainted(w);
130+
});
131+
});
132+
}
133+
134+
/**
135+
* Waits until the routed CSR view has been committed to the DOM and painted, then removes the SSR
136+
* snapshot overlay. "Painted" is approximated by the real <ds-app> reaching a non-trivial height
137+
* AND containing its `#main-content` host (i.e. it is no longer the empty shell the overlay script
138+
* left behind). We poll this cheap layout signal once per animation frame, capped at MAX_FRAMES so
139+
* that — unlike isStable in #1288 — nothing can hold the overlay open indefinitely; the 15s hard
140+
* fallback in index.html stays as the catastrophic-error safety net.
141+
*/
142+
private removeSsrOverlayAfterContentPainted(w: Window): void {
143+
const doc: Document = this.document;
144+
const raf: ((cb: FrameRequestCallback) => number) | null =
145+
typeof w.requestAnimationFrame === 'function' ? w.requestAnimationFrame.bind(w) : null;
146+
const remove = () => {
147+
if (typeof w.__dspaceRemoveSsrOverlay === 'function') {
148+
w.__dspaceRemoveSsrOverlay();
149+
}
150+
};
151+
const MAX_FRAMES = 180; // ~3s @60fps safety cap; the routed shell normally paints within a few frames
152+
const MIN_CONTENT_HEIGHT = 200; // px: enough to prove the real <ds-app> is no longer the empty shell
153+
let frames = 0;
154+
const contentPainted = (): boolean => {
155+
const app: Element | null = doc.querySelector('ds-app');
156+
if (!app) {
157+
return false;
158+
}
159+
let height = 0;
160+
try {
161+
height = app.getBoundingClientRect().height;
162+
} catch (e) {
163+
height = 0;
164+
}
165+
return height >= MIN_CONTENT_HEIGHT && app.querySelector('#main-content') !== null;
166+
};
167+
const tick = () => {
168+
if (contentPainted() || ++frames >= MAX_FRAMES) {
169+
// one more frame so the painted content is committed to screen before the snapshot fades
170+
if (raf) {
171+
raf(remove);
126172
} else {
127173
remove();
128174
}
129-
});
130-
});
175+
return;
176+
}
177+
if (raf) {
178+
raf(tick);
179+
} else {
180+
setTimeout(tick, 16);
181+
}
182+
};
183+
if (raf) {
184+
raf(tick);
185+
} else {
186+
setTimeout(tick, 16);
187+
}
131188
}
132189

133190
ngOnInit() {

0 commit comments

Comments
 (0)