Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable preboot event replay for shorter apparent TTI #1761

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"@nguniversal/express-engine": "^13.0.2",
"@ngx-translate/core": "^13.0.0",
"@nicky-lenaers/ngx-scroll-to": "^9.0.0",
"@rezonant/preboot": "^8.0.0",
"angular-idle-preload": "3.0.0",
"angulartics2": "^12.0.0",
"axios": "^0.27.2",
Expand Down Expand Up @@ -116,6 +117,7 @@
"ngx-moment": "^5.0.0",
"ngx-pagination": "5.0.0",
"ngx-sortablejs": "^11.1.0",
"ngx-ui-switch": "^11.0.1",
"nouislider": "^14.6.3",
"pem": "1.14.4",
"postcss-cli": "^9.1.0",
Expand All @@ -128,8 +130,7 @@
"url-parse": "^1.5.6",
"uuid": "^8.3.2",
"webfontloader": "1.6.28",
"zone.js": "~0.11.5",
"ngx-ui-switch": "^11.0.1"
"zone.js": "~0.11.5"
},
"devDependencies": {
"@angular-builders/custom-webpack": "~13.1.0",
Expand Down
64 changes: 63 additions & 1 deletion src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +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 { defaultOptions, PrebootModule } from '@rezonant/preboot';

export function getConfig() {
return environment;
Expand Down Expand Up @@ -77,6 +78,68 @@ const IMPORTS = [
StoreDevModules,
EagerThemesModule,
RootModule,
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,
}
]
})
];

const PROVIDERS = [
Expand Down Expand Up @@ -148,7 +211,6 @@ const EXPORTS = [

@NgModule({
imports: [
BrowserModule.withServerTransition({ appId: 'dspace-angular' }),
...IMPORTS
],
providers: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
class="example-tree-node expandable-node">
<div class="btn-group">
<button type="button" class="btn btn-default" cdkTreeNodeToggle
[id]="'toggle-' + node.id"
[title]="'toggle ' + node.name"
[attr.aria-label]="'toggle ' + node.name"
(click)="toggleExpanded(node)"
Expand Down
18 changes: 10 additions & 8 deletions src/app/core/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { NotificationsServiceStub } from '../../shared/testing/notifications-ser
import { SetUserAsIdleAction, UnsetUserAsIdleAction } from './auth.actions';
import { SpecialGroupDataMock, SpecialGroupDataMock$ } from '../../shared/testing/special-group.mock';
import { cold } from 'jasmine-marbles';
import { NgZone } from '@angular/core';

describe('AuthService test', () => {

Expand Down Expand Up @@ -134,6 +135,7 @@ describe('AuthService test', () => {
{ provide: HardRedirectService, useValue: hardRedirectService },
{ provide: NotificationsService, useValue: NotificationsServiceStub },
{ provide: TranslateService, useValue: getMockTranslateService() },
{ provide: NgZone, useValue: new NgZone({}) },
CookieService,
AuthService
],
Expand Down Expand Up @@ -254,13 +256,13 @@ describe('AuthService test', () => {
}).compileComponents();
}));

beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => {
beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store<AppState>, 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', () => {
Expand Down Expand Up @@ -330,7 +332,7 @@ describe('AuthService test', () => {
}).compileComponents();
}));

beforeEach(inject([ClientCookieService, AuthRequestService, Store, Router, RouteService], (cookieService: ClientCookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => {
beforeEach(inject([ClientCookieService, AuthRequestService, Store, Router, RouteService], (cookieService: ClientCookieService, authReqService: AuthRequestService, store: Store<AppState>, 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 = {
Expand All @@ -345,7 +347,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);
Expand Down Expand Up @@ -559,13 +561,13 @@ describe('AuthService test', () => {
}).compileComponents();
}));

beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => {
beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store<AppState>, 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', () => {
Expand Down Expand Up @@ -599,13 +601,13 @@ describe('AuthService test', () => {
}).compileComponents();
}));

beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => {
beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store<AppState>, 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', () => {
Expand Down
22 changes: 15 additions & 7 deletions src/app/core/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -90,7 +90,8 @@ export class AuthService {
protected store: Store<AppState>,
protected hardRedirectService: HardRedirectService,
private notificationService: NotificationsService,
private translateService: TranslateService
private translateService: TranslateService,
private zone: NgZone,
) {
this.store.pipe(
select(isAuthenticated),
Expand Down Expand Up @@ -350,7 +351,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
Expand All @@ -368,10 +373,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);
});
}
}
});
Expand Down
2 changes: 2 additions & 0 deletions src/app/root/root.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
value: (!(sidebarVisible | async) ? 'hidden' : (slideSidebarOver | async) ? 'shown' : 'expanded'),
params: {collapsedSidebarWidth: (collapsedSidebarWidth | async), totalSidebarWidth: (totalSidebarWidth | async)}
}">

<ds-loading-csr></ds-loading-csr>
<ds-themed-header-navbar-wrapper></ds-themed-header-navbar-wrapper>
<main class="main-content">
<ds-themed-breadcrumbs></ds-themed-breadcrumbs>
Expand Down
5 changes: 3 additions & 2 deletions src/app/root/root.component.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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()) {
Expand Down
2 changes: 1 addition & 1 deletion src/app/search-navbar/search-navbar.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<input #searchInput [@toggleAnimation]="isExpanded" [attr.aria-label]="('nav.search' | translate)" name="query"
formControlName="query" type="text" placeholder="{{searchExpanded ? ('nav.search' | translate) : ''}}"
class="d-inline-block bg-transparent position-absolute form-control dropdown-menu-right p-1" [attr.data-test]="'header-search-box' | dsBrowserOnly">
<a class="submit-icon" [routerLink]="" (click)="searchExpanded ? onSubmit(searchForm.value) : expand()" [attr.data-test]="'header-search-icon' | dsBrowserOnly">
<a class="submit-icon preboot-replay" id="search-navbar-container-submit" href="javascript:void(0);" (click)="searchExpanded ? onSubmit(searchForm.value) : expand()" [attr.data-test]="'header-search-icon' | dsBrowserOnly">
<em class="fas fa-search fa-lg fa-fw"></em>
</a>
</form>
Expand Down
4 changes: 2 additions & 2 deletions src/app/shared/auth-nav-menu/auth-nav-menu.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<li *ngIf="!(isAuthenticated | async) && !(isXsOrSm$ | async) && (showAuth | async)" class="nav-item"
(click)="$event.stopPropagation();">
<div ngbDropdown #loginDrop display="dynamic" placement="bottom-right" class="d-inline-block" @fadeInOut>
<a href="javascript:void(0);" class="dropdownLogin px-1 " [attr.aria-label]="'nav.login' |translate" (click)="$event.preventDefault()" [attr.data-test]="'login-menu' | dsBrowserOnly" ngbDropdownToggle>
<a href="javascript:void(0);" class="dropdownLogin px-1 " [attr.aria-label]="'nav.login' |translate" (click)="$event.preventDefault()" id="login-toggle" [attr.data-test]="'login-menu' | dsBrowserOnly" ngbDropdownToggle>
{{ 'nav.login' | translate }}
</a>
<div class="loginDropdownMenu" [ngClass]="{'pl-3 pr-3': (loading | async)}" ngbDropdownMenu
Expand All @@ -19,7 +19,7 @@
</li>
<li *ngIf="(isAuthenticated | async) && !(isXsOrSm$ | async) && (showAuth | async)" class="nav-item">
<div ngbDropdown display="dynamic" placement="bottom-right" class="d-inline-block" @fadeInOut>
<a href="javascript:void(0);" role="button" [attr.aria-label]="'nav.logout' |translate" (click)="$event.preventDefault()" [title]="'nav.logout' | translate" class="px-1" [attr.data-test]="'user-menu' | dsBrowserOnly" ngbDropdownToggle>
<a href="javascript:void(0);" role="button" [attr.aria-label]="'nav.logout' |translate" (click)="$event.preventDefault()" [title]="'nav.logout' | translate" class="px-1" id="logout-toggle" [attr.data-test]="'user-menu' | dsBrowserOnly" ngbDropdownToggle>
<i class="fas fa-user-circle fa-lg fa-fw"></i></a>
<div class="logoutDropdownMenu" ngbDropdownMenu [attr.aria-label]="'nav.logout' |translate">
<ds-user-menu></ds-user-menu>
Expand Down
2 changes: 1 addition & 1 deletion src/app/shared/lang-switch/lang-switch.component.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<div ngbDropdown class="navbar-nav" *ngIf="moreThanOneLanguage" display="dynamic" placement="bottom-right">
<a href="javascript:void(0);" role="button"
<a href="javascript:void(0);" role="button" id="lang-switch-toggle"
[attr.aria-label]="'nav.language' |translate"
[title]="'nav.language' | translate" class="px-1"
(click)="$event.preventDefault()" data-toggle="dropdown" ngbDropdownToggle
Expand Down
1 change: 1 addition & 0 deletions src/app/shared/loading-csr/loading-csr.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="csr-progress-bar fixed-top" role="progressbar" *ngIf="loading"></div>
20 changes: 20 additions & 0 deletions src/app/shared/loading-csr/loading-csr.component.scss
Original file line number Diff line number Diff line change
@@ -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)
}
}
46 changes: 46 additions & 0 deletions src/app/shared/loading-csr/loading-csr.component.spec.ts
Original file line number Diff line number Diff line change
@@ -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<LoadingCsrComponent>;

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);
});
});
});
20 changes: 20 additions & 0 deletions src/app/shared/loading-csr/loading-csr.component.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading