Skip to content

Commit 96ab415

Browse files
KasinhouMatus Kasak
andauthored
VSB-TUO/Fix post login non-interactive UI (#1317)
* Change is stable to is visible in remove ssr overlay * Refactor tests based on changes * Fix specs --------- Co-authored-by: Matus Kasak <matus.kasak@dataquest.sk>
1 parent 47b14cc commit 96ab415

4 files changed

Lines changed: 53 additions & 37 deletions

File tree

src/app/app.component.spec.ts

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Store, StoreModule } from '@ngrx/store';
2-
import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing';
3-
import { ApplicationRef, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
2+
import { ComponentFixture, discardPeriodicTasks, fakeAsync, flush, inject, TestBed, waitForAsync } from '@angular/core/testing';
3+
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
44
import { CommonModule } from '@angular/common';
55
import { ActivatedRoute, Router } from '@angular/router';
66
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
@@ -31,7 +31,7 @@ import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider';
3131
import { storeModuleConfig } from './app.reducer';
3232
import { LocaleService } from './core/locale/locale.service';
3333
import { authReducer } from './core/auth/auth.reducer';
34-
import { provideMockStore } from '@ngrx/store/testing';
34+
import { MockStore, provideMockStore } from '@ngrx/store/testing';
3535
import { ThemeService } from './shared/theme-support/theme.service';
3636
import { getMockThemeService } from './shared/mocks/theme-service.mock';
3737
import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service';
@@ -42,7 +42,7 @@ let comp: AppComponent;
4242
let fixture: ComponentFixture<AppComponent>;
4343
const menuService = new MenuServiceStub();
4444
const initialState = {
45-
core: { auth: { loading: false } }
45+
core: { auth: { loading: false, blocking: false } }
4646
};
4747

4848
export function getMockLocaleService(): LocaleService {
@@ -129,20 +129,22 @@ describe('App component', () => {
129129

130130
});
131131

132-
describe('removeSsrOverlayWhenStable', () => {
133-
// The inline bootstrap script in src/index.html injects window.__dspaceRemoveSsrOverlay
134-
// and AppComponent must call it exactly once when ApplicationRef.isStable first emits true.
135-
let appRef: ApplicationRef;
136-
let isStable$: BehaviorSubject<boolean>;
132+
describe('removeSsrOverlayWhenContentVisible', () => {
133+
// 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.
135+
let mockStore: MockStore;
136+
let themeLoading$: BehaviorSubject<boolean>;
137+
let themeService: ThemeService;
137138
let originalRaF: typeof window.requestAnimationFrame;
138139

139140
beforeEach(() => {
140-
appRef = TestBed.inject(ApplicationRef);
141-
isStable$ = new BehaviorSubject<boolean>(false);
142-
// Patch isStable to our controllable subject for this test only
143-
Object.defineProperty(appRef, 'isStable', { value: isStable$.asObservable() });
141+
mockStore = TestBed.inject(MockStore);
142+
themeService = TestBed.inject(ThemeService);
143+
themeLoading$ = new BehaviorSubject<boolean>(true);
144+
(themeService as any).isThemeLoading$ = themeLoading$.asObservable();
145+
mockStore.setState({ core: { auth: { loading: false, blocking: true } } });
144146

145-
// Force rAF to a synchronous shim so we can flush() through the chain deterministically.
147+
// Force rAF to a synchronous shim so assertions are deterministic.
146148
originalRaF = window.requestAnimationFrame;
147149
(window as any).requestAnimationFrame = (cb: FrameRequestCallback) => {
148150
cb(0);
@@ -155,21 +157,22 @@ describe('App component', () => {
155157
delete (window as any).__dspaceRemoveSsrOverlay;
156158
});
157159

158-
it('removes the overlay once isStable emits true', fakeAsync(() => {
160+
it('removes the overlay once auth is unblocked and theme loading is finished', fakeAsync(() => {
159161
const spy = jasmine.createSpy('__dspaceRemoveSsrOverlay');
160162
window.__dspaceRemoveSsrOverlay = spy;
161163

162-
// Re-construct so the constructor-time subscription picks up our patched isStable + global.
164+
// Re-construct so constructor-time subscription picks up our patched streams + global.
163165
const f = TestBed.createComponent(AppComponent);
164166
f.detectChanges();
165167

166168
expect(spy).not.toHaveBeenCalled();
167169

168-
isStable$.next(true);
169-
tick(50); // matches the 50ms pad after rAF in removeSsrOverlayWhenStable
170+
mockStore.setState({ core: { auth: { loading: false, blocking: false } } });
171+
themeLoading$.next(false);
170172
flush();
171173

172174
expect(spy).toHaveBeenCalledTimes(1);
175+
discardPeriodicTasks();
173176
}));
174177

175178
it('is a no-op when the global is not injected (e.g. CSR-only route, SSR skipped)', fakeAsync(() => {
@@ -179,11 +182,12 @@ describe('App component', () => {
179182
const f = TestBed.createComponent(AppComponent);
180183
expect(() => f.detectChanges()).not.toThrow();
181184

182-
isStable$.next(true);
183-
tick(50);
185+
mockStore.setState({ core: { auth: { loading: false, blocking: false } } });
186+
themeLoading$.next(false);
184187
flush();
185188

186189
expect(window.__dspaceRemoveSsrOverlay).toBeUndefined();
190+
discardPeriodicTasks();
187191
}));
188192
});
189193
});

src/app/app.component.ts

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { distinctUntilChanged, filter, first, take, withLatestFrom, delay } from
22
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
33
import {
44
AfterViewInit,
5-
ApplicationRef,
65
ChangeDetectionStrategy,
76
Component,
87
HostListener,
@@ -18,7 +17,7 @@ import {
1817
Router,
1918
} from '@angular/router';
2019

21-
import { BehaviorSubject, Observable } from 'rxjs';
20+
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
2221
import { select, Store } from '@ngrx/store';
2322
import { NgbModal, NgbModalConfig } from '@ng-bootstrap/ng-bootstrap';
2423
import { TranslateService } from '@ngx-translate/core';
@@ -76,7 +75,6 @@ export class AppComponent implements OnInit, AfterViewInit {
7675
private cssService: CSSVariableService,
7776
private modalService: NgbModal,
7877
private modalConfig: NgbModalConfig,
79-
private appRef: ApplicationRef,
8078
private ngZone: NgZone,
8179
) {
8280
this.notificationOptions = environment.notifications;
@@ -86,7 +84,7 @@ export class AppComponent implements OnInit, AfterViewInit {
8684

8785
if (isPlatformBrowser(this.platformId)) {
8886
this.trackIdleModal();
89-
this.removeSsrOverlayWhenStable();
87+
this.removeSsrOverlayWhenContentVisible();
9088
}
9189

9290
this.isThemeLoading$ = this.themeService.isThemeLoading$;
@@ -95,33 +93,38 @@ export class AppComponent implements OnInit, AfterViewInit {
9593
}
9694

9795
/**
98-
* Drops the SSR mask overlay installed by the inline bootstrap script in src/index.html as soon
99-
* as Angular reaches its first stable state. The overlay is the only thing the user sees while
100-
* Angular 15 rebuilds the SSR DOM; removing it too early would expose the rebuild flicker, too
101-
* late would feel sluggish. We add a short safety pad to let the first paint settle, and there
102-
* is also a 15s hard fallback inside the script itself in case isStable never fires.
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.
103104
*/
104-
private removeSsrOverlayWhenStable(): void {
105+
private removeSsrOverlayWhenContentVisible(): void {
105106
const w: Window | undefined = this._window?.nativeWindow;
106107
if (!w || typeof w.__dspaceRemoveSsrOverlay !== 'function') {
107108
return;
108109
}
109-
// run outside Angular so we don't keep changeDetection ticking on the overlay timer
110+
// run outside Angular so the subscription does not keep change detection alive
110111
this.ngZone.runOutsideAngular(() => {
111-
this.appRef.isStable.pipe(
112-
filter((stable: boolean) => stable),
112+
combineLatest([
113+
this.store.pipe(select(isAuthenticationBlocking), distinctUntilChanged()),
114+
this.themeService.isThemeLoading$,
115+
]).pipe(
116+
filter(([blocking, themeLoading]: [boolean, boolean]) => !blocking && !themeLoading),
113117
first(),
114118
).subscribe(() => {
115-
// one rAF + small pad to let the first stable paint commit before fading the overlay
116119
const remove = () => {
117120
if (typeof w.__dspaceRemoveSsrOverlay === 'function') {
118121
w.__dspaceRemoveSsrOverlay();
119122
}
120123
};
121124
if (typeof w.requestAnimationFrame === 'function') {
122-
w.requestAnimationFrame(() => setTimeout(remove, 50));
125+
w.requestAnimationFrame(remove);
123126
} else {
124-
setTimeout(remove, 50);
127+
remove();
125128
}
126129
});
127130
});

src/app/shared/mocks/theme-service.mock.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,18 @@ import { ThemeConfig } from '../../../config/theme.config';
44
import { isNotEmpty } from '../empty.util';
55

66
export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]): ThemeService {
7+
// getThemeName$ is a real method on ThemeService (called as getThemeName$()),
8+
// so it must stay a spy method that returns an Observable.
9+
// isThemeLoading$ is a real property getter on ThemeService, so it must be a
10+
// property on the mock - not a spy method - or AsyncPipe / combineLatest will
11+
// receive a function instead of a stream.
712
const spy = jasmine.createSpyObj('themeService', {
813
getThemeName: themeName,
914
getThemeName$: observableOf(themeName),
1015
getThemeConfigFor: undefined,
1116
listenForRouteChanges: undefined,
17+
}, {
18+
isThemeLoading$: observableOf(false),
1219
});
1320

1421
if (isNotEmpty(themes)) {

src/index.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,9 @@
117117
}, 200);
118118
};
119119

120-
// Safety net: if the app never reaches isStable (e.g. permanent HTTP poll), remove anyway.
120+
// Hard safety net: only fires on catastrophic JS errors that prevent AppComponent from
121+
// running its overlay-removal logic. Under normal operation the overlay is removed
122+
// event-driven (no timeout) the moment the real CSR content becomes visible.
121123
setTimeout(function () {
122124
if (typeof window.__dspaceRemoveSsrOverlay === 'function') {
123125
window.__dspaceRemoveSsrOverlay();

0 commit comments

Comments
 (0)