Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 83 additions & 2 deletions src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import {
ComponentFixture,
discardPeriodicTasks,
fakeAsync,
inject,
TestBed,
tick,
waitForAsync,
} from '@angular/core/testing';
import {
Expand All @@ -14,11 +17,15 @@ import {
Store,
StoreModule,
} from '@ngrx/store';
import { provideMockStore } from '@ngrx/store/testing';
import {
MockStore,
provideMockStore,
} from '@ngrx/store/testing';
import {
TranslateLoader,
TranslateModule,
} from '@ngx-translate/core';
import { BehaviorSubject } from 'rxjs';

import { APP_CONFIG } from '../config/app-config.interface';
import { environment } from '../environments/environment';
Expand Down Expand Up @@ -58,7 +65,7 @@ let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>;
const menuService = new MenuServiceStub();
const initialState = {
core: { auth: { loading: false } },
core: { auth: { loading: false, blocking: false } },
};

export function getMockLocaleService(): LocaleService {
Expand Down Expand Up @@ -149,4 +156,78 @@ describe('App component', () => {
});

});

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

beforeEach(() => {
mockStore = TestBed.inject(MockStore);
themeService = TestBed.inject(ThemeService);
themeLoading$ = new BehaviorSubject<boolean>(true);
(themeService as any).isThemeLoading$ = themeLoading$.asObservable();
mockStore.setState({ core: { auth: { loading: false, blocking: true } } });

// A settled <ds-app> with real content present, so the DOM-settle watcher can resolve.
dsAppEl = document.createElement('ds-app');
dsAppEl.setAttribute('style', 'display:block;height:800px');
dsAppEl.innerHTML = '<main id="main-content" style="display:block;height:800px">home content</main>';
document.body.appendChild(dsAppEl);

// Force rAF to a synchronous shim so assertions are deterministic.
originalRaF = window.requestAnimationFrame;
(window as any).requestAnimationFrame = (cb: FrameRequestCallback) => {
cb(0);
return 0 as any;
};
});

afterEach(() => {
(window as any).requestAnimationFrame = originalRaF;
delete (window as any).__dspaceRemoveSsrOverlay;
if (dsAppEl && dsAppEl.parentNode) { dsAppEl.parentNode.removeChild(dsAppEl); }
});

it('removes the overlay once auth/theme are ready AND the DOM has settled', fakeAsync(() => {
const spy = jasmine.createSpy('__dspaceRemoveSsrOverlay');
window.__dspaceRemoveSsrOverlay = spy;

// Re-construct so constructor-time subscription picks up our patched streams + global.
const f = TestBed.createComponent(AppComponent);
f.detectChanges();

expect(spy).not.toHaveBeenCalled();

mockStore.setState({ core: { auth: { loading: false, blocking: false } } });
themeLoading$.next(false);

// Not removed at the gate: the DOM-settle quiet window must elapse first.
expect(spy).not.toHaveBeenCalled();
tick(700); // > SETTLE_QUIET_MS
expect(spy).toHaveBeenCalledTimes(1);

discardPeriodicTasks();
}));

it('is a no-op when the global is not injected (e.g. CSR-only route, SSR skipped)', fakeAsync(() => {
// Global intentionally absent; constructor should not throw and should not break later.
delete (window as any).__dspaceRemoveSsrOverlay;

const f = TestBed.createComponent(AppComponent);
expect(() => f.detectChanges()).not.toThrow();

mockStore.setState({ core: { auth: { loading: false, blocking: false } } });
themeLoading$.next(false);
tick(700);

expect(window.__dspaceRemoveSsrOverlay).toBeUndefined();
discardPeriodicTasks();
}));
});
});
131 changes: 130 additions & 1 deletion src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
Component,
HostListener,
Inject,
NgZone,
OnDestroy,
OnInit,
PLATFORM_ID,
} from '@angular/core';
Expand All @@ -29,12 +31,21 @@ import {
import { TranslateService } from '@ngx-translate/core';
import {
BehaviorSubject,
combineLatest,
Observable,
race,
Subject,
timer,
} from 'rxjs';
import {
debounceTime,
delay,
distinctUntilChanged,
filter,
startWith,
switchMap,
take,
takeUntil,
withLatestFrom,
} from 'rxjs/operators';

Expand Down Expand Up @@ -64,10 +75,22 @@ import { ThemeService } from './shared/theme-support/theme.service';
ThemedRootComponent,
],
})
export class AppComponent implements OnInit, AfterViewInit {
export class AppComponent implements OnInit, AfterViewInit, OnDestroy {
notificationOptions;
models;

/**
* Emits on destroy to tear down the SSR-overlay-removal pipeline (gate subscription, the
* MutationObserver and its debounce/cap timers all unsubscribe via `takeUntil(destroyed$)`).
* AppComponent is the root component so this is mostly defensive + test hygiene.
*/
private destroyed$ = new Subject<void>();

/** SSR anti-flicker overlay (see src/index.html) removal tuning. */
private readonly ssrOverlaySettleQuietMs = 600; // routed page is "done" after this long with no DOM change
private readonly ssrOverlaySettleMaxMs = 10000; // backstop reveal (below index.html's 15s catastrophic net)
private readonly ssrOverlayMinContentHeightPx = 200; // proves <ds-app> is no longer the empty shell

/**
* Whether or not the authentication is currently blocking the UI
*/
Expand Down Expand Up @@ -100,18 +123,124 @@ export class AppComponent implements OnInit, AfterViewInit {
private cssService: CSSVariableService,
private modalService: NgbModal,
private modalConfig: NgbModalConfig,
private ngZone: NgZone,
) {
this.notificationOptions = environment.notifications;

if (isPlatformBrowser(this.platformId)) {
this.trackIdleModal();
this.removeSsrOverlayWhenContentVisible();
}

this.isThemeLoading$ = this.themeService.isThemeLoading$;

this.storeCSSVariables();
}

/**
* Drops the hydration-safe SSR freeze-frame (a detached clone painted on top; see src/index.html)
* once the routed CSR page has actually finished rendering. Earlier revisions waited for
* `ApplicationRef.isStable`, but on DSpace 9 (Angular 18) the theme system re-creates every
* `ds-themed-*` wrapper imperatively on the client, and post-login zone activity keeps isStable
* busy — so the frozen clone lingered (up to the 15s fallback) over an app that had long finished.
*
* Instead we drop the clone once the auth/theme loader gate has opened AND the live <ds-app> DOM
* has SETTLED (no element added/removed for a short quiet window, with real content present) —
* i.e. once the imperative themed re-render is done. This stays decoupled from isStable.
* See {@link routedPageReadyToReveal$}.
*/
private removeSsrOverlayWhenContentVisible(): void {
const win: Window | undefined = this._window?.nativeWindow;
if (!win || typeof win.__dspaceRemoveSsrOverlay !== 'function') {
return; // SSR was skipped for this route, so no overlay was installed — nothing to remove
}
// Run outside Angular: a MutationObserver watching the whole app must not trigger change
// detection (it would also keep ApplicationRef.isStable permanently false).
this.ngZone.runOutsideAngular(() => {
this.routedPageReadyToReveal$().pipe(
takeUntil(this.destroyed$),
).subscribe(() => {
// one frame so the freshly rendered content is painted before the snapshot fades out
this.runAfterNextFrame(win, () => win.__dspaceRemoveSsrOverlay?.());
});
});
}

/**
* Emits once when it is safe to drop the SSR snapshot: the auth/theme loader gate has opened
* (same condition root.component.html uses to swap its fullscreen loader for the routed content)
* AND the routed page's DOM has settled. See {@link dsAppDomSettled$}.
*/
private routedPageReadyToReveal$(): Observable<unknown> {
const loaderGateOpen$ = combineLatest([
this.store.pipe(select(isAuthenticationBlocking), distinctUntilChanged()),
this.themeService.isThemeLoading$,
]).pipe(
filter(([authBlocking, themeLoading]: [boolean, boolean]) => !authBlocking && !themeLoading),
take(1),
);
return loaderGateOpen$.pipe(
switchMap(() => this.dsAppDomSettled$()),
);
}

/**
* Emits once when the live <ds-app> subtree stops being mutated (elements added/removed) for
* `ssrOverlaySettleQuietMs` AND it holds real content — or after `ssrOverlaySettleMaxMs`, whichever
* comes first. The cap guarantees a page that never goes quiet (constant background DOM updates)
* still reveals; the 15s fallback in index.html remains the ultimate net.
*/
private dsAppDomSettled$(): Observable<unknown> {
const dsApp: Element | null = this.document.querySelector('ds-app');
if (!dsApp) {
return timer(this.ssrOverlaySettleMaxMs);
}
const elementMutations$ = new Observable<void>((subscriber) => {
const observer = new MutationObserver((records) => {
if (records.some((record) => this.isElementChildListChange(record))) {
subscriber.next();
}
});
observer.observe(dsApp, { childList: true, subtree: true });
return () => observer.disconnect();
});
const settled$ = elementMutations$.pipe(
startWith(undefined), // start the quiet window immediately
debounceTime(this.ssrOverlaySettleQuietMs), // ... reset by each render, fires once quiet
filter(() => this.dsAppHasRenderedContent(dsApp)), // ... but never on the empty shell
);
return race(settled$, timer(this.ssrOverlaySettleMaxMs)).pipe(take(1));
}

/** True once the live <ds-app> is no longer the empty shell the overlay script left behind. */
private dsAppHasRenderedContent(dsApp: Element): boolean {
const height = dsApp.getBoundingClientRect?.().height ?? 0;
return height >= this.ssrOverlayMinContentHeightPx && dsApp.querySelector('#main-content') !== null;
}

/** A childList mutation that adds or removes at least one element node (ignores text/attr noise). */
private isElementChildListChange(record: MutationRecord): boolean {
if (record.type !== 'childList') {
return false;
}
const changedNodes = [...Array.from(record.addedNodes), ...Array.from(record.removedNodes)];
return changedNodes.some((node) => node.nodeType === Node.ELEMENT_NODE);
}

/** Runs `callback` after the next paint (or synchronously if requestAnimationFrame is unavailable). */
private runAfterNextFrame(win: Window, callback: () => void): void {
if (typeof win.requestAnimationFrame === 'function') {
win.requestAnimationFrame(() => callback());
} else {
callback();
}
}

ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
}

ngOnInit() {
/** Implement behavior for interface {@link ModalBeforeDismiss} */
this.modalConfig.beforeDismiss = async function () {
Expand Down
4 changes: 4 additions & 0 deletions src/app/shared/mocks/theme-service.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ import { isNotEmpty } from '../empty.util';
import { ThemeService } from '../theme-support/theme.service';

export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]): ThemeService {
// isThemeLoading$ is a real property getter on ThemeService, so it must be a property on the mock
// (not a spy method) - AppComponent's overlay-removal gate reads it as a stream.
const spy = jasmine.createSpyObj('themeService', {
getThemeName: themeName,
getThemeName$: of(themeName),
getThemeConfigFor: undefined,
listenForRouteChanges: undefined,
}, {
isThemeLoading$: of(false),
});

if (isNotEmpty(themes)) {
Expand Down
Loading
Loading