© Majid Hajian 2019
Majid HajianProgressive Web Apps with Angularhttps://doi.org/10.1007/978-1-4842-4448-7_9

9. Resilient Angular App and Offline Browsing

Majid Hajian1 
(1)
Oslo, Norway
 

One important aspect of PWAs is the concept of building an app that can be served offline. Up until this point of the book, we have made an application and enabled offline capabilities. We have seen the power of Service Worker, which has done most of the heavy lifting when it comes to storing static assets and dynamic content by leveraging Cache API. All in all, the achievement is significant compared to a traditional web application.

However, there is still room for improvement. Let’s imagine that you are building an application that communicates through REST API. Although Service Worker is facilitating to cache content and serve faster, it doesn’t help with a poor internet connection as soon as the network first strategy must be applied, and the respond and request have a long latency. Or what should we do with the application state or the app data set?

In PWA Note app, users’ experience is most likely disrupted because we keep them waiting until sending a message to the server is successfully completed if they have a poor internet connection. in fact, a delay of more than 10 seconds will often make users leave a site instantly. Slowness and lack of acceptable user experience could abruptly affect your business if it relies on the app.

In this chapter, I am going to explorer an approach that provides a consistent user experience whether the users’ devices have no connectively, limited connectivity, or great connectively. This model reduces latency down to zero as it provides access to content stored directly on the device and synchronizes data in all users’ devices over HTTP.

Offline Storage

Before HTML5, application data had to be stored in cookies, included in every server request while it was limited up to 4 KB. Web Storage is not only more secure but also capable of storing large amounts of data locally without affecting website performance. It is per origin and all pages, from the same origin, can store and access the same data. The two mechanisms within web storages are as follows:
  • sessionStorage maintains a separate storage area for each given origin that’s available for the duration of the page session (as long as the browser is open, including page reloads and restores).

  • localStorage does the same thing but persists even when the browser is closed and reopened.

There are two downsides to this API:
  1. 1.

    You need to serialize and deserialize data when you want to store (only strings).

     
  2. 2.

    API is synchronous, which means it blocks the application and has no Web Worker support.

     
Due to these issues, we shift our focus to other options in order to achieve better performance and support in Web Worker.
  • WebSQL is asynchronous (callback-based); however, it also has no Web Worker support and was rejected by Firefox and Edge but is in Chrome and Safari. It’s also depreciated.

  • File System API is asynchronous too (callback-based) and does work in Web Workers and Windows (albeit with a synchronous API). Unfortunately, it doesn’t have much interest outside of Chrome and is sandboxed (meaning you don’t get native file access).

  • File API is being improved over in the File and Directory Entries API and File API specs. A File API library exists and for file saving, I’ve been using FileSaver.js as a stopgap. The writable-files proposal may eventually give us a better standards-track solution for seamless, local file interaction.

  • IndexedDB is a key-value pair NoSQL database and supports large scale storage (up to 20%–50% of hard drive capacity) and supports many data types like number, string, JSON, blob, and so on. As it is asynchronous, it can be used everywhere including Web Workers and is widely supported in the browsers.

  • Cache API provides a storage mechanism for Request / Response object pairs that are cached, for example, as part of the Service Worker life cycle. Note that the Cache interface is exposed to window scopes as well as workers.

As we have seen, it seems the best options are IndexedDB1 and Cache API. A combination of both APIs makes it much more reliable and provides a better user experience. We have used Cache API to store URL addressable resources such as static files and request and respond from REST APIs. There are no hard rules how to use and architect your application to leverage these APIs. Some applications might be sufficiently simple that they can just use the Cache API alone, while others may find it valuable to partially cache their JSON payloads in IDB so that in browsers without Cache API support, you still get the benefit of some local caching during the session.

Note

IndexedDB API is powerful but may seem too complicated for simple cases. I recommend trying libraries such as LocalForage, Dexie.js, zangoDB, PouchDB, LoxiJs, JsStore, IDB, LokiJs that help to wrap IndexedDB APIs, which make it more programmer friendly. Also, this API was buggy and slow in Safari 10; therefore some of these libraries implemented a fall back to WebSQL in Safari as opposed to indexedDB to gain a better performance. Although this issue was resolved and IndexedDB is stable in all major browsers, if your apps target older browsers for some certain reasons, you may need to use suggested libraries: for example, Localforage

Even though there is no specific architecture, it’s recommended that
  • For the network resources necessary to load your app while offline, use the Cache.

  • For all other data, use IndexedDB, for instance, application state and data set are the best candidates to be stored in IndexedDB.

Offline First Approach

A common way to build a web application is to be a consumer of a back-end server to store and retrieve data for persistency (Figure 9-1).
../images/470914_1_En_9_Chapter/470914_1_En_9_Fig1_HTML.png
Figure 9-1

Data-binding ways in traditional web applications

One issue with this approach is that a flaky or nonexistent internet connection may interrupt the user experience and lead to unreliable performance. To fix that, we have used Service Worker and will leverage other storage techniques to substantially improve the user experience in all situations, including a perfect wireless environment.

In this approach (shown in Figure 9-2), the user interacts with the cache constantly where it’s stored in the client device; therefore, there will be zero latency.
../images/470914_1_En_9_Chapter/470914_1_En_9_Fig2_HTML.png
Figure 9-2

Offline first approach, 4-way data binding

Service Worker can intercept request between the client and server if needed. We can even think of how to synchronize our data with the server.

Note

Thanks to Background Sync event in Service Worker, it’s easily possible to resolve synchronization. I will explore the sync event in Chapter 14 when we are implementing Workbox because this feature is not available in Angular Service Worker as of now (Angular 7.1).

I am going to step forward and tweak this model a bit more. What if we can implement a logic that can sync data from and to a server whether the user is online or offline; and therefore, that server can manipulate data and do necessary adjustments afterward (see Figures 9-3 and 9-4). Think how much this approach can improve a user’s experience.
../images/470914_1_En_9_Chapter/470914_1_En_9_Fig3_HTML.png
Figure 9-3

Offline first approach with syncing in mind

../images/470914_1_En_9_Chapter/470914_1_En_9_Fig4_HTML.png
Figure 9-4

Data can be distributed and synchronized through all user’s devices from/to sync server

Let’s experiment with the offline first database approach in PWA Note application and see how it works in action.

Implement Offline First Approach with Sync Server

We have figured out that IndexedDB is what we need to use in a client app. The next hurdle is figuring out how to store and sync the app’s data and state. Offline syncing is a bit more challenging than it looks. I believe one of the best solutions to overcome this obstacle is to use PouchDB .2 Keep in mind, you are not limited to this solution, and you may need to either implement your own logic for your application or use another third party.3 All in all, the goal is to implement offline first cache for storing data and sync back to the server accordingly.

Note

PouchDB is an open source JavaScript database inspired by Apache CouchDB4 that is designed to run well within the browser. PouchDB was created to help web developers build applications that work as well offline as they do online. It enables applications to store data locally while offline, then synchronize it with CouchDB and compatible servers when the application is back online, keeping the user’s data in sync no matter where they next log in.

You can use PouchDB without the sync feature too, but for the sake of offline capability, I enable the sync and offline features in PouchDB.

First, we need to install pouchdb:
npm install pouchdb

The pouchdb-browser preset contains the version of PouchDB that is designed for the browser. In particular, it ships with the IndexedDB and WebSQL adapters as its default adapters. It also contains the replication, HTTP, and map/reduce plugins. Use this preset if you only want to use PouchDB in the browser, and don’t want to use it in Node.js. (e.g., to avoid installing LevelDB.)

Therefore, instead of pouchdb, I install pouchdb-browser alternately:
npm install pouchdb-browser
Carry on and create a new service in Angular by running:
ng g s modules/core/offline-db
To create a remote sync database server, for simplicity, I install pouchdb-server.5
npm install -g pouchdb-server
Run the PouchDB server:
pouchdb-server --port 5984

If you clone the project repository and want to see the example codes, first install npm packages and then npm run pouchdb-server

In OfflineDbService, we need to instantiate PouchDB. To sync, the simplest case is unidirectional replication, meaning you just want one database to mirror its changes to a second one. Writes to the second database, however, will not propagate back to the master database; however, we need bidirectional replication to make things easier for your poor, tired fingers; PouchDB has a shortcut API.
import PouchDB from 'pouchdb-browser';
  constructor() {
// create new local database
    this._DB = new PouchDB(this.DB_NAME);
// shortcut API for bidirectional replication
    this._DB.sync(this.REMOTE_DB, {
      live: true,
      retry: true
    });
  }

Note

If you see an error due to undefined global object in console, please add (window as any).global = window; at the bottom of Polyfills.ts.6

Database has been instantiated successfully; therefore, CRUD operations need to be implemented.
public get(id: string) {
    return this._DB.get(id);
  }
  public async delete(id) {
    const doc = await this.get(id);
    const deleteResult = this._DB.remove(doc);
    return deleteResult;
  }
  public add(note: any) {
    return this._DB.post({
      ...note,
      created_at: this.timestamp,
      updated_at: this.timestamp
    });
  }
  public async edit(document: any) {
    const result = await this.get(document._id);
    document._rev = result._rev;
    return this._DB.put({
      ...document,
      updated_at: this.timestamp
    });
  }
To retrieve all notes from the database, I define another function getAll where I will call this method on loading application to show notes to my user.
public async getAll(page?: number) {
    const doc = await this._DB.allDocs({
      include_docs: true,
      limit: 40,
      skip: page || 0
    });
    this._allDocs = doc.rows.map(row => row.doc);
    // Handle database change on documents
    this.listenToDBChange();
    return this._allDocs;
  }
PouchDB provides a changes() method that is an event emitter and will emit a 'change' event on each document change, a 'complete' event when all the changes have been processed, and an 'error' event when an error occurs. Calling cancel() will automatically unsubscribe all event listeners.
  listenToDBChange() {
    if (this.listener) {
      return;
    }
    this.listener = this._DB
      .changes({ live: true, since: 'now', include_docs: true })
      .on('change', change => {
        this.onDBChange(change);
      });
  }
From now on, we have a listener that can detect each document change and manipulate the data accordingly. For instance, in onDBChange method in the OfflineDbService, I have implemented a very simple logic to detect what types of change have happened to the document and run a logic based on that.
private onDBChange(change) {
    this.ngZone.run(() => {
      const index = this._allDocs.findIndex(row => row._id === change.id);
      if (change.deleted) {
        this._allDocs.splice(index, 1);
        return;
      }
      if (index > -1) {
        // doc is updated
        this._allDocs[index] = change.doc;
      } else {
        // new doc
        this._allDocs.unshift(change.doc);
      }
    });
  }
Altogether, the OfflineDBServer looks like the following:
export class OfflineDbService {
  private readonly LOCAL_DB_NAME = 'apress_pwa_note';
  private readonly DB_NAME = `${this.LOCAL_DB_NAME}__${this.auth.id}`;
  private readonly REMOTE_DB = `http://localhost:5984/${this.DB_NAME}`;
  private _DB: PouchDB.Database;
  private listener = null;
  private _allDocs: any[];
  get timestamp() {
    return;
  }
  constructor(private auth: AuthService, private ngZone: NgZone) {
    this._DB = new PouchDB(this.DB_NAME);
    this._DB.sync(this.REMOTE_DB, {
      live: true,
      retry: true
    });
  }
  listenToDBChange() {
    if (this.listener) {
      return;
    }
    this.listener = this._DB
      .changes({ live: true, since: 'now', include_docs: true })
      .on('change', change => {
        this.onDBChange(change);
      });
  }
  private onDBChange(change) {
    console.log('>>>>>> DBChange', change);
    this.ngZone.run(() => {
      const index = this._allDocs.findIndex(row => row._id === change.id);
      if (change.deleted) {
        this._allDocs.splice(index, 1);
        return;
      }
      if (index > -1) {
        // doc is updated
        this._allDocs[index] = change.doc;
      } else {
        // new doc
        this._allDocs.unshift(change.doc);
      }
    });
  }
  public async getAll(page?: number) {
    const doc = await this._DB.allDocs({
      include_docs: true,
      limit: 40,
      skip: page || 0
    });
    this._allDocs = doc.rows.map(row => row.doc);
    // Handle database change on documents
    this.listenToDBChange();
    return this._allDocs;
  }
  public get(id: string) {
    return this._DB.get(id);
  }
  public async delete(id) {
    const doc = await this.get(id);
    const deleteResult = this._DB.remove(doc);
    return deleteResult;
  }
  public add(note: any) {
    return this._DB.post({
      ...note,
      created_at: this.timestamp,
      updated_at: this.timestamp
    });
  }
  public async edit(document: any) {
    const result = await this.get(document._id);
    document._rev = result._rev;
    return this._DB.put({
      ...document,
      updated_at: this.timestamp
    });
  }
}
Now I need to change all components and replace DataService with OfflineDbService. To begin, NotesListComponent :
constructor(
    private offlineDB: OfflineDbService,
  ) {}
  ngOnInit() {
// here is we call getAll() and consequesntly subscribe to change listerner
    this.offlineDB.getAll().then(allDoc => {
      this.notes = allDoc;
    });
  }
onSaveNote() on NotesAddComponent is updated to
constructor(
    private router: Router,
    private offlineDB: OfflineDbService,
    private snackBar: SnackBarService
  ) {}
  onSaveNote(values) {
    this.loading$.next(true);
// Notice we add everything to local DB
    this.offlineDB.add(values).then(
      doc => {
        this.router.navigate(['/notes']);
        this.snackBar.open(`LOCAL: ${doc.id} has been succeffully saved`);
        this.loading$.next(false);
      },
      e => {
        this.loading$.next(false);
        this.errorMessages$.next('something is wrong when adding to DB');
      }
    );
  }
And here is the same change to NoteDetailsComponent where we have Edit, Get, Delete operations.
constructor(
    private offlineDB: OfflineDbService,
    private route: ActivatedRoute,
    private snackBar: SnackBarService,
    private router: Router
  ) {}
  ngOnInit() {
    const id = this.route.snapshot.paramMap.get('id');
    this.id = id;
    this.getNote(id);
  }
  getNote(id) {
// get note from offline DB
    this.offlineDB.get(id).then(note => {
      this.note = note;
    });
  }
  delete() {
    if (confirm('Are you sure?')) {
// delete note from offline DB
      this.offlineDB
        .delete(this.id)
        .then(() => {
          this.router.navigate(['/notes']);
          this.snackBar.open(`${this.id} successfully was deleted`);
        })
        .catch(e => {
          this.snackBar.open('Unable to delete this note');
        });
    }
  }
  edit() {
    this.isEdit = !this.isEdit;
  }
  saveNote(values) {
// edit in offline DB
    this.offlineDB
      .edit(values)
      .then(() => {
        this.getNote(values._id);
        this.snackBar.open('Successfully done');
        this.edit();
      })
      .catch(e => {
        this.snackBar.open('Unable to edit this note');
        this.edit();
      });
  }
It’s time to test the application, and we don’t necessarily need Service Worker; therefore, we can simply run my application in development mode locally. So, run npm start and then navigate to localhost:4200 to see the application. Try to add a new note and observe the console messages (see Figure 9-5).
../images/470914_1_En_9_Chapter/470914_1_En_9_Fig5_HTML.jpg
Figure 9-5

Change object is emitted for each change on database

As you have been shown in Figure 9-5, each document has an _id and _rev property that is being added automatically. The change object contains all the necessary information that we can use in our app logic to manipulate the data.

Note

The rev field in the response indicates a revision of the document. Each document has a field by the name _rev. Every time a document is updated, the _rev field of the document is changed. Each revision points to its previous revision. PouchDB maintains a history of each document (much like git). _rev allows PouchDB and CouchDB to elegantly handle conflicts, among its other benefits.

Open two different browsers on your computer, for instance, Chrome and Firefox and open the app on each. First, you’ll notice that you will have the exact same notes on both browsers. Now add a new note in one browser, and check the other one (see Figure 9-6); you’ll notice the new note will appear quickly in another browser where the app is open.
../images/470914_1_En_9_Chapter/470914_1_En_9_Fig6_HTML.jpg
Figure 9-6

The App is running in two different browsers (devices), and by adding a note from one, as soon as it’s added to sync server, a change will get emitted and immediately the note will appear in another browser (device)

So far so good; you’ll notice that there will be zero latency to show or add a note, since the content is going to be added to the cache first and then will be synced back with the server. Therefore, our user will not notice the latency between the cache and server.

What if our user goes offline? Let’s test it out. We’ll disconnect the network by checking offline in Chrome and then will try to delete a note from Safari where it’s online still and add a note from Chrome browser, which is offline (see Figures 9-7 and 9-8).

Note

PouchDB has two types of data: documents and attachments.

Documents

As in CouchDB, the documents you store must be serializable as JSON.

Attachments

PouchDB also supports attachments, which are the most efficient way to store binary data. Attachments may either be supplied as base64-encoded strings or as Blob objects.

../images/470914_1_En_9_Chapter/470914_1_En_9_Fig7_HTML.jpg
Figure 9-7

Deleting one note from another browser that is online will reflect on the remote database, but since another browser is offline, it will not receive the update

../images/470914_1_En_9_Chapter/470914_1_En_9_Fig8_HTML.jpg
Figure 9-8

Add a note in a browser (device) even when the user is offline. App allows the user to add this note; however, it does not reflect on the remote database until the user comes back online.

Once I am done, I will make the Chrome network online again and will wait a bit. You’ll see after a few seconds that the app in both browsers will be synced successfully (see Figure 9-9).
../images/470914_1_En_9_Chapter/470914_1_En_9_Fig9_HTML.jpg
Figure 9-9

App in both browsers (devices) is synced when user comes back online

There has been no interruption in the user experience, and there has been fast performance and reliable data and synchronization – isn't it amazing?

As said, PouchDB is one way to the implement offline first approach. Depending on your application and requirements, you may use different libraries or even your own implementation where you use IndexedDB APIs directly.

Implement Persistent Data with Angular Firebase

Cloud Firestore supports offline data persistence. This feature caches a copy of the Cloud Firestore data that your app is actively using, so your app can access the data when the device is offline. You can write, read, listen to, and query the cached data. When the device comes back online, Cloud Firestore synchronizes any local changes made by your app to the data stored remotely in Cloud Firestore.
Offline persistence is an experimental feature that is supported only by the Chrome, Safari, and Firefox web browsers.
To enable offline persistence, enablePersistence() must be called while importing AngularFirestoreModule into your @NgModule:
@NgModule({
  declarations: [AppComponent, LoadingComponent],
  imports: [
    CoreModule,
    LayoutModule,
    BrowserModule.withServerTransition({ appId: 'serverApp' }),
    HttpClientModule,
    AppRoutingModule,
    AngularFireModule.initializeApp(environment.firebase),
    AngularFirestoreModule.enablePersistence(),
    // AngularFirestoreModule, // needed for database features
    AngularFireAuthModule, // needed for auth features,
    BrowserAnimationsModule, // needed for animation
    ServiceWorkerModule.register('ngsw-worker.js', {
      enabled: environment.production
    }),
    RouterModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule {}
If a user opens multiple browser tabs that point to the same Cloud Firestore database, and offline persistence is enabled, Cloud Firestore will work correctly only in the first tab. However, As of September 2018, experimental multi-tab is available for you to play with. You just need to pass {experimentalTabSynchronization: true} to enbalePersistence() function such as:
AngularFirestoreModule.enablePersistence({experimentalTabSynchronization: true})

Next, we need to make sure we are using Angular Firestore APIs.

For instance, in NotesListComponent, use getNotes() method instead of initializedNotes()
ngOnInit() {
        this.notes$ = this.db.getNotes();
            // this.notes$ = this.db.initializeNotes();
}
In NoteDetailsComponent, use getNote() method instead of getNoteFromDirectApi():
  ngOnInit() {
    const id = this.route.snapshot.paramMap.get('id');
    this.id = id;
    this.note$ = this.data.getNote(id);
    // this.note$ = this.data.getNoteFromDirectApi(id);
  }
And in NotesAddComponent, call the addNote() method on DataService.
onSaveNote(values) {
    this.data.addNote(values).then(
      doc => {
        this.snackBar.open(`LOCAL: ${doc.id} has been succeffully saved`);
      },
      e => {
        this.errorMessages$.next('something is wrong when adding to DB');
      }
    );
    this.router.navigate(['/notes']);
  }

Run the application and disconnect from the network. You can add a note even though you are offline; and as soon as you come back online, the data will sync back to Firestore.

We can go ahead and deploy the app to Firebase by running:
npm run deploy

User Interface Considerations

Imagine that our application works even when users are offline. Users will continue adding content and modify more and more. Users usually do not notice that the data is not synced due to slowness or no internet connection. In this case, there are several UI considerations that can be done in the app to show some signals to the users as to whether they are offline or online:
  1. 1.

    Change the header and footer color to some other colors that indicate they are offline; for instance, in the Note app, we can gray out the blue header when the user is offline.

     
  2. 2.

    Show a notification or a popup when the user is offline; for instance, when the user is adding a note in Note PWA app, we can show a message that you are offline, but we will sync back data to server as soon as you are online.

     
  3. 3.

    Display an icon or other indication that clearly shows even though a note has been added, it’s not synced with the server yet and only exists on the user local device.

     
  4. 4.

    Resolve conflicts based on user decision; for instance, a user may edit a note in different devices at once when all devices are offline, and when all devices come online again, there might be conflicts between each revision. In this case, it’s a good practice to show our user a notification and tell them that there are different revisions based on their edit; therefore, they can select which update is the one that needs to be applied.

     

These are just a few ideas. You may have better ideas based on your app. It is important to enhance the UIs along with adding more functionalities and features to boost the user experience.

Last but not least, by listening for a change event on navigator.connection, we can react to proper logic based on the change accordingly. As an example, take a look at the function below where we can find out more about the network information:
  constructor(
    private auth: AuthService,
    private swPush: SwPush,
    private snackBar: SnackBarService,
    private dataService: DataService,
    private router: Router
  ) {
    (<any>navigator).connection.addEventListener('change', this.onConnectionChange);
  }
  onConnectionChange() {
    const { downlink, effectiveType, type } = (<any>navigator).connection;
    console.log(`Effective network connection type: ${effectiveType}`);
    console.log(`Downlink Speed/bandwidth estimate: ${downlink}Mb/s`);
    console.log(
      `type of connection is ${type} but could be of bluetooth , cellular, ethernet, none, wifi, wimax, other, unknown`
    );
    if (/slow-2g|2g|3g/.test((<any>navigator).connection.effectiveType)) {
      this.snackBar.open(`You connection is slow!`);
    } else {
      this.snackBar.open(`Connection is fast!`);
    }
  }

As you see, you can write your own logic based on how the network information changes.

Note

If you want to see and run all the examples and codes on your machine, simply clone https://github.com/mhadaily/awesome-apress-pwa.git , then go to chapter09. For pouchdb implementation, you’ll find 01-pouchdb; go to folder and install all packages first by running npm install and then run both app and pouchdb-server by running npm start and npm run pouchdb-server respectively. For Firestore implementation, go to 02-firebase-presistent-db, run npm install and npm start respectively.

Summary

One main aspect of PWAs is to enhance the user experience. Providing an offline experience – whether with a flaky connection in transport or being offline in the airplane – is invaluable to boost user satisfaction and improve the app’s performance.

To support a meaningful experience in an offline case, not only we should cache our static assets and requests and responses, but also storing data on the client side seems essential. By rethinking how to architect an application in the front end and make it offline – first by leveraging browser offline storage, like IndexedDB, with one of the libraries available (PouchDB), the app has been moved to the next level.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.191.147.190