Update dependency ckeditor5 to v49 (#4516)

* Update dependency ckeditor5 to v49

* Add editor crash recovery and complete the CKEditor 5 v49 migration

CKEditor 5 v49 removed the Watchdog, so crashed editors are no longer
restarted automatically. The editor wrapper now recreates a crashed
editor from the content synced to its form element (up to 3 times
within a 5s window) and locks it read-only if it keeps crashing, so
the last known-good content is never lost and forms stay submittable.

Also replaces the deprecated ClassicEditor.create() overload with the
config-only form, and drops the dark-mode inline-style hack from the
wysiwyg() Twig function - the refreshed v49 theme pins the editor
colors itself.

* [CodeFactor] Apply fixes to commit b6aa70c

* Address review: reuse editor wrapper and clean up bookkeeping

Recovery now swaps the recreated CKEditor instance into the existing
normalized wrapper via replaceRaw() instead of building a second
wrapper, so references held by callers (element.editor, the resolved
create() promise) stay valid and their change listeners survive.

Editor destruction now unregisters the editor from the page-level
error registry and drops its crash record, instead of retaining both
for the life of the page.

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Adam Daley <admdly@users.noreply.github.com>
Co-authored-by: codefactor-io <support@codefactor.io>
This commit is contained in:
authored and GitHub committed 2026-10-18 02:24:19 +01:00
1 parent ec848c6c49
commit f17a82610a
7 files changed
+1060 -651

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
/**
* Pure helpers for the FOSSBilling editor runtime.
*
* Kept free of window/document access so they can be unit tested with plain
* stubs. See tests/editor-helpers.test.mts.
*/
/** How many times an editor may be recreated for crashes inside the window. */
export const EDITOR_CRASH_LIMIT = 3;
/** Rolling window (ms) inside which repeated crashes count as a crash loop. */
export const EDITOR_CRASH_WINDOW_MS = 5000;
export interface EditorCrashPolicyOptions {
limit: number;
windowMs: number;
}
export interface EditorCrashDecision {
times: number[];
shouldRecover: boolean;
}
/**
* Records a crash at `now` against previous crash timestamps and decides
* whether the editor may be recreated again. Crashes older than the rolling
* window are forgotten; once `limit` recoveries have been spent inside the
* window, the next crash is refused (the editor is locked instead).
*/
export function recordEditorCrash(
previousTimes: number[],
now: number,
options: EditorCrashPolicyOptions,
): EditorCrashDecision {
const times = previousTimes.filter((time) => now - time < options.windowMs);
times.push(now);
return { times, shouldRecover: times.length <= options.limit };
}
/**
* Reads the current editor content from the source element. FOSSBilling keeps
* the element in sync on every editor change, so this is the last known-good
* copy of the content - the safe source for recreating a crashed editor.
*/
export function readElementData(element) {
if ('value' in element) {
return element.value;
}
return element.textContent;
}
export function writeElementData(element, data) {
if ('value' in element) {
element.value = data;
} else {
element.textContent = data;
}
}
/**
* Wraps a raw adapter editor behind the stable API used across FOSSBilling
* (getData/setData/focus/destroy/onChange/raw).
*
* Crash recovery recreates the underlying instance; `replaceRaw()` repoints
* every method at the new instance and re-attaches change listeners on the
* `model.document` path. Adapters that implement their own `onChange()` need
* to survive instance swaps on their own.
*/
export function normalizeEditor(element, editor) {
if (!editor || typeof editor.getData !== 'function' || typeof editor.setData !== 'function') {
throw new Error('Editor adapters must return getData() and setData() methods.');
}
let current = editor;
const changeCallbacks = [];
const attachChangeListeners = () => {
if (current.model?.document?.on) {
changeCallbacks.forEach((callback) => {
current.model.document.on('change:data', callback);
});
}
};
attachChangeListeners();
const normalized = {
get raw() {
return current.raw || current;
},
getData: () => current.getData(),
setData: (value) => current.setData(value),
focus: () => {
if (typeof current.focus === 'function') {
current.focus();
return;
}
if (current.editing?.view?.focus) {
current.editing.view.focus();
}
},
destroy: () => {
if (typeof current.destroy === 'function') {
return current.destroy();
}
return Promise.resolve();
},
onChange: (callback) => {
changeCallbacks.push(callback);
if (typeof current.onChange === 'function') {
return current.onChange(callback);
}
if (current.model?.document?.on) {
current.model.document.on('change:data', callback);
}
},
replaceRaw(next) {
if (!next || next === current) {
return;
}
current = next;
attachChangeListeners();
},
element,
name: element.getAttribute('name') || element.id || null,
required: element.dataset.editorRequired === 'true',
};
return normalized;
}
+126 -56
View File
@@ -1,5 +1,13 @@
// @ts-nocheck -- Runtime DOM/widget integration; converted to TS without changing behavior.
import { formatCurrencyAmount } from './currency-format.mts';
import {
EDITOR_CRASH_LIMIT,
EDITOR_CRASH_WINDOW_MS,
normalizeEditor,
readElementData,
recordEditorCrash,
writeElementData,
} from './editor-helpers.mts';
/**
* FOSSBilling browser runtime.
@@ -14,6 +22,9 @@ import { formatCurrencyAmount } from './currency-format.mts';
const editorsByElement = new WeakMap();
const editorsByName = new Map();
const adapters = new Map();
// Per-element crash-recovery bookkeeping: adapter, options, crash history,
// and the unregister function for the adapter's error reporting.
const crashRecords = new WeakMap();
const cookieNames = Object.freeze({
locale: 'fossbilling_locale',
timezone: 'fossbilling_timezone',
@@ -158,58 +169,129 @@ import { formatCurrencyAmount } from './currency-format.mts';
FOSSBilling.initTimezone = initTimezone;
function getEditorName(element) {
return element.getAttribute('name') || element.id || null;
}
function syncElementData(element, editor) {
const data = editor.getData();
if ('value' in element) {
element.value = data;
} else {
element.textContent = data;
}
writeElementData(element, editor.getData());
}
function normalizeEditor(element, editor) {
if (!editor || typeof editor.getData !== 'function' || typeof editor.setData !== 'function') {
throw new Error('Editor adapters must return getData() and setData() methods.');
function registerEditorInstance(element, adapter, options, rawEditor) {
const editor = normalizeEditor(element, rawEditor);
// Normal destruction must also drop the crash-recovery bookkeeping, so
// the page-level error registry does not retain the editor.
const destroy = editor.destroy;
editor.destroy = () => {
crashRecords.get(element)?.off?.();
crashRecords.delete(element);
return destroy();
};
syncElementData(element, editor);
editor.onChange(() => syncElementData(element, editor));
editorsByElement.set(element, editor);
if (editor.name) {
editorsByName.set(editor.name, editor);
}
return {
raw: editor.raw || editor,
getData: () => editor.getData(),
setData: (value) => editor.setData(value),
focus: () => {
if (typeof editor.focus === 'function') {
editor.focus();
return;
}
element.editor = editor;
element.setAttribute('data-editor', 'true');
if (editor.editing?.view?.focus) {
editor.editing.view.focus();
}
},
destroy: () => {
if (typeof editor.destroy === 'function') {
return editor.destroy();
}
attachCrashRecovery(element, adapter, options, rawEditor);
return Promise.resolve();
},
onChange: (callback) => {
if (typeof editor.onChange === 'function') {
return editor.onChange(callback);
}
return editor;
}
if (editor.model?.document?.on) {
editor.model.document.on('change:data', callback);
}
},
element,
name: getEditorName(element),
required: element.dataset.editorRequired === 'true',
function attachCrashRecovery(element, adapter, options, rawEditor) {
if (typeof adapter.onFatalError !== 'function') {
return;
}
const previous = crashRecords.get(element);
previous?.off?.();
const record = {
adapter,
options,
// Crash history survives recreations of the same element; a fresh
// registration after a permanent lock starts over.
times: previous && !previous.locked ? previous.times : [],
recovering: false,
locked: false,
off: null,
};
record.off = adapter.onFatalError(rawEditor, (error) => {
handleFatalError(element, error);
});
crashRecords.set(element, record);
}
function handleFatalError(element, error) {
const record = crashRecords.get(element);
if (!record || record.recovering || record.locked) {
return;
}
const decision = recordEditorCrash(record.times, Date.now(), {
limit: EDITOR_CRASH_LIMIT,
windowMs: EDITOR_CRASH_WINDOW_MS,
});
record.times = decision.times;
if (!decision.shouldRecover) {
record.locked = true;
record.off?.();
record.adapter.lock?.(editorsByElement.get(element)?.raw);
console.error(
'FOSSBilling editor crashed repeatedly and is now read-only. Its content is kept in the form field - reload the page to get a fresh editor.',
error,
);
return;
}
record.recovering = true;
recreateEditor(element, record)
.catch((recoveryError) => {
record.locked = true;
record.off?.();
console.error(
'FOSSBilling editor recovery failed. Its last content is kept in the form field - reload the page to get a fresh editor.',
recoveryError,
);
})
.finally(() => {
record.recovering = false;
});
}
async function recreateEditor(element, record) {
// Read the content from the synced form element, not editor.getData():
// the element holds the last known-good copy of the content.
const previous = editorsByElement.get(element);
const data = readElementData(element);
try {
await previous?.raw?.destroy?.();
} catch (destroyError) {
console.error('FOSSBilling editor could not destroy the crashed instance:', destroyError);
}
// The destroyed editor may write its own state back to the element
// (updateSourceElementOnDestroy), so restore the captured copy.
writeElementData(element, data);
const rawEditor = await record.adapter.create(element, { ...record.options, initialData: data });
// Reuse the existing wrapper so references callers hold stay valid;
// replaceRaw() re-attaches their change listeners to the new instance.
if (previous) {
previous.replaceRaw(rawEditor);
syncElementData(element, previous);
attachCrashRecovery(element, record.adapter, record.options, rawEditor);
return previous;
}
return registerEditorInstance(element, record.adapter, record.options, rawEditor);
}
FOSSBilling.editor = Object.assign({
@@ -238,20 +320,8 @@ import { formatCurrencyAmount } from './currency-format.mts';
}
const rawEditor = await adapter.create(element, options);
const editor = normalizeEditor(element, rawEditor);
syncElementData(element, editor);
editor.onChange(() => syncElementData(element, editor));
editorsByElement.set(element, editor);
if (editor.name) {
editorsByName.set(editor.name, editor);
}
element.editor = editor;
element.setAttribute('data-editor', 'true');
return editor;
return registerEditorInstance(element, adapter, options, rawEditor);
},
init(selector, options = {}) {
+218
View File
@@ -0,0 +1,218 @@
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import {
EDITOR_CRASH_LIMIT,
EDITOR_CRASH_WINDOW_MS,
normalizeEditor,
readElementData,
recordEditorCrash,
writeElementData,
} from '../editor-helpers.mts';
function makeElement(overrides = {}) {
return {
getAttribute: () => null,
id: 'editor-1',
dataset: {},
...overrides,
};
}
function makeRawEditor(overrides = {}) {
const model = makeModel();
return { getData: () => '', setData: () => {}, model, ...overrides };
}
function makeModel() {
const listeners = [];
return {
document: {
on: (event, callback) => {
listeners.push([event, callback]);
},
},
fire(event) {
listeners.filter(([name]) => name === event).forEach(([, callback]) => callback());
},
listenerCount(event) {
return listeners.filter(([name]) => name === event).length;
},
};
}
describe('recordEditorCrash', () => {
const policy = { limit: EDITOR_CRASH_LIMIT, windowMs: EDITOR_CRASH_WINDOW_MS };
test('recovers on the first crash', () => {
const decision = recordEditorCrash([], 1000, policy);
assert.deepEqual(decision.times, [1000]);
assert.equal(decision.shouldRecover, true);
});
test('recovers up to the crash limit within the window', () => {
let times: number[] = [];
for (const now of [1000, 2000, 3000]) {
const decision = recordEditorCrash(times, now, policy);
times = decision.times;
assert.equal(decision.shouldRecover, true);
}
});
test('refuses to recover once the limit is spent inside the window', () => {
let times: number[] = [];
for (const now of [1000, 2000, 3000]) {
times = recordEditorCrash(times, now, policy).times;
}
const decision = recordEditorCrash(times, 4000, policy);
assert.equal(decision.times.length, 4);
assert.equal(decision.shouldRecover, false);
});
test('forgets crashes older than the rolling window', () => {
const decision = recordEditorCrash([1000, 2000, 3000], 3000 + EDITOR_CRASH_WINDOW_MS, policy);
assert.deepEqual(decision.times, [3000 + EDITOR_CRASH_WINDOW_MS]);
assert.equal(decision.shouldRecover, true);
});
});
describe('readElementData/writeElementData', () => {
test('uses the value property for form-field elements', () => {
const element = { value: '<p>old</p>' };
assert.equal(readElementData(element), '<p>old</p>');
writeElementData(element, '<p>new</p>');
assert.equal(element.value, '<p>new</p>');
});
test('falls back to textContent for non-form elements', () => {
const element = { textContent: 'old' };
assert.equal(readElementData(element), 'old');
writeElementData(element, 'new');
assert.equal(element.textContent, 'new');
});
});
describe('normalizeEditor', () => {
test('rejects editors without getData and setData', () => {
assert.throws(() => normalizeEditor(makeElement(), {}), /getData\(\) and setData\(\)/);
assert.throws(() => normalizeEditor(makeElement(), null), /getData\(\) and setData\(\)/);
});
test('delegates getData and setData to the current instance', () => {
let firstSet;
let secondSet;
const first = makeRawEditor({ getData: () => 'first', setData: (value) => { firstSet = value; } });
const second = makeRawEditor({ getData: () => 'second', setData: (value) => { secondSet = value; } });
const editor = normalizeEditor(makeElement(), first);
assert.equal(editor.getData(), 'first');
editor.replaceRaw(second);
assert.equal(editor.getData(), 'second');
editor.setData('hello');
assert.equal(secondSet, 'hello');
assert.equal(firstSet, undefined);
});
test('ignores empty or identical replaceRaw calls', () => {
const first = makeRawEditor();
const editor = normalizeEditor(makeElement(), first);
editor.replaceRaw(null);
editor.replaceRaw(first);
assert.equal(editor.raw, first);
});
test('raw exposes editor.raw when the adapter provides one', () => {
const inner = { marker: true };
const raw = makeRawEditor({ raw: inner });
const editor = normalizeEditor(makeElement(), raw);
assert.equal(editor.raw, inner);
});
test('re-binds change listeners onto the replaced instance', () => {
const first = makeRawEditor();
const second = makeRawEditor();
const editor = normalizeEditor(makeElement(), first);
const seen = [];
editor.onChange(() => seen.push('change'));
editor.replaceRaw(second);
second.model.fire('change:data');
assert.deepEqual(seen, ['change']);
assert.equal(first.model.listenerCount('change:data'), 1);
assert.equal(second.model.listenerCount('change:data'), 1);
});
test('delegates onChange to editors that provide their own', () => {
const registered = [];
const raw = makeRawEditor({
onChange: (callback) => registered.push(callback),
});
const editor = normalizeEditor(makeElement(), raw);
editor.onChange(() => {});
assert.equal(registered.length, 1);
});
test('focus prefers editor.focus and falls back to the editing view', () => {
let focused = 0;
const withFocus = makeRawEditor({ focus: () => { focused += 1; } });
normalizeEditor(makeElement(), withFocus).focus();
assert.equal(focused, 1);
let viewFocused = 0;
const withView = makeRawEditor({
editing: { view: { focus: () => { viewFocused += 1; } } },
});
normalizeEditor(makeElement(), withView).focus();
assert.equal(viewFocused, 1);
});
test('destroy delegates to the instance', async () => {
let destroyed = 0;
const raw = makeRawEditor({ destroy: () => { destroyed += 1; return Promise.resolve(); } });
const editor = normalizeEditor(makeElement(), raw);
await editor.destroy();
assert.equal(destroyed, 1);
});
test('destroy resolves when the instance has no destroy method', async () => {
const raw = makeRawEditor();
const editor = normalizeEditor(makeElement(), raw);
await assert.doesNotReject(editor.destroy());
});
test('reads the name from the name attribute, then the id', () => {
const byName = normalizeEditor(makeElement({ getAttribute: (key) => (key === 'name' ? 'body' : null) }), makeRawEditor());
assert.equal(byName.name, 'body');
const byId = normalizeEditor(makeElement({ id: 'intro' }), makeRawEditor());
assert.equal(byId.name, 'intro');
});
test('tracks the required flag from the dataset', () => {
assert.equal(normalizeEditor(makeElement(), makeRawEditor()).required, false);
assert.equal(
normalizeEditor(makeElement({ dataset: { editorRequired: 'true' } }), makeRawEditor()).required,
true,
);
});
});
+22 -3
View File
@@ -32,7 +32,6 @@ import '@ckeditor/ckeditor5-typing/dist/index.css';
import '@ckeditor/ckeditor5-undo/dist/index.css';
import '@ckeditor/ckeditor5-upload/dist/index.css';
import '@ckeditor/ckeditor5-utils/dist/index.css';
import '@ckeditor/ckeditor5-watchdog/dist/index.css';
import '@ckeditor/ckeditor5-widget/dist/index.css';
import '@ckeditor/ckeditor5-autoformat/dist/index.css';
@@ -99,8 +98,28 @@ class FOSSBillingCKEditor extends ClassicEditor {
window.FOSSBilling.editor.registerAdapter('ckeditor', {
create(element, options = {}) {
const { adapter, ...editorOptions } = options;
const { adapter, initialData, ...editorOptions } = options;
return FOSSBillingCKEditor.create(element, editorOptions);
const config = { ...editorOptions, attachTo: element };
if (initialData !== undefined) {
config.root = { ...config.root, initialData };
}
return FOSSBillingCKEditor.create(config);
},
// Reports runtime errors that escape the given editor instance so the
// wrapper can recover from them (v49 removed the Watchdog).
onFatalError(editor, callback) {
return FOSSBillingCKEditor.onEditorError(({ error, source }) => {
if (source === editor) {
callback(error);
}
});
},
// Stops further editing while keeping the content visible and submittable.
lock(editor) {
editor.enableReadOnlyMode('fossbilling-editor-crash');
},
});
+556 -583
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -33,7 +33,7 @@
"@playwright/test": "1.64.1",
"@types/node": "24.19.2",
"autoprefixer": "10.6.1",
"ckeditor5": "48.5.2",
"ckeditor5": "49.0.0",
"esbuild": "0.28.2",
"github-markdown-css": "5.9.0",
"postcss": "8.5.30",
@@ -311,14 +311,6 @@ class FOSSBillingExtension
<script>
FOSSBilling.ready(function () {
FOSSBilling.editor.init({$selectorJson}, {$optionsJson});
if (document.documentElement.getAttribute('data-bs-theme') === 'dark' || localStorage.getItem('theme') === 'dark') {
setTimeout(function () {
document.querySelectorAll('.ck-editor__main').forEach(function (element) {
element.style.color = '#1d273b';
});
}, 1000);
}
});
</script>
HTML,