Skip to content
This repository has been archived by the owner on Oct 12, 2021. It is now read-only.

design: App Shell library #12

Open
jeffbcross opened this issue Apr 14, 2016 · 5 comments
Open

design: App Shell library #12

jeffbcross opened this issue Apr 14, 2016 · 5 comments
Assignees

Comments

@jeffbcross
Copy link
Contributor

Scope

This issue is concerned with a runtime library to instrument an Angular component to be used to generate an App Shell.

Goal

make it easy to turn any component into a component that can be compiled into an "App Shell", i.e. an html page with inlined html, JS, styles, and images, which can be rendered as soon as the document is served, without waiting for any additional resources to load from network or disk.

Problems to solve:

  • Child components and directives of the App Shell component's template that should not be pre-compiled into an app shell (either hidden completely, or replaced with another directive).
  • Inversely, child components and directives of the App Shell component's template that should only be included in the app shell.
  • Imperative JS logic within an App Shell that should not be run during pre-compilation
  • The ability for a runtime library to parse an app shell from a rendered page, such as when the document has been pre-rendered with specific content on the server, but the app shell should be cached without the content to serve from Service Worker on subsequent requests.

Design

For the sake of this doc, let's assume developers would like to take their existing "App" component and turn it into an App Shell. The developer might start by using one of the prerender build tools from universal: gulp, grunt, webpack. The developer would incorporate these tools into their build process, providing the component, its providers, and the base html document to the prerender tool, and would generate an index.html in their final output that would contain the inlined component and CSS, and whatever scripts the base html file already contained to load the rest of the application.

For simple app components, this would work out of the box without any problems. But most components depend on some third-party library, or some code which might not be designed to be run in a nodejs build context. Additionally, the developer probably doesn't want any child routes to be included in the App Shell.

Consider this component which depends on AngularFire, and has child routes.

@Component({
  styles: [`
    navBar {
      height: 40px;
      background: #999;
      color: #111;
    }
  `],
  template: `
    <navbar>
      My App
      <button *ngIf="!(af.auth | async)">Log In</button>
      <button *ngIf="af.auth | async">Log Out</button>
    </navbar>
    <router-outlet></router-outlet>`
})
@RouteConfig([
  {path: '/login', component: Login},
  {path: '/issues', useAsDefault: true, component: Issues}
])
export class AppComponent {
  constructor(public af:AngularFire) {
    af.auth.do(loginState => {
      if (!loginState) {
        router.navigate(['/issues']);
      }
    });
  }
}

There are things in the template that don't belong in an application-wide app shell:

  • A router outlet (the shell will be the same regardless of route)
  • The login button (app shell should have minimum interactivity)

And in the component constructor, I don't want to actually execute any logic to change the route, or to try to access AngularFire data which doesn't (yet) support running in nodejs.

Proposal

npm install @angular/app-shell

IS_PRERENDER Provider

This provider will be a simple boolean value to use with
imperative code inside of component's to modify behavior if
App is being pre-rendered.

import {IS_PRERENDER} from '@angular/app-shell';

@Component({...})
export class AppComponent {
  constructor(@Inject(IS_PRERENDER) isPrerender:boolean) {
    // Do nothing if pre-rendering
    if (isPrerender) return;
    // Otherwise, carry on
    // ...
  }
}

APP_SHELL_BUILD_PROVIDERS

This will be the default providers to include in the prerender configuration.
This sets the value of IS_PRERENDER to true when injected.

import {APP_SHELL_BUILD_PROVIDERS} from '@angular/app-shell';

prerender({
  providers: [APP_SHELL_BUILD_PROVIDERS]
})

APP_SHELL_RUNTIME_PROVIDERS

This will be the default providers to include when bootstrapping the app at runtime
configuration. This sets the value of IS_PRERENDER to false when injected.

Although it is technically possible to automatically infer the environment and use
a single export of default providers, i.e. APP_SHELL_PROVIDERS, being explicit is
better future-proofing for different platforms that might be supported, and better for testing.

It is also more intuitive, clearer, less verbose, and more confidence-instilling
to use this clearly-named provider group than to just provide the IS_PRERENDER token
and tell the user to bind it to a boolean value.

import {APP_SHELL_RUNTIME_PROVIDERS} from '@angular/app-shell';

bootstrap(AppComponent, [
  APP_SHELL_RUNTIME_PROVIDERS
]);

APP_SHELL_DIRECTIVES

Set of directives to prepare a component for pre-rendering, and to help with parsing
an app shell out of a fully-compiled application with unwanted superfluous content.

import {APP_SHELL_DIRECTIVES} from '@angular/app-shell';

@Component({
  template: `
    <navbar>
      <div class="login" *asNoRender>
        <button (click)="login()">Log In</button>
      </div>
    </navbar>
  `,
  directives: [APP_SHELL_DIRECTIVES]
})
export class AppComponent {
}

*asNoRender Directive

The *asNoRender directive is essentially the equivalent of applying an
*ngIf directive to an element like so: <span *ngIf="!isPrerender">No Prerender</span>.
Being available as a directive saves code of having to inject the IS_PRERENDER
constant, and attaching it to the component class. The directive also provides power
in how an app shell can be derived from a fully-rendered page at runtime, even
if the page includes content that doesn't belong in the app shell.

When this directive is used in a non-build-time context, such as in the browser
or pre-rendered at runtime with Angular Universal, this directive will cause HTML
comments to be placed at the beginning and end
of an element's contents, which can be easily parsed at runtime to throw away
non-app-shell content, so a pure app shell can be stored in the Service Worker
cache.

This is particularly useful when using server-side prerendering of components,
so no special app shell build step is necessary. The fully-rendered page can be
parsed and modified to be a general-purpose App Shell.

Example component that will be rendered at runtime by Angular Universal:

import {APP_SHELL_DIRECTIVES} from '@angular/app-shell';

@Component({
  template: `
    <navbar>
      International Developer News Worldwide
    </navbar>
    <news-list *asNoRender>
      <news-list-item *ngFor="item of newsItems">
        <h1>{{item.title}}</h1>
      </news-list-item>
    </news-list>
  `
})
export class NewsAppComponent {
}

The compiled component returned from Angular Universal would look something like:

<body>
  <news-app>
    <navbar>
      International Developer News Worldwide
    </navbar>
    <!-- asNoRenderBegin="id:0"-->
    <news-list *asNoRender>
      <news-list-item *ngFor="item of newsItems">
        <h1>Bay Area Housing Prices Soar!</h1>
      </news-list-item>
      <news-list-item *ngFor="item of newsItems">
        <h1>Bay Area Experiences Record-Breaking Great Weather!</h1>
      </news-list-item>
    </news-list>
    <!-- asNoRenderEnd="id:0"-->
  </news-app>
</body>

Which could easily be parsed by a runtime parser in the browser to store this app shell:

<body>
  <news-app>
    <navbar>
      International Developer News Worldwide
    </navbar>
  </news-app>
</body>

*asRender Directive

For elements that should be shown only be rendered at build time and not at runtime,
such as loading indicators or substitute elements, the *asRender directive
can be applied.

@Component({
  template: `
    <md-toolbar>
      <md-progress-circle *asRender mode="indeterminate"></md-progress-circle>
      <button *asNoRender (click)="sidenav.open()"><i class="material-icons">menu</i></button>
    </md-toolbar>
  `
})

Runtime Parser

The runtime parser will be a small JS library to run in a Service Worker, which will parse an
html page and return a version of the page that can be cached as an app shell to be returned
on future page requests. This is particularly useful when there is server-side pre-rendering
involved, such as Angular Universal.

interface ngShellParser {
  fetchDoc (url?:string): Promise<Response>;
  parseDoc (res:Response): Promise<Response>;
  match (definitions:RouteDefinition[], req: Request): Promise<Response>;
}

Example Service Worker Script:

// Imports global object: ngShellParser
importScripts('vendor/@angular/app-shell/runtime-parser.js');

var routeDefinitionPaths = [
  '/',
  '/issues/',
  '/issues/:id/',
  '/login'
];

self.addEventListener('install', function (event) {
  /**
   * fetchDoc() will fetch the current document, by examining
   * self.registration.scope
   **/
  event.waitUntil(() => ngShellParser.fetchDoc()
    // Optional step to strip unwanted *asNoRender content
    .then(res:Response => ngShellParser.parseDoc(res))
    // Add the final app shell to the cache
    .then(strippedResponse:Response => caches.open('ngAppShell')
      .then(cache => cache.put('/app-shell.html', strippedResponse)));
});

self.addEventListener('fetch', function(event) {
  event.respondWith(
    // Checks to see if any routes should match this request
    ngShellParser.match(routeDefinitionPaths, event.request)
      .then((response) => {
        // If response, then this page should receive an app shell
        if (response) return response;

        // Otherwise see if there's a cache entry for this request
        return caches.match(event.request)
          .then(function(response) {
            // Cache hit - return response
            if (response) {
              return response;
            }

            // Otherwise get from network
            return fetch(event.request);
          })
      });
  );
});
@jeffbcross
Copy link
Contributor Author

I know it's long, so feel free to skip to code samples, but I'd love comments from:
@addyosmani @mgechev @gdi2290 @jeffwhelpley @alxhub @robwormald

@jeffbcross jeffbcross self-assigned this Apr 14, 2016
@jeffwhelpley
Copy link

Very cool to see this project start to take some shape. I will think about this some more over the weekend, but my biggest initial thoughts center around *asNoRender. In short, I would like to expand the idea of *asNoRender to a more generic mechanism for configuring whether a component renders in a particular container/platform/context. At a high level, the ideal is to have a declarative way of specifying whether a component should or should not render in a particular container. With services, this is very easy since we can use DI and simply bootstrap different implementations for different environments. We can't use this approach with components, though, since they aren't injected. As a workaround, Patrick and I have started to use a hack with the package.json browser field, but this solution has a lot of limitations. A more ideal solution would be allow developers to specify either within templates (as you have here) or within component decorator config the target containers. I will think about what the API for this type of thing would be, but it would basically involve the following changes:

  • Instead of a IS_PRERENDER token, you would have a CONTAINER token that has a value of a string which is the name of the target container.
  • You could just do *ngIf in templates or perhaps some convenience decorators. In fact, I guess we could even still use *asNoRender but it would use the CONTAINER token under the scenes instead of IS_PRERENDER.
  • I think it is also really useful to expose a config value on the @Component decorator. Something like displayInContainers: ['browser', 'prerenderer', 'webserver']. We could also add convenience values like onlyBrowser: true.

@jeffposnick
Copy link

Apologies for the drive-by feedback, but I'm curious to hear how updating the previously cached App Shell is going to work. What's the process for changing the service worker script in order to trigger a new install event?

Having thought about similar questions when writing sw-precache, I ended up with an approach that required build-time integration and included the hashes of each individual resource inline within the generated service worker script, so that any changes to resources would in turn result in a change to the service worker script.

Since this is described as a runtime library, I'm assuming that same approach wouldn't be possible in what you're building.

jeffbcross added a commit to jeffbcross/mobile-toolkit that referenced this issue May 7, 2016
This adds a new library, published to @angular/app-shell, which
provides providers and directives to make it easier to show and
hide components based on whether or not the component is being
prerendered.

Paired with @mgechev

Part of angular#12
jeffbcross added a commit to jeffbcross/mobile-toolkit that referenced this issue May 7, 2016
This adds a new library, published to @angular/app-shell, which
provides providers and directives to make it easier to show and
hide components based on whether or not the component is being
prerendered.

Paired with @mgechev

Part of angular#12
@mgechev
Copy link
Member

mgechev commented Jun 14, 2016

@jeffbcross the App Shell library is already in master, can we close this?

@addyosmani
Copy link

addyosmani commented Oct 7, 2016

Imo it's worth getting this one closed. We've talked about the design here in person through some of our syncs while Jeff was thinking them through and last I tested app-shell support was in a decent place. cc @jeffbcross

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests

5 participants