diff --git a/assets/js/app.js b/assets/js/app.js index a551a3b..9df8001 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -116,6 +116,8 @@ var verse = event.target.closest('.v'); if (verse && verse.id) { event.preventDefault(); openFor(verse); return; } if (event.target.closest('[data-action="copy"]')) copyLink(currentUrl); + var reset = event.target.closest('#app-reset'); + if (reset) resetApp(reset); }); document.addEventListener('keydown', function (event) { @@ -134,7 +136,9 @@ // Close the modal once a bookmark write has come back and swapped, leaving // the status line up long enough to be read. if (target.id === 'bookmark') return setTimeout(hideModal, 900); - if (target.id === 'content') land(); + // Both selects answer into the verse list, so this is either of them. + if (target.id === 'reference-verse') return landOnReference(); + if (target.id === 'content') { land(); showVersion(); } }); // A history restore does not go through the request pipeline -- htmx swaps @@ -145,6 +149,7 @@ document.body.addEventListener('htmx:historyRestore', function () { markVerses(); land(); + showVersion(); }); // -- startup ------------------------------------------------------------ @@ -162,8 +167,28 @@ // :root { scroll-behavior: smooth }, so the default animates. Bootstrap // wraps that in prefers-reduced-motion, which is the right call for the // chapter nav's own anchors; these are not that. + // + // Asked for twice, because "instant" is a later addition to an old enum and + // a browser that does not know the value rejects the whole call with a + // TypeError rather than ignoring the word -- so on an older WebKit this did + // not scroll slowly, it did not scroll at all. "auto" is what the enum has + // always had; it animates here, which is worse than instant and far better + // than staying put. function jumpTo(el) { - el.scrollIntoView({ block: 'start', behavior: 'instant' }); + try { + el.scrollIntoView({ block: 'start', behavior: 'instant' }); + } catch (err) { + el.scrollIntoView({ block: 'start', behavior: 'auto' }); + } + } + + // The same, for the top of the page and the same reason. + function jumpToTop() { + try { + window.scrollTo({ top: 0, behavior: 'instant' }); + } catch (err) { + window.scrollTo(0, 0); + } } // A worker-composed page is an ordinary document and the browser scrolls to @@ -192,7 +217,133 @@ function land() { var el = hashTarget(); if (el) jumpTo(el); - else window.scrollTo({ top: 0, behavior: 'instant' }); + else jumpToTop(); + } + + // Where the reference picker lands, which is the one scroll in the app that + // is a gesture: the reader asked for chapter 3 and is waiting to be taken + // there. + // + // It used to ride along on the answer, as show: on HX-Reswap, and be htmx's + // scroll rather than this file's. Two reasons it is here now. The smaller + // one is that htmx's own scrollIntoView says "instant" with nothing around + // it, so the enum above takes the whole scroll down with it. The larger one + // is the phone: a select on iOS is a picker drawn over the page, the change + // event arrives while it is still up, and dismissing it puts the page back + // where it stood when it opened -- undoing anything scrolled in between. + // The options were swapping correctly and the book never moved. + // + // So: once now, for a pointer, and once more after the picker has had time + // to finish, but only if the page did not stay where it was put. That test + // is also what keeps the second attempt from yanking back a reader who has + // started scrolling on their own. + // + // The anchor comes from the address bar. The worker sends HX-Replace-Url + // with the answer and htmx updates history before it swaps, so by the time + // this runs location.hash is already naming the chapter or verse chosen. + var PICKER_SETTLE_MS = 350; + + function landOnReference() { + var el = hashTarget(); + if (!el) return; + jumpTo(el); + var landed = window.pageYOffset; + setTimeout(function () { + var again = hashTarget(); + if (again && Math.abs(window.pageYOffset - landed) > 2) jumpTo(again); + }, PICKER_SETTLE_MS); + } + + // The version readout, shown under the book list and nowhere else. + // + // It is chrome: it sits outside #content, so it outlives every swap -- and + // so does the body class it was rendered with, which is exactly why that + // cannot be what decides. What is in #content can, because that is the one + // thing a swap changes: the book list is the fragment rooted at .index. + // + // Toggling the class rather than the style because Bootstrap declares + // .d-none !important, which an inline display would lose to. + // + // Only the digest line is missing on a page boot.html composed -- there was + // no worker to name a version -- so the element itself is always here. + function showVersion() { + var el = document.getElementById('app-version'); + if (!el) return; + el.classList.toggle('d-none', !document.querySelector('#content .index')); + } + + // -- reset -------------------------------------------------------------- + + // Everything this app has put on the device, cleared in the order that makes + // the next load a first visit. + // + // The worker goes first, so that nothing is left answering out of a cache + // while the caches themselves are being deleted. Unregistering does not stop + // the copy already controlling this page -- an active worker keeps its + // clients until they unload -- which is what the reload at the end is for: + // the registration is gone by then, the navigation reaches server.py, and + // boot.html registers a new worker against whatever is on disk now. + // + // Then the caches, both of them, by whatever names they carry: the point of + // this button is the case where the names are not the ones this build would + // have chosen. Then the bookmark, which is the only thing here a reader + // would miss -- hence the question first. + // + // One after another rather than all at once, so that the order above is the + // order it happens in, and so that a step cannot be reading storage another + // is halfway through deleting. + // + // It still races the worker's own background download, which writes a book + // into the cache on any request that arrives while this is running -- there + // is no way to call that off from here. The reload is what settles it: the + // worker is gone by then, and the one that installs in its place opens + // caches under its own names regardless of what survived. + // + // Each step is allowed to fail on its own. A browser with no caches API, or + // storage the user has locked down, should still get everything else + // cleared and still reload. + var RESET_QUESTION = + 'Clear this app from the device? The offline copy of the books and the ' + + 'bookmark go with it, and the app reloads as a first visit.'; + + function settle(promise) { + return promise.catch(function () {}); + } + + function forgetWorker() { + if (!('serviceWorker' in navigator)) return Promise.resolve(); + return navigator.serviceWorker.getRegistrations().then(function (regs) { + return Promise.all(regs.map(function (reg) { return reg.unregister(); })); + }); + } + + function forgetCaches() { + if (!window.caches) return Promise.resolve(); + return caches.keys().then(function (keys) { + return Promise.all(keys.map(function (key) { return caches.delete(key); })); + }); + } + + // Resolved on blocked as well as on done. Blocked means another tab still + // has the database open, and there is nothing useful to do about it from + // here: this tab is about to reload, and the delete completes when the last + // one lets go. + function forgetBookmark() { + return new Promise(function (resolve) { + if (!window.indexedDB) return resolve(); + var req = indexedDB.deleteDatabase('bible'); + req.onsuccess = req.onerror = req.onblocked = function () { resolve(); }; + }); + } + + function resetApp(button) { + if (!window.confirm(RESET_QUESTION)) return; + button.disabled = true; + button.textContent = 'Clearing…'; + settle(forgetWorker()) + .then(function () { return settle(forgetCaches()); }) + .then(function () { return settle(forgetBookmark()); }) + .then(function () { location.reload(); }); } // api/bookmark has no origin server -- it is answered by the worker -- so @@ -210,6 +361,7 @@ document.addEventListener('DOMContentLoaded', function () { markVerses(); scrollToHash(); + showVersion(); // Registered from the page it is going to serve, not from a page of its // own: there is nothing to wait for and nothing to reload. This load is diff --git a/assets/js/render.js b/assets/js/render.js index 990ac69..0143f88 100644 --- a/assets/js/render.js +++ b/assets/js/render.js @@ -24,6 +24,26 @@ // template that extends or includes another names only the file. var TEMPLATES = './templates/'; + // All of them. Named here rather than in either caller because this file is + // the one that knows what a template is: sw.js precaches them from this list + // and both renderers load them from it, so there is one place to add the + // next one. A template missing from here is a template nunjucks would have + // to fetch mid-render, which is the one thing loadAll() exists to prevent. + var NAMES = [ + 'base.njk', + 'page.njk', + 'book.njk', + 'index.njk', + 'bookmark.njk', + 'reference.njk', + 'verses.njk', + 'version.njk', + 'status.njk', + 'notice.njk' + ]; + + scope.TEMPLATE_URLS = NAMES.map(function (name) { return TEMPLATES + name; }); + // readText(path) -> Promise is the only thing the two callers do // differently: the worker reads through its cache, the page over the network. scope.createRenderer = function (readText) { @@ -42,10 +62,50 @@ // worker, so they never go stale. var env = new nunjucks.Environment(new Loader(), { autoescape: true }); + // Every template, in nunjucks' hands before the first render, because an + // include that has to wait for one does not come out whole. + // + // getTemplate answers from the loader's cache by calling straight back, + // and only reaches this loader -- and this loader's promise -- for a + // template it has never seen. Nunjucks compiles {% include %} into a + // waterfall, and the rest of the block it sits in is the waterfall's + // callback while the code after that block runs on regardless. Answered + // in the same tick the two happen in order and the output is whole; + // answered a tick later the render is finished and handed back first, + // and what comes back is missing the include and everything between it + // and the end of its block. The verse list, and then the after + // it -- a picker with a chapter and no verses, on whichever navigation + // happened to be the first to need the template. + // + // Which is why it is all of them and not just the ones that get included: + // whether a template has been reached yet depends on which pages have + // been rendered, and in the worker on whether it has been restarted since. + // That is the whole shape of the bug -- intermittent, and never twice in a + // row. One pass over a few kilobytes already in the cache buys the + // question being settled. + // + // Failures resolve rather than reject: a template that cannot be read now + // will fail again in the render that actually needs it, where the error + // belongs to a request and can be answered with the bare fragment. + var loaded = null; + + function loadAll() { + if (!loaded) { + loaded = Promise.all(NAMES.map(function (name) { + return new Promise(function (resolve) { + env.getTemplate(name, true, null, false, function () { resolve(); }); + }); + })); + } + return loaded; + } + function render(name, context) { - return new Promise(function (resolve, reject) { - env.render(name, context, function (err, out) { - return err ? reject(err) : resolve(out); + return loadAll().then(function () { + return new Promise(function (resolve, reject) { + env.render(name, context, function (err, out) { + return err ? reject(err) : resolve(out); + }); }); }); } @@ -61,9 +121,11 @@ // base.njk is for. // // chrome is what the shell needs and the fragment cannot carry: the - // bookmark, and the navbar picker's model. Both are null from boot.html, - // which has no worker to ask for either, and both render as a hidden - // slot that fills itself if one ever appears. + // bookmark, the navbar picker's model, and the two versions the worker + // was built with. All three are null from boot.html, which has no worker + // to ask for any of them; the first two render as a hidden slot that + // fills itself if one ever appears, and the third renders as nothing, + // because no worker is the whole of what there is to report. compose: function (fragment, chrome) { var title = (TITLE_RE.exec(fragment) || ['', ''])[1].trim(); var content = fragment.replace(TITLE_RE, ''); @@ -72,7 +134,8 @@ title: title, content: content, mark: (chrome && chrome.mark) || null, - reference: (chrome && chrome.reference) || null + reference: (chrome && chrome.reference) || null, + version: (chrome && chrome.version) || null }); }, diff --git a/assets/sw.js b/assets/sw.js index c9ebaff..0cfab0f 100644 --- a/assets/sw.js +++ b/assets/sw.js @@ -83,6 +83,11 @@ const LIBRARY = []; const SHELL_CACHE = 'bible-shell-' + VERSION; const LIBRARY_CACHE = 'bible-library-' + LIBRARY_VERSION; +// The same two values, handed to the shell so a device can be asked what it is +// running. A phone that has kept an old worker is the hardest thing here to +// see from the outside, and these are what say so. See templates/version.njk. +const VERSIONS = { shell: VERSION, library: LIBRARY_VERSION }; + // Resolved once, and the set is the test for which cache a response belongs // in. Membership, not a path pattern: the split is whichever directory the // file came from, which is a fact only the server has. @@ -103,17 +108,12 @@ const PRECACHE = [ './css/bootstrap.min.css', './js/app.js', './js/htmx.min.js', - './js/bootstrap.bundle.min.js', - './templates/base.njk', - './templates/page.njk', - './templates/book.njk', - './templates/index.njk', - './templates/bookmark.njk', - './templates/reference.njk', - './templates/verses.njk', - './templates/status.njk', - './templates/notice.njk' -]; + './js/bootstrap.bundle.min.js' +// The templates come from render.js, which is the file that knows what one is +// -- and which loads exactly this list into nunjucks before it renders +// anything. Two lists would be one list and a way for the renderer to be +// asked for a template that was never cached. +].concat(TEMPLATE_URLS); function cacheFor(req) { return caches.open(LIBRARY_URLS.has(req.url) ? LIBRARY_CACHE : SHELL_CACHE); @@ -409,7 +409,8 @@ async function servePage(path, code, call) { } try { return await view.compose(fragment, { mark: await getBookmark(), - reference: reference }); + reference: reference, + version: VERSIONS }); } catch (err) { // A template that will not render is a bug, not a condition to design for, // but it should not take the text down with it: the bare fragment has no @@ -457,17 +458,21 @@ router.get('/api/bookmark', () => bookmarkFragment('')); // under a page boot.html composed once a worker turns up, for nothing extra: // those requests carry the address bar too. // -// What the response headers are doing is the whole of what app.js used to do -// on a change event, and htmx has a name for each part of it: +// Two response headers carry what a change means beyond the options: // -// settle:0 swap the options now. The default waits 20ms, and the point -// of this request is a scroll the reader is waiting on. -// show:#id:top scroll the named anchor to the top -- htmx's scrollBehavior -// is "instant", which is what this app wants everywhere. -// Reboot sets scroll-behavior: smooth, so saying nothing here -// would animate. +// settle:0 swap the options now. The default waits 20ms, and this +// answer is one the reader is waiting on. // HX-Replace-Url put the anchor in the address bar without stacking a // history entry per option spun past. +// +// The scroll used to be a third, show:#id:top, and is app.js's again. Not +// because the header was wrong but because the moment was: on iOS the change +// event arrives while the native picker is still drawn over the page, and +// dismissing it restores the scroll offset it opened at -- so a scroll made +// inside the swap is undone before it is seen. The page has to be the one +// doing it, because only the page is still there afterwards to try again. +// See landOnReference() in js/app.js. The address bar above is how it knows +// where to go: htmx updates history before it swaps. router.get('/api/reference', async (call) => { const params = call.url.searchParams; const page = call.currentUrl(); @@ -505,7 +510,7 @@ router.get('/api/reference', async (call) => { headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', - 'HX-Reswap': 'innerHTML settle:0 show:#' + target + ':top', + 'HX-Reswap': 'innerHTML settle:0', 'HX-Replace-Url': page.pathname + page.search + '#' + target } }); diff --git a/assets/templates/base.njk b/assets/templates/base.njk index 968d6c4..0408b16 100644 --- a/assets/templates/base.njk +++ b/assets/templates/base.njk @@ -105,6 +105,7 @@ {% block content %}{% endblock %} + {% include "version.njk" %} {# Chrome, so it is written once here instead of built in JavaScript on every diff --git a/assets/templates/reference.njk b/assets/templates/reference.njk index c4f48b3..220962f 100644 --- a/assets/templates/reference.njk +++ b/assets/templates/reference.njk @@ -44,12 +44,20 @@ settled is what stops the load trigger looping: the answer to it is a picker already standing in the right place, and it says so by leaving the - trigger out of the copy it sends back. #} + trigger out of the copy it sends back. + + !!location.hash, and not location.hash, because htmx keeps an event only + when the filter returns boolean true exactly -- it tests the result with + !== true -- and a hash is a string. Written without the coercion the + trigger is dead: every link carrying an anchor lands with the picker still + reading 1:1, which is where the worker rendered it, because a browser + keeps the fragment to itself and the request that would correct it is + never sent. #}