diff --git a/docs/editing.md b/docs/editing.md
index 82ee4c5..f2df883 100644
--- a/docs/editing.md
+++ b/docs/editing.md
@@ -17,7 +17,7 @@ In-place page re-rendering shared by the banner/site/structure tabs lives in `sw
The page and structure tabs share one language selector: `LangSelect.vue` (small flag + dropdown) v-modeled on the shell-wide selection in `editorLang.js` (`''` = primary). While the panel is open that selection overrides the page's normal language preferences: EditorShell calls `swapdoc.setLangOverride`, which pins every `loadPlain` fetch (`?lang=`, the primary by its own code) and pagerite.js's own fetches/prefetches (`pagerite:session-lang`), until the panel closes and the override clears.
-All WebSockets (page/banner editors, analytics view, the pagerite.js activity channel) pace their connections through `reconnect.js`: new sockets are created a staggered slot apart (a page load opens Vite's HMR socket plus several of ours at the same moment, and such bursts — like rapid retries — trip the browser's WebSocket throttling, leaving every socket to the host "pending" for minutes), a watchdog closes sockets stuck CONNECTING so they reschedule instead of hanging forever, and retries follow an exponential backoff with jitter that only a healthy connection resets.
+All WebSockets (page/banner editors, analytics view, the pagerite.js activity channel) pace their connections through `reconnect.js`: new sockets are created a staggered slot apart (a page load opens Vite's HMR socket plus several of ours at the same moment, and such bursts — like rapid retries — trip the browser's WebSocket throttling, leaving every socket to the host "pending" for minutes), a watchdog closes sockets stuck CONNECTING so they reschedule instead of hanging forever, and retries follow an exponential backoff with jitter that only a healthy connection resets. While a socket is connecting or waiting to reconnect the panel says so (`ConnNote.vue`), and the CodeMirror editors stay locked until their document arrives (typing before the doc accept would be clobbered by it).
## Saving behavior
diff --git a/frontend/src/AnalyticsView.vue b/frontend/src/AnalyticsView.vue
index ca82644..6cb69e3 100644
--- a/frontend/src/AnalyticsView.vue
+++ b/frontend/src/AnalyticsView.vue
@@ -28,6 +28,7 @@ import TransitionGraph from './TransitionGraph.vue'
import VisitorCharts from './VisitorCharts.vue'
import { VIEW_W } from './analytics/chart.js'
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
+import ConnNote from './ConnNote.vue'
// Same centering margin as the charts, so the totals row's left edge
// aligns with the chart svg above the natural width.
@@ -45,6 +46,16 @@ let connectWatchdog = null
const reconnects = reconnectPolicy()
let timeInterval = null
+// The panel is live data over its socket: while it is connecting or waiting
+// to reconnect, say so (ConnNote) instead of showing a silent stale view.
+const conn = ref('connecting') // connecting | open | waiting
+const retryIn = ref(0)
+const connNote = computed(() =>
+ conn.value === 'connecting' ? 'connecting to the server…'
+ : conn.value === 'waiting' ? `connection lost — reconnecting in ~${retryIn.value} s…`
+ : '',
+)
+
// The initial range comes from the URL hash (shareable links); without one,
// it is derived from the first analytics snapshot: day when the recorded
// history is shorter than 24 h, week otherwise.
@@ -54,11 +65,13 @@ let rangePinned = Boolean(RANGES[hashRange])
function connectAnalytics() {
if (ws) return
+ conn.value = 'connecting'
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
ws = new WebSocket(`${proto}//${location.host}/_api/ws/analytics`)
clearTimeout(connectWatchdog)
connectWatchdog = watchConnecting(ws, 'analytics')
ws.onopen = () => {
+ conn.value = 'open'
reconnects.opened()
error.value = ''
}
@@ -86,7 +99,10 @@ function connectAnalytics() {
// The policy paces the retry: doubling backoff with jitter, reset only
// by a healthy connection — a fixed rapid loop trips the browser's
// WebSocket throttling (all sockets then sit "pending" for minutes).
- reconnectTimeout = setTimeout(connectAnalytics, reconnects.closed())
+ const wait = reconnects.closed()
+ retryIn.value = Math.max(1, Math.round(wait / 1000))
+ conn.value = 'waiting'
+ reconnectTimeout = setTimeout(connectAnalytics, wait)
}
}
@@ -164,6 +180,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
✕
+
⚠️ {{ error }}
loading…
diff --git a/frontend/src/BannerEditor.vue b/frontend/src/BannerEditor.vue index 56502dc..543ffa4 100644 --- a/frontend/src/BannerEditor.vue +++ b/frontend/src/BannerEditor.vue @@ -3,11 +3,12 @@ // the real #page-banner region. Close and tab switching live in EditorShell. import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue' import { EditorView, basicSetup } from 'codemirror' -import { EditorState } from '@codemirror/state' +import { Compartment, EditorState } from '@codemirror/state' import { keymap } from '@codemirror/view' import { indentWithTab } from '@codemirror/commands' import { html } from '@codemirror/lang-html' import { cmHighlight, cmTheme } from './cmtheme' +import ConnNote from './ConnNote.vue' import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect' import { dropPageCache, loadPlain, runScripts } from './swapdoc' @@ -30,6 +31,18 @@ let connectWatchdog = null // Reconnection pacing lives in ./reconnect (shared with the other sockets). const reconnects = reconnectPolicy() let everConnected = false +// Connection state drives the note at the top (ConnNote), and locks input +// until the banner's document has arrived (typing before it would be +// clobbered by the doc accept). +const conn = ref('connecting') // connecting | open | waiting +const retryIn = ref(0) +const docReady = ref(false) +const editable = new Compartment() +const connNote = computed(() => + conn.value === 'connecting' ? 'connecting to the server…' + : conn.value === 'waiting' ? `connection lost — reconnecting in ~${retryIn.value} s…` + : docReady.value ? '' : 'loading the banner…', +) let view = null // CodeMirror for the banner HTML let syncing = false // set while replacing the document programmatically @@ -92,6 +105,9 @@ function save() { function openPath(p) { path.value = p + // Lock input until the doc arrives (typing would be clobbered by it). + docReady.value = false + view?.dispatch({ effects: editable.reconfigure(EditorView.editable.of(false)) }) send({ type: 'open', path: p }) } watch(() => props.pagePath, (p) => { openPath(normPath(p)) }) @@ -211,6 +227,8 @@ function onMessage(ev) { const msg = JSON.parse(ev.data) if (msg.type === 'doc' && msg.path === path.value) { setDocument(msg.banner ?? '') + docReady.value = true + view.dispatch({ effects: editable.reconfigure(EditorView.editable.of(true)) }) bannerDesign.value = msg.banner_design ?? null bannerDesignFrom.value = msg.banner_design_from ?? null bannerDesignInherited.value = msg.banner_design_inherited ?? '' @@ -237,6 +255,7 @@ function onKeydown(ev) { function connect() { clearTimeout(reconnectTimer) + conn.value = 'connecting' if (ws) { // Replacing a stale socket: detach its handlers so its close is silent. ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null @@ -249,6 +268,7 @@ function connect() { clearTimeout(connectWatchdog) connectWatchdog = watchConnecting(ws, 'banner') ws.onopen = () => { + conn.value = 'open' reconnects.opened() if (everConnected) { if (pendingSave) send(pendingSave) @@ -261,7 +281,10 @@ function connect() { // The wait is the policy's: doubling backoff with jitter (./reconnect), // reset only by a healthy connection — rapid retries trip the browser's // WebSocket throttling (sockets stuck "pending" for minutes). - reconnectTimer = setTimeout(connect, reconnects.closed()) + const wait = reconnects.closed() + retryIn.value = Math.max(1, Math.round(wait / 1000)) + conn.value = 'waiting' + reconnectTimer = setTimeout(connect, wait) } } @@ -279,6 +302,8 @@ onMounted(async () => { cmTheme, cmHighlight, EditorView.lineWrapping, + // Locked until the banner's document arrives (docReady/ConnNote). + editable.of(EditorView.editable.of(false)), EditorView.updateListener.of((u) => { if (u.docChanged && !syncing) { banner.value = view.state.doc.toString() @@ -311,6 +336,7 @@ onUnmounted(() => {