diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3adee70813c..2a7bc9ff362 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -48,6 +48,7 @@
- Fixed a bug where pressing Ctrl/Cmd + S in a slideout could also save the page behind it. ([#19705](https://github.com/craftcms/cms/pull/19705))
- Fixed a bug where Shift-selecting a shorter range didn’t deselect items outside the new range. ([#19727](https://github.com/craftcms/cms/pull/19727))
- Fixed a bug where select inputs could lose their selected value if their options were loaded asynchronously. ([#19728](https://github.com/craftcms/cms/pull/19728))
+- Fixed a bug where reactive controls in element editors didn’t refresh their field layouts, including nested form scopes. ([#19769](https://github.com/craftcms/cms/pull/19769))
### Assets
- Migrated Control Panel uploads to the native Uppy picker and shared upload sessions, including user photos.
diff --git a/resources/js/modules/elements/components/ElementEditor.vue b/resources/js/modules/elements/components/ElementEditor.vue
index 33872602d8e..49faaec31ac 100644
--- a/resources/js/modules/elements/components/ElementEditor.vue
+++ b/resources/js/modules/elements/components/ElementEditor.vue
@@ -59,6 +59,7 @@
renderer,
refreshAfterNestedChange,
refreshForm,
+ refreshLayout,
save,
sidebarErrors,
sidebarPayload,
@@ -383,6 +384,7 @@
ref="renderer"
:payload="formPayload"
:errors="errors"
+ :refresh="formPayload.refreshable ? refreshLayout : undefined"
:modified="autosave.modified.value"
:disabled="workflowReviewLocked"
@update:mutation="onMutation"
diff --git a/resources/js/modules/elements/composables/useElementEditor.ts b/resources/js/modules/elements/composables/useElementEditor.ts
index 2e5847196e8..d31d03c159d 100644
--- a/resources/js/modules/elements/composables/useElementEditor.ts
+++ b/resources/js/modules/elements/composables/useElementEditor.ts
@@ -508,10 +508,14 @@ export function useElementEditor({saveData, root}: Options = {}) {
return;
}
- applyingSavedPayload = true;
- savedForm.value = response.form;
- await nextTick();
- applyingSavedPayload = false;
+ // A nested scope's payload is only that form; the renderer that asked for
+ // it reconciles it into the layout.
+ if (JSON.stringify(scope) === JSON.stringify(rootScope)) {
+ applyingSavedPayload = true;
+ savedForm.value = response.form;
+ await nextTick();
+ applyingSavedPayload = false;
+ }
if (generation !== refreshGeneration) {
return;
@@ -522,6 +526,22 @@ export function useElementEditor({saveData, root}: Options = {}) {
return response;
}
+ /**
+ * Refreshes the field layout for the renderer's reactive controls.
+ */
+ async function refreshLayout(
+ _values: FormValues,
+ scope?: string[]
+ ): Promise {
+ const response = await refreshForm(scope);
+
+ if (!response) {
+ throw new Error('A newer refresh superseded this one.');
+ }
+
+ return response.form as FormPayload;
+ }
+
// Set for the duration of one submission when an alternate action owns it,
// so the shared save pipeline (elevated sessions, error handling, the
// processing flag) is reused rather than reimplemented per action.
@@ -899,6 +919,7 @@ export function useElementEditor({saveData, root}: Options = {}) {
renderer,
refreshAfterNestedChange,
refreshForm,
+ refreshLayout,
save,
sidebarErrors,
sidebarPayload,