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
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,16 @@ python_data_import/debug.log.txt
python_data_import/logs.txt
python_data_import/date.txt
*/__pycache__/

# Local-only artifacts generated by scripts/dspace-deploy.bat (one-per-instance, never committed)
/docker/.env.dspace-*
/docker/.override.dspace-*.yml

# Playwright MCP captures + investigation artifacts
/.playwright-mcp/
/PROGRESS.md
/flicker-snapshots-*.json
/perf-*.json
/home-initial.png
/overlay-state.png
/verify-*.png
4 changes: 2 additions & 2 deletions angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "3mb",
"maximumError": "5mb"
"maximumWarning": "5.5mb",
"maximumError": "6mb"
},
{
"type": "anyComponentStyle",
Expand Down
63 changes: 61 additions & 2 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 { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing';
import { ApplicationRef, 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 Down Expand Up @@ -127,4 +128,62 @@ describe('App component', () => {
});

});

describe('removeSsrOverlayWhenStable', () => {
// The inline bootstrap script in src/index.html injects window.__dspaceRemoveSsrOverlay
// and AppComponent must call it exactly once when ApplicationRef.isStable first emits true.
let appRef: ApplicationRef;
let isStable$: BehaviorSubject<boolean>;
let originalRaF: typeof window.requestAnimationFrame;

beforeEach(() => {
appRef = TestBed.inject(ApplicationRef);
isStable$ = new BehaviorSubject<boolean>(false);
// Patch isStable to our controllable subject for this test only
Object.defineProperty(appRef, 'isStable', { value: isStable$.asObservable() });

// Force rAF to a synchronous shim so we can flush() through the chain deterministically.
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 isStable emits true', fakeAsync(() => {
const spy = jasmine.createSpy('__dspaceRemoveSsrOverlay');
window.__dspaceRemoveSsrOverlay = spy;

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

expect(spy).not.toHaveBeenCalled();

isStable$.next(true);
tick(50); // matches the 50ms pad after rAF in removeSsrOverlayWhenStable
flush();

expect(spy).toHaveBeenCalledTimes(1);
}));

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

isStable$.next(true);
tick(50);
flush();

expect(window.__dspaceRemoveSsrOverlay).toBeUndefined();
}));
});
});
40 changes: 39 additions & 1 deletion src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { distinctUntilChanged, take, withLatestFrom, delay } from 'rxjs/operators';
import { distinctUntilChanged, filter, first, take, withLatestFrom, delay } from 'rxjs/operators';
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import {
AfterViewInit,
ApplicationRef,
ChangeDetectionStrategy,
Component,
HostListener,
Inject,
NgZone,
OnInit,
PLATFORM_ID,
} from '@angular/core';
Expand Down Expand Up @@ -74,6 +76,8 @@ export class AppComponent implements OnInit, AfterViewInit {
private cssService: CSSVariableService,
private modalService: NgbModal,
private modalConfig: NgbModalConfig,
private appRef: ApplicationRef,
private ngZone: NgZone,
) {
this.notificationOptions = environment.notifications;

Expand All @@ -82,13 +86,47 @@ export class AppComponent implements OnInit, AfterViewInit {

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

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

this.storeCSSVariables();
}

/**
* Drops the SSR mask overlay installed by the inline bootstrap script in src/index.html as soon
* as Angular reaches its first stable state. The overlay is the only thing the user sees while
* Angular 15 rebuilds the SSR DOM; removing it too early would expose the rebuild flicker, too
* late would feel sluggish. We add a short safety pad to let the first paint settle, and there
* is also a 15s hard fallback inside the script itself in case isStable never fires.
*/
private removeSsrOverlayWhenStable(): void {
const w: Window | undefined = this._window?.nativeWindow;
if (!w || typeof w.__dspaceRemoveSsrOverlay !== 'function') {
return;
}
// run outside Angular so we don't keep changeDetection ticking on the overlay timer
this.ngZone.runOutsideAngular(() => {
this.appRef.isStable.pipe(
filter((stable: boolean) => stable),
first(),
).subscribe(() => {
// one rAF + small pad to let the first stable paint commit before fading the overlay
const remove = () => {
if (typeof w.__dspaceRemoveSsrOverlay === 'function') {
w.__dspaceRemoveSsrOverlay();
}
};
if (typeof w.requestAnimationFrame === 'function') {
w.requestAnimationFrame(() => setTimeout(remove, 50));
} else {
setTimeout(remove, 50);
}
});
});
}
Comment thread
vidiecan marked this conversation as resolved.

ngOnInit() {
/** Implement behavior for interface {@link ModalBeforeDismiss} */
this.modalConfig.beforeDismiss = async function () {
Expand Down
119 changes: 119 additions & 0 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,131 @@
<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.removeSsrOverlayWhenStable.
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 ApplicationRef.isStable settles.
*/
(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);
Comment thread
vidiecan marked this conversation as resolved.

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;

var el = document.getElementById('__dspace_ssr_overlay');
if (!el) return;
app.removeAttribute('data-dspace-ssr-hidden');
el.style.transition = 'opacity 150ms ease-out';
el.style.opacity = '0';
setTimeout(function () {
if (el && el.parentNode) el.parentNode.removeChild(el);
for (var i = 0; i < keptStyles.length; i++) {
if (keptStyles[i].parentNode) keptStyles[i].parentNode.removeChild(keptStyles[i]);
}
keptStyles = [];
}, 200);
};

// Safety net: if the app never reaches isStable (e.g. permanent HTTP poll), remove anyway.
setTimeout(function () {
if (typeof window.__dspaceRemoveSsrOverlay === 'function') {
window.__dspaceRemoveSsrOverlay();
}
}, 15000);
Comment thread
vidiecan marked this conversation as resolved.
} 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);
}
}
Comment thread
vidiecan marked this conversation as resolved.
})();
</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
9 changes: 9 additions & 0 deletions src/typings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,12 @@ declare module '*.scss' {
const content: any;
export default content;
}

/**
* Window global injected by the inline anti-flicker bootstrap script in `src/index.html`.
* Called once by `AppComponent.removeSsrOverlayWhenStable()` when `ApplicationRef.isStable`
* fires, to drop the SSR-mask overlay and let the freshly built CSR DOM become visible.
*/
interface Window {
__dspaceRemoveSsrOverlay?: (() => void) | null;
}
Loading