Panels show their socket state; editors locked until the doc arrives

- ConnNote.vue: a status strip for the WebSocket-backed panels (page and
  banner editors, analytics view) — "connecting…" while the socket is
  pending (the staggered slot included), "reconnecting in ~N s…" during
  backoff, hidden once open. A pending/lost socket no longer reads as a
  silently empty or stale panel.
- PageEditor/BannerEditor: CodeMirror stays non-editable until the
  page/banner doc has been accepted — typing before it would be clobbered
  by the accept. A mere disconnect keeps the editor live: text stashes
  and pending saves flush on reconnect.
This commit is contained in:
2026-09-03 01:59:35 +00:00
parent 2a430247e0
commit 652f1f82ac
5 changed files with 98 additions and 6 deletions
+1 -1
View File
@@ -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. 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 ## Saving behavior
+18 -1
View File
@@ -28,6 +28,7 @@ import TransitionGraph from './TransitionGraph.vue'
import VisitorCharts from './VisitorCharts.vue' import VisitorCharts from './VisitorCharts.vue'
import { VIEW_W } from './analytics/chart.js' import { VIEW_W } from './analytics/chart.js'
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect' import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
import ConnNote from './ConnNote.vue'
// Same centering margin as the charts, so the totals row's left edge // Same centering margin as the charts, so the totals row's left edge
// aligns with the chart svg above the natural width. // aligns with the chart svg above the natural width.
@@ -45,6 +46,16 @@ let connectWatchdog = null
const reconnects = reconnectPolicy() const reconnects = reconnectPolicy()
let timeInterval = null 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, // The initial range comes from the URL hash (shareable links); without one,
// it is derived from the first analytics snapshot: day when the recorded // it is derived from the first analytics snapshot: day when the recorded
// history is shorter than 24 h, week otherwise. // history is shorter than 24 h, week otherwise.
@@ -54,11 +65,13 @@ let rangePinned = Boolean(RANGES[hashRange])
function connectAnalytics() { function connectAnalytics() {
if (ws) return if (ws) return
conn.value = 'connecting'
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
ws = new WebSocket(`${proto}//${location.host}/_api/ws/analytics`) ws = new WebSocket(`${proto}//${location.host}/_api/ws/analytics`)
clearTimeout(connectWatchdog) clearTimeout(connectWatchdog)
connectWatchdog = watchConnecting(ws, 'analytics') connectWatchdog = watchConnecting(ws, 'analytics')
ws.onopen = () => { ws.onopen = () => {
conn.value = 'open'
reconnects.opened() reconnects.opened()
error.value = '' error.value = ''
} }
@@ -86,7 +99,10 @@ function connectAnalytics() {
// The policy paces the retry: doubling backoff with jitter, reset only // The policy paces the retry: doubling backoff with jitter, reset only
// by a healthy connection — a fixed rapid loop trips the browser's // by a healthy connection — a fixed rapid loop trips the browser's
// WebSocket throttling (all sockets then sit "pending" for minutes). // 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
</nav> </nav>
<a href="/" class="close" title="home"></a> <a href="/" class="close" title="home"></a>
</header> </header>
<ConnNote :text="connNote" />
<p v-if="error" class="error"> {{ error }}</p> <p v-if="error" class="error"> {{ error }}</p>
<p v-else-if="!data" class="loading">loading</p> <p v-else-if="!data" class="loading">loading</p>
<template v-else> <template v-else>
+28 -2
View File
@@ -3,11 +3,12 @@
// the real #page-banner region. Close and tab switching live in EditorShell. // the real #page-banner region. Close and tab switching live in EditorShell.
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { EditorView, basicSetup } from 'codemirror' import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state' import { Compartment, EditorState } from '@codemirror/state'
import { keymap } from '@codemirror/view' import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands' import { indentWithTab } from '@codemirror/commands'
import { html } from '@codemirror/lang-html' import { html } from '@codemirror/lang-html'
import { cmHighlight, cmTheme } from './cmtheme' import { cmHighlight, cmTheme } from './cmtheme'
import ConnNote from './ConnNote.vue'
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect' import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
import { dropPageCache, loadPlain, runScripts } from './swapdoc' import { dropPageCache, loadPlain, runScripts } from './swapdoc'
@@ -30,6 +31,18 @@ let connectWatchdog = null
// Reconnection pacing lives in ./reconnect (shared with the other sockets). // Reconnection pacing lives in ./reconnect (shared with the other sockets).
const reconnects = reconnectPolicy() const reconnects = reconnectPolicy()
let everConnected = false 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 view = null // CodeMirror for the banner HTML
let syncing = false // set while replacing the document programmatically let syncing = false // set while replacing the document programmatically
@@ -92,6 +105,9 @@ function save() {
function openPath(p) { function openPath(p) {
path.value = 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 }) send({ type: 'open', path: p })
} }
watch(() => props.pagePath, (p) => { openPath(normPath(p)) }) watch(() => props.pagePath, (p) => { openPath(normPath(p)) })
@@ -211,6 +227,8 @@ function onMessage(ev) {
const msg = JSON.parse(ev.data) const msg = JSON.parse(ev.data)
if (msg.type === 'doc' && msg.path === path.value) { if (msg.type === 'doc' && msg.path === path.value) {
setDocument(msg.banner ?? '') setDocument(msg.banner ?? '')
docReady.value = true
view.dispatch({ effects: editable.reconfigure(EditorView.editable.of(true)) })
bannerDesign.value = msg.banner_design ?? null bannerDesign.value = msg.banner_design ?? null
bannerDesignFrom.value = msg.banner_design_from ?? null bannerDesignFrom.value = msg.banner_design_from ?? null
bannerDesignInherited.value = msg.banner_design_inherited ?? '' bannerDesignInherited.value = msg.banner_design_inherited ?? ''
@@ -237,6 +255,7 @@ function onKeydown(ev) {
function connect() { function connect() {
clearTimeout(reconnectTimer) clearTimeout(reconnectTimer)
conn.value = 'connecting'
if (ws) { if (ws) {
// Replacing a stale socket: detach its handlers so its close is silent. // Replacing a stale socket: detach its handlers so its close is silent.
ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null
@@ -249,6 +268,7 @@ function connect() {
clearTimeout(connectWatchdog) clearTimeout(connectWatchdog)
connectWatchdog = watchConnecting(ws, 'banner') connectWatchdog = watchConnecting(ws, 'banner')
ws.onopen = () => { ws.onopen = () => {
conn.value = 'open'
reconnects.opened() reconnects.opened()
if (everConnected) { if (everConnected) {
if (pendingSave) send(pendingSave) if (pendingSave) send(pendingSave)
@@ -261,7 +281,10 @@ function connect() {
// The wait is the policy's: doubling backoff with jitter (./reconnect), // The wait is the policy's: doubling backoff with jitter (./reconnect),
// reset only by a healthy connection — rapid retries trip the browser's // reset only by a healthy connection — rapid retries trip the browser's
// WebSocket throttling (sockets stuck "pending" for minutes). // 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, cmTheme,
cmHighlight, cmHighlight,
EditorView.lineWrapping, EditorView.lineWrapping,
// Locked until the banner's document arrives (docReady/ConnNote).
editable.of(EditorView.editable.of(false)),
EditorView.updateListener.of((u) => { EditorView.updateListener.of((u) => {
if (u.docChanged && !syncing) { if (u.docChanged && !syncing) {
banner.value = view.state.doc.toString() banner.value = view.state.doc.toString()
@@ -311,6 +336,7 @@ onUnmounted(() => {
<template> <template>
<div class="banner-editor"> <div class="banner-editor">
<div v-if="saveError">{{ saveError }}</div> <div v-if="saveError">{{ saveError }}</div>
<ConnNote :text="connNote" />
<section class="block" @paste="onBannerPaste"> <section class="block" @paste="onBannerPaste">
<div class="block-head"> <div class="block-head">
+21
View File
@@ -0,0 +1,21 @@
<script setup>
// Connection-state note for the WebSocket-backed panels (page/banner
// editors, analytics view): while the socket is connecting or waiting to
// reconnect the panel cannot load or save, and this says so. An empty
// text hides the note.
defineProps({ text: { type: String, default: '' } })
</script>
<template>
<div v-if="text" class="conn-note" role="status">{{ text }}</div>
</template>
<style scoped>
.conn-note {
padding: 0.2rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--surface);
color: var(--muted);
font-size: 0.8rem;
}
</style>
+30 -2
View File
@@ -27,7 +27,7 @@
// was loaded in. // was loaded in.
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { EditorView, basicSetup } from 'codemirror' import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state' import { Compartment, EditorState } from '@codemirror/state'
import { keymap } from '@codemirror/view' import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands' import { indentWithTab } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown' import { markdown } from '@codemirror/lang-markdown'
@@ -35,6 +35,7 @@ import { cmHighlight, cmTheme } from './cmtheme'
import { flagFor, langName } from './langs' import { flagFor, langName } from './langs'
import { editorLang } from './editorLang' import { editorLang } from './editorLang'
import LangSelect from './LangSelect.vue' import LangSelect from './LangSelect.vue'
import ConnNote from './ConnNote.vue'
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect' import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
import { dropPageCache, loadPlain } from './swapdoc' import { dropPageCache, loadPlain } from './swapdoc'
@@ -68,6 +69,20 @@ let titleTouched = false
// (a dropped open would otherwise leave the editor empty/stale forever). // (a dropped open would otherwise leave the editor empty/stale forever).
let sessionDoc = null let sessionDoc = null
// Connection state drives the note above the editor (ConnNote), and locks
// input until the page's document has arrived: typing before the accept
// would be clobbered by it. While merely DISconnected the editor stays
// editable — text stashes and pending saves flush on reconnect.
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 page…',
)
let ws = null let ws = null
let view = null let view = null
let savedResolve = null let savedResolve = null
@@ -684,6 +699,9 @@ function stashCurrent() {
function openPath(p) { function openPath(p) {
if (p !== path.value) stashCurrent() if (p !== path.value) stashCurrent()
path.value = 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, lang: lang.value }) send({ type: 'open', path: p, lang: lang.value })
} }
@@ -738,6 +756,8 @@ function onMessage(ev) {
siteLangs.value = msg.translate_langs || [] siteLangs.value = msg.translate_langs || []
lang.value = msg.lang || '' lang.value = msg.lang || ''
sessionDoc = { path: msg.path, lang: lang.value } sessionDoc = { path: msg.path, lang: lang.value }
docReady.value = true
view.dispatch({ effects: editable.reconfigure(EditorView.editable.of(true)) })
titleTouched = false titleTouched = false
// Restore stashed unsaved edits over the server doc when returning // Restore stashed unsaved edits over the server doc when returning
// to a page left dirty. // to a page left dirty.
@@ -961,6 +981,7 @@ function consumePendingLine() {
function connect() { function connect() {
clearTimeout(reconnectTimer) clearTimeout(reconnectTimer)
conn.value = 'connecting'
if (ws) { if (ws) {
// Replacing a stale socket: detach its handlers so its close is silent. // Replacing a stale socket: detach its handlers so its close is silent.
ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null
@@ -976,6 +997,7 @@ function connect() {
clearTimeout(connectWatchdog) clearTimeout(connectWatchdog)
connectWatchdog = watchConnecting(ws, 'editor') connectWatchdog = watchConnecting(ws, 'editor')
ws.onopen = () => { ws.onopen = () => {
conn.value = 'open'
reconnects.opened() reconnects.opened()
if (sessionDoc && sessionDoc.path === path.value && sessionDoc.lang === lang.value) { if (sessionDoc && sessionDoc.path === path.value && sessionDoc.lang === lang.value) {
// Reconnected: local text is authoritative — don't re-open (that // Reconnected: local text is authoritative — don't re-open (that
@@ -995,7 +1017,10 @@ function connect() {
// before the first doc bricks the editor until a retry lands one. // before the first doc bricks the editor until a retry lands one.
// The wait is the policy's: doubling backoff with jitter (./reconnect). // The wait is the policy's: doubling backoff with jitter (./reconnect).
console.warn('[pagerite] editor socket closed:', ev.code, ev.reason || '') console.warn('[pagerite] editor socket closed:', ev.code, ev.reason || '')
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)
} }
} }
@@ -1016,6 +1041,8 @@ onMounted(() => {
cmTheme, cmTheme,
cmHighlight, cmHighlight,
EditorView.lineWrapping, // Markdown lines are long: soft-wrap them EditorView.lineWrapping, // Markdown lines are long: soft-wrap them
// Locked until the page's document arrives (docReady/ConnNote).
editable.of(EditorView.editable.of(false)),
EditorView.updateListener.of((u) => { EditorView.updateListener.of((u) => {
if (u.docChanged) requestRender() if (u.docChanged) requestRender()
// Cursor moves (typing included) drive the page scroll sync. // Cursor moves (typing included) drive the page scroll sync.
@@ -1096,6 +1123,7 @@ onUnmounted(() => {
@click="saveAndRefresh" @click="saveAndRefresh"
>💾</button> >💾</button>
</header> </header>
<ConnNote :text="connNote" />
<div v-if="langOptions.length > 1" class="lang-note"> <div v-if="langOptions.length > 1" class="lang-note">
<template v-if="lang"> <template v-if="lang">
{{ currentLang.name }} translation edits affect only this language. {{ currentLang.name }} translation edits affect only this language.