Skip to content

Commit e1024e2

Browse files
jr-rkclaude
andcommitted
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>
1 parent 2d44043 commit e1024e2

4 files changed

Lines changed: 182 additions & 77 deletions

File tree

src/app/app.component.spec.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Store, StoreModule } from '@ngrx/store';
2-
import { ComponentFixture, discardPeriodicTasks, fakeAsync, flush, inject, TestBed, waitForAsync } from '@angular/core/testing';
2+
import { ComponentFixture, discardPeriodicTasks, fakeAsync, inject, TestBed, tick, waitForAsync } from '@angular/core/testing';
33
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
44
import { CommonModule } from '@angular/common';
55
import { ActivatedRoute, Router } from '@angular/router';
@@ -131,11 +131,13 @@ describe('App component', () => {
131131

132132
describe('removeSsrOverlayWhenContentVisible', () => {
133133
// The inline bootstrap script in src/index.html injects window.__dspaceRemoveSsrOverlay.
134-
// AppComponent should remove it once both auth blocking and theme loading are false.
134+
// Once auth blocking and theme loading are both false, AppComponent waits for the <ds-app> DOM
135+
// to settle (no element added/removed for the quiet window) and only then removes the overlay.
135136
let mockStore: MockStore;
136137
let themeLoading$: BehaviorSubject<boolean>;
137138
let themeService: ThemeService;
138139
let originalRaF: typeof window.requestAnimationFrame;
140+
let dsAppEl: HTMLElement;
139141

140142
beforeEach(() => {
141143
mockStore = TestBed.inject(MockStore);
@@ -144,6 +146,12 @@ describe('App component', () => {
144146
(themeService as any).isThemeLoading$ = themeLoading$.asObservable();
145147
mockStore.setState({ core: { auth: { loading: false, blocking: true } } });
146148

149+
// A settled <ds-app> with real content present, so the DOM-settle watcher can resolve.
150+
dsAppEl = document.createElement('ds-app');
151+
dsAppEl.setAttribute('style', 'display:block;height:800px');
152+
dsAppEl.innerHTML = '<main id="main-content" style="display:block;height:800px">home content</main>';
153+
document.body.appendChild(dsAppEl);
154+
147155
// Force rAF to a synchronous shim so assertions are deterministic.
148156
originalRaF = window.requestAnimationFrame;
149157
(window as any).requestAnimationFrame = (cb: FrameRequestCallback) => {
@@ -155,9 +163,10 @@ describe('App component', () => {
155163
afterEach(() => {
156164
(window as any).requestAnimationFrame = originalRaF;
157165
delete (window as any).__dspaceRemoveSsrOverlay;
166+
if (dsAppEl && dsAppEl.parentNode) { dsAppEl.parentNode.removeChild(dsAppEl); }
158167
});
159168

160-
it('removes the overlay once auth is unblocked and theme loading is finished', fakeAsync(() => {
169+
it('removes the overlay once auth/theme are ready AND the DOM has settled', fakeAsync(() => {
161170
const spy = jasmine.createSpy('__dspaceRemoveSsrOverlay');
162171
window.__dspaceRemoveSsrOverlay = spy;
163172

@@ -169,9 +178,12 @@ describe('App component', () => {
169178

170179
mockStore.setState({ core: { auth: { loading: false, blocking: false } } });
171180
themeLoading$.next(false);
172-
flush();
173181

182+
// Not removed at the gate: the DOM-settle quiet window must elapse first.
183+
expect(spy).not.toHaveBeenCalled();
184+
tick(700); // > SETTLE_QUIET_MS
174185
expect(spy).toHaveBeenCalledTimes(1);
186+
175187
discardPeriodicTasks();
176188
}));
177189

@@ -184,7 +196,7 @@ describe('App component', () => {
184196

185197
mockStore.setState({ core: { auth: { loading: false, blocking: false } } });
186198
themeLoading$.next(false);
187-
flush();
199+
tick(700);
188200

189201
expect(window.__dspaceRemoveSsrOverlay).toBeUndefined();
190202
discardPeriodicTasks();

src/app/app.component.ts

Lines changed: 120 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { distinctUntilChanged, filter, first, take, withLatestFrom } from 'rxjs/operators';
1+
import { debounceTime, distinctUntilChanged, filter, startWith, switchMap, take, takeUntil, withLatestFrom } from 'rxjs/operators';
22
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
33
import {
44
AfterViewInit,
@@ -7,6 +7,7 @@ import {
77
HostListener,
88
Inject,
99
NgZone,
10+
OnDestroy,
1011
OnInit,
1112
PLATFORM_ID,
1213
} from '@angular/core';
@@ -17,7 +18,7 @@ import {
1718
Router,
1819
} from '@angular/router';
1920

20-
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
21+
import { BehaviorSubject, combineLatest, Observable, race, Subject, timer } from 'rxjs';
2122
import { select, Store } from '@ngrx/store';
2223
import { NgbModal, NgbModalConfig } from '@ng-bootstrap/ng-bootstrap';
2324
import { TranslateService } from '@ngx-translate/core';
@@ -39,10 +40,22 @@ import { distinctNext } from './core/shared/distinct-next';
3940
styleUrls: ['./app.component.scss'],
4041
changeDetection: ChangeDetectionStrategy.OnPush,
4142
})
42-
export class AppComponent implements OnInit, AfterViewInit {
43+
export class AppComponent implements OnInit, AfterViewInit, OnDestroy {
4344
notificationOptions;
4445
models;
4546

47+
/**
48+
* Emits on destroy to tear down the SSR-overlay-removal pipeline (gate subscription, the
49+
* MutationObserver and its debounce/cap timers all unsubscribe via `takeUntil(destroyed$)`).
50+
* AppComponent is the root component so this is mostly defensive + test hygiene.
51+
*/
52+
private destroyed$ = new Subject<void>();
53+
54+
/** SSR anti-flicker overlay (see src/index.html) removal tuning. */
55+
private readonly ssrOverlaySettleQuietMs = 600; // routed page is "done" after this long with no DOM change
56+
private readonly ssrOverlaySettleMaxMs = 10000; // backstop reveal (below index.html's 15s catastrophic net)
57+
private readonly ssrOverlayMinContentHeightPx = 200; // proves <ds-app> is no longer the empty shell
58+
4659
/**
4760
* Whether or not the authentication is currently blocking the UI
4861
*/
@@ -93,41 +106,118 @@ export class AppComponent implements OnInit, AfterViewInit {
93106
}
94107

95108
/**
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.
109+
* Drops the SSR mask overlay installed by the inline bootstrap script in src/index.html once the
110+
* routed CSR page has actually finished rendering. Picking the right moment is the whole problem,
111+
* and the two earlier attempts each fixed one symptom and reintroduced the other:
112+
*
113+
* - PR #1288 waited for `ApplicationRef.isStable`. No flicker, but isStable is held hostage by ANY
114+
* ongoing zone async — after an admin login the app keeps the zone busy (authz/widgets, periodic
115+
* polling, AAI/discojuice scripts) so isStable fires many seconds late (or hits the 15s
116+
* fallback). The inert snapshot then masks the live page -> "looks rendered but not interactive"
117+
* (issue #725).
118+
* - PR #1317 switched to the loader-swap gate `!isAuthenticationBlocking && !isThemeLoading` plus a
119+
* single requestAnimationFrame. Prompt, but that gate only un-hides `<router-outlet>`; the home
120+
* page then renders piecewise (navbar, search box, community list, recent submissions) as each
121+
* section's data arrives. Dropping the snapshot at the gate — or, as an earlier revision of this
122+
* method did, as soon as *some* content exists — exposes a half-built page that visibly pops
123+
* into place on a hard reload -> the flicker.
124+
*
125+
* The signal we actually need is "the routed page has stopped changing". So, after the gate opens,
126+
* we keep the snapshot until the live <ds-app> DOM has SETTLED (no element added/removed for a
127+
* short quiet window, with real content present). This stays decoupled from isStable (DOM-settle
128+
* ignores non-rendering background async, so admin reveals in a few seconds rather than ~15s).
129+
* See {@link routedPageReadyToReveal$}.
104130
*/
105131
private removeSsrOverlayWhenContentVisible(): void {
106-
const w: Window | undefined = this._window?.nativeWindow;
107-
if (!w || typeof w.__dspaceRemoveSsrOverlay !== 'function') {
108-
return;
132+
const win: Window | undefined = this._window?.nativeWindow;
133+
if (!win || typeof win.__dspaceRemoveSsrOverlay !== 'function') {
134+
return; // SSR was skipped for this route, so no overlay was installed — nothing to remove
109135
}
110-
// run outside Angular so the subscription does not keep change detection alive
136+
// Run outside Angular: a MutationObserver watching the whole app must not trigger change
137+
// detection (it would also keep ApplicationRef.isStable permanently false).
111138
this.ngZone.runOutsideAngular(() => {
112-
combineLatest([
113-
this.store.pipe(select(isAuthenticationBlocking), distinctUntilChanged()),
114-
this.themeService.isThemeLoading$,
115-
]).pipe(
116-
filter(([blocking, themeLoading]: [boolean, boolean]) => !blocking && !themeLoading),
117-
first(),
139+
this.routedPageReadyToReveal$().pipe(
140+
takeUntil(this.destroyed$),
118141
).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);
126-
} else {
127-
remove();
142+
// one frame so the freshly rendered content is painted before the snapshot fades out
143+
this.runAfterNextFrame(win, () => win.__dspaceRemoveSsrOverlay?.());
144+
});
145+
});
146+
}
147+
148+
/**
149+
* Emits once when it is safe to drop the SSR snapshot: the auth/theme loader gate has opened
150+
* (same condition root.component.html uses to swap its fullscreen loader for the routed content)
151+
* AND the routed page's DOM has settled. See {@link dsAppDomSettled$}.
152+
*/
153+
private routedPageReadyToReveal$(): Observable<unknown> {
154+
const loaderGateOpen$ = combineLatest([
155+
this.store.pipe(select(isAuthenticationBlocking), distinctUntilChanged()),
156+
this.themeService.isThemeLoading$,
157+
]).pipe(
158+
filter(([authBlocking, themeLoading]: [boolean, boolean]) => !authBlocking && !themeLoading),
159+
take(1),
160+
);
161+
return loaderGateOpen$.pipe(
162+
switchMap(() => this.dsAppDomSettled$()),
163+
);
164+
}
165+
166+
/**
167+
* Emits once when the live <ds-app> subtree stops being mutated (elements added/removed) for
168+
* `ssrOverlaySettleQuietMs` AND it holds real content — or after `ssrOverlaySettleMaxMs`, whichever
169+
* comes first. The cap guarantees a page that never goes quiet (constant background DOM updates)
170+
* still reveals; the 15s fallback in index.html remains the ultimate net.
171+
*/
172+
private dsAppDomSettled$(): Observable<unknown> {
173+
const dsApp: Element | null = this.document.querySelector('ds-app');
174+
if (!dsApp) {
175+
return timer(this.ssrOverlaySettleMaxMs);
176+
}
177+
const elementMutations$ = new Observable<void>((subscriber) => {
178+
const observer = new MutationObserver((records) => {
179+
if (records.some((record) => this.isElementChildListChange(record))) {
180+
subscriber.next();
128181
}
129182
});
183+
observer.observe(dsApp, { childList: true, subtree: true });
184+
return () => observer.disconnect();
130185
});
186+
const settled$ = elementMutations$.pipe(
187+
startWith(undefined), // start the quiet window immediately
188+
debounceTime(this.ssrOverlaySettleQuietMs), // ... reset by each render, fires once quiet
189+
filter(() => this.dsAppHasRenderedContent(dsApp)), // ... but never on the empty shell
190+
);
191+
return race(settled$, timer(this.ssrOverlaySettleMaxMs)).pipe(take(1));
192+
}
193+
194+
/** True once the live <ds-app> is no longer the empty shell the overlay script left behind. */
195+
private dsAppHasRenderedContent(dsApp: Element): boolean {
196+
const height = dsApp.getBoundingClientRect?.().height ?? 0;
197+
return height >= this.ssrOverlayMinContentHeightPx && dsApp.querySelector('#main-content') !== null;
198+
}
199+
200+
/** A childList mutation that adds or removes at least one element node (ignores text/attr noise). */
201+
private isElementChildListChange(record: MutationRecord): boolean {
202+
if (record.type !== 'childList') {
203+
return false;
204+
}
205+
const changedNodes = [...Array.from(record.addedNodes), ...Array.from(record.removedNodes)];
206+
return changedNodes.some((node) => node.nodeType === Node.ELEMENT_NODE);
207+
}
208+
209+
/** Runs `callback` after the next paint (or synchronously if requestAnimationFrame is unavailable). */
210+
private runAfterNextFrame(win: Window, callback: () => void): void {
211+
if (typeof win.requestAnimationFrame === 'function') {
212+
win.requestAnimationFrame(() => callback());
213+
} else {
214+
callback();
215+
}
216+
}
217+
218+
ngOnDestroy(): void {
219+
this.destroyed$.next();
220+
this.destroyed$.complete();
131221
}
132222

133223
ngOnInit() {

0 commit comments

Comments
 (0)