Docs

Installing and Using Offline (PWA)

Learn how to detect network status and how to create offline functionality in a Hilla application

Hilla applications are progressive web applications (PWAs) by default. This allows users to install the application on supported devices and browsers. PWAs give you control over network access and caching, which helps to build faster, more reliable applications, and even provide offline functionality.

In this chapter, you use PWA technologies enable read-only offline use. Users are able to continue to view contacts and stats while offline, but not to add or modify contacts, until they have a connection. It’s possible to build PWAs with full read-write offline functionality, but this tutorials only covers the simpler read-only case.

This chapter covers:

  • Hilla PWA configuration,

  • detecting network status,

  • caching content for offline use,

  • disabling functionality while offline,

  • installing the application.

Hilla PWA Configuration

A browser considers an application a PWA if it meets the following three criteria:

  1. It includes a web app manifest file that contains information about the application: name, icon, colors, URL.

  2. It has a registered ServiceWorker that handles fetch events and loads the application even when offline.

  3. The application is served over HTTPS or on localhost.

Hilla automates the creation of the web application manifest and ServiceWorker with the @PWA annotation. Add the @PWA annotation on Application.java as follows:

@PWA(name = "Hilla CRM", shortName = "CRM", offlineResources = {"images/logo.png"})
public class Application implements AppShellConfigurator {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

}

By default, Hilla caches all your front-end views for offline use. You can specify additional resources to cache with the offlineResources array.

You can simulate an offline situation using the browser developer tools. In Chrome, you can find the network throttling controls under the Network tab. Select the Offline option to simulate going offline.

You can simulate an offline connection through the network panel of Chrome DevTools

If you take the application offline now, you see a "Connection lost" notification, but you can continue to use the application. However, there are still two issues:

  1. Users can try to save changes or delete contacts, even though these operations are guaranteed to fail.

  2. While offline, the application has no data when you open it.

Reacting to Network Status Changes

To disable functionality while offline, you first need to track the network status in a store. Do this in UiStore.

Start by importing connection state helpers from Hilla.

import { ConnectionState, ConnectionStateStore } from '@vaadin/common-frontend';

Next, add an offline observable and helpers to track changes in the connection state:

offline = false;
connectionStateStore?: ConnectionStateStore;

connectionStateListener = () => {
  this.setOffline(
    this.connectionStateStore?.state === ConnectionState.CONNECTION_LOST
  );
};

setupOfflineListener() {
  const $wnd = window as any;
  if ($wnd.Vaadin?.connectionState) {
    this.connectionStateStore = $wnd.Vaadin
      .connectionState as ConnectionStateStore;
    this.connectionStateStore.addStateChangeListener(
      this.connectionStateListener
    );
    this.connectionStateListener();
  }
}

private setOffline(offline: boolean) {
  // Refresh from server when going online
  if (this.offline && !offline) {
    crmStore.initFromServer();
  }
  this.offline = offline;
}

The connectionStateListener sets the offline property to true whenever Hilla detects a lost connection. The setOffline() action also tells crmStore to refresh its data whenever the application is back online.

The setupOfflineListener() method registers the listener with the Hilla ConnectionStateStore and calls the listener once to initialize the value on startup.

Lastly, update the constructor to avoid creating actions for the listener and setup methods and to call the setup method on start.

constructor() {
  makeAutoObservable(
    this,
    {
      connectionStateListener: false,
      connectionStateStore: false,
      setupOfflineListener: false,
    },
    { autoBind: true }
  );
  this.setupOfflineListener();
}

Disabling Functionality While Offline

Now that you have an offline observable, you can use it to disable and hide features while the application is offline. Here is what you need to do:

  • Disable the form inputs and the Save and Delete buttons.

  • Hide the logout link.

  • Disable the login form.

Begin by updating the form. Add uiStore to the existing crmStore import statement.

import { crmStore, uiStore } from 'Frontend/stores/app-store';

Next, use the offline state to disable components when the application is offline:

render() {
  const { model } = this.binder;

  return html`
    <vaadin-text-field
      label="First name"
      ?disabled=${uiStore.offline}
      ${field(model.firstName)}
    ></vaadin-text-field>
    <vaadin-text-field
      label="Last name"
      ?disabled=${uiStore.offline}
      ${field(model.lastName)}
    ></vaadin-text-field>
    <vaadin-text-field
      label="Email"
      ?disabled=${uiStore.offline}
      ${field(model.email)}
    ></vaadin-text-field>
    <vaadin-combo-box
      label="Status"
      ?disabled=${uiStore.offline}
      ${field(model.status)}
      item-label-path="name"
      .items=${crmStore.statuses}
    ></vaadin-combo-box>
    <vaadin-combo-box
      label="Company"
      ?disabled=${uiStore.offline}
      ${field(model.company)}
      item-label-path="name"
      .items=${crmStore.companies}
    ></vaadin-combo-box>

    <div class="flex gap-s">
      <vaadin-button
        theme="primary"
        ?disabled=${this.binder.invalid || uiStore.offline}
        @click=${this.save}
      >
        ${this.binder.value.id ? 'Save' : 'Create'}
      </vaadin-button>
      <vaadin-button
        theme="error"
        ?disabled=${!this.binder.value.id || uiStore.offline}
        @click=${listViewStore.delete}
      >
        Delete
      </vaadin-button>
      <vaadin-button theme="tertiary" @click=${listViewStore.cancelEdit}>
        Cancel
      </vaadin-button>
    </div>
  `;
}

Also hide the logout link while offline, as it has no effect.

In the main layout, import uiStore, then use the offline state to toggle the hidden attribute of the link.

import { uiStore } from './stores/app-store';
<a href="/logout" class="ms-auto" ?hidden=${uiStore.offline}>Log out</a>

Lastly, update the login view to disable the login button when the application is offline. You cannot authenticate the user or fetch data if they weren’t logged in before losing their connection.

render() {
  return html`
    <h1>Hilla CRM</h1>
    <vaadin-login-form
      no-forgot-password
      @login=${this.login}
      .error=${this.error}
      ?disabled=${uiStore.offline}
    ></vaadin-login-form>
    ${uiStore.offline
      ? html`<b>You are offline. Login is only available while online.</b>`
      : nothing}
  `;
}

Show a helpful message to users, explaining why the login functionality isn’t available when offline. (Import the nothing token from lit.)

Caching Content to Start the Application While Offline

The application now works well offline, as long as you were online when you launched it. But if you start it while offline, you are greeted by an empty grid and no data.

You can solve this by caching server responses and using the cached data if there is no connection when starting. Only cache data once the user is authenticated, and clear it when they log out.

Begin by creating a helper to cache requests, frontend/stores/cacheable.ts:

const CACHE_NAME = 'crm-cache';

export async function cacheable<T>(
  fn: () => Promise<T>,
  key: string,
  defaultValue: T
) {
  let result;
  try {
    // retrieve the data from backend.
    result = await fn();
    // save the data to localStorage.
    const cache = getCache();
    cache[key] = result;
    localStorage.setItem(CACHE_NAME, JSON.stringify(cache));
  } catch {
    // if failed to retrieve the data from backend, try localStorage.
    const cache = getCache();
    const cached = cache[key];
    // use the cached data if available, otherwise the default value.
    result = result = cached === undefined ? defaultValue : cached;
 }

  return result;
}

function getCache(): any {
  const cache = localStorage.getItem(CACHE_NAME) || '{}';
  return JSON.parse(cache);
}

export function clearCache() {
  localStorage.removeItem(CACHE_NAME);
}

The helper exports two functions:

  • cacheable(), which takes in an async endpoint method, a cache key name, and a default value. When online, it fetches the data from the endpoint and stores it in localStorage using the key, before returning it. If offline, it instead attempts to return a stored value from localStorage, if one exists.

  • clearCache() clears the cache from localStorage.

Update the initFromServer() method in crm-store.ts to use the cacheable() helper, and default to an empty value.

async initFromServer() {
  const data = await cacheable(
    CrmEndpoint.getCrmData,
    'crm',
    CrmDataModel.createEmptyValue()
  );

  runInAction(() => {
    this.contacts = data.contacts;
    this.companies = data.companies;
    this.statuses = data.statuses;
  });
}

Installing the Application

The CRM application is now a functional PWA and can be installed on supported browsers. Installing works only on localhost and when serving over HTTPS.

In Chrome, you can install the application through the install icon in the address bar.

Install prompt in Chrome

Once the application is installed, it opens in its own window with its own icon.

Installed application running in its own window