From 79c4d0958947fe4b617f4ada62effa87592ade6a Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Wed, 3 Aug 2022 11:41:08 +0200 Subject: [PATCH 01/10] 93492: Use preboot for SSR/CSR transition https://github.com/angular/preboot --- package.json | 1 + src/app/app.module.ts | 6 +++++- yarn.lock | 7 +++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 8be2b57c046..01ed27403d6 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "nouislider": "^14.6.3", "pem": "1.14.4", "postcss-cli": "^8.3.0", + "preboot": "^8.0.0", "reflect-metadata": "^0.1.13", "rxjs": "^6.6.3", "sortablejs": "1.13.0", diff --git a/src/app/app.module.ts b/src/app/app.module.ts index a1db89b60d2..611f2950837 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -36,6 +36,7 @@ import { EagerThemesModule } from '../themes/eager-themes.module'; import { APP_CONFIG, AppConfig } from '../config/app-config.interface'; import { RootModule } from './root.module'; +import { PrebootModule } from 'preboot'; export function getConfig() { return environment; @@ -72,6 +73,10 @@ const IMPORTS = [ StoreRouterConnectingModule.forRoot(), EagerThemesModule, RootModule, + BrowserModule.withServerTransition({ appId: 'dspace-angular' }), + PrebootModule.withConfig({ + appRoot: 'ds-app', + }) ]; IMPORTS.push( @@ -150,7 +155,6 @@ const EXPORTS = [ @NgModule({ imports: [ - BrowserModule.withServerTransition({ appId: 'dspace-angular' }), ...IMPORTS ], providers: [ diff --git a/yarn.lock b/yarn.lock index ad8cb4ef252..8c85dda1229 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11327,6 +11327,13 @@ postcss@^8.1.4, postcss@^8.3.7: picocolors "^1.0.0" source-map-js "^1.0.1" +preboot@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/preboot/-/preboot-8.0.0.tgz#cfba7787cd3366e7f05842e6868d09f0f1500d54" + integrity sha512-unyVkACJDK9Y1Y2gJpKef9z+d/w0XRbVRU493Nbc38E5QaTCJuZUmsosVjXxILHVq0r/HLJzHxIDRj7omKJeZA== + dependencies: + tslib "^2.0.0" + prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" From aa5ce3ceabc234616cc182cf46c916ba5d30c12f Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Thu, 4 Aug 2022 20:16:11 +0200 Subject: [PATCH 02/10] 93492: Fix unresolved ApplicationRef.isStable preboot is triggered when the app first becomes stable, which caused the app to hang once CSR started Tracked the issue down to initialization - GoogleAnalyticsService.addTrackingIdToPage() - AuthService.trackTokenExpiration() --- src/app/core/auth/auth.service.spec.ts | 18 ++++---- src/app/core/auth/auth.service.ts | 22 ++++++---- .../google-analytics.service.spec.ts | 7 ++-- .../statistics/google-analytics.service.ts | 41 ++++++++++--------- 4 files changed, 51 insertions(+), 37 deletions(-) diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index ced8bb94c8d..be82f515f04 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -32,6 +32,7 @@ import { TranslateService } from '@ngx-translate/core'; import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; import { SetUserAsIdleAction, UnsetUserAsIdleAction } from './auth.actions'; +import { NgZone } from '@angular/core'; describe('AuthService test', () => { @@ -125,6 +126,7 @@ describe('AuthService test', () => { { provide: HardRedirectService, useValue: hardRedirectService }, { provide: NotificationsService, useValue: NotificationsServiceStub }, { provide: TranslateService, useValue: getMockTranslateService() }, + { provide: NgZone, useValue: new NgZone({}) }, CookieService, AuthService ], @@ -245,13 +247,13 @@ describe('AuthService test', () => { }).compileComponents(); })); - beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => { + beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService, zone: NgZone) => { store .subscribe((state) => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService, zone); })); it('should return true when user is logged in', () => { @@ -321,7 +323,7 @@ describe('AuthService test', () => { }).compileComponents(); })); - beforeEach(inject([ClientCookieService, AuthRequestService, Store, Router, RouteService], (cookieService: ClientCookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => { + beforeEach(inject([ClientCookieService, AuthRequestService, Store, Router, RouteService], (cookieService: ClientCookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService, zone: NgZone) => { const expiredToken: AuthTokenInfo = new AuthTokenInfo('test_token'); expiredToken.expires = Date.now() - (1000 * 60 * 60); authenticatedState = { @@ -336,7 +338,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService, zone); storage = (authService as any).storage; routeServiceMock = TestBed.inject(RouteService); routerStub = TestBed.inject(Router); @@ -537,13 +539,13 @@ describe('AuthService test', () => { }).compileComponents(); })); - beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => { + beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService, zone: NgZone) => { store .subscribe((state) => { (state as any).core = Object.create({}); (state as any).core.auth = unAuthenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService, zone); })); it('should return null for the shortlived token', () => { @@ -577,13 +579,13 @@ describe('AuthService test', () => { }).compileComponents(); })); - beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => { + beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService, zone: NgZone) => { store .subscribe((state) => { (state as any).core = Object.create({}); (state as any).core.auth = idleState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService, zone); })); it('isUserIdle should return true when user is not idle', () => { diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 5738948ebd3..f3bcc5badb5 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, Optional } from '@angular/core'; +import { Inject, Injectable, NgZone, Optional } from '@angular/core'; import { Router } from '@angular/router'; import { HttpHeaders } from '@angular/common/http'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; @@ -85,7 +85,8 @@ export class AuthService { protected store: Store, protected hardRedirectService: HardRedirectService, private notificationService: NotificationsService, - private translateService: TranslateService + private translateService: TranslateService, + private zone: NgZone, ) { this.store.pipe( select(isAuthenticated), @@ -329,7 +330,11 @@ export class AuthService { if (currentlyRefreshingToken && token !== undefined && authTokenInfo === undefined) { // Token refresh failed => Error notification => 10 second wait => Page reloads & user logged out this.notificationService.error(this.translateService.get('auth.messages.token-refresh-failed')); - setTimeout(() => this.navigateToRedirectUrl(this.hardRedirectService.getCurrentRoute()), 10000); + + this.zone.runOutsideAngular(() => { + setTimeout(() => this.navigateToRedirectUrl(this.hardRedirectService.getCurrentRoute()), 10000); + }); + currentlyRefreshingToken = false; } // If new token.expires is different => Refresh succeeded @@ -347,10 +352,13 @@ export class AuthService { if (hasValue(this.tokenRefreshTimer)) { clearTimeout(this.tokenRefreshTimer); } - this.tokenRefreshTimer = setTimeout(() => { - this.store.dispatch(new RefreshTokenAction(token)); - currentlyRefreshingToken = true; - }, timeLeftBeforeRefresh); + + this.zone.runOutsideAngular(() => { + this.tokenRefreshTimer = setTimeout(() => { + this.store.dispatch(new RefreshTokenAction(token)); + currentlyRefreshingToken = true; + }, timeLeftBeforeRefresh); + }); } } }); diff --git a/src/app/statistics/google-analytics.service.spec.ts b/src/app/statistics/google-analytics.service.spec.ts index c9a267a76fe..db0bfa7d930 100644 --- a/src/app/statistics/google-analytics.service.spec.ts +++ b/src/app/statistics/google-analytics.service.spec.ts @@ -6,6 +6,7 @@ import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { ConfigurationProperty } from '../core/shared/configuration-property.model'; +import { NgZone } from '@angular/core'; describe('GoogleAnalyticsService', () => { const trackingIdProp = 'google.analytics.key'; @@ -51,7 +52,7 @@ describe('GoogleAnalyticsService', () => { body: bodyElementSpy, }); - service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy); + service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy, new NgZone({})); }); it('should be created', () => { @@ -71,7 +72,7 @@ describe('GoogleAnalyticsService', () => { findByPropertyName: createFailedRemoteDataObject$(), }); - service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy); + service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy, new NgZone({})); }); it('should NOT add a script to the body', () => { @@ -89,7 +90,7 @@ describe('GoogleAnalyticsService', () => { describe('when the tracking id is empty', () => { beforeEach(() => { configSpy = createConfigSuccessSpy(); - service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy); + service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy, new NgZone({})); }); it('should NOT add a script to the body', () => { diff --git a/src/app/statistics/google-analytics.service.ts b/src/app/statistics/google-analytics.service.ts index 94e5ad20af0..e18b6feb67e 100644 --- a/src/app/statistics/google-analytics.service.ts +++ b/src/app/statistics/google-analytics.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@angular/core'; +import { Inject, Injectable, NgZone } from '@angular/core'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { ConfigurationDataService } from '../core/data/configuration-data.service'; import { getFirstCompletedRemoteData } from '../core/shared/operators'; @@ -16,6 +16,7 @@ export class GoogleAnalyticsService { private angulartics: Angulartics2GoogleAnalytics, private configService: ConfigurationDataService, @Inject(DOCUMENT) private document: any, + private zone: NgZone, ) { } /** @@ -25,28 +26,30 @@ export class GoogleAnalyticsService { * page and starts tracking. */ addTrackingIdToPage(): void { - this.configService.findByPropertyName('google.analytics.key').pipe( - getFirstCompletedRemoteData(), - ).subscribe((remoteData) => { - // make sure we got a success response from the backend - if (!remoteData.hasSucceeded) { return; } + this.zone.runOutsideAngular(() => { + this.configService.findByPropertyName('google.analytics.key').pipe( + getFirstCompletedRemoteData(), + ).subscribe((remoteData) => { + // make sure we got a success response from the backend + if (!remoteData.hasSucceeded) { return; } - const trackingId = remoteData.payload.values[0]; + const trackingId = remoteData.payload.values[0]; - // make sure we received a tracking id - if (isEmpty(trackingId)) { return; } + // make sure we received a tracking id + if (isEmpty(trackingId)) { return; } - // add trackingId snippet to page - const keyScript = this.document.createElement('script'); - keyScript.innerHTML = `(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ - (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), - m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) - })(window,document,'script','https://www.google-analytics.com/analytics.js','ga'); - ga('create', '${trackingId}', 'auto');`; - this.document.body.appendChild(keyScript); + // add trackingId snippet to page + const keyScript = this.document.createElement('script'); + keyScript.innerHTML = `(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ + (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), + m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) + })(window,document,'script','https://www.google-analytics.com/analytics.js','ga'); + ga('create', '${trackingId}', 'auto');`; + this.document.body.appendChild(keyScript); - // start tracking - this.angulartics.startTracking(); + // start tracking + this.angulartics.startTracking(); + }); }); } } From d692f10b6ccccd92b12daae413c8079cf13f0f61 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Fri, 5 Aug 2022 10:22:56 +0200 Subject: [PATCH 03/10] 93492: Fix flash of unstyled content during CSR SSR HTML inlines CSS into style.ng-transition elements. By default, Angular removes these as soon as CSR starts. Because preboot buffers CSR off-screen, the SSR HTML remains visible during the transition - without CSS. This workaround strips the ng-transition attribute from SSR styles so they aren't removed automatically and subsequently removes these same styles once preboot is finished. --- src/modules/app/browser-app.module.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index 20c68898aee..f91f48fbe47 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -38,6 +38,7 @@ import { extendEnvironmentWithAppConfig } from '../../config/config.util'; import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; import { environment } from '../../environments/environment'; +import { DOCUMENT } from '@angular/common'; export const REQ_KEY = makeStateKey('req'); @@ -77,7 +78,8 @@ export function getRequest(transferState: TransferState): any { useFactory: ( transferState: TransferState, dspaceTransferState: DSpaceTransferState, - correlationIdService: CorrelationIdService + correlationIdService: CorrelationIdService, + document: any, ) => { if (transferState.hasKey(APP_CONFIG_STATE)) { const appConfig = transferState.get(APP_CONFIG_STATE, new DefaultAppConfig()); @@ -87,10 +89,23 @@ export function getRequest(transferState: TransferState): any { return () => dspaceTransferState.transfer().then((b: boolean) => { correlationIdService.initCorrelationId(); + + // Workaround for flash of unstyled content during preboot: + // Adapted from https://github.com/angular/preboot/issues/75#issuecomment-421266570 + const styles: any[] = Array.prototype.slice.apply(document.querySelectorAll(`style[ng-transition]`)); + styles.forEach(el => { + // Remove ng-transition attribute from SSR styles to prevent Angular appInitializerFactory + // from removing them before preboot has completed + el.removeAttribute('ng-transition'); + }); + document.addEventListener('PrebootComplete', () => { + // Once preboot is finished, remove SSR styles + styles.forEach(el => el.remove()); + }); return b; }); }, - deps: [TransferState, DSpaceTransferState, CorrelationIdService], + deps: [TransferState, DSpaceTransferState, CorrelationIdService, DOCUMENT], multi: true }, { From 754c1b774b63d1f04ea5d1c1f4fce3f67a8b781e Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Fri, 5 Aug 2022 11:36:32 +0200 Subject: [PATCH 04/10] 93492: Fix ds-search-navbar submit button expand on click --- src/app/search-navbar/search-navbar.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/search-navbar/search-navbar.component.html b/src/app/search-navbar/search-navbar.component.html index 693c7daef8c..e4dbdf3e99c 100644 --- a/src/app/search-navbar/search-navbar.component.html +++ b/src/app/search-navbar/search-navbar.component.html @@ -4,7 +4,7 @@ - + From d759e510699fbada82b75ccfdb4da53d12a73e4b Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Fri, 5 Aug 2022 12:23:03 +0200 Subject: [PATCH 05/10] 93492: Fix SSR dodging pinned admin sidebar On the server, @slideSidebarPadding always resolved to 'expanded' because slideSidebarOver did not emit true --- src/app/root/root.component.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/root/root.component.ts b/src/app/root/root.component.ts index dc44095573f..d811d28ca19 100644 --- a/src/app/root/root.component.ts +++ b/src/app/root/root.component.ts @@ -1,4 +1,4 @@ -import { map } from 'rxjs/operators'; +import { map, startWith } from 'rxjs/operators'; import { Component, Inject, Input, OnInit } from '@angular/core'; import { Router } from '@angular/router'; @@ -71,7 +71,8 @@ export class RootComponent implements OnInit { const sidebarCollapsed = this.menuService.isMenuCollapsed(MenuID.ADMIN); this.slideSidebarOver = combineLatestObservable([sidebarCollapsed, this.windowService.isXsOrSm()]) .pipe( - map(([collapsed, mobile]) => collapsed || mobile) + map(([collapsed, mobile]) => collapsed || mobile), + startWith(true), ); if (this.router.url === getPageInternalServerErrorRoute()) { From 53068c449c21b89a4b08cbed88d68fd376744f34 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Thu, 4 Aug 2022 18:48:08 +0200 Subject: [PATCH 06/10] 93492: Turn off preboot event replay for now --- src/app/app.module.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 611f2950837..fa67d45117e 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -76,6 +76,11 @@ const IMPORTS = [ BrowserModule.withServerTransition({ appId: 'dspace-angular' }), PrebootModule.withConfig({ appRoot: 'ds-app', + + // Event replay doesn't work well for most of our components because it requires elements to have a unique id. + // By not defining any selectors for event capture we can turn it off for now. (shouldn't set replay:false because that also affects the transition itself) + eventSelectors: [ + ] }) ]; From 92975cee7ed039001d97291e511bc76eaf5c2351 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Wed, 10 Aug 2022 15:25:56 +0200 Subject: [PATCH 07/10] 93492: Add loading animation to mark when CSR is finished --- src/app/root/root.component.html | 2 + .../loading-csr/loading-csr.component.html | 1 + .../loading-csr/loading-csr.component.scss | 20 ++++++++ .../loading-csr/loading-csr.component.spec.ts | 46 +++++++++++++++++++ .../loading-csr/loading-csr.component.ts | 20 ++++++++ src/app/shared/shared.module.ts | 3 ++ src/styles/_custom_variables.scss | 5 ++ 7 files changed, 97 insertions(+) create mode 100644 src/app/shared/loading-csr/loading-csr.component.html create mode 100644 src/app/shared/loading-csr/loading-csr.component.scss create mode 100644 src/app/shared/loading-csr/loading-csr.component.spec.ts create mode 100644 src/app/shared/loading-csr/loading-csr.component.ts diff --git a/src/app/root/root.component.html b/src/app/root/root.component.html index 898074352cd..846b088ec36 100644 --- a/src/app/root/root.component.html +++ b/src/app/root/root.component.html @@ -4,6 +4,8 @@ value: (!(sidebarVisible | async) ? 'hidden' : (slideSidebarOver | async) ? 'shown' : 'expanded'), params: {collapsedSidebarWidth: (collapsedSidebarWidth | async), totalSidebarWidth: (totalSidebarWidth | async)} }"> + + diff --git a/src/app/shared/loading-csr/loading-csr.component.scss b/src/app/shared/loading-csr/loading-csr.component.scss new file mode 100644 index 00000000000..f2e02b84452 --- /dev/null +++ b/src/app/shared/loading-csr/loading-csr.component.scss @@ -0,0 +1,20 @@ +.csr-progress-bar { + background: linear-gradient(to left, transparent 50%, var(--ds-csr-loading-color) 50%); + background-size: var(--ds-csr-loading-dash); + height: var(--ds-csr-loading-height); + width: 100%; + + // make sure it stays above the navbar but below the admin sidebar + z-index: calc(var(--ds-sidebar-z-index) - 1); + + animation: csr-loading-animation 0.5s linear infinite; +} + +@keyframes csr-loading-animation { + 0% { + background-position-x: 0 + } + 100% { + background-position-x: var(--ds-csr-loading-dash) + } +} diff --git a/src/app/shared/loading-csr/loading-csr.component.spec.ts b/src/app/shared/loading-csr/loading-csr.component.spec.ts new file mode 100644 index 00000000000..5b6848d71b0 --- /dev/null +++ b/src/app/shared/loading-csr/loading-csr.component.spec.ts @@ -0,0 +1,46 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoadingCsrComponent } from './loading-csr.component'; +import { PLATFORM_ID } from '@angular/core'; + +describe('LoadingCsrComponent', () => { + let component: LoadingCsrComponent; + let fixture: ComponentFixture; + + const init = async (platformId) => { + + await TestBed.configureTestingModule({ + declarations: [ LoadingCsrComponent ], + providers: [ + { + provide: PLATFORM_ID, + useValue: platformId, + }, + ] + }).compileComponents(); + + fixture = TestBed.createComponent(LoadingCsrComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }; + + describe('on the server', () => { + beforeEach(async () => { + await init('server'); + }); + + it('should have loading=true', () => { + expect(component.loading).toBe(true); + }); + }); + + describe('in the browser', () => { + beforeEach(async () => { + await init('browser'); + }); + + it('should have loading=false', () => { + expect(component.loading).toBe(false); + }); + }); +}); diff --git a/src/app/shared/loading-csr/loading-csr.component.ts b/src/app/shared/loading-csr/loading-csr.component.ts new file mode 100644 index 00000000000..a18b4b67590 --- /dev/null +++ b/src/app/shared/loading-csr/loading-csr.component.ts @@ -0,0 +1,20 @@ +import { Component, Inject, PLATFORM_ID } from '@angular/core'; +import { isPlatformServer } from '@angular/common'; + +/** + * Shows a loading animation when rendered on the server + */ +@Component({ + selector: 'ds-loading-csr', + templateUrl: './loading-csr.component.html', + styleUrls: ['./loading-csr.component.scss'] +}) +export class LoadingCsrComponent { + loading: boolean; + + constructor( + @Inject(PLATFORM_ID) private platformId: any, + ) { + this.loading = isPlatformServer(this.platformId); + } +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 715ee66a99b..6fd138d0f3d 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -177,6 +177,7 @@ import { ScopeSelectorModalComponent } from './search-form/scope-selector-modal/ import { BitstreamRequestACopyPageComponent } from './bitstream-request-a-copy-page/bitstream-request-a-copy-page.component'; import { DsSelectComponent } from './ds-select/ds-select.component'; import { LogInOidcComponent } from './log-in/methods/oidc/log-in-oidc.component'; +import { LoadingCsrComponent } from './loading-csr/loading-csr.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -234,6 +235,7 @@ const COMPONENTS = [ FileSectionComponent, LangSwitchComponent, LoadingComponent, + LoadingCsrComponent, LogInComponent, LogOutComponent, NumberPickerComponent, @@ -454,6 +456,7 @@ const DIRECTIVES = [ ...SHARED_ITEM_PAGE_COMPONENTS, ItemVersionsSummaryModalComponent, ItemVersionsDeleteModalComponent, + LoadingCsrComponent, ], providers: [ ...PROVIDERS diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index 66e0e87f93c..045b6edd5dc 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -85,4 +85,9 @@ --ds-slider-handle-width: 18px; --ds-search-form-scope-max-width: 150px; + + --ds-csr-loading-color: var(--bs-green); + --ds-csr-loading-height: 2px; + --ds-csr-loading-dash: 20px; + --ds-csr-loading-color: var(--bs-green); } From 4dd3715f3ca2013e5080c9524788ac5b1dc96105 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Wed, 10 Aug 2022 17:36:11 +0200 Subject: [PATCH 08/10] 93492: Use preboot fork with workaround for Angular 12+ issue --- package.json | 2 +- src/app/app.module.ts | 2 +- yarn.lock | 14 +++++++------- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 108a2e5d9d2..8891ceacd19 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "@nguniversal/express-engine": "^13.0.2", "@ngx-translate/core": "^13.0.0", "@nicky-lenaers/ngx-scroll-to": "^9.0.0", - "preboot": "^8.0.0", + "@rezonant/preboot": "^8.0.0", "angular-idle-preload": "3.0.0", "angulartics2": "^12.0.0", "axios": "^0.27.2", diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 619d722143e..e2ce7363f6e 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -37,7 +37,7 @@ import { APP_CONFIG, AppConfig } from '../config/app-config.interface'; import { NgxMaskModule } from 'ngx-mask'; import { StoreDevModules } from '../config/store/devtools'; import { RootModule } from './root.module'; -import { PrebootModule } from 'preboot'; +import { PrebootModule } from '@rezonant/preboot'; export function getConfig() { return environment; diff --git a/yarn.lock b/yarn.lock index c68ee9a50b1..64a44cd9df5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1956,6 +1956,13 @@ resolved "https://registry.yarnpkg.com/@researchgate/react-intersection-observer/-/react-intersection-observer-1.3.5.tgz#0321d2dd609aaacdb9bace8004d99c72824fb142" integrity sha512-aYlsex5Dd6BAHMJvJrUoFp8gzgMSL27xFvrxkVYW0bV1RMAapVsO+QeYLtTaSF/QCflktODodvv+wJm49oMnnQ== +"@rezonant/preboot@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@rezonant/preboot/-/preboot-8.0.0.tgz#dc201887c4cfe8e20f58feefbfef7851921dcf86" + integrity sha512-t3ViT5ns3EYezQ5pnMvwqVe1ki6E0jqa6y2QFFFCaEADKoYZO6tyb+dDknhPQ0LiegftcG3DG1igoA5crNzlLQ== + dependencies: + tslib "^2.0.0" + "@scarf/scarf@^1.1.0": version "1.1.1" resolved "https://registry.yarnpkg.com/@scarf/scarf/-/scarf-1.1.1.tgz#d8b9f20037b3a37dbf8dcdc4b3b72f9285bfce35" @@ -10318,13 +10325,6 @@ postcss@^8.2.14: picocolors "^1.0.0" source-map-js "^1.0.2" -preboot@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/preboot/-/preboot-8.0.0.tgz#cfba7787cd3366e7f05842e6868d09f0f1500d54" - integrity sha512-unyVkACJDK9Y1Y2gJpKef9z+d/w0XRbVRU493Nbc38E5QaTCJuZUmsosVjXxILHVq0r/HLJzHxIDRj7omKJeZA== - dependencies: - tslib "^2.0.0" - prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" From 9d4fc956903a2c56c4eaf540a3ed4c2f8d8a4a34 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Fri, 5 Aug 2022 12:38:59 +0200 Subject: [PATCH 09/10] Revert "93492: Turn off preboot event replay for now" This reverts commit d1741e26a0c03067853e7a20d055c70a5d8a465c. --- src/app/app.module.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/app/app.module.ts b/src/app/app.module.ts index e2ce7363f6e..17a2e315f02 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -81,11 +81,6 @@ const IMPORTS = [ BrowserModule.withServerTransition({ appId: 'dspace-angular' }), PrebootModule.withConfig({ appRoot: 'ds-app', - - // Event replay doesn't work well for most of our components because it requires elements to have a unique id. - // By not defining any selectors for event capture we can turn it off for now. (shouldn't set replay:false because that also affects the transition itself) - eventSelectors: [ - ] }) ]; From 818c1426d6bdf4df92bd88f7220eeb6c1c534d06 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Fri, 5 Aug 2022 12:47:08 +0200 Subject: [PATCH 10/10] TEMP: Proof-of-concept event replay fixes (partial) --- src/app/app.module.ts | 60 ++++++++++++++++++- .../community-list.component.html | 1 + .../search-navbar.component.html | 2 +- .../auth-nav-menu.component.html | 4 +- .../lang-switch/lang-switch.component.html | 2 +- .../browse-entry-list-element.component.html | 2 +- .../collection-list-element.component.html | 2 +- .../community-list-element.component.html | 2 +- .../item/item-list-element.component.html | 2 +- .../search-form/search-form.component.html | 2 +- 10 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 17a2e315f02..99a84cfdc5a 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -37,7 +37,7 @@ import { APP_CONFIG, AppConfig } from '../config/app-config.interface'; import { NgxMaskModule } from 'ngx-mask'; import { StoreDevModules } from '../config/store/devtools'; import { RootModule } from './root.module'; -import { PrebootModule } from '@rezonant/preboot'; +import { defaultOptions, PrebootModule } from '@rezonant/preboot'; export function getConfig() { return environment; @@ -81,6 +81,64 @@ const IMPORTS = [ BrowserModule.withServerTransition({ appId: 'dspace-angular' }), PrebootModule.withConfig({ appRoot: 'ds-app', + eventSelectors: [ + // Preboot defaults START + + // for recording changes in form elements + { + selector: 'input,textarea', + events: ['keypress', 'keyup', 'keydown', 'input', 'change'] + }, + { selector: 'select,option', events: ['change'] }, + + // when user hits return button in an input box + { + selector: 'input', + events: ['keyup'], + preventDefault: true, + keyCodes: [13], + freeze: true + }, + + // when user submit form (press enter, click on button/input[type="submit"]) + { + selector: 'form', + events: ['submit'], + preventDefault: true, + freeze: true + }, + + // for tracking focus (no need to replay) + { + selector: 'input,textarea', + events: ['focusin', 'focusout', 'mousedown', 'mouseup'], + replay: false + }, + + // // user clicks on a button + // { + // selector: 'button', + // events: ['click'], + // preventDefault: true, + // freeze: true + // }, + + // Preboot defaults END + + // we have a lot of "link buttons" + // we probably don't want to freeze either (or maybe make the overlay transparent?) + { + selector: 'a.preboot-replay,a.dropdown-toggle,button', + events: ['click'], + preventDefault: true, + }, + // router links can misbehave with event replay enabled + { + selector: 'a', + events: ['click'], + preventDefault: true, + } + ] }) ]; diff --git a/src/app/community-list-page/community-list/community-list.component.html b/src/app/community-list-page/community-list/community-list.component.html index 821cb58473b..8facc331284 100644 --- a/src/app/community-list-page/community-list/community-list.component.html +++ b/src/app/community-list-page/community-list/community-list.component.html @@ -25,6 +25,7 @@ class="example-tree-node expandable-node">
-