Shared editor language selection; paced WebSocket reconnects

- LangSelect.vue: one language selector for the page and structure tabs
  (small flag, clean dropdown), v-modeled on the shell-wide editorLang —
  switching in either panel switches both AND the page preview. While the
  panel is open the selection overrides the normal language preferences
  (swapdoc.setLangOverride: loadPlain and pagerite.js fetches/prefetches
  pin ?lang=, the primary language by its own code); closing restores.
- reconnect.js: every socket (page/banner editors, analytics view, the
  activity channel) now connects through a staggered slot — simultaneous
  attempts at page load (Vite's HMR socket plus ours, repeated on every
  refresh) trip the browser's WebSocket throttling, leaving all sockets
  to the host "pending" for minutes, which was the recurring empty
  editor. A watchdog closes sockets stuck CONNECTING so they reschedule
  through the policy (jittered exponential backoff, reset only by a
  healthy connection) instead of hanging forever; a reconnected editor
  re-opens its document when the previous open died with its socket.
This commit is contained in:
2026-09-03 01:47:24 +00:00
parent 43793ef9a4
commit 2a430247e0
13 changed files with 411 additions and 223 deletions
+16 -3
View File
@@ -27,6 +27,7 @@ import VisitorCell from './VisitorCell.vue'
import TransitionGraph from './TransitionGraph.vue'
import VisitorCharts from './VisitorCharts.vue'
import { VIEW_W } from './analytics/chart.js'
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
// Same centering margin as the charts, so the totals row's left edge
// aligns with the chart svg above the natural width.
@@ -40,6 +41,8 @@ const error = ref('')
const now = ref(Date.now())
let ws = null
let reconnectTimeout = null
let connectWatchdog = null
const reconnects = reconnectPolicy()
let timeInterval = null
// The initial range comes from the URL hash (shareable links); without one,
@@ -53,7 +56,12 @@ function connectAnalytics() {
if (ws) return
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
ws = new WebSocket(`${proto}//${location.host}/_api/ws/analytics`)
ws.onopen = () => { error.value = '' }
clearTimeout(connectWatchdog)
connectWatchdog = watchConnecting(ws, 'analytics')
ws.onopen = () => {
reconnects.opened()
error.value = ''
}
ws.onmessage = (event) => {
try {
data.value = JSON.parse(event.data)
@@ -75,12 +83,16 @@ function connectAnalytics() {
}
ws.onclose = () => {
ws = null
reconnectTimeout = setTimeout(connectAnalytics, 2000)
// 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())
}
}
onMounted(async () => {
connectAnalytics()
// The first connection takes a staggered slot (see ./reconnect).
reconnectTimeout = setTimeout(connectAnalytics, socketSlot())
now.value = Date.now()
timeInterval = setInterval(() => { now.value = Date.now() }, 1000)
// The site tree for the transition map (all pages in menu order). Not
@@ -93,6 +105,7 @@ onMounted(async () => {
onUnmounted(() => {
if (reconnectTimeout) clearTimeout(reconnectTimeout)
if (connectWatchdog) clearTimeout(connectWatchdog)
if (timeInterval) clearInterval(timeInterval)
if (ws) {
ws.onclose = null