Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
/tsd_typings/
npm-debug.log

# build/install/spec logs emitted by local deploy tooling
/_*.log

/build/

/coverage
Expand Down
4 changes: 2 additions & 2 deletions angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "3mb",
"maximumError": "5mb"
"maximumWarning": "5.5mb",
"maximumError": "6mb"
},
{
"type": "anyComponentStyle",
Expand Down
69 changes: 66 additions & 3 deletions src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Store, StoreModule } from '@ngrx/store';
import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing';
import { ComponentFixture, discardPeriodicTasks, fakeAsync, flush, inject, TestBed, waitForAsync } from '@angular/core/testing';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject } from 'rxjs';

// Load the implementations that should be tested
import { AppComponent } from './app.component';
Expand All @@ -30,7 +31,7 @@ import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider';
import { storeModuleConfig } from './app.reducer';
import { LocaleService } from './core/locale/locale.service';
import { authReducer } from './core/auth/auth.reducer';
import { provideMockStore } from '@ngrx/store/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { ThemeService } from './shared/theme-support/theme.service';
import { getMockThemeService } from './shared/mocks/theme-service.mock';
import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service';
Expand All @@ -41,7 +42,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 @@ -127,4 +128,66 @@ describe('App component', () => {
});

});

describe('removeSsrOverlayWhenContentVisible', () => {
// The inline bootstrap script in src/index.html injects window.__dspaceRemoveSsrOverlay.
// AppComponent should remove it once both auth blocking and theme loading are false.
let mockStore: MockStore;
let themeLoading$: BehaviorSubject<boolean>;
let themeService: ThemeService;
let originalRaF: typeof window.requestAnimationFrame;

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 } } });

// 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;
});

it('removes the overlay once auth is unblocked and theme loading is finished', 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);
flush();

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);
flush();

expect(window.__dspaceRemoveSsrOverlay).toBeUndefined();
discardPeriodicTasks();
}));
});
});
45 changes: 43 additions & 2 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { distinctUntilChanged, take, withLatestFrom } from 'rxjs/operators';
import { distinctUntilChanged, filter, first, take, withLatestFrom } from 'rxjs/operators';
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
HostListener,
Inject,
NgZone,
OnInit,
PLATFORM_ID,
} from '@angular/core';
Expand All @@ -16,7 +17,7 @@ import {
Router,
} from '@angular/router';

import { BehaviorSubject, Observable } from 'rxjs';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { select, Store } from '@ngrx/store';
import { NgbModal, NgbModalConfig } from '@ng-bootstrap/ng-bootstrap';
import { TranslateService } from '@ngx-translate/core';
Expand Down Expand Up @@ -74,6 +75,7 @@ export class AppComponent implements OnInit, AfterViewInit {
private cssService: CSSVariableService,
private modalService: NgbModal,
private modalConfig: NgbModalConfig,
private ngZone: NgZone,
) {
this.notificationOptions = environment.notifications;

Expand All @@ -82,13 +84,52 @@ export class AppComponent implements OnInit, AfterViewInit {

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

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

this.storeCSSVariables();
}

/**
* 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.
*/
private removeSsrOverlayWhenContentVisible(): void {
const w: Window | undefined = this._window?.nativeWindow;
if (!w || typeof w.__dspaceRemoveSsrOverlay !== 'function') {
return;
}
// run outside Angular so the subscription does not keep change detection alive
this.ngZone.runOutsideAngular(() => {
combineLatest([
this.store.pipe(select(isAuthenticationBlocking), distinctUntilChanged()),
this.themeService.isThemeLoading$,
]).pipe(
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);
} else {
remove();
}
});
});
}

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

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

if (isNotEmpty(themes)) {
Expand Down
132 changes: 132 additions & 0 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,144 @@
<title>DSpace</title>
<meta name="viewport" content="width=device-width,minimum-scale=1">
<meta http-equiv="cache-control" content="no-store">
<style id="__dspace-ssr-overlay-style">
/* Overlay used to mask the Angular 15 bootstrap re-render of the SSR DOM.
See src/index.html bootstrap script + AppComponent.removeSsrOverlayWhenContentVisible.
The overlay element holds the SSR-rendered children moved out of <ds-app>,
so it keeps every original Angular view-encapsulation attribute (_ngcontent-scXXX),
inline style, and lifecycle context — i.e. it looks pixel-identical to what SSR sent. */
#__dspace_ssr_overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
width: 100%;
z-index: 10000;
background: #fff;
pointer-events: none;
}
/* Keep <ds-app> taking layout space (height of CSR result) but hidden, so the page
does not collapse the moment we drop the overlay. */
ds-app[data-dspace-ssr-hidden] { visibility: hidden; }
</style>
</head>
<body>
<!-- dependencies -->
<script type="text/javascript" src="https://code.jquery.com/jquery-2.1.4.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.10.1/lodash.min.js"></script>
<ds-app></ds-app>
<script>
/*
Anti-flicker overlay.
Why this exists: this codebase is on Angular 15 + ngUniversal. There is no provideClientHydration
available before Angular 16, so on every browser load Angular tears down the entire SSR DOM and
re-renders the component tree from scratch. The rebuild takes ~600-1500 ms on slow connections,
during which the user sees the SSR view -> blank/half-built CSR view -> final CSR view.
This script captures the SSR DOM as a non-interactive snapshot the moment it's parsed (before
any module/main script runs - those are type=module and therefore deferred). While Angular
rebuilds the real <ds-app> invisibly, the snapshot keeps the page looking stable. AppComponent
removes the overlay once the real CSR content becomes visible.
*/
(function () {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
// Skip when Cypress is driving the page. The overlay duplicates SSR DOM (moved into the
// overlay) alongside the CSR DOM (rendered into <ds-app>) during the masking window — so
// any cy.get('#some-id').click() picks up two elements and fails. The overlay is a pure
// UX nicety, and Cypress E2E doesn't measure visual smoothness anyway; bail early.
if (typeof window.Cypress !== 'undefined') return;
try {
var app = document.querySelector('ds-app');
// If SSR was skipped for this route (excludePathPatterns), there are no children; nothing to mask.
if (!app || !app.firstElementChild) return;
if (document.getElementById('__dspace_ssr_overlay')) return;

// Critical: Angular's BrowserModule.withServerTransition removes ALL <style ng-transition="...">
// tags during bootstrap. Those tags hold the component-scoped CSS that styles the SSR DOM
// via attribute selectors like [_ngcontent-scXXX]. If we don't preserve copies, the overlay
// renders unstyled. Clone the SSR styles into <style data-dspace-ssr-keep> tags that Angular
// ignores, so the overlay keeps looking like the page the user already saw.
var ssrStyles = document.querySelectorAll('style[ng-transition]');
var keptStyles = [];
for (var i = 0; i < ssrStyles.length; i++) {
var copy = document.createElement('style');
copy.setAttribute('data-dspace-ssr-keep', '');
copy.textContent = ssrStyles[i].textContent;
document.head.appendChild(copy);
keptStyles.push(copy);
}

// Build overlay and MOVE (not clone) the SSR children into it. Moving keeps every live DOM
// detail (Angular's view-encapsulation attributes, computed inline styles, image-load state)
// so the overlay is pixel-identical to what the user already saw before Angular booted.
// Cloning via innerHTML loses parent-context-dependent rendering.
//
// Accessibility note: we deliberately do NOT set aria-hidden on the overlay. The overlay
// *is* the visible page during the masking window, so assistive technologies should read
// it. The original <ds-app> underneath gets visibility:hidden (via attribute + CSS rule),
// which removes both itself and its children from the accessibility tree.
var overlay = document.createElement('div');
overlay.id = '__dspace_ssr_overlay';
while (app.firstChild) {
overlay.appendChild(app.firstChild);
}

// Hide the now-empty <ds-app> so Angular can rebuild into it invisibly. We use an attribute
// (CSS in <head> targets it) rather than setting .style.visibility directly so Angular's
// template doesn't blow it away on first ChangeDetection.
app.setAttribute('data-dspace-ssr-hidden', '');
document.body.appendChild(overlay);

var removing = false;
window.__dspaceRemoveSsrOverlay = function () {
// Re-entrancy guard: null the pointer up-front so a racing isStable + 15s safety
// fallback cannot start two interleaving fade-out passes (which would re-remove
// the kept styles from underneath the first pass).
if (removing) return;
removing = true;
window.__dspaceRemoveSsrOverlay = null;

// Always unhide the real <ds-app> and drop the kept SSR styles first, even if the
// overlay node has gone missing (e.g. removed by an extension or another script).
// A bare early return here would otherwise leave the app permanently hidden.
app.removeAttribute('data-dspace-ssr-hidden');

var removeKeptStyles = function () {
for (var i = 0; i < keptStyles.length; i++) {
if (keptStyles[i].parentNode) keptStyles[i].parentNode.removeChild(keptStyles[i]);
}
keptStyles = [];
};

var el = document.getElementById('__dspace_ssr_overlay');
if (!el) {
removeKeptStyles();
return;
}
el.style.transition = 'opacity 150ms ease-out';
el.style.opacity = '0';
setTimeout(function () {
if (el && el.parentNode) el.parentNode.removeChild(el);
removeKeptStyles();
}, 200);
};

// Hard safety net: only fires on catastrophic JS errors that prevent AppComponent from
// running its overlay-removal logic. Under normal operation the overlay is removed
// event-driven (no timeout) the moment the real CSR content becomes visible.
setTimeout(function () {
if (typeof window.__dspaceRemoveSsrOverlay === 'function') {
window.__dspaceRemoveSsrOverlay();
}
}, 15000);
} catch (e) {
// Don't let the overlay logic kill the page, but surface the failure so a silently-broken
// flicker fix is at least diagnosable in DevTools.
if (window.console && typeof console.warn === 'function') {
console.warn('[dspace-ssr-overlay] disabled due to error:', e);
}
}
})();
</script>
</body>

<!-- do not include client bundle, it is injected with Zone already loaded -->
Expand Down
9 changes: 7 additions & 2 deletions src/themes/eager-themes.module.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { NgModule } from '@angular/core';
import { EagerThemeModule as DSpaceEagerThemeModule } from './dspace/eager-theme.module';
// import { EagerThemeModule as CustomEagerThemeModule } from './custom/eager-theme.module';
import { EagerThemeModule as CustomEagerThemeModule } from './custom/eager-theme.module';

/**
* This module bundles the eager theme modules for all available themes.
* Eager modules contain components that are present on every page (to speed up initial loading)
* and entry components (to ensure their decorators get picked up).
*
* Themes that aren't in use should not be imported here so they don't take up unnecessary space in the main bundle.
*
* NOTE: CustomEagerThemeModule is included to prevent the home-page flicker that occurs when
* the active theme is `custom`. Without it, every themed wrapper (footer, header, root, ...) is
* lazy-loaded via webpack code-splitting on the browser, leaving visible gaps after the SSR DOM
* is torn down and before the CSR DOM is materialised.
*/
@NgModule({
imports: [
DSpaceEagerThemeModule,
// CustomEagerThemeModule,
CustomEagerThemeModule,
],
})
export class EagerThemesModule {
Expand Down
Loading
Loading