diff --git a/blocks/edit/da-title/da-title.js b/blocks/edit/da-title/da-title.js index 55efc999..57ba7d95 100644 --- a/blocks/edit/da-title/da-title.js +++ b/blocks/edit/da-title/da-title.js @@ -264,6 +264,22 @@ export default class DaTitle extends LitElement { // AEM Actions if (action === 'preview' || action === 'publish') { + // Force-flush pending collab saves to da-admin before writing to AEM. + // This ensures that what the user sees in the editor matches what AEM gets. + // Only applies to the prose editor (edit view) — sheets/configs save synchronously above. + if (view === 'edit') { + const daContent = document.querySelector('da-content'); + if (daContent?.forceSave) { + const flushResult = await daContent.forceSave(); + if (!flushResult.ok) { + const msg = flushResult.error || 'Unable to confirm save. Please retry or reload the editor.'; + this._status = { message: msg }; + this._isSending = false; + return; + } + } + } + let json = await saveToAem(aemPath, 'preview'); if (json.error) { this.handleError(json, 'preview'); diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js index ebe8cf4e..bb26306f 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -89,6 +89,96 @@ export async function createConnection(path) { return { wsProvider: provider, ydoc }; } +// ---- Force-save protocol ---- +// Matches da-collab messageFlushRequest / messageFlushResponse constants. +const MSG_FLUSH_REQUEST = 2; +const MSG_FLUSH_RESPONSE = 3; +const FLUSH_TIMEOUT_MS = 8000; +const FLUSH_MAX_RETRIES = 3; + +function decodeFlushAck(data) { + const ok = data[1] === 1; + if (ok) return { ok: true }; + // Decode varint-prefixed error string written by lib0/encoding.writeVarString + let offset = 2; + let len = 0; + let shift = 0; + while (offset < data.length) { + const b = data[offset]; + offset += 1; + // eslint-disable-next-line no-bitwise + len |= (b & 0x7f) << shift; + // eslint-disable-next-line no-bitwise + if ((b & 0x80) === 0) break; + shift += 7; + } + const error = new TextDecoder().decode(data.slice(offset, offset + len)); + return { ok: false, error }; +} + +function sendFlushRequest(ws) { + return new Promise((resolve) => { + let timer; + + const onMessage = (event) => { + const data = new Uint8Array(event.data); + if (data[0] !== MSG_FLUSH_RESPONSE) return; + clearTimeout(timer); + ws.removeEventListener('message', onMessage); + resolve(decodeFlushAck(data)); + }; + + timer = setTimeout(() => { + ws.removeEventListener('message', onMessage); + resolve({ ok: false, timeout: true }); + }, FLUSH_TIMEOUT_MS); + + ws.addEventListener('message', onMessage); + ws.send(new Uint8Array([MSG_FLUSH_REQUEST])); + }); +} + +function waitForWsConnection(provider) { + return new Promise((resolve, reject) => { + if (provider.wsconnected) { + resolve(); + return; + } + let timer; + + const onStatus = ({ status }) => { + if (status === 'connected') { + clearTimeout(timer); + provider.off('status', onStatus); + resolve(); + } + }; + + timer = setTimeout(() => { + provider.off('status', onStatus); + reject(new Error('connection timeout')); + }, FLUSH_TIMEOUT_MS); + + provider.on('status', onStatus); + }); +} + +export async function forceSave(provider) { + for (let attempt = 0; attempt < FLUSH_MAX_RETRIES; attempt += 1) { + try { + if (!provider.wsconnected) { + await waitForWsConnection(provider); + } + const result = await sendFlushRequest(provider.ws); + if (result.ok) return { ok: true }; + if (!result.timeout) return result; // server-side error, no retry + } catch { + // connection wait timed out, try again + } + } + return { ok: false, error: 'Unable to confirm save. Please retry or reload the editor.' }; +} + async function loadCustomPlugins() { const [ keyHandlers, @@ -539,4 +629,5 @@ export default async function initProse({ path, permissions, doc, daContent, wsP daContent.proseEl = editor; daContent.wsProvider = wsProvider; + daContent.forceSave = () => forceSave(wsProvider); } diff --git a/test/unit/blocks/edit/prose/index.test.js b/test/unit/blocks/edit/prose/index.test.js index 8ad80a88..997d331c 100644 --- a/test/unit/blocks/edit/prose/index.test.js +++ b/test/unit/blocks/edit/prose/index.test.js @@ -5,6 +5,7 @@ import { setNx } from '../../../../../scripts/utils.js'; import initProse, { createConnection, createAwarenessStatusWidget, + forceSave, } from '../../../../../blocks/edit/prose/index.js'; // initProse lazily imports da-library.js, which (a) builds URLs from @@ -493,3 +494,122 @@ describe('prose/index initProse default export', () => { expect(destroyed).to.equal(1); }); }); + +// ---- forceSave tests ---- + +function buildFakeWs({ connected = true, responseOk = true, responseError = '', delayMs = 0 } = {}) { + const listeners = []; + const sent = []; + + const ws = { + sent, + addEventListener(type, cb) { if (type === 'message') listeners.push(cb); }, + removeEventListener(type, cb) { + if (type !== 'message') return; + const i = listeners.indexOf(cb); + if (i > -1) listeners.splice(i, 1); + }, + send(data) { + sent.push(data); + if (!connected) return; + // Simulate server response after optional delay + setTimeout(() => { + // Build MSG_FLUSH_RESPONSE (3) + ok flag + optional error string + let resp; + if (responseOk) { + resp = new Uint8Array([3, 1]); + } else { + const errBytes = new TextEncoder().encode(responseError); + resp = new Uint8Array([3, 0, errBytes.length, ...errBytes]); + } + listeners.forEach((cb) => cb({ data: resp.buffer })); + }, delayMs); + }, + }; + return ws; +} + +function buildFakeProvider({ wsconnected = true, ws = null } = {}) { + const listeners = new Map(); + return { + wsconnected, + ws, + on(event, cb) { + if (!listeners.has(event)) listeners.set(event, []); + listeners.get(event).push(cb); + }, + off(event, cb) { + const arr = listeners.get(event); + if (!arr) return; + const i = arr.indexOf(cb); + if (i > -1) arr.splice(i, 1); + }, + _emit(event, ...args) { + (listeners.get(event) || []).forEach((cb) => cb(...args)); + }, + }; +} + +describe('forceSave', () => { + it('returns ok:true when server acks the flush', async () => { + const ws = buildFakeWs({ responseOk: true }); + const provider = buildFakeProvider({ wsconnected: true, ws }); + + const result = await forceSave(provider); + expect(result.ok).to.be.true; + expect(ws.sent).to.have.length(1); + expect(ws.sent[0][0]).to.equal(2); // MSG_FLUSH_REQUEST + }); + + it('returns ok:false with error message when server reports failure', async () => { + const ws = buildFakeWs({ responseOk: false, responseError: 'save failed' }); + const provider = buildFakeProvider({ wsconnected: true, ws }); + + const result = await forceSave(provider); + expect(result.ok).to.be.false; + expect(result.error).to.equal('save failed'); + }); + + it('waits for connection then sends flush when initially disconnected', async () => { + const ws = buildFakeWs({ responseOk: true }); + const provider = buildFakeProvider({ wsconnected: false, ws }); + + // Simulate reconnect after a tick + setTimeout(() => { + provider.wsconnected = true; + provider._emit('status', { status: 'connected' }); + }, 5); + + const result = await forceSave(provider); + expect(result.ok).to.be.true; + expect(ws.sent).to.have.length(1); + }); + + it('ignores unrelated message types while waiting for ack', async () => { + const listeners = []; + const sent = []; + + const ws = { + sent, + addEventListener(type, cb) { if (type === 'message') listeners.push(cb); }, + removeEventListener(type, cb) { + if (type !== 'message') return; + const i = listeners.indexOf(cb); + if (i > -1) listeners.splice(i, 1); + }, + send(data) { + sent.push(data); + // Send a yjs sync message (type 0) first, then the real ack + setTimeout(() => { + listeners.forEach((cb) => cb({ data: new Uint8Array([0, 0]).buffer })); + }, 5); + setTimeout(() => { + listeners.forEach((cb) => cb({ data: new Uint8Array([3, 1]).buffer })); + }, 10); + }, + }; + const provider = buildFakeProvider({ wsconnected: true, ws }); + const result = await forceSave(provider); + expect(result.ok).to.be.true; + }); +});