Gallery: after a deploy, a returning reader runs the new bundles, not stale cached copies
A host that serves the tree with Last-Modified and no Cache-Control lets a browser run its cached files for hours. Measured: a typed-URL revisit after a deploy requested none of the gallery, the manifest or the bundles, so a returning reader ran the old files, or an old manifest against a new bundle. - The manifest is fetched with cache 'no-cache', so it is always revalidated (a 304 when unchanged). - A fingerprint of the manifest (every bundle's integrity) is kept per tree. When it differs from the reader's last visit, every bundle the browser already holds gets one conditional request before any tab mounts: a 304 refreshes it, and a 200 is cancelled at its headers, which drops the stale copy. The plain <script> that follows, the gallery's own or a composite's for its children, then runs the new file at the same URL. - A version query is not used: loadScriptOnce keys its registry by the full href, so X.js?v= next to a composite's plain X.js would run the bundle twice. - A first visit with an empty cache costs nothing extra (only-if-cached finds nothing held), and so does an ordinary visit with an unchanged manifest. The mount waits at most 8 s for the check.
This commit is contained in:
parent
1bddf78a67
commit
c590e9fef4
1 changed files with 52 additions and 3 deletions
|
|
@ -657,6 +657,26 @@ function assertNoDuplicateTabKeys(navModel) {
|
|||
return dupes
|
||||
}
|
||||
|
||||
// ★ FRESH BUNDLES AFTER A DEPLOY, AT THE SAME URLs (2026-10-02). A host that serves the tree with Last-Modified and no
|
||||
// Cache-Control (the DX Life landing does) lets a browser run its cached copies for hours: measured, a typed-URL revisit
|
||||
// after a deploy requested NONE of the gallery, the manifest or the bundles, so a returning reader ran the old files, or
|
||||
// an old manifest against a new bundle. A version query cannot fix it here: loadScriptOnce keys its registry by the full
|
||||
// href, so X.js?v= beside a composite's plain X.js would run the bundle twice. So the manifest is always revalidated, and
|
||||
// when its fingerprint (every bundle's integrity) differs from this reader's last visit to this tree, every listed bundle
|
||||
// gets ONE conditional request before any tab mounts: a 304 refreshes the cached copy, and a 200 is cancelled at its
|
||||
// headers, which drops the stale entry. The plain <script> that follows (ours, or a composite's for its children) then
|
||||
// runs the new file. An ordinary visit costs nothing extra.
|
||||
const TREE_FP_KEY = 'dxm.treeFingerprint@'
|
||||
function treeFingerprint(manifest) {
|
||||
const bundles = (manifest && Array.isArray(manifest.bundles)) ? manifest.bundles : []
|
||||
const s = bundles.map(b => (b && b.bundle) + ':' + ((b && b.integrity) || '')).join('|')
|
||||
let h = 2166136261 // FNV-1a: a short, stable key, not a security hash
|
||||
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) }
|
||||
return (h >>> 0).toString(36) + '.' + s.length
|
||||
}
|
||||
function readTreeFp(root) { try { return localStorage.getItem(TREE_FP_KEY + root) } catch { return null } }
|
||||
function writeTreeFp(root, fp) { try { localStorage.setItem(TREE_FP_KEY + root, fp) } catch { /* private mode: revalidate next time */ } }
|
||||
|
||||
// Turn a bundle id into a human label: "all-people-groups" → "All People Groups". Small, local and
|
||||
// dependency-free; a bundle may override it by shipping a profile `title` in the manifest.
|
||||
function titleize(id) {
|
||||
|
|
@ -1321,17 +1341,24 @@ export default defineComponent({
|
|||
const devFallback = '/public/' + __DOXA_TREE_NAME__ + '/manifest.json'
|
||||
if (!candidates.some(c => c.url === devFallback)) candidates.push({ url: devFallback, root: dirOf(devFallback) })
|
||||
const tried = []
|
||||
// Every mount waits for this gate (see FRESH BUNDLES above): it opens once the manifest's bundles are known
|
||||
// to be current, when no manifest can be read (fail-soft, as before), or after 8 s whatever happens.
|
||||
let open
|
||||
this._freshGate = new Promise(res => { open = res })
|
||||
setTimeout(open, 8000)
|
||||
const attempt = (i) => {
|
||||
if (i >= candidates.length) {
|
||||
// Every candidate failed. Record it on the tabs so the PANE says so — see mountTab().
|
||||
this.manifestError = 'manifest.json not found (tried ' + tried.join(', ') + ')'
|
||||
console.error('[doxa-maps-spa] ' + this.manifestError +
|
||||
' — every tab except Big Picture is composed from it, so their panes cannot mount.')
|
||||
open()
|
||||
return
|
||||
}
|
||||
const { url, root: treeRoot } = candidates[i]
|
||||
tried.push(url)
|
||||
return fetch(url, { credentials: 'same-origin' })
|
||||
// no-cache: always revalidated (a 304 when unchanged), so the manifest is never a stale copy.
|
||||
return fetch(url, { credentials: 'same-origin', cache: 'no-cache' })
|
||||
.then(r => { if (!r || !r.ok) throw new Error(url + ' → HTTP ' + (r ? r.status : '?')); return r.json() })
|
||||
.then(m => {
|
||||
if (!m) throw new Error(url + ' → empty')
|
||||
|
|
@ -1341,11 +1368,32 @@ export default defineComponent({
|
|||
// resolves '../' and rooted forms alike, so a caller-supplied treeBase, a built-tree
|
||||
// path and the dev fallback all normalise here.
|
||||
this.manifestBase = treeRoot || ''
|
||||
this.revalidateBundles(m, treeRoot || '').then(open, open)
|
||||
this.applyManifest(m)
|
||||
})
|
||||
.catch(() => attempt(i + 1))
|
||||
}
|
||||
try { attempt(0) } catch { /* fetch unavailable (non-browser): same fail-soft path */ }
|
||||
try { attempt(0) } catch { open() /* fetch unavailable (non-browser): same fail-soft path */ }
|
||||
},
|
||||
// After a deploy (the manifest's fingerprint changed since this reader's last visit to this tree), ONE conditional
|
||||
// request per listed bundle, so no stale cached copy is run (see FRESH BUNDLES). Never rejects.
|
||||
revalidateBundles(m, root) {
|
||||
const fp = treeFingerprint(m)
|
||||
const prev = readTreeFp(root)
|
||||
if (prev === fp) return Promise.resolve()
|
||||
const urls = [...new Set(((m && m.bundles) || []).map(b => (b && b.scriptUrl) ? this.resolve(b.scriptUrl) : '').filter(Boolean))]
|
||||
// Only a copy this browser already HOLDS can be stale, so the cache is asked first (only-if-cached never touches
|
||||
// the network) and only those get the conditional request: a first visit with an empty cache costs nothing.
|
||||
// only-if-cached needs a same-origin request; across origins, only a reader who has been here before is checked.
|
||||
const sameOrigin = (u) => { try { return new URL(u, location.href).origin === location.origin } catch { return false } }
|
||||
const held = (u) => (sameOrigin(u)
|
||||
? fetch(u, { mode: 'same-origin', credentials: 'same-origin', cache: 'only-if-cached' })
|
||||
.then(r => { if (r && r.body && r.body.cancel) r.body.cancel(); return !!(r && r.ok) }, () => false)
|
||||
: Promise.resolve(prev !== null))
|
||||
const revalidate = (u) => fetch(u, { credentials: 'same-origin', cache: 'no-cache' })
|
||||
.then(r => { if (r && r.status === 200 && r.body && r.body.cancel) return r.body.cancel() })
|
||||
const one = (u) => held(u).then(h => (h ? revalidate(u) : null)).catch(() => {})
|
||||
return Promise.all(urls.map(one)).then(() => writeTreeFp(root, fp))
|
||||
},
|
||||
// Complete the curated tabs from the manifest, drop any whose bundle this tree does not ship,
|
||||
// then append the discovered Experimental tabs — and finally (re)drive the active tab's mount,
|
||||
|
|
@ -1589,7 +1637,8 @@ export default defineComponent({
|
|||
// Gate the mount on the TAG being registered, NOT on the script's onload — onload fires even
|
||||
// when a dev ES module then fails to execute (see loadScriptOnce), which would hand us an inert
|
||||
// HTMLUnknownElement and an empty pane cheerfully reporting 'ready'.
|
||||
loadScriptOnce(jsUrl).then(() => whenTagDefined(t.tag)).then(() => {
|
||||
// …and on the freshness gate first, so a deploy's new bundle is the copy that runs (see FRESH BUNDLES).
|
||||
Promise.resolve(this._freshGate).then(() => loadScriptOnce(jsUrl)).then(() => whenTagDefined(t.tag)).then(() => {
|
||||
// Everything below is per-nested-plugin machinery; a throw here (a nested element that blows
|
||||
// up in its connectedCallback, a serialise failure) must NOT crash the SPA — it lands in THIS
|
||||
// tab's error pane. Hence the try/catch on top of the .catch() that handles a failed load.
|
||||
|
|
|
|||
Loading…
Reference in a new issue