Skip to content

Commit ab33464

Browse files
committed
defenUntil() + fix legacy FLD support
Fixes #19380
1 parent e106f16 commit ab33464

8 files changed

Lines changed: 180 additions & 41 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Release Notes for Craft CMS 6
22

3+
## Unreleased
4+
5+
- Fixed a JavaScript error that occurred on non-Inertial pages that rendered field layout designers. ([#19380]())
6+
37
## 6.0.0-alpha.16 - 2026-08-05
48

59
- Fixed a bug where Yii-style migrations could be required twice. ([#19376](https://github.com/craftcms/cms/pull/19376))

packages/craftcms-garnish/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export {
7575
isArray,
7676
isPlainObject,
7777
isTextNode,
78+
deferUntil,
7879
log,
7980
handleActivatingKeypress,
8081
} from './misc';

packages/craftcms-garnish/src/utils/misc.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,48 @@ export function log(msg: unknown): void {
7272
}
7373
}
7474

75+
/**
76+
* Resolves once `test()` returns a truthy value, calling it again every
77+
* `delay` milliseconds in the meantime.
78+
*
79+
* `test` may return a promise; its resolved value is awaited before being
80+
* checked for truthiness, and the next call isn’t scheduled until it settles.
81+
* A rejection/throw from `test()` propagates, rejecting the returned promise.
82+
*
83+
* @param test A function to call repeatedly until it returns (or resolves to) a truthy value.
84+
* @param delay The interval, in milliseconds, between calls (default `100`).
85+
* @param signal An optional `AbortSignal`. If already aborted, or aborted while
86+
* waiting for the next call, the returned promise rejects with `signal.reason`
87+
* instead of continuing to poll — use this to stop polling once a caller (e.g.
88+
* an owning class' `destroy()`) no longer cares about the result.
89+
* @returns A promise that resolves with `test()`’s truthy return value.
90+
*/
91+
export async function deferUntil<T>(
92+
test: () => T | Promise<T>,
93+
delay = 100,
94+
signal?: AbortSignal
95+
): Promise<T> {
96+
if (signal?.aborted) {
97+
throw signal.reason;
98+
}
99+
const result = await test();
100+
if (result) {
101+
return result;
102+
}
103+
await new Promise<void>((resolve, reject) => {
104+
const timer = setTimeout(resolve, delay);
105+
signal?.addEventListener(
106+
'abort',
107+
() => {
108+
clearTimeout(timer);
109+
reject(signal.reason);
110+
},
111+
{once: true}
112+
);
113+
});
114+
return deferUntil(test, delay, signal);
115+
}
116+
75117
/**
76118
* Space/Enter → preventDefault + callback.
77119
* @deprecated The `activate` event should be used instead.

packages/craftcms-garnish/tests/utils.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import {describe, expect, it} from 'vite-plus/test';
22

3-
import {getDist, within, isString, isTextNode} from '../src/utils/misc';
3+
import {
4+
getDist,
5+
within,
6+
isString,
7+
isTextNode,
8+
deferUntil,
9+
} from '../src/utils/misc';
410
import {getInputPostVal, getPostData, findInputs} from '../src/utils/forms';
511
import {hasAttr, nearestSibling, closestRegistered} from '../src/utils/dom';
612

@@ -21,6 +27,52 @@ describe('misc utils', () => {
2127
expect(isTextNode(document.createTextNode('x'))).toBe(true);
2228
expect(isTextNode(document.createElement('div'))).toBe(false);
2329
});
30+
it('deferUntil resolves immediately if test() is already truthy', async () => {
31+
const test = () => 'ready';
32+
expect(await deferUntil(test)).toBe('ready');
33+
});
34+
it('deferUntil polls test() at the given interval until it’s truthy', async () => {
35+
let calls = 0;
36+
const test = () => (++calls >= 3 ? calls : false);
37+
expect(await deferUntil(test, 5)).toBe(3);
38+
expect(calls).toBe(3);
39+
});
40+
it('deferUntil awaits an async test() before checking truthiness', async () => {
41+
let calls = 0;
42+
const test = async () => {
43+
calls++;
44+
return calls >= 2;
45+
};
46+
expect(await deferUntil(test, 5)).toBe(true);
47+
expect(calls).toBe(2);
48+
});
49+
it('deferUntil rejects if test() throws', async () => {
50+
const test = () => {
51+
throw new Error('nope');
52+
};
53+
await expect(deferUntil(test, 5)).rejects.toThrow('nope');
54+
});
55+
it('deferUntil rejects immediately if the signal is already aborted', async () => {
56+
const controller = new AbortController();
57+
controller.abort(new Error('cancelled'));
58+
const test = () => true;
59+
await expect(deferUntil(test, 5, controller.signal)).rejects.toThrow(
60+
'cancelled'
61+
);
62+
});
63+
it('deferUntil rejects if the signal aborts while waiting to poll again', async () => {
64+
const controller = new AbortController();
65+
const test = () => false;
66+
const promise = deferUntil(test, 20, controller.signal);
67+
setTimeout(() => controller.abort(new Error('cancelled')), 5);
68+
await expect(promise).rejects.toThrow('cancelled');
69+
});
70+
it('deferUntil stops polling once truthy, even with a signal attached', async () => {
71+
const controller = new AbortController();
72+
let calls = 0;
73+
const test = () => (++calls >= 2 ? calls : false);
74+
expect(await deferUntil(test, 5, controller.signal)).toBe(2);
75+
});
2476
});
2577

2678
describe('dom utils', () => {

resources/js/modules/field-layout-designer/field-layout-designer.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
Base,
3+
deferUntil,
34
ESC_KEY,
45
type GarnishEvent,
56
hasAttr,
@@ -111,6 +112,13 @@ export class FieldLayoutDesigner extends Base<FieldLayoutDesignerSettings> {
111112
if (this.settings!.readOnly) {
112113
this.$fieldLibrary.setAttribute('tabindex', '-1');
113114
}
115+
116+
deferUntil(() => !!Craft?.Grid).then(() => {
117+
this.deferredInit();
118+
});
119+
}
120+
121+
deferredInit(): void {
114122
// Set up the layout grids — Craft.Grid is a jQuery seam.
115123
this.tabGrid = new Craft.Grid($(this.$tabContainer), {
116124
itemSelector: '.fld-tab',

resources/js/modules/matrix/matrix-entry.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* `elements/update-field-layout` driven by a `Craft.FormObserver`.
88
*/
99

10-
import {Base, getInputPostVal, hasAttr} from '@craftcms/garnish';
10+
import {Base, deferUntil, getInputPostVal, hasAttr} from '@craftcms/garnish';
1111
import {t} from '@craftcms/ui';
1212
import {escapeHtml} from '@craftcms/ui/utilities/escapeHtml';
1313
import {animationDuration, MatrixInput} from './matrix-input';
@@ -189,11 +189,11 @@ export class MatrixEntry extends Base {
189189
this.visibleLayoutElements = this.dataJson('visible-layout-elements');
190190
this.staticLayoutElements = this.dataJson('static-layout-elements');
191191

192-
setTimeout(() => {
192+
deferUntil(() => !!craft().FormObserver).then(() => {
193193
this.formObserver = new (craft().FormObserver)(container, (data) => {
194194
this.updateFieldLayout(data);
195195
});
196-
}, 1);
196+
});
197197
}
198198

199199
/** Reads a JSON-ish data attribute the way jQuery `.data()` did. */

resources/js/modules/matrix/matrix-input.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
Base,
1818
DragSort,
1919
type GarnishBaseSettings,
20+
deferUntil,
2021
firstFocusableElement,
2122
prefersReducedMotion,
2223
scrollContainerToElement,
@@ -136,6 +137,8 @@ export class MatrixInput extends Base<MatrixInputSettings> {
136137
entrySelect: LegacySelect | null = null;
137138

138139
elementEditor: LegacyElementEditor | null = null;
140+
/** Aborts the after-init `elementEditor` lookup on `destroy()`. */
141+
private elementEditorController: AbortController | null = null;
139142

140143
addingEntry = false;
141144

@@ -288,26 +291,41 @@ export class MatrixInput extends Base<MatrixInputSettings> {
288291

289292
this.updateAddEntryBtn();
290293

291-
setTimeout(() => {
292-
this.elementEditor = this.form
293-
? ((jqData(this.form, 'elementEditor') as LegacyElementEditor) ?? null)
294-
: null;
295-
296-
if (this.elementEditor) {
297-
this.elementEditor.on('update', () => {
298-
this.settings!.ownerId = this.elementEditor!.getDraftElementId(
299-
this.settings!.ownerId
300-
) as MatrixInputSettings['ownerId'];
301-
});
302-
}
294+
// The owner's element editor boots after this input does; keep checking
295+
// until it's attached (legacy parity: the poll interval mirrors the old
296+
// fixed delay, but retries instead of gambling on a single check).
297+
const finishInit = (elementEditor: LegacyElementEditor | null): void => {
298+
this.elementEditor = elementEditor;
299+
this.elementEditor?.on('update', () => {
300+
this.settings!.ownerId = this.elementEditor!.getDraftElementId(
301+
this.settings!.ownerId
302+
) as MatrixInputSettings['ownerId'];
303+
});
303304

304305
this.trigger('afterInit');
305306

306307
const defaultEntries = this.settings!.addDefaultEntries;
307308
if (defaultEntries && defaultEntries.count > 0) {
308309
void this.addDefaultEntries(defaultEntries.type, defaultEntries.count);
309310
}
310-
}, 100);
311+
};
312+
313+
this.elementEditorController = new AbortController();
314+
315+
const form = this.form;
316+
if (form) {
317+
deferUntil(
318+
() => jqData(form, 'elementEditor') as LegacyElementEditor | undefined,
319+
100,
320+
this.elementEditorController.signal
321+
)
322+
.then((elementEditor) => finishInit(elementEditor ?? null))
323+
.catch(() => {
324+
// Destroyed before the editor showed up — nothing left to do.
325+
});
326+
} else {
327+
finishInit(null);
328+
}
311329

312330
// If this field is nested within something that's deletable, be ready to
313331
// handle that
@@ -698,6 +716,9 @@ export class MatrixInput extends Base<MatrixInputSettings> {
698716
}
699717

700718
override destroy(): void {
719+
this.elementEditorController?.abort();
720+
this.elementEditorController = null;
721+
701722
this.entrySort?.destroy();
702723
this.entrySelect?.destroy();
703724
this.entrySort = null;

resources/js/modules/nested-element-manager/nested-element-manager.ts

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
Base,
33
DragSort,
44
Select,
5+
deferUntil,
56
firstFocusableElement,
67
hasAttr,
78
isCtrlKeyPressed,
@@ -175,7 +176,8 @@ export class NestedElementManager extends Base<NestedElementManagerSettings> {
175176
#activateBound: any[] = [];
176177
/** Teardown callbacks for per-card native listeners (delete items, …). */
177178
#disposers: Array<() => void> = [];
178-
#afterInitTimeout: ReturnType<typeof setTimeout> | null = null;
179+
/** Aborts the after-init `elementEditor` lookup on `destroy()`. */
180+
#afterInitController: AbortController | null = null;
179181

180182
constructor(
181183
container: HTMLElement | string,
@@ -235,28 +237,39 @@ export class NestedElementManager extends Base<NestedElementManagerSettings> {
235237
this.#initCreateButton();
236238
}
237239

238-
// The owner's element editor boots after this manager does; look it up a
239-
// beat later (legacy parity, including the delay).
240-
this.#afterInitTimeout = setTimeout(() => {
241-
this.elementEditor = $(this.container)
242-
.closest('form')
243-
.data('elementEditor');
244-
245-
if (this.elementEditor) {
246-
this.elementEditor.on('update', () => {
247-
this.settings.ownerId = this.elementEditor.getDraftElementId(
248-
this.settings.ownerId
249-
);
240+
// The owner's element editor boots after this manager does; keep checking
241+
// until it's attached (legacy parity: the poll interval mirrors the old
242+
// fixed delay, but retries instead of gambling on a single check).
243+
const $form = $(this.container).closest('form');
244+
this.#afterInitController = new AbortController();
245+
246+
if ($form.length) {
247+
deferUntil(
248+
() => $form.data('elementEditor'),
249+
100,
250+
this.#afterInitController.signal
251+
)
252+
.then((elementEditor) => {
253+
this.elementEditor = elementEditor;
254+
this.elementEditor.on('update', () => {
255+
this.settings.ownerId = this.elementEditor.getDraftElementId(
256+
this.settings.ownerId
257+
);
258+
259+
if (this.elementIndex) {
260+
this.elementIndex.settings.criteria[this.settings.ownerIdParam!] =
261+
this.settings.ownerId;
262+
}
263+
});
250264

251-
if (this.elementIndex) {
252-
this.elementIndex.settings.criteria[this.settings.ownerIdParam!] =
253-
this.settings.ownerId;
254-
}
265+
this.trigger('afterInit');
266+
})
267+
.catch(() => {
268+
// Destroyed before the editor showed up — nothing left to do.
255269
});
256-
}
257-
270+
} else {
258271
this.trigger('afterInit');
259-
}, 100);
272+
}
260273

261274
// NOTE: `Craft.cp` has no unregister API for this; the callback holds this
262275
// instance until the page unloads (legacy parity — see README).
@@ -1355,10 +1368,8 @@ export class NestedElementManager extends Base<NestedElementManagerSettings> {
13551368
// --- Teardown ---------------------------------------------------------------
13561369

13571370
override destroy(): void {
1358-
if (this.#afterInitTimeout !== null) {
1359-
clearTimeout(this.#afterInitTimeout);
1360-
this.#afterInitTimeout = null;
1361-
}
1371+
this.#afterInitController?.abort();
1372+
this.#afterInitController = null;
13621373

13631374
for (const $bound of this.#activateBound) {
13641375
$bound.off('activate');

0 commit comments

Comments
 (0)