Skip to content

Commit

Permalink
feat(preload): added Preload strategies module
Browse files Browse the repository at this point in the history
  • Loading branch information
xmlking committed Jan 21, 2019
1 parent 04401e0 commit a8a22f7
Show file tree
Hide file tree
Showing 22 changed files with 431 additions and 19 deletions.
16 changes: 9 additions & 7 deletions PLAYBOOK.md
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions angular.json
Expand Up @@ -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": {
Expand Down
8 changes: 5 additions & 3 deletions apps/webapp/src/app/app.module.ts
Expand Up @@ -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 = <any>{
Expand All @@ -23,6 +23,7 @@ export class MyHammerConfig extends HammerGestureConfig {
imports: [
BrowserModule,
BrowserAnimationsModule,
// PreloadViewportModule,
RouterModule.forRoot(
[
{ path: '', redirectTo: 'home', pathMatch: 'full' },
Expand All @@ -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'
Expand All @@ -50,7 +53,6 @@ export class MyHammerConfig extends HammerGestureConfig {
CoreModule, // IMP: Please keep CoreModule after RouterModule
],
providers: [
PreloadSelectedModulesList,
{
provide: HAMMER_GESTURE_CONFIG,
useClass: MyHammerConfig,
Expand Down
8 changes: 0 additions & 8 deletions apps/webapp/src/app/preloading-strategy.ts

This file was deleted.

29 changes: 29 additions & 0 deletions 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.

5 changes: 5 additions & 0 deletions libs/preload/jest.config.js
@@ -0,0 +1,5 @@
module.exports = {
name: 'preload',
preset: '../../jest.config.js',
coverageDirectory: '../../coverage/libs/preload',
};
7 changes: 7 additions & 0 deletions 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"
}
}
8 changes: 8 additions & 0 deletions 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"
}
}
3 changes: 3 additions & 0 deletions 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';
@@ -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<any> {
return route.data && route.data.preload ? load() : of(null);
}
}
84 changes: 84 additions & 0 deletions 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<Element, LinkDirective>();
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 = [];
});
}
}
35 changes: 35 additions & 0 deletions 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;
}
}
@@ -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);
}

0 comments on commit a8a22f7

Please sign in to comment.