diff --git a/PLAYBOOK.md b/PLAYBOOK.md index ac534e28e..9d478641e 100644 --- a/PLAYBOOK.md +++ b/PLAYBOOK.md @@ -318,13 +318,6 @@ ng g lib ContextMenu --tags=public-module --publishable=true --unit-test-runner= ng g component ContextMenu --project=context-menu --flat -d ng g directive ContextMenuTrigger --project=context-menu --flat -d -# generate components for `Prefetch` Module -ng g service Prefetch --tags=public-module --prefix=ngx --publishable=true --unit-test-runner=jest -ng g service PrefetchRegistry --project=prefetch --skipTests -d -ng g service QuicklinkStrategy --project=prefetch --skipTests -d -ng g service LinkHandler --project=prefetch --skipTests -d -ng g directive Link --project=prefetch --flat --skipTests -d - # generate components, services for `ThemePicker` Module ng g lib ThemePicker --tags=public-module --publishable=true --unit-test-runner=jest ng g component ThemePicker --project=theme-picker --flat -d @@ -407,6 +400,15 @@ ng g directive directives/in-viewport/inViewport --selector=inViewport --projec ng g service directives/in-viewport/Viewport --project=ngx-utils --module=in-viewport -d +# generate components for `preload` Module +ng g lib preload --tags=public-module --prefix=ngx --publishable=true --unit-test-runner=jest --skipTests +ng g service strategies/selected/PreloadSelectedStrategy --project=preload --skipTests -d +ng g module strategies/viewport/PreloadViewport --flat --project=preload --skipTests -d +ng g service strategies/viewport/PreloadViewportStrategy --project=preload --skipTests -d +ng g service strategies/viewport/PrefetchRegistry --project=preload --skipTests -d +ng g service strategies/viewport/LinkHandler --project=preload --skipTests -d +ng g directive strategies/viewport/Link --project=preload --module=preload-viewport --skipTests -d + # generate components for `toolbar` Module ng g lib toolbar --tags=private-module --unit-test-runner=jest -d ng g component toolbar --project=toolbar --flat -d diff --git a/angular.json b/angular.json index 274b55ba5..d20f41f90 100644 --- a/angular.json +++ b/angular.json @@ -1299,6 +1299,36 @@ } } } + }, + "preload": { + "root": "libs/preload", + "sourceRoot": "libs/preload/src", + "projectType": "library", + "prefix": "ngx", + "architect": { + "build": { + "builder": "@angular-devkit/build-ng-packagr:build", + "options": { + "tsConfig": "libs/preload/tsconfig.lib.json", + "project": "libs/preload/ng-package.json" + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": ["libs/preload/tsconfig.lib.json", "libs/preload/tsconfig.spec.json"], + "exclude": ["**/node_modules/**"] + } + }, + "test": { + "builder": "@nrwl/builders:jest", + "options": { + "jestConfig": "libs/preload/jest.config.js", + "tsConfig": "libs/preload/tsconfig.spec.json", + "setupFile": "libs/preload/src/test-setup.ts" + } + } + } } }, "schematics": { diff --git a/apps/webapp/src/app/app.module.ts b/apps/webapp/src/app/app.module.ts index 77d70a392..f2692218d 100644 --- a/apps/webapp/src/app/app.module.ts +++ b/apps/webapp/src/app/app.module.ts @@ -9,7 +9,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { CoreModule } from '@ngx-starter-kit/core'; import { environment } from '@env/environment'; -import { PreloadSelectedModulesList } from './preloading-strategy'; +import { PreloadViewportModule, PreloadViewportStrategy, PreloadSelectedStrategy } from '@ngx-starter-kit/preload'; export class MyHammerConfig extends HammerGestureConfig { overrides = { @@ -23,6 +23,7 @@ export class MyHammerConfig extends HammerGestureConfig { imports: [ BrowserModule, BrowserAnimationsModule, + // PreloadViewportModule, RouterModule.forRoot( [ { path: '', redirectTo: 'home', pathMatch: 'full' }, @@ -40,7 +41,9 @@ export class MyHammerConfig extends HammerGestureConfig { { scrollPositionRestoration: 'enabled', anchorScrolling: 'enabled', - preloadingStrategy: PreloadAllModules, // TODO: PreloadSelectedModulesList + preloadingStrategy: PreloadSelectedStrategy, + // preloadingStrategy: PreloadViewportStrategy, + // preloadingStrategy: PreloadAllModules, paramsInheritanceStrategy: 'always', // enableTracing: true, // enable to debug routing during development // onSameUrlNavigation: 'reload' @@ -50,7 +53,6 @@ export class MyHammerConfig extends HammerGestureConfig { CoreModule, // IMP: Please keep CoreModule after RouterModule ], providers: [ - PreloadSelectedModulesList, { provide: HAMMER_GESTURE_CONFIG, useClass: MyHammerConfig, diff --git a/apps/webapp/src/app/preloading-strategy.ts b/apps/webapp/src/app/preloading-strategy.ts deleted file mode 100644 index 02f50544f..000000000 --- a/apps/webapp/src/app/preloading-strategy.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { RouterModule, Routes, Route, PreloadingStrategy } from '@angular/router'; -import { Observable, of } from 'rxjs'; - -export class PreloadSelectedModulesList implements PreloadingStrategy { - preload(route: Route, load: Function): Observable { - return route.data && route.data.preload ? load() : of(null); - } -} diff --git a/libs/preload/README.md b/libs/preload/README.md new file mode 100644 index 000000000..b74a79b92 --- /dev/null +++ b/libs/preload/README.md @@ -0,0 +1,29 @@ +# Preload + +Preload lazy-module strategies + +* PreloadSelectedStrategy - Deterministic pre-fetching based on `preload` value in Route Data. +* PreloadViewportStrategy - Speculative pre-fetching based on links in `Viewport` +* PredictivePreloadStrategy - Predictive pre-fetching based google analytics. Use `Guess.JS` + +> `PreloadViewportStrategy` same as @mgechev [ngx-quicklink](https://github.com/mgechev/ngx-quicklink) + +### Usecase + +- feature-1: load with main core bundle +- feature-2: preload in background to be ready to use when user navigates to feature-2 +- feature-3: only lazy load if the user navigates to feature-3 + +### Publish +```bash +# build +ng build preload +# replace your npm key +export NPM_TOKEN="00000000-0000-0000-0000-000000000000" +# publish +npm publish dist/libs/preload --access public +``` + +### TODO +* make `PreloadViewportStrategy` customizable. + diff --git a/libs/preload/jest.config.js b/libs/preload/jest.config.js new file mode 100644 index 000000000..b188dee96 --- /dev/null +++ b/libs/preload/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + name: 'preload', + preset: '../../jest.config.js', + coverageDirectory: '../../coverage/libs/preload', +}; diff --git a/libs/preload/ng-package.json b/libs/preload/ng-package.json new file mode 100644 index 000000000..d05c676cb --- /dev/null +++ b/libs/preload/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/libs/preload", + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/preload/package.json b/libs/preload/package.json new file mode 100644 index 000000000..7ba3c1632 --- /dev/null +++ b/libs/preload/package.json @@ -0,0 +1,8 @@ +{ + "name": "@ngx-starter-kit/preload", + "version": "0.0.1", + "peerDependencies": { + "@angular/common": "^7.2.0", + "@angular/core": "^7.2.0" + } +} diff --git a/libs/preload/src/index.ts b/libs/preload/src/index.ts new file mode 100644 index 000000000..69a62a06b --- /dev/null +++ b/libs/preload/src/index.ts @@ -0,0 +1,3 @@ +export * from './lib/strategies/viewport/preload-viewport.module'; +export * from './lib/strategies/viewport/preload-viewport-strategy.service'; +export * from './lib/strategies/selected/preload-selected-strategy.service'; diff --git a/libs/preload/src/lib/strategies/selected/preload-selected-strategy.service.ts b/libs/preload/src/lib/strategies/selected/preload-selected-strategy.service.ts new file mode 100644 index 000000000..0a08a640a --- /dev/null +++ b/libs/preload/src/lib/strategies/selected/preload-selected-strategy.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@angular/core'; +import { PreloadingStrategy, Route } from '@angular/router'; +import { Observable, of } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class PreloadSelectedStrategy implements PreloadingStrategy { + preload(route: Route, load: Function): Observable { + return route.data && route.data.preload ? load() : of(null); + } +} diff --git a/libs/preload/src/lib/strategies/viewport/link-handler.service.ts b/libs/preload/src/lib/strategies/viewport/link-handler.service.ts new file mode 100644 index 000000000..9281273af --- /dev/null +++ b/libs/preload/src/lib/strategies/viewport/link-handler.service.ts @@ -0,0 +1,84 @@ +import { Injectable } from '@angular/core'; +import { LinkDirective } from './link.directive'; +import { RouterPreloader } from '@angular/router'; +import { PrefetchRegistryService } from './prefetch-registry.service'; + +type RequestIdleCallbackHandle = any; +interface RequestIdleCallbackOptions { + timeout: number; +} +interface RequestIdleCallbackDeadline { + readonly didTimeout: boolean; + timeRemaining: () => number; +} + +declare global { + interface Window { + requestIdleCallback: ( + callback: (deadline: RequestIdleCallbackDeadline) => void, + opts?: RequestIdleCallbackOptions, + ) => RequestIdleCallbackHandle; + cancelIdleCallback: (handle: RequestIdleCallbackHandle) => void; + } +} + +const requestIdleCallback = + window.requestIdleCallback || + function(cb: Function) { + const start = Date.now(); + return setTimeout(function() { + cb({ + didTimeout: false, + timeRemaining: function() { + return Math.max(0, 50 - (Date.now() - start)); + }, + }); + }, 1); + }; + +const cancelIdleCallback = window.cancelIdleCallback || clearTimeout; + +@Injectable({ + providedIn: 'root', +}) +export class LinkHandlerService { + private registerIdle: any; + private unregisterIdle: any; + private registerBuffer: Element[] = []; + private unregisterBuffer: Element[] = []; + private elementLink = new Map(); + private observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const link = entry.target as HTMLAnchorElement; + this.queue.add(this.elementLink.get(link).urlTree); + this.observer.unobserve(link); + requestIdleCallback(() => { + this.loader.preload().subscribe(() => void 0); + }); + } + }); + }); + + constructor(private loader: RouterPreloader, private queue: PrefetchRegistryService) {} + + register(el: LinkDirective) { + this.elementLink.set(el.element, el); + cancelIdleCallback(this.registerIdle); + this.registerBuffer.push(el.element); + this.registerIdle = requestIdleCallback(() => { + this.registerBuffer.forEach(e => this.observer.observe(e)); + this.registerBuffer = []; + }); + } + + unregister(el: LinkDirective) { + this.elementLink.delete(el.element); + cancelIdleCallback(this.unregisterIdle); + this.unregisterBuffer.push(el.element); + this.unregisterIdle = window.requestIdleCallback(() => { + this.unregisterBuffer.forEach(e => this.observer.unobserve(e)); + this.unregisterBuffer = []; + }); + } +} diff --git a/libs/preload/src/lib/strategies/viewport/link.directive.ts b/libs/preload/src/lib/strategies/viewport/link.directive.ts new file mode 100644 index 000000000..7204e9c56 --- /dev/null +++ b/libs/preload/src/lib/strategies/viewport/link.directive.ts @@ -0,0 +1,35 @@ +import { Directive, ElementRef, OnDestroy, OnInit, Optional } from '@angular/core'; +import { RouterLink, RouterLinkWithHref } from '@angular/router'; +import { LinkHandlerService } from './link-handler.service'; + +@Directive({ + selector: '[routerLink]', +}) +export class LinkDirective implements OnInit, OnDestroy { + private routerLink: RouterLink | RouterLinkWithHref; + + constructor( + private linkHandler: LinkHandlerService, + private el: ElementRef, + @Optional() link: RouterLink, + @Optional() linkWithHref: RouterLinkWithHref, + ) { + this.routerLink = link || linkWithHref; + } + + ngOnInit() { + this.linkHandler.register(this); + } + + ngOnDestroy() { + this.linkHandler.unregister(this); + } + + get element(): Element { + return this.el.nativeElement; + } + + get urlTree() { + return this.routerLink.urlTree; + } +} diff --git a/libs/preload/src/lib/strategies/viewport/prefetch-registry.service.ts b/libs/preload/src/lib/strategies/viewport/prefetch-registry.service.ts new file mode 100644 index 000000000..ae314ede9 --- /dev/null +++ b/libs/preload/src/lib/strategies/viewport/prefetch-registry.service.ts @@ -0,0 +1,85 @@ +import { Params, PRIMARY_OUTLET, Router, UrlSegment, UrlSegmentGroup, UrlTree } from '@angular/router'; +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class PrefetchRegistryService { + private trees: UrlTree[] = []; + constructor(private router: Router) {} + + add(tree: UrlTree) { + this.trees.push(tree); + } + + shouldPrefetch(url: string) { + const tree = this.router.parseUrl(url); + return this.trees.some(child => containsTree(child, tree)); + } +} + +function containsQueryParams(container: Params, containee: Params): boolean { + // TODO: This does not handle array params correctly. + return ( + Object.keys(containee).length <= Object.keys(container).length && + Object.keys(containee).every(key => containee[key] === container[key]) + ); +} + +function containsTree(container: UrlTree, containee: UrlTree): boolean { + return ( + containsQueryParams(container.queryParams, containee.queryParams) && + containsSegmentGroup(container.root, containee.root) + ); +} + +function containsSegmentGroup(container: UrlSegmentGroup, containee: UrlSegmentGroup): boolean { + return containsSegmentGroupHelper(container, containee, containee.segments); +} + +function containsSegmentGroupHelper( + container: UrlSegmentGroup, + containee: UrlSegmentGroup, + containeePaths: UrlSegment[], +): boolean { + if (container.segments.length > containeePaths.length) { + const current = container.segments.slice(0, containeePaths.length); + if (!equalPath(current, containeePaths)) { + return false; + } + if (containee.hasChildren()) { + return false; + } + return true; + } else if (container.segments.length === containeePaths.length) { + if (!equalPath(container.segments, containeePaths)) { + return false; + } + for (const c in containee.children) { + if (!container.children[c]) { + return false; + } + if (!containsSegmentGroup(container.children[c], containee.children[c])) { + return false; + } + } + return true; + } else { + const current = containeePaths.slice(0, container.segments.length); + const next = containeePaths.slice(container.segments.length); + if (!equalPath(container.segments, current)) { + return false; + } + if (!container.children[PRIMARY_OUTLET]) { + return false; + } + return containsSegmentGroupHelper(container.children[PRIMARY_OUTLET], containee, next); + } +} + +export function equalPath(as: UrlSegment[], bs: UrlSegment[]): boolean { + if (as.length !== bs.length) { + return false; + } + return as.every((a, i) => a.path === bs[i].path); +} diff --git a/libs/preload/src/lib/strategies/viewport/preload-viewport-strategy.service.ts b/libs/preload/src/lib/strategies/viewport/preload-viewport-strategy.service.ts new file mode 100644 index 000000000..51a4b30b5 --- /dev/null +++ b/libs/preload/src/lib/strategies/viewport/preload-viewport-strategy.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@angular/core'; +import { PreloadingStrategy, Route, Router } from '@angular/router'; +import { PrefetchRegistryService } from './prefetch-registry.service'; +import { EMPTY } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class PreloadViewportStrategy implements PreloadingStrategy { + constructor(private queue: PrefetchRegistryService, private router: Router) {} + + preload(route: Route, load: Function) { + const conn = (navigator as any).connection; + if (conn) { + // Don't prefetch if the user is on 2G. or if Save-Data is enabled.. + if ((conn.effectiveType || '').includes('2g') || conn.saveData) { + return EMPTY; + } + } + const fullPath = findPath(this.router.config, route); + console.log('fullPath', fullPath); + console.log('shouldPrefetch', this.queue.shouldPrefetch(fullPath)); + // TODO(mgechev): make sure it works for parameterized routes + if (this.queue.shouldPrefetch(fullPath)) { + return load(); + } + return EMPTY; + } +} + +const findPath = (config: Route[], route: Route): string => { + config = config.slice(); + const parent = new Map(); + while (config.length) { + const el = config.shift(); + if (el === route) { + break; + } + const current1 = (el as any)._loadedConfig; + if (!current1 || !current1.routes) { + continue; + } + current1.routes.forEach((r: Route) => { + parent.set(r, el); + config.push(r); + }); + } + const segments: string[] = []; + let current = route; + while (current) { + segments.unshift(current.path); + current = parent.get(current); + } + return '/' + segments.join('/'); +}; diff --git a/libs/preload/src/lib/strategies/viewport/preload-viewport.module.ts b/libs/preload/src/lib/strategies/viewport/preload-viewport.module.ts new file mode 100644 index 000000000..22474cba1 --- /dev/null +++ b/libs/preload/src/lib/strategies/viewport/preload-viewport.module.ts @@ -0,0 +1,8 @@ +import { NgModule } from '@angular/core'; +import { LinkDirective } from './link.directive'; + +@NgModule({ + declarations: [LinkDirective], + exports: [LinkDirective], +}) +export class PreloadViewportModule {} diff --git a/libs/preload/src/test-setup.ts b/libs/preload/src/test-setup.ts new file mode 100644 index 000000000..8d88704e8 --- /dev/null +++ b/libs/preload/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular'; diff --git a/libs/preload/tsconfig.json b/libs/preload/tsconfig.json new file mode 100644 index 000000000..e5decd5e2 --- /dev/null +++ b/libs/preload/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node", "jest"] + }, + "include": ["**/*.ts"] +} diff --git a/libs/preload/tsconfig.lib.json b/libs/preload/tsconfig.lib.json new file mode 100644 index 000000000..51872d3d9 --- /dev/null +++ b/libs/preload/tsconfig.lib.json @@ -0,0 +1,26 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc/libs/preload", + "target": "es2015", + "module": "es2015", + "moduleResolution": "node", + "declaration": true, + "sourceMap": true, + "inlineSources": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "importHelpers": true, + "types": [], + "lib": ["dom", "es2018"] + }, + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "fullTemplateTypeCheck": true, + "strictInjectionParameters": true, + "enableResourceInlining": true + }, + "exclude": ["src/test.ts", "**/*.spec.ts"] +} diff --git a/libs/preload/tsconfig.spec.json b/libs/preload/tsconfig.spec.json new file mode 100644 index 000000000..3c0dcc6d5 --- /dev/null +++ b/libs/preload/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc/libs/preload", + "module": "commonjs", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": ["**/*.spec.ts", "**/*.d.ts"] +} diff --git a/libs/preload/tslint.json b/libs/preload/tslint.json new file mode 100644 index 000000000..f644aa972 --- /dev/null +++ b/libs/preload/tslint.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tslint.json", + "rules": { + "directive-selector": [true, "attribute", "ngx", "router", "camelCase"], + "component-selector": [true, "element", "ngx", "kebab-case"] + } +} diff --git a/nx.json b/nx.json index b0ffaea9e..f532ba0e5 100644 --- a/nx.json +++ b/nx.json @@ -132,6 +132,9 @@ }, "models": { "tags": ["utils"] + }, + "preload": { + "tags": ["public-module"] } } } diff --git a/tsconfig.json b/tsconfig.json index ea4e45d55..ca2e774b8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -52,7 +52,8 @@ "@ngx-starter-kit/clap": ["dist/libs/clap", "libs/clap/src/index.ts"], "@ngx-starter-kit/image-comparison": ["dist/libs/image-comparison", "libs/image-comparison/src/index.ts"], "@ngx-starter-kit/ngx-utils": ["dist/libs/ngx-utils", "libs/ngx-utils/src/index.ts"], - "@ngx-starter-kit/admin": ["libs/admin/src/index.ts"] + "@ngx-starter-kit/admin": ["libs/admin/src/index.ts"], + "@ngx-starter-kit/preload": ["libs/preload/src/index.ts"] } }, "exclude": ["node_modules", "tmp", "dist/**/*", "coverage/**/*", "todo/**/*", "tools/**/_*files/**/*"]