diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 000000000..16e1ee4bc --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,205 @@ +const baseVariablesConfig = require('eslint-config-airbnb-base/rules/variables'); +const baseStyleConfig = require('eslint-config-airbnb-base/rules/style'); + +const serviceWorkerGlobals = baseVariablesConfig.rules[ + 'no-restricted-globals' +].filter((item) => item.name !== 'self' && item !== 'self'); + +const baseNoRestrictedSyntax = + baseStyleConfig.rules['no-restricted-syntax'].slice(1); +const noRestrictedSyntax = [ + 'warn', + ...baseNoRestrictedSyntax.filter( + (rule) => rule.selector !== 'ForOfStatement' + ), +]; + +module.exports = { + env: { + es6: true, + browser: true, + node: false, + }, + globals: { + Promise: true, + structuredClone: true, + }, + extends: ['airbnb', 'prettier'], + plugins: ['chai-friendly', 'jsdoc', 'prettier', 'unicorn'], + parserOptions: { + sourceType: 'module', + ecmaVersion: 2021, + }, + settings: { + jsdoc: { + tagNamePreference: { + returns: 'return', + }, + }, + }, + ignorePatterns: ['public/js/build/**/*'], + rules: { + 'prettier/prettier': 'error', + 'import/no-unresolved': [ + 'error', + { + ignore: [ + 'enketo/config', + 'enketo/widgets', + 'enketo/translator', + 'enketo/dialog', + 'enketo/file-manager', + ], + }, + ], + + 'react/destructuring-assignment': 'off', + + 'array-callback-return': 'warn', + 'consistent-return': 'warn', + 'global-require': 'warn', + 'import/order': 'warn', + 'import/extensions': 'warn', + 'no-param-reassign': 'warn', + 'no-plusplus': 'warn', + 'no-promise-executor-return': 'warn', + 'no-restricted-globals': 'warn', + 'no-restricted-syntax': noRestrictedSyntax, + 'no-return-assign': 'warn', + 'no-shadow': 'warn', + 'no-underscore-dangle': 'warn', + 'no-unused-expressions': 'warn', + 'no-use-before-define': [ + 'warn', + { + functions: false, + }, + ], + 'prefer-const': 'warn', + 'no-cond-assign': 'warn', + 'no-nested-ternary': 'warn', + 'prefer-destructuring': 'warn', + 'import/no-dynamic-require': 'warn', + 'prefer-promise-reject-errors': 'warn', + }, + overrides: [ + { + files: ['**/*.md'], + parser: 'markdown-eslint-parser', + rules: { + 'prettier/prettier': ['error', { parser: 'markdown' }], + }, + }, + + { + files: [ + 'app.js', + 'app/**/*.js', + '!app/views/**/*.js', + 'tools/redis-repl', + ], + env: { + browser: false, + node: true, + }, + ecmaFeatures: { + modules: false, + }, + }, + + { + files: [ + 'Gruntfile.js', + 'config/build.js', + 'scripts/build.js', + 'test/client/config/karma.conf.js', + 'test/server/**/*.js', + 'tools/**/*.js', + ], + env: { + browser: false, + node: true, + }, + ecmaFeatures: { + modules: false, + }, + rules: { + 'import/no-extraneous-dependencies': [ + 'error', + { devDependencies: true }, + ], + }, + }, + + { + files: [ + 'app/views/**/*.js', + 'public/js/src/**/*.js', + 'test/client/**/*.js', + ], + env: { + browser: true, + node: false, + }, + }, + + { + files: ['public/js/src/**/*.js'], + globals: { + ENV: true, + }, + }, + + { + files: ['public/js/src/module/offline-app-worker.js'], + globals: { + self: true, + }, + rules: { + 'no-restricted-globals': serviceWorkerGlobals, + }, + }, + + { + files: ['test/client/**/*.js'], + env: { + mocha: true, + }, + globals: { + expect: true, + sinon: true, + }, + rules: { + 'no-console': 0, + 'import/no-extraneous-dependencies': [ + 'error', + { devDependencies: true }, + ], + }, + }, + + { + files: ['test/server/**/*.js'], + env: { + mocha: true, + }, + globals: { + expect: true, + sinon: true, + }, + rules: { + 'no-console': 0, + }, + }, + + { + files: ['**/*.mjs'], + parser: '@babel/eslint-parser', + parserOptions: { + sourceType: 'module', + ecmaVersion: 2021, + requireConfigFile: false, + }, + }, + ], +}; diff --git a/.prettierignore b/.prettierignore index d1a8c3126..109b0d908 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,8 +1,7 @@ # Ignore artifacts: .nyc* -public/js/build/* -*/offline-app-worker-partial.js +public/js/build/**/* */node_modules/* docs/* test-coverage/* diff --git a/Gruntfile.js b/Gruntfile.js index 582a1a9c4..c0b277b9f 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -108,7 +108,7 @@ module.exports = (grunt) => { 'find locales -name "translation-combined.json" -delete && rm -fr locales/??', }, 'clean-js': { - command: 'rm -f public/js/build/* && rm -f public/js/*.js', + command: 'rm -rf public/js/build/* && rm -f public/js/*.js', }, translation: { command: diff --git a/app/controllers/offline-controller.js b/app/controllers/offline-controller.js index 6598bd507..53c8d5396 100644 --- a/app/controllers/offline-controller.js +++ b/app/controllers/offline-controller.js @@ -3,8 +3,6 @@ */ const fs = require('fs'); -const path = require('path'); -const crypto = require('crypto'); const express = require('express'); const router = express.Router(); @@ -15,6 +13,7 @@ const config = require('../models/config-model').server; module.exports = (app) => { app.use(`${app.get('base path')}/`, router); }; + router.get('/x/offline-app-worker.js', (req, res, next) => { if (config['offline enabled'] === false) { const error = new Error( @@ -23,59 +22,25 @@ router.get('/x/offline-app-worker.js', (req, res, next) => { error.status = 404; next(error); } else { - res.set('Content-Type', 'text/javascript').send(getScriptContent()); + // We add as few explicit resources as possible because the offline-app-worker can do this dynamically and that is preferred + // for easier maintenance of the offline launch feature. + const resources = config['themes supported'] + .flatMap((theme) => [ + `${config['base path']}${config['offline path']}/css/theme-${theme}.css`, + `${config['base path']}${config['offline path']}/css/theme-${theme}.print.css`, + ]) + .concat([ + `${config['base path']}${config['offline path']}/images/icon_180x180.png`, + ]); + + const link = resources + .map((resource) => `<${resource}>; rel="prefetch"`) + .join(', '); + + const script = fs.readFileSync(config.offlineWorkerPath, 'utf-8'); + + res.set('Content-Type', 'text/javascript'); + res.set('Link', link); + res.send(script); } }); - -/** - * Assembles script contentå - */ -function getScriptContent() { - // Determining hash every time, is done to make development less painful (refreshing service worker) - // The partialScriptHash is not actually required but useful to see which offline-app-worker-partial.js is used during troubleshooting. - // by going to http://localhost:8005/x/offline-app-worker.js and comparing the version with the version shown in the side slider of the webform. - const partialOfflineAppWorkerScript = fs.readFileSync( - path.resolve( - config.root, - 'public/js/src/module/offline-app-worker-partial.js' - ), - 'utf8' - ); - const partialScriptHash = crypto - .createHash('md5') - .update(partialOfflineAppWorkerScript) - .digest('hex') - .substring(0, 7); - const configurationHash = crypto - .createHash('md5') - .update(JSON.stringify(config)) - .digest('hex') - .substring(0, 7); - const version = [config.version, configurationHash, partialScriptHash].join( - '-' - ); - // We add as few explicit resources as possible because the offline-app-worker can do this dynamically and that is preferred - // for easier maintenance of the offline launch feature. - const resources = config['themes supported'] - .reduce((accumulator, theme) => { - accumulator.push( - `${config['base path']}${config['offline path']}/css/theme-${theme}.css` - ); - accumulator.push( - `${config['base path']}${config['offline path']}/css/theme-${theme}.print.css` - ); - - return accumulator; - }, []) - .concat([ - `${config['base path']}${config['offline path']}/images/icon_180x180.png`, - ]); - - return ` -const version = '${version}'; -const resources = [ - '${resources.join("',\n '")}' -]; - -${partialOfflineAppWorkerScript}`; -} diff --git a/app/models/config-model.js b/app/models/config-model.js index d5e29eafd..bd3e27df6 100644 --- a/app/models/config-model.js +++ b/app/models/config-model.js @@ -342,6 +342,11 @@ if (config['id length'] < 4) { config['id length'] = 31; } +config.offlineWorkerPath = path.resolve( + config.root, + 'public/js/build/module/offline-app-worker.js' +); + module.exports = { /** * @type { object } @@ -371,6 +376,7 @@ module.exports = { csrfCookieName: config['csrf cookie name'], excludeNonRelevant: config['exclude non-relevant'], experimentalOptimizations: config['experimental optimizations'], + version: config.version, }, getThemesSupported, }; diff --git a/config/build.js b/config/build.js index fa9437f5f..4b5719761 100644 --- a/config/build.js +++ b/config/build.js @@ -5,14 +5,16 @@ const pkg = require('../package.json'); const cwd = process.cwd(); const entryPoints = pkg.entries.map((entry) => path.resolve(cwd, entry)); - -const isProduction = process.env.NODE_ENV === 'production'; +const { NODE_ENV } = process.env; module.exports = { bundle: true, + define: { + ENV: JSON.stringify(NODE_ENV ?? 'production'), + }, entryPoints, format: 'iife', - minify: isProduction, + minify: true, outdir: path.resolve(cwd, './public/js/build'), plugins: [ alias( @@ -24,6 +26,6 @@ module.exports = { ) ), ], - sourcemap: isProduction ? false : 'inline', + sourcemap: NODE_ENV === 'production' ? false : 'inline', target: ['chrome89', 'edge89', 'firefox90', 'safari13'], }; diff --git a/package.json b/package.json index 136fed8e6..c468c22ca 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "esbuild": "^0.12.29", "esbuild-plugin-alias": "^0.1.2", "eslint-config-airbnb": "^19.0.4", + "eslint-config-airbnb-base": "^15.0.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-chai-friendly": "^0.7.2", "eslint-plugin-import": "^2.26.0", @@ -150,7 +151,8 @@ "public/js/src/enketo-webform.js", "public/js/src/enketo-webform-edit.js", "public/js/src/enketo-webform-view.js", - "public/js/src/enketo-offline-fallback.js" + "public/js/src/enketo-offline-fallback.js", + "public/js/src/module/offline-app-worker.js" ], "volta": { "node": "16.6.1", diff --git a/public/js/src/enketo-webform.js b/public/js/src/enketo-webform.js index 052f672a4..41116f636 100644 --- a/public/js/src/enketo-webform.js +++ b/public/js/src/enketo-webform.js @@ -44,7 +44,6 @@ if (settings.offline) { return formParts; }) - .then(formCache.updateMedia) .then(_setFormCacheEventHandlers) .catch(_showErrorOrAuthenticate); } else { @@ -167,7 +166,6 @@ function _setEmergencyHandlers() { */ function _addBranding(survey) { const brandImg = document.querySelector('.form-header__branding img'); - const attribute = settings.offline ? 'data-offline-src' : 'src'; if ( brandImg && @@ -175,9 +173,9 @@ function _addBranding(survey) { survey.branding.source && brandImg.src !== survey.branding.source ) { - brandImg.src = ''; - brandImg.setAttribute(attribute, survey.branding.source); + brandImg.src = survey.branding.source; } + brandImg.classList.remove('hide'); return survey; diff --git a/public/js/src/module/application-cache.js b/public/js/src/module/application-cache.js index 795d7333d..3d10dc0c6 100644 --- a/public/js/src/module/application-cache.js +++ b/public/js/src/module/application-cache.js @@ -5,68 +5,126 @@ import events from './event'; import settings from './settings'; -function init(survey) { - if ('serviceWorker' in navigator) { - window.addEventListener('load', () => { - navigator.serviceWorker - .register(`${settings.basePath}/x/offline-app-worker.js`) - .then( - (registration) => { - // Registration was successful - console.log( - 'Offline application service worker registration successful with scope: ', - registration.scope - ); - setInterval(() => { - console.log( - 'Checking for offline application cache service worker update' - ); - registration.update(); - }, 60 * 60 * 1000); - - if (registration.active) { - _reportOfflineLaunchCapable(true); +/** + * @private + * + * Used only for mocking `window.reload` in tests. + */ +const location = { + get protocol() { + return window.location.protocol; + }, + + reload() { + window.location.reload(); + }, +}; + +/** + * @private + * + * Exported only for testing. + */ +const RELOAD_ON_UPDATE_TIMEOUT = 500; + +/** + * @private + * + * Exported only for testing. + */ +const UPDATE_REGISTRATION_INTERVAL = 60 * 60 * 1000; + +/** + * @typedef {import('../../../../app/models/survey-model').SurveyObject} Survey + */ + +/** + * @param {Survey} survey + */ +const init = async (survey) => { + try { + if (navigator.serviceWorker != null) { + const workerPath = `${settings.basePath}/x/offline-app-worker.js`; + const workerURL = new URL(workerPath, window.location.href); + + workerURL.searchParams.set('version', settings.version); + + const registration = await navigator.serviceWorker.register( + workerURL + ); + + let reloadOnUpdate = true; + + setTimeout(() => { + reloadOnUpdate = false; + }, RELOAD_ON_UPDATE_TIMEOUT); + + // Registration was successful + console.log( + 'Offline application service worker registration successful with scope: ', + registration.scope + ); + setInterval(() => { + console.log( + 'Checking for offline application cache service worker update' + ); + registration.update(); + }, UPDATE_REGISTRATION_INTERVAL); + + const currentActive = registration.active; + + if (currentActive != null) { + registration.addEventListener('updatefound', () => { + _reportOfflineLaunchCapable(false); + }); + + navigator.serviceWorker.addEventListener( + 'controllerchange', + () => { + if (reloadOnUpdate) { + location.reload(); + } else { + document.dispatchEvent(events.ApplicationUpdated()); } - registration.addEventListener('updatefound', () => { - const newWorker = registration.installing; - - newWorker.addEventListener('statechange', () => { - if (newWorker.state === 'activated') { - console.log( - 'New offline application service worker activated!' - ); - document.dispatchEvent( - events.ApplicationUpdated() - ); - } - }); - }); - }, - (err) => { - // registration failed :( - console.error( - 'Offline application service worker registration failed: ', - err - ); - _reportOfflineLaunchCapable(true); } ); - }); - } else { - if (location.protocol.startsWith('http:')) { - console.error( - 'Service workers not supported on this http URL (insecure)' - ); + } + + registration.update(); + + if (currentActive == null) { + location.reload(); + } else { + _reportOfflineLaunchCapable(true); + } } else { - console.error( - 'Service workers not supported on this browser. This form cannot launch online' - ); + if (location.protocol.startsWith('http:')) { + console.error( + 'Service workers not supported on this http URL (insecure)' + ); + } else { + console.error( + 'Service workers not supported on this browser. This form cannot launch online' + ); + } + + _reportOfflineLaunchCapable(false); } + } catch (error) { + // registration failed :( + const registrationError = Error( + `Offline application service worker registration failed: ${error.message}` + ); + + registrationError.stack = error.stack; + _reportOfflineLaunchCapable(false); + + throw registrationError; } - return Promise.resolve(survey); -} + return survey; +}; function _reportOfflineLaunchCapable(capable = true) { document.dispatchEvent(events.OfflineLaunchCapable({ capable })); @@ -74,6 +132,9 @@ function _reportOfflineLaunchCapable(capable = true) { export default { init, + location, + RELOAD_ON_UPDATE_TIMEOUT, + UPDATE_REGISTRATION_INTERVAL, get serviceWorkerScriptUrl() { if ( 'serviceWorker' in navigator && diff --git a/public/js/src/module/controller-webform.js b/public/js/src/module/controller-webform.js index 42d7d3a66..ebeab1dc9 100644 --- a/public/js/src/module/controller-webform.js +++ b/public/js/src/module/controller-webform.js @@ -18,7 +18,6 @@ import { } from './translator'; import records from './records-queue'; import encryptor from './encryptor'; -import formCache from './form-cache'; import { getLastSavedRecord, populateLastSavedInstances } from './last-saved'; import { replaceMediaSources, replaceModelMediaSources } from './media'; @@ -205,25 +204,7 @@ function _checkAutoSavedRecord() { * @property {boolean} [isOffline] */ -/** - * Controller function to reset to the initial state of a form. - * - * Note: Previously this function accepted a boolean `confirmed` parameter, presumably - * intending to block the reset behavior until user confirmation of save. But this - * parameter was always passed as `true`. Relatedly, the `FormReset` event fired here - * previously indirectly triggered `formCache.updateMedia` method, but it was triggered - * with stale `survey` state, overwriting any changes to `survey.externalData` - * referencing last-saved instances. - * - * That event listener has been removed in favor of calling `updateMedia` directly with - * the current state of `survey` in offline mode. This change is being called out in - * case the removal of that event listener impacts downstream forks. - * - * @param {Survey} survey - * @param {ResetFormOptions} [options] - * @return {Promise} - */ -function _resetForm(survey, options = {}) { +function _resetForm(survey) { return getLastSavedRecord(survey.enketoId) .then((lastSavedRecord) => populateLastSavedInstances(survey, lastSavedRecord) @@ -246,10 +227,6 @@ function _resetForm(survey, options = {}) { form.view.html.dispatchEvent(events.FormReset()); - if (options.isOffline) { - formCache.updateMedia(survey); - } - if (records) { records.setActive(null); } @@ -308,8 +285,6 @@ function _loadRecord(survey, instanceId, confirmed) { form.view.html.dispatchEvent(events.FormReset()); - formCache.updateMedia(survey); - form.recordName = record.name; records.setActive(record.instanceId); diff --git a/public/js/src/module/form-cache.js b/public/js/src/module/form-cache.js index 761d65156..7f0f201a8 100644 --- a/public/js/src/module/form-cache.js +++ b/public/js/src/module/form-cache.js @@ -43,7 +43,7 @@ function init(survey) { return set(survey); }) .then(_processDynamicData) - .then(_setUpdateIntervals); + .then(setUpdateIntervals); } /** @@ -66,16 +66,6 @@ function get({ enketoId }) { ); } -/** - * @param {Survey} survey - * @return {Promise} - */ -function prepareOfflineSurvey(survey) { - return Promise.resolve(_swapMediaSrc(survey)).then( - _addBinaryDefaultsAndUpdateModel - ); -} - /** * @param {Survey} survey * @return {Promise} @@ -93,7 +83,7 @@ const updateSurveyCache = (survey) => function set(survey) { return connection .getFormParts(survey) - .then(prepareOfflineSurvey) + .then(addBinaryDefaultsAndUpdateModel) .then(store.survey.set); } @@ -175,56 +165,27 @@ function _processDynamicData(survey) { * @param {Survey} survey * @return {Promise} */ -function _setUpdateIntervals(survey) { +const setUpdateIntervals = async (survey) => { hash = survey.hash; // Check for form update upon loading. + updateCache(survey); + // Note that for large Xforms where the XSL transformation takes more than 30 seconds, // the first update make take 20 minutes to propagate to the browser of the very first user(s) // that open the form right after the XForm update. + setTimeout(() => { - _updateCache(survey); + updateCache(survey); }, CACHE_UPDATE_INITIAL_DELAY); + // check for form update every 20 minutes setInterval(() => { - _updateCache(survey); + updateCache(survey); }, CACHE_UPDATE_INTERVAL); - return Promise.resolve(survey); -} - -/** - * Handles loading form media for newly added repeats. - * - * @param { Survey } survey - survey object - * @return { Promise } - */ -function _setRepeatListener(survey) { - // Instantiate only once, after loadMedia has been completed (once) - document - .querySelector('form.or') - .addEventListener(events.AddRepeat().type, (event) => { - _loadMedia(survey, [event.target]); - }); - - return Promise.resolve(survey); -} - -/** - * Changes src attributes in view to data-offline-src to facilitate loading those resources - * from the browser storage. - * - * @param { Survey } survey - survey object - * @return { Survey } - */ -function _swapMediaSrc(survey) { - survey.form = survey.form.replace( - /(src="[^"]*")/g, - 'data-offline-$1 src=""' - ); - return survey; -} +}; /** * Loads all default binary files and adds them to the survey object. It removes the src @@ -233,7 +194,7 @@ function _swapMediaSrc(survey) { * @param { Survey } survey - survey object * @return { Promise } */ -function _addBinaryDefaultsAndUpdateModel(survey) { +function addBinaryDefaultsAndUpdateModel(survey) { // The mechanism for default binary files is as follows: // 1. They are stored as binaryDefaults in the resources table with the key being comprised of the VALUE (i.e. jr:// url) // 2. Filemanager.getFileUrl will determine whether to load from (survey) resources of (record) files @@ -300,157 +261,11 @@ function updateMaxSubmissionSize(survey) { return Promise.resolve(survey); } -/** - * Loads survey resources either from the store or via HTTP (and stores them). - * - * @param { Survey } survey - survey object - * @return { Promise } - */ -function updateMedia(survey) { - const formElement = document.querySelector('form.or'); - - replaceMediaSources(formElement, survey.media, { - isOffline: true, - }); - - const containers = [formElement]; - const formHeader = document.querySelector('.form-header'); - if (formHeader) { - containers.push(formHeader); - } - - return _loadMedia(survey, containers) - .then((resources) => { - // if all resources were already in the database, _loadMedia returned undefined - if (resources) { - // Filter out the failed requests (undefined) - survey.resources = resources.filter((resource) => !!resource); - - // Store any resources that are now available for this form. - console.log('Survey media has been updated. Updating cache.'); - return updateSurveyCache(survey); - } - return survey; - }) - .then(_setRepeatListener) - .catch((error) => { - console.error('updateMedia failed', error); - - // Let the flow continue. - return survey; - }); -} - -/** - * To be used with Promise.all if you want the results to be returned even if some - * have failed. Failed tasks will return undefined. - * - * @param { Promise } task - [description] - * @return { object } [description] - */ -function _reflect(task) { - return task.then( - (response) => response, - (error) => { - console.error(error); - } - ); -} - -/** - * @typedef Resource - * @property {string} url URL to resource - * @property {Blob} item resource as Blob - */ - -/** - * Loads and displays survey resources either from the store or via HTTP. - * - * @param { Survey } survey - survey object - * @param { [Element]} targetContainers - HTML container elements to load media into - * @return { Promise<[Resource] | undefined> } - */ -function _loadMedia(survey, targetContainers) { - let updated = false; - - const requests = []; - _getElementsGroupedBySrc(targetContainers).forEach((elements) => { - const src = elements[0].dataset.offlineSrc; - - const request = store.survey.resource - .get(survey.enketoId, src) - .then(async (resource) => { - if (!resource || !resource.item) { - // no need to try/catch here as we don't care about catching failures - const downloaded = await connection.getMediaFile(src); - // only when successful - updated = true; - return downloaded; - } - return resource; - }) - // render the media - .then((resource) => { - if (resource.item) { - // create a resourceURL - const resourceUrl = URL.createObjectURL(resource.item); - // add this resourceURL as the src for all elements in the group - elements.forEach((element) => { - element.src = resourceUrl; - }); - } - return resource; - }) - .catch((error) => { - console.error(error); - }); - - requests.push(request); - }); - - return Promise.all(requests.map(_reflect)).then((resources) => { - if (updated) { - return resources; - } - }); -} - -function _getElementsGroupedBySrc(containers) { - const groupedElements = []; - const urls = {}; - let els = []; - - containers.forEach( - (container) => - (els = els.concat([ - ...container.querySelectorAll('[data-offline-src]'), - ])) - ); - - els.forEach((el) => { - if (!urls[el.dataset.offlineSrc]) { - const src = el.dataset.offlineSrc; - const group = els.filter((e) => { - if (e.dataset.offlineSrc === src) { - // remove from $els to improve performance - // els = els.filter( es => !es.matches( `[data-offline-src="${src}"]` ) ); - return true; - } - }); - - urls[src] = true; - groupedElements.push(group); - } - }); - - return groupedElements; -} - /** * @param {Survey} survey * @return {Promise} */ -function _updateCache(survey) { +function updateCache(survey) { console.log('Checking for survey update...'); return connection @@ -474,7 +289,7 @@ function _updateCache(survey) { return formParts; }) - .then(prepareOfflineSurvey) + .then(addBinaryDefaultsAndUpdateModel) .then(updateSurveyCache) .then((result) => { // set the hash so that subsequent update checks won't redownload the form @@ -536,7 +351,6 @@ export default { init, get, updateMaxSubmissionSize, - updateMedia, remove, flush, CACHE_UPDATE_INITIAL_DELAY, diff --git a/public/js/src/module/media.js b/public/js/src/module/media.js index 7f4aba1eb..96eed7db0 100644 --- a/public/js/src/module/media.js +++ b/public/js/src/module/media.js @@ -1,31 +1,39 @@ /** - * @typedef ReplaceMediaOptions - * @property {boolean} isOffline + * This is a stopgap measure to support forms previously cached with + * `data-offline-src` attributes. It can be removed when forms are + * loaded by the service worker. + * + * @param {HTMLElement} rootElement */ +const reviveOfflineMediaSources = (rootElement) => { + rootElement.querySelectorAll('[data-offline-src]').forEach((element) => { + element.src = element.dataset.offlineSrc; + delete element.dataset.offlineSrc; + }); +}; /** * @param {Element} rootElement * @param {Record} [media] - * @param {ReplaceMediaOptions} [options] */ -export const replaceMediaSources = (rootElement, media = {}, options = {}) => { - const sourceElements = rootElement.querySelectorAll( - '[src^="jr:"], [data-offline-src^="jr:"]' - ); +export const replaceMediaSources = (rootElement, media = {}) => { const isHTML = rootElement instanceof HTMLElement; + if (isHTML) { + reviveOfflineMediaSources(rootElement); + } + + const sourceElements = rootElement.querySelectorAll('[src^="jr:"]'); + sourceElements.forEach((element) => { - const offlineSrc = isHTML ? element.dataset.offlineSrc : null; const source = ( - isHTML ? offlineSrc ?? element.src : element.getAttribute('src') + isHTML ? element.src : element.getAttribute('src') )?.trim(); const fileName = source.replace(/.*\/([^/]+)$/, '$1'); const replacement = media[fileName]; if (replacement != null) { - if (offlineSrc != null) { - element.dataset.offlineSrc = replacement; - } else if (isHTML) { + if (isHTML) { element.src = replacement; } else { element.setAttribute('src', replacement); @@ -42,11 +50,7 @@ export const replaceMediaSources = (rootElement, media = {}, options = {}) => { if (formLogoContainer.firstElementChild == null) { const formLogoImg = document.createElement('img'); - if (options.isOffline) { - formLogoImg.dataset.offlineSrc = formLogoURL; - } else { - formLogoImg.src = formLogoURL; - } + formLogoImg.src = formLogoURL; formLogoImg.alt = 'form logo'; formLogoContainer.append(formLogoImg); diff --git a/public/js/src/module/offline-app-worker-partial.js b/public/js/src/module/offline-app-worker-partial.js deleted file mode 100644 index a3d76eb29..000000000 --- a/public/js/src/module/offline-app-worker-partial.js +++ /dev/null @@ -1,120 +0,0 @@ -/** - * The version, resources variables above are dynamically prepended by the offline-controller. - */ - -const CACHES = [`enketo-common_${version}`]; - -self.addEventListener('install', (event) => { - self.skipWaiting(); - // Perform install steps - event.waitUntil( - caches - .open(CACHES[0]) - .then((cache) => { - console.log('Opened cache'); - - // To bypass any HTTP caching, always obtain resource from network - return cache.addAll( - resources.map( - (resource) => new Request(resource, { cache: 'reload' }) - ) - ); - }) - .catch((e) => { - console.log('Service worker install error', e); - }) - ); -}); - -self.addEventListener('activate', (event) => { - console.log('activated!'); - // Delete old resource caches - event.waitUntil( - caches - .keys() - .then((keys) => - Promise.all( - keys.map((key) => { - if (!CACHES.includes(key)) { - console.log('deleting cache', key); - - return caches.delete(key); - } - }) - ) - ) - .then(() => { - console.log(`${version} now ready to handle fetches!`); - }) - ); -}); - -self.addEventListener('fetch', (event) => { - event.respondWith( - caches.match(event.request).then((response) => { - if (response) { - console.log('returning cached response for', event.request.url); - - return response; - } - - // TODO: we have a fallback page we could serve when offline, but tbc if that is actually useful at all - - // To bypass any HTTP caching, always obtain resource from network - return fetch(event.request, { - credentials: 'same-origin', - cache: 'reload', - }) - .then((response) => { - const isScopedResource = event.request.url.includes('/x/'); - const isTranslation = - event.request.url.includes('/locales/build/'); - const isServiceWorkerScript = - event.request.url === self.location.href; - - // The second clause prevents confusing logging when opening the service worker directly in a separate tab. - if ( - isScopedResource && - !isServiceWorkerScript && - !isTranslation - ) { - console.error( - 'Resource missing from cache?', - event.request.url - ); - } - - // Check if we received a valid response - if ( - !response || - response.status !== 200 || - response.type !== 'basic' || - !isScopedResource || - isServiceWorkerScript - ) { - return response; - } - - // Cache any additional loaded languages - const responseToCache = response.clone(); - - // Cache any non-English language files that are requested - // Also, if the cache didn't get built correctly using the explicit resources list, - // just cache whatever is scoped dynamically to self-heal the cache. - caches.open(CACHES[0]).then((cache) => { - console.log( - 'Dynamically adding resource to cache', - event.request.url - ); - cache.put(event.request, responseToCache); - }); - - return response; - }) - .catch((e) => { - // Let fail silently - console.log('Failed to fetch resource', event.request, e); - }); - }) - ); -}); diff --git a/public/js/src/module/offline-app-worker.js b/public/js/src/module/offline-app-worker.js new file mode 100644 index 000000000..744ad79ce --- /dev/null +++ b/public/js/src/module/offline-app-worker.js @@ -0,0 +1,161 @@ +const STATIC_CACHE = 'enketo-common'; +const FORMS_CACHE = 'enketo-forms'; + +/** + * @param {string} url + */ +const cacheName = (url) => { + if ( + url === '/favicon.ico' || + url.endsWith('/x/') || + /\/x\/((css|fonts|images|js|locales)\/|offline-app-worker.js)/.test(url) + ) { + return STATIC_CACHE; + } + + return FORMS_CACHE; +}; + +/** + * @param {Request | string} key + * @param {Response} response + */ +const cacheResponse = async (key, response) => { + const clone = response.clone(); + const cache = await caches.open(cacheName(key.url ?? key)); + + await cache.put(key, clone); + + return response; +}; + +/** + * @param {Response} response + */ +const cachePrefetchURLs = async (response) => { + const linkHeader = response.headers.get('link') ?? ''; + const prefetchURLs = [ + ...linkHeader.matchAll(/<([^>]+)>;\s*rel="prefetch"/g), + ].map(([, url]) => url); + + const cache = await caches.open(STATIC_CACHE); + + await Promise.allSettled(prefetchURLs.map((url) => cache.add(url))); +}; + +self.addEventListener('install', () => { + self.skipWaiting(); +}); + +const removeStaleCaches = async () => { + const cacheStorageKeys = await caches.keys(); + + cacheStorageKeys.forEach((key) => { + if (key !== FORMS_CACHE) { + caches.delete(key); + } + }); +}; + +const onActivate = async () => { + await self.clients.claim(); + await removeStaleCaches(); +}; + +self.addEventListener('activate', (event) => { + event.waitUntil(onActivate()); +}); + +const FETCH_OPTIONS = { + cache: 'reload', + credentials: 'same-origin', +}; + +/** + * @param {Request} request + */ +const onFetch = async (request) => { + const { method, referrer, url } = request; + + if (method !== 'GET') { + return fetch(request, FETCH_OPTIONS); + } + + const { pathname } = new URL(url); + const isFormPageRequest = + /\/x\/[^/]+\/?$/.test(pathname) && + (referrer === '' || referrer === url); + + /** + * A response for the form page initial HTML is always cached with the + * same key: `https://example.com/basePath/x/`. This ensures that forms + * previously cached before a future service worker update will still + * be available after that update. + * + * @see {@link https://github.com/enketo/enketo-express/issues/470} + */ + const cacheKey = isFormPageRequest ? url.replace(/\/x\/.*/, '/x/') : url; + + const cached = await caches.match(cacheKey); + + let response = cached; + + if (response == null || ENV === 'development') { + try { + response = await fetch(request, FETCH_OPTIONS); + } catch { + // Probably offline + } + } + + if ( + response == null || + response.status !== 200 || + response.type !== 'basic' + ) { + return response; + } + + /** + * In addition to storing the form page initial HTML with a single + * cache key, we store a sentinel 204 response for each individual + * cached form page URL. This ensures we don't load forms cached + * prior to introducing this caching strategy, as their attachments + * will not yet have been cached. + * + * @see {cacheKey} + */ + if (isFormPageRequest) { + const { status } = response.clone(); + + if (status === 204) { + return caches.match(cacheKey); + } + + await cacheResponse( + url, + new Response(null, { status: 204, statusText: 'No Content' }) + ); + } + + const isServiceWorkerScript = url === self.location.href; + + if (isServiceWorkerScript) { + cachePrefetchURLs(response); + } + + await cacheResponse(cacheKey, response.clone()); + + return response; +}; + +const { origin } = self.location; + +self.addEventListener('fetch', (event) => { + const { request } = event; + const requestURL = new URL(request.url); + + if (requestURL.origin === origin) { + event.respondWith(onFetch(request)); + } +}); diff --git a/public/js/src/module/utils.js b/public/js/src/module/utils.js index 0009939ba..7d321cecc 100644 --- a/public/js/src/module/utils.js +++ b/public/js/src/module/utils.js @@ -245,9 +245,13 @@ function _throwInvalidXmlNodeName(name) { ); } +/** + * @typedef {Record} URLLike + */ + /** * - * @param { string } path - location.pathname in a browser + * @param {URLLike | string} url - location.pathname in a browser */ function getEnketoId(path) { path = path.endsWith('/') ? path.substring(0, path.length - 1) : path; diff --git a/test/client/application-cache.spec.js b/test/client/application-cache.spec.js new file mode 100644 index 000000000..8912bbe41 --- /dev/null +++ b/test/client/application-cache.spec.js @@ -0,0 +1,224 @@ +import applicationCache from '../../public/js/src/module/application-cache'; +import events from '../../public/js/src/module/event'; +import settings from '../../public/js/src/module/settings'; + +describe('Application cache initialization (offline service worker registration)', () => { + const basePath = '-'; + const version = `1.2.3-BADB3D`; + const applicationUpdatedEvent = events.ApplicationUpdated(); + const applicationUpdatedType = applicationUpdatedEvent.type; + const offlineLaunchCapableType = events.OfflineLaunchCapable().type; + + /** @type {ServiceWorker | null} */ + let activeServiceWorker; + + /** @type {sinon.SinonSandbox} */ + let sandbox; + + /** @type {sinon.SinonFakeTimers} */ + let timers; + + /** @type {sinon.SinonFake} */ + let offlineLaunchCapableListener; + + /** @type {sinon.SinonStub} */ + let reloadStub; + + /** @type {sinon.SinonStub} */ + let registrationStub; + + /** @type {sinon.SinonFake} */ + let registrationUpdateFake; + + /** @type {Function | null} */ + let controllerChangeListener; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + timers = sandbox.useFakeTimers(Date.now()); + + offlineLaunchCapableListener = sinon.fake(); + + document.addEventListener( + offlineLaunchCapableType, + offlineLaunchCapableListener + ); + + activeServiceWorker = null; + + registrationUpdateFake = sandbox.fake(() => Promise.resolve()); + + registrationStub = sandbox + .stub(navigator.serviceWorker, 'register') + .callsFake(() => + Promise.resolve({ + addEventListener() {}, + active: activeServiceWorker, + update: registrationUpdateFake, + }) + ); + reloadStub = sandbox + .stub(applicationCache.location, 'reload') + .callsFake(() => {}); + + settings.basePath ??= undefined; + settings.version ??= undefined; + sandbox.stub(settings, 'basePath').value(basePath); + sandbox.stub(settings, 'version').value(version); + + const addControllerChangeListener = + navigator.serviceWorker.addEventListener; + + controllerChangeListener = null; + + sandbox + .stub(navigator.serviceWorker, 'addEventListener') + .callsFake((type, listener) => { + if (type === 'controllerchange') { + expect(controllerChangeListener).to.equal(null); + controllerChangeListener = listener; + } + addControllerChangeListener.call( + navigator.serviceWorker, + type, + listener + ); + }); + }); + + afterEach(() => { + document.removeEventListener( + offlineLaunchCapableType, + offlineLaunchCapableListener + ); + + if (controllerChangeListener != null) { + navigator.serviceWorker.removeEventListener( + 'controllerchange', + controllerChangeListener + ); + } + + timers.reset(); + timers.restore(); + sandbox.restore(); + }); + + it('registers the service worker script', async () => { + await applicationCache.init(); + + expect(registrationStub).to.have.been.calledWith( + new URL( + `${basePath}/x/offline-app-worker.js?version=${version}`, + window.location.href + ) + ); + }); + + it('reloads immediately after registering the service worker for the first time', async () => { + await applicationCache.init(); + + expect(reloadStub).to.have.been.called; + }); + + it('does not reload immediately after registering the service worker for subsequent times', async () => { + activeServiceWorker = {}; + + await applicationCache.init(); + + expect(reloadStub).not.to.have.been.called; + }); + + it('reports offline capability after registering the service worker for subsequent times', async () => { + activeServiceWorker = {}; + + await applicationCache.init(); + + expect(offlineLaunchCapableListener).to.have.been.calledWith( + events.OfflineLaunchCapable({ capable: true }) + ); + }); + + it('reports offline capability is not available when service workers are not available', async () => { + activeServiceWorker = {}; + + sandbox.stub(navigator, 'serviceWorker').value(null); + + await applicationCache.init(); + + expect(offlineLaunchCapableListener).to.have.been.calledWith( + events.OfflineLaunchCapable({ capable: false }) + ); + }); + + it('reports offline capability is not available when registration throws an error', async () => { + activeServiceWorker = {}; + + const error = new Error('Something bad'); + + registrationStub.callsFake(() => Promise.reject(error)); + + /** @type {Error} */ + let caught; + + try { + await applicationCache.init(); + } catch (error) { + caught = error; + } + + expect(offlineLaunchCapableListener).to.have.been.calledWith( + events.OfflineLaunchCapable({ capable: false }) + ); + expect(caught instanceof Error).to.equal(true); + expect(caught.message).to.include(error.message); + expect(caught.stack).to.equal(error.stack); + }); + + it('reloads when an updated service worker becomes active on load', async () => { + activeServiceWorker = {}; + await applicationCache.init(); + + expect(applicationCache.location.reload).not.to.have.been.called; + + navigator.serviceWorker.dispatchEvent(new Event('controllerchange')); + + expect(applicationCache.location.reload).to.have.been.called; + }); + + it('checks for updates immediately after registration', async () => { + await applicationCache.init(); + + expect(registrationUpdateFake).to.have.been.calledOnce; + }); + + it('checks for updates periodically', async () => { + await applicationCache.init(); + + expect(registrationUpdateFake).to.have.been.calledOnce; + + timers.tick(applicationCache.UPDATE_REGISTRATION_INTERVAL); + + expect(registrationUpdateFake).to.have.been.calledTwice; + + timers.tick(applicationCache.UPDATE_REGISTRATION_INTERVAL); + + expect(registrationUpdateFake).to.have.been.calledThrice; + }); + + it('notifies the user, rather than reloading, when a service worker update is detected some time after the page is loaded', async () => { + activeServiceWorker = {}; + await applicationCache.init(); + + timers.tick(applicationCache.RELOAD_ON_UPDATE_TIMEOUT); + + const listener = sandbox.fake(); + + document.addEventListener(applicationUpdatedType, listener); + navigator.serviceWorker.dispatchEvent(new Event('controllerchange')); + document.removeEventListener(applicationUpdatedType, listener); + + expect(reloadStub).not.to.have.been.called; + expect(listener).to.have.been.calledOnceWith(applicationUpdatedEvent); + }); +}); diff --git a/test/client/form-cache.spec.js b/test/client/form-cache.spec.js index 160f3575b..04c314abd 100644 --- a/test/client/form-cache.spec.js +++ b/test/client/form-cache.spec.js @@ -60,9 +60,6 @@ describe('Client Form Cache', () => { /** @type {GetFormPartsStubResult} */ let getFormPartsStubResult; - /** @type {SinonStub} */ - let getFileSpy; - /** @type {SinonFakeTimers} */ let timers; @@ -118,15 +115,6 @@ describe('Client Form Cache', () => { }) ); - getFileSpy = sandbox.stub(connection, 'getMediaFile').callsFake((url) => - Promise.resolve({ - url, - item: new Blob(['babdf'], { - type: 'image/png', - }), - }) - ); - store.init().then(done, done); }); @@ -156,26 +144,6 @@ describe('Client Form Cache', () => { .then(done, done); }); - it('will call connection.getMediaFile to obtain form resources', (done) => { - survey.enketoId = '20'; - formCache - .init(survey) - .then((result) => { - const currentForm = document.querySelector('form.or'); - const form = document - .createRange() - .createContextualFragment(result.form); - - currentForm.parentNode.replaceChild(form, currentForm); - - return formCache.updateMedia(result); - }) - .then(() => { - expect(getFileSpy).to.have.been.calledWith(url1); - }) - .then(done, done); - }); - it('will populate the cache upon initialization', (done) => { survey.enketoId = '30'; formCache @@ -196,18 +164,6 @@ describe('Client Form Cache', () => { }) .then(done, done); }); - - it('will empty src attributes and copy the original value to a data-offline-src attribute ', (done) => { - survey.enketoId = '40'; - formCache - .init(survey) - .then((result) => { - expect(result.form) - .to.contain('src=""') - .and.to.contain(`data-offline-src="${url1}"`); - }) - .then(done, done); - }); }); describe('form cache updates', () => { @@ -275,87 +231,5 @@ describe('Client Form Cache', () => { }) .then(done, done); }); - - describe('form media (only) cache updates', () => { - let resultSurvey; - - /** @type {SinonSpy} */ - let storeSurveyUpdateSpy; - - beforeEach((done) => { - getFileSpy.restore(); - getFileSpy = sandbox - .stub(connection, 'getMediaFile') - .callsFake(() => Promise.reject(new Error('Fail!'))); - - storeSurveyUpdateSpy = sandbox.spy(store.survey, 'update'); - - survey.enketoId = '200'; - formCache - .init(survey) - .then((result) => { - const currentForm = document.querySelector('form.or'); - const form = document - .createRange() - .createContextualFragment(result.form); - - currentForm.parentNode.replaceChild(form, currentForm); - - resultSurvey = result; - return formCache.updateMedia(result); - }) - .then(() => { - getFileSpy.restore(); - getFileSpy = sandbox - .stub(connection, 'getMediaFile') - .callsFake((url) => - Promise.resolve({ - url, - item: new Blob(['babdf'], { - type: 'image/png', - }), - }) - ); - }) - .then(done, done); - }); - - afterEach(() => { - storeSurveyUpdateSpy.restore(); - getFileSpy.restore(); - }); - - it('will re-attempt to download failed media files (at next load) and update the cache', (done) => { - expect(getFileSpy).to.not.have.been.called; - expect(storeSurveyUpdateSpy).to.not.have.been.called; - - // simulate re-opening a cached form by calling updateMedia again - formCache - .updateMedia(resultSurvey) - .then(() => { - // another attempt is made to download the previously-failed media file - expect(getFileSpy).to.have.been.calledOnce; - expect(getFileSpy).to.have.been.calledWith(url1); - // and to cache it when successful - expect(storeSurveyUpdateSpy).to.have.been.calledOnce; - }) - .then(done, done); - }); - - it('will not re-attempt to download and update again after the cache is complete', (done) => { - // simulate re-opening a cached form by calling updateMedia again - formCache - .updateMedia(resultSurvey) - .then(formCache.updateMedia) - .then(formCache.updateMedia) - .then(() => { - // Despite 3 calls the media file was only downloaded once, - // and the cache was updated only once. - expect(getFileSpy).to.have.been.calledOnce; - expect(storeSurveyUpdateSpy).to.have.been.calledOnce; - }) - .then(done, done); - }); - }); }); }); diff --git a/test/client/media.spec.js b/test/client/media.spec.js index a746708ba..e15ff6616 100644 --- a/test/client/media.spec.js +++ b/test/client/media.spec.js @@ -97,7 +97,7 @@ describe('Media replacement', () => { ); }); - it('replaces jr: URLs in a form with sources swapped for offline-capable mode', () => { + it('replaces jr: URLs in a form with sources previously swapped for offline-capable mode', () => { const sourceElements = formRoot.querySelectorAll('[src]'); sourceElements.forEach((element) => { @@ -109,10 +109,10 @@ describe('Media replacement', () => { const img = formRoot.querySelector('label img'); const audio = formRoot.querySelector('audio'); - expect(img.dataset.offlineSrc).to.equal( + expect(img.src).to.equal( 'https://example.com/-/media/get/0/WXMDbc0H/c0f15ee04dacb1db7cc60797285ff1c8/an%20image.jpg' ); - expect(audio.dataset.offlineSrc).to.equal( + expect(audio.src).to.equal( 'https://example.com/-/media/get/0/WXMDbc0H/c0f15ee04dacb1db7cc60797285ff1c8/a%20song.mp3' ); }); @@ -127,16 +127,6 @@ describe('Media replacement', () => { ); }); - it('appends a form logo with an offline source attribute if present in the media mapping', () => { - replaceMediaSources(formRoot, media, { isOffline: true }); - - const formLogo = formRoot.querySelector('.form-logo img'); - - expect(formLogo.dataset.offlineSrc).to.equal( - 'https://example.com/-/media/get/0/WXMDbc0H/c0f15ee04dacb1db7cc60797285ff1c8/form_logo.png' - ); - }); - it('replaces jr: URLs in `src` attributes in a model when the `modelRoot` property is set', () => { const enketoForm = { model: {}, diff --git a/test/client/utils.spec.js b/test/client/utils.spec.js index 235ada253..f5e8ea712 100644 --- a/test/client/utils.spec.js +++ b/test/client/utils.spec.js @@ -395,6 +395,8 @@ describe('Client Utilities', () => { '/edit/i/abcd/', '/xform/abcd', '/xform/abcd/', + '/x/abcd', + '/x/abcd/', ].forEach((test) => { it('extracts the id "abcd" correctly', () => { expect(utils.getEnketoId(test)).to.equal('abcd'); diff --git a/tutorials/37-offline.md b/tutorials/37-offline.md new file mode 100644 index 000000000..b44edfec0 --- /dev/null +++ b/tutorials/37-offline.md @@ -0,0 +1,64 @@ +Enketo may be [configured](./tutorial-10-configure.html#offline-enabled) to support offline access to forms. When this configuration is set to `true`, accessing a form in offline-capable mode _while online_ will allow that form to be available for subsequent requests _while offline_. + +**Note:** unless specified otherwise, all of the caching and storage discussed below refers to storage _on an individual user's device and browser_. Also unless specified otherwise, caching is not used when the Enketo deployment is not configured to support offline forms, or when the user accesses a form in online-only mode (which is the default). + +### Offline-capable mode + +#### Service Worker and Cache Storage + +The Enketo app registers a [Service Worker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API), which intercepts some network requests in order to cache certain resources in [CacheStorage](https://developer.mozilla.org/en-US/docs/Web/API/CacheStorage) as they're requested. The following resources are stored in one of two Caches: + +- `enketo-common`: common static assets used by Enketo to serve and render forms: + + - The Service Worker script itself + - The Enketo app's static assests (core JavaScript and CSS) + - CSS supplied by [configured themes](./tutorial-10-configure.html#themes-supported) + - The Enketo app icon, which may be displayed if a user bookmarks the app or registers it as a mobile web app + - The initial HTML which begins loading the app and the requested form + + This Cache is cleared and rebuilt in production, after a brief delay when the user again accesses a form in offline-capable mode _while online_, and when the Enketo service is (re)started with changes to the version specified in `package.json` or the Service Worker script (typically following a new release or during development), or with changes to the configured themes. + +- `enketo-forms`: resources associated with individual forms: + + - Media attachments used by the form + - [External secondary instances](https://getodk.github.io/xforms-spec/#secondary-instances---external) used by the form + + The resources in this Cache will be updated when Enketo detects that they have changed. + +For production Enketo deployments, these resources will always be retrieved from CacheStorage if present, without performing additional network requests other than to determine whether they have been updated. To aid maintenance and improvement of Enketo's offline functionality, the requests _are_ performed in development mode. + +**Important note:** forms cached prior to release of [these changes to media and HTML caching](https://github.com/enketo/enketo-express/pull/465) must be requested in offline-capable mode while online to be re-added to CacheStorage. Forms cached after that update will no longer have this issue. + +#### IndexedDB storage + +When loading a form in offline-capable mode for the first time, the following resources are requested and cached in IndexedDB for future access while offline: + +- The form definition itself, [transformed](https://github.com/enketo/enketo-transformer) into a `survey` format which can be consumed by [Enketo Core](https://github.com/enketo/enketo-core) + +Once cached, if a user requests this form again in offline-capable mode _while online_, a request is made after a short delay to determine whether the form or its associated resources have changed. If they have changed, the above requests are performed again to update the IndexedDB cache. + +If the form and its associated resources **have not** changed, they will always be retrieved from IndexedDB without performing a network request. + +#### Submissions and submission attachments + +When a user submits a form entry in offline-capable mode, the submission (`record`) and any media files submitted by the user are also added to the IndexedDB cache, acting as a queue of submissions to send when the user is online. After a brief delay, if the user is online at that time, those submissions are sent and removed from the queue. If the user is not online at that time, the app will periodically check online status to determine whether it can process the queue. + +#### Draft and auto-saved submissions + +As a user fills a form, they may choose to save the submission as a draft, to be completed and submitted at a later time. These are also stored in IndexedDB as `record`s, but they will not be queued for submission until complete. A draft is also automatically saved as the user makes changes to a submission in progress, allowing an incomplete submission to be recovered if the page is reloaded or reopened. + +### Caveats + +- Service Workers may not be available in certain environments, such as a browser's private or incognito mode. In those conditions, a user will not be able to access forms offline. + +- As noted above, forms cached prior to the current caching behavior will not be available until they are re-added to the cache. + +- Browsers vary in how long they preserve storage for Service Workers, Cache Storage, and data stored in IndexedDB. They also vary in how much storage is allowed for a given site/app. Users may also manually clear storage. All offline caches should be assumed to be temporary. + +### Related caching and use of browser storage in Enketo + +There are two cases where Enketo uses, or allows to be used, caching and browser storage in _both_ online-only and offline-capable modes: + +- Forms which reference the [last-saved virtual endpoint](https://getodk.github.io/xforms-spec/#virtual-endpoints) as a secondary instance store the user's most recent submission in IndexedDB in a manner similar to storage of offline submissions and drafts. + +- Resources which would normally be cached by the browser according to their response headers.