Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions blocks/edit/da-title/da-title.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
89 changes: 89 additions & 0 deletions blocks/edit/prose/forcesave.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// ---- 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.' };
}
2 changes: 2 additions & 0 deletions blocks/edit/prose/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { COLLAB_ORIGIN, DA_ORIGIN } from '../../shared/constants.js';
import { daFetch, getAuthToken } from '../../shared/utils.js';
import { getDiffClass, checkForLocNodes, addActiveView } from './diff/diff-utils.js';
import { debounce, initDaMetadata } from '../utils/helpers.js';
import { forceSave } from './forcesave.js';

async function checkDoc(path) {
return daFetch(path, { method: 'HEAD' });
Expand Down Expand Up @@ -541,4 +542,5 @@ export default async function initProse({ path, permissions, doc, daContent, wsP

daContent.proseEl = editor;
daContent.wsProvider = wsProvider;
daContent.forceSave = () => forceSave(wsProvider);
}
120 changes: 120 additions & 0 deletions test/unit/blocks/edit/prose/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import initProse, {
createConnection,
createAwarenessStatusWidget,
} from '../../../../../blocks/edit/prose/index.js';
import { forceSave } from '../../../../../blocks/edit/prose/forcesave.js';

// initProse lazily imports da-library.js, which (a) builds URLs from
// `${getNx()}/...` and (b) calls loadLibrary() at module import time.
Expand Down Expand Up @@ -572,3 +573,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;
});
});
Loading