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:
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
</nav>
|
||||
<a href="/" class="close" title="home">✕</a>
|
||||
</header>
|
||||
<ConnNote :text="connNote" />
|
||||
<p v-if="error" class="error">⚠️ {{ error }}</p>
|
||||
<p v-else-if="!data" class="loading">loading…</p>
|
||||
<template v-else>
|
||||
|
||||
@@ -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(() => {
|
||||
<template>
|
||||
<div class="banner-editor">
|
||||
<div v-if="saveError">{{ saveError }}</div>
|
||||
<ConnNote :text="connNote" />
|
||||
|
||||
<section class="block" @paste="onBannerPaste">
|
||||
<div class="block-head">
|
||||
|
||||
@@ -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>
|
||||
@@ -27,7 +27,7 @@
|
||||
// was loaded in.
|
||||
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 { markdown } from '@codemirror/lang-markdown'
|
||||
@@ -35,6 +35,7 @@ import { cmHighlight, cmTheme } from './cmtheme'
|
||||
import { flagFor, langName } from './langs'
|
||||
import { editorLang } from './editorLang'
|
||||
import LangSelect from './LangSelect.vue'
|
||||
import ConnNote from './ConnNote.vue'
|
||||
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
|
||||
import { dropPageCache, loadPlain } from './swapdoc'
|
||||
|
||||
@@ -68,6 +69,20 @@ let titleTouched = false
|
||||
// (a dropped open would otherwise leave the editor empty/stale forever).
|
||||
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 view = null
|
||||
let savedResolve = null
|
||||
@@ -684,6 +699,9 @@ function stashCurrent() {
|
||||
function openPath(p) {
|
||||
if (p !== path.value) stashCurrent()
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -738,6 +756,8 @@ function onMessage(ev) {
|
||||
siteLangs.value = msg.translate_langs || []
|
||||
lang.value = msg.lang || ''
|
||||
sessionDoc = { path: msg.path, lang: lang.value }
|
||||
docReady.value = true
|
||||
view.dispatch({ effects: editable.reconfigure(EditorView.editable.of(true)) })
|
||||
titleTouched = false
|
||||
// Restore stashed unsaved edits over the server doc when returning
|
||||
// to a page left dirty.
|
||||
@@ -961,6 +981,7 @@ function consumePendingLine() {
|
||||
|
||||
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
|
||||
@@ -976,6 +997,7 @@ function connect() {
|
||||
clearTimeout(connectWatchdog)
|
||||
connectWatchdog = watchConnecting(ws, 'editor')
|
||||
ws.onopen = () => {
|
||||
conn.value = 'open'
|
||||
reconnects.opened()
|
||||
if (sessionDoc && sessionDoc.path === path.value && sessionDoc.lang === lang.value) {
|
||||
// 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.
|
||||
// The wait is the policy's: doubling backoff with jitter (./reconnect).
|
||||
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,
|
||||
cmHighlight,
|
||||
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) => {
|
||||
if (u.docChanged) requestRender()
|
||||
// Cursor moves (typing included) drive the page scroll sync.
|
||||
@@ -1096,6 +1123,7 @@ onUnmounted(() => {
|
||||
@click="saveAndRefresh"
|
||||
>💾</button>
|
||||
</header>
|
||||
<ConnNote :text="connNote" />
|
||||
<div v-if="langOptions.length > 1" class="lang-note">
|
||||
<template v-if="lang">
|
||||
{{ currentLang.name }} translation — edits affect only this language.
|
||||
|
||||
Reference in New Issue
Block a user