doxa-maps/library/composables/retryableAsyncComponent.js
Doxa Maps bfc99ff827 library/ keeps the reason and drops the speaker: no comment quotes or cites the Driver
Every reference to the Driver in library/ is reworded: the engineering reason
stays, the speaker and the quote go, and a decision still open reads
"provisional, pending a product decision". Comments only: each changed file
is identical after stripping comments (JavaScript, template and CSS, including
CSS comments inside the style strings shadow-DOM components inject).

Those style-string comments do ship, so several bundles change by exactly
those comment bytes.
2026-09-29 05:26:25 +00:00

81 lines
4 KiB
JavaScript

/**
* retryableAsyncComponent.js — SHARED (@map/composables/retryableAsyncComponent.js).
*
* ★ WHY (card BUG-LAZY-PLUGIN-DEATH-ON-A-SINGLE-FAILED-IMPORT). Vue's defineAsyncComponent
* caches a REJECTED loader promise: once a dynamic import() fails, that component is dead for
* the life of the page — every later render replays the same rejection. It happened live, exactly
* like this: one net::ERR_NETWORK_CHANGED event (wifi switch / VPN / sleep-wake, a TRANSPORT blip,
* not a 404) failed five unrelated module fetches at the same moment, and doxa-simple-map +
* doxa-research-map stayed "not loading" until a full reload — a working product reading as
* two broken plugins.
*
* This is the ONE shared wrapper every lazy component goes through instead. It uses
* defineAsyncComponent's own onError contract — retry() re-invokes the loader, which issues a
* FRESH import() request — with 3 attempts and a short growing backoff, which is precisely the
* shape a transient network blip needs. If all attempts fail (a real 404, a dead server), it
* fails for good: retrying forever would hide genuine breakage.
*
* Usage (drop-in): retryableAsyncComponent(() => import('./Heavy.vue'))
*/
import { defineAsyncComponent, defineComponent, h, ref, shallowRef } from 'vue'
import { useOptionalT } from './useOptionalT.js'
// ★ WHEN ALL RETRIES FAIL the pane used to render BLANK: no message, no way back but a full reload.
// Now it says so and offers Retry. Retry cannot revive the dead async component (Vue caches its
// rejected load), so it swaps in a FRESH one: `current` is reactive, the wrapper renders whatever
// it holds, and every mounted instance of this lazy component re-renders with the new loader.
// The wrapper declares no props, so props, listeners and slots pass straight through; no consumer
// puts a template ref on a lazy component (checked at all 8 before adding the layer).
// Its own background and colour: it renders over map panes and dark basemaps, where inherited
// text with no surface can be invisible, the blank-pane symptom this exists to remove.
const LIGHT = 'background:var(--dx-elevated, #fff);color:var(--dx-text, #3b463d);border:1px solid var(--dx-line, #d1d4cd);'
const DARK = 'background:#2b2f2c;color:#e8e9e6;border:1px solid #4a4f4b;'
const LoadFailed = defineComponent({
name: 'LazyLoadFailed',
props: { onRetry: { type: Function, required: true }, dark: Boolean },
setup(props) {
const t = useOptionalT('messages')
// One press per failure: the press swaps the component and this view unmounts, so a second
// press could only queue a duplicate attempt.
const pressed = ref(false)
const press = () => { if (pressed.value) return; pressed.value = true; props.onRetry() }
return () => h('div', {
class: 'dx-lazy-failed', role: 'alert',
style: 'padding:12px;font-size:13px;line-height:1.4;border-radius:8px;' + (props.dark ? DARK : LIGHT),
}, [
h('span', t('viewLoadFailed')),
' ',
h('button', { type: 'button', disabled: pressed.value, style: 'font:inherit;cursor:pointer;', onClick: press }, t('retry')),
])
},
})
function attempt(loader, onRetry) {
return defineAsyncComponent({
loader,
// The consumer's attrs fall through the async wrapper to this root, so a pane passed isDark
// hands it on; everything else is dropped rather than landing on the message's <div>.
errorComponent: defineComponent({
inheritAttrs: false,
props: { isDark: Boolean },
setup: (p) => () => h(LoadFailed, { onRetry, dark: p.isDark }),
}),
onError(error, retry, fail, attempts) {
if (attempts <= 3) setTimeout(retry, attempts * 500)
else fail()
},
})
}
export function retryableAsyncComponent(loader) {
const current = shallowRef(null)
const rearm = () => { current.value = attempt(loader, rearm) }
rearm()
return defineComponent({
name: 'RetryableAsync',
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h(current.value, attrs, slots)
},
})
}