diff --git a/.gitignore b/.gitignore index bdab34cb367..34e4510974f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/angular.json b/angular.json index a57b7582109..cf0b2f24038 100644 --- a/angular.json +++ b/angular.json @@ -99,8 +99,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "3mb", - "maximumError": "5mb" + "maximumWarning": "5.5mb", + "maximumError": "6mb" }, { "type": "anyComponentStyle", diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index e921c67acea..9294f1ff1dd 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -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'; @@ -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; + let originalRaF: typeof window.requestAnimationFrame; + + beforeEach(() => { + appRef = TestBed.inject(ApplicationRef); + isStable$ = new BehaviorSubject(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(); + })); + }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index b77d53c81d4..8200033fc20 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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'; @@ -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; @@ -82,6 +86,7 @@ export class AppComponent implements OnInit, AfterViewInit { if (isPlatformBrowser(this.platformId)) { this.trackIdleModal(); + this.removeSsrOverlayWhenStable(); } this.isThemeLoading$ = this.themeService.isThemeLoading$; @@ -89,6 +94,39 @@ export class AppComponent implements OnInit, AfterViewInit { 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); + } + }); + }); + } + ngOnInit() { /** Implement behavior for interface {@link ModalBeforeDismiss} */ this.modalConfig.beforeDismiss = async function () { diff --git a/src/index.html b/src/index.html index 74fc0c9861b..14a782b7f5e 100644 --- a/src/index.html +++ b/src/index.html @@ -7,12 +7,131 @@ DSpace + + diff --git a/src/themes/eager-themes.module.ts b/src/themes/eager-themes.module.ts index 4a46595f358..29d46032de8 100644 --- a/src/themes/eager-themes.module.ts +++ b/src/themes/eager-themes.module.ts @@ -1,6 +1,6 @@ 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. @@ -8,11 +8,16 @@ import { EagerThemeModule as DSpaceEagerThemeModule } from './dspace/eager-theme * 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 { diff --git a/src/typings.d.ts b/src/typings.d.ts index c1c86511f88..f7397dc290e 100644 --- a/src/typings.d.ts +++ b/src/typings.d.ts @@ -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; +}