Skip to content

Commit 97d751c

Browse files
arbrandesclaude
andcommitted
fix: explicit per-field semantics for mergeSiteConfig app merging
App fields fall into three groups with distinct merge semantics: dictionaries (config, provides) are usefully deep-merged across calls; arrays of self-contained module declarations (slots, routes, providers, externalScripts) only make sense as full replacements, since element-wise merging mashes positionally-corresponding items from unrelated apps; appId is the merge key. The previous lodash.merge of whole App objects treated all of these uniformly, doing the right thing for config and something incoherent (or actively harmful) for the others. Replace the generic deep-merge with mergeApps/mergeApp that name those semantics explicitly and use Object.getOwnPropertyDescriptors so own properties (including getters) survive the merge. The descriptor- preserving copy fixes a latent footgun for any App that uses a lazy field: under the old behavior the getter was invoked mid-merge against a still-empty siteConfig.apps and its return frozen onto the merged copy. createLegacyPluginApp's slots getter is the first concrete victim, but the contract now holds for any future lazy field. Add a regression test asserting a lazy slots getter remains a getter after mergeSiteConfig. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 1f9f07d commit 97d751c

2 files changed

Lines changed: 107 additions & 13 deletions

File tree

runtime/config/index.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,31 @@ describe('mergeSiteConfig', () => {
192192
const app = getSiteConfig().apps!.find(a => a.appId === 'test-app')!;
193193
expect(app.config!.NESTED).toEqual({ a: 1, b: 3, c: 4 });
194194
});
195+
196+
it('should preserve a lazy `slots` getter through the merge', () => {
197+
/* Regression: lodash.merge invokes any getter on the source app while
198+
* `siteConfig.apps` is still the pre-merge value, then snapshots the
199+
* return value onto the merged copy. Apps with lazy `slots` getters
200+
* (e.g. createLegacyPluginApp) need the getter to survive so it can
201+
* resolve later, against the post-merge sibling apps. */
202+
let getterCalls = 0;
203+
const lazyApp: any = {
204+
appId: 'lazy-app',
205+
get slots() {
206+
getterCalls += 1;
207+
return [{ slotId: 'fake.slot', id: 'w', op: 'widgetAppend', element: null }];
208+
},
209+
};
210+
211+
mergeSiteConfig({ apps: [lazyApp] });
212+
213+
const merged = getSiteConfig().apps!.find(a => a.appId === 'lazy-app')!;
214+
const callsAfterMerge = getterCalls;
215+
// Reading slots should still hit the getter (i.e. it survived as a getter).
216+
void merged.slots;
217+
void merged.slots;
218+
expect(getterCalls).toBeGreaterThan(callsAfterMerge);
219+
});
195220
});
196221

197222
describe('app merging (limitAppMergeToConfig: true)', () => {

runtime/config/index.ts

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ import isEqual from 'lodash/isEqual';
104104
import keyBy from 'lodash/keyBy';
105105
import merge from 'lodash/merge';
106106
import {
107+
App,
107108
AppConfig,
108109
EnvironmentTypes,
109110
SiteConfig
@@ -226,11 +227,7 @@ export function mergeSiteConfig(
226227

227228
// if we're doing a full merge, merge the objects
228229
if (!limitAppMergeToConfig) {
229-
siteConfig.apps = Object.values(merge(
230-
{},
231-
keyBy(siteConfig.apps || [], 'appId'),
232-
keyBy(newApps, 'appId')
233-
));
230+
siteConfig.apps = mergeApps(siteConfig.apps || [], newApps);
234231
publish(CONFIG_CHANGED);
235232
return;
236233
}
@@ -243,18 +240,90 @@ export function mergeSiteConfig(
243240
}
244241

245242
// handle config-only merging
246-
const newAppsById = keyBy(newApps, 'appId');
247-
siteConfig.apps = siteConfig.apps.map((app) => {
248-
const newApp = newAppsById[app.appId];
249-
if (!newApp?.config) {
250-
return app;
251-
}
252-
return { ...app, config: merge({}, app.config, newApp.config) };
253-
});
243+
siteConfig.apps = mergeApps(siteConfig.apps, newApps, { configOnly: true });
254244

255245
publish(CONFIG_CHANGED);
256246
}
257247

248+
/*
249+
* Merge two App[] by appId. Existing apps stay in their original positions
250+
* (with their pair-merged counterpart from `newApps` substituted in when
251+
* present); apps in `newApps` not already in `oldApps` append at the end.
252+
* With `{ configOnly: true }`, no apps are added and apps not appearing in
253+
* `newApps` pass through unchanged. Per-pair merging is delegated to
254+
* `mergeApp`.
255+
*/
256+
function mergeApps(
257+
oldApps: App[],
258+
newApps: App[],
259+
options: { configOnly?: boolean } = {},
260+
): App[] {
261+
const incomingByAppId = keyBy(newApps, 'appId');
262+
263+
// Phase 1: walk existing apps in their original order, pair-merging any
264+
// that have a counterpart in newApps.
265+
const updatedExisting = oldApps.map((oldApp) => {
266+
const newApp = incomingByAppId[oldApp.appId];
267+
return newApp ? mergeApp(oldApp, newApp, options) : oldApp;
268+
});
269+
270+
// configOnly mode never adds apps, so we're done.
271+
if (options.configOnly) {
272+
return updatedExisting;
273+
}
274+
275+
// Phase 2: append apps from newApps that weren't already in oldApps.
276+
const existingIds = new Set(oldApps.map((a) => a.appId));
277+
const additions = newApps.filter((a) => !existingIds.has(a.appId));
278+
return [...updatedExisting, ...additions];
279+
}
280+
281+
/*
282+
* Merge a pair of Apps with the same appId. Deep-merges `config` (and, in the
283+
* full-merge case, `provides`); other fields take `newApp`'s value verbatim.
284+
* The result is built via `Object.getOwnPropertyDescriptors` so any lazy
285+
* getters survive: a snapshot via `lodash.merge` or spread would invoke the
286+
* getter at merge time and freeze its return value, which is typically empty
287+
* mid-init. Per-field replacement is also the only sensible behavior for the
288+
* array fields (`slots`/`routes`/`providers`/`externalScripts`), which don't
289+
* survive element-wise merging anyway.
290+
*/
291+
function mergeApp(
292+
oldApp: App,
293+
newApp: App,
294+
options: { configOnly?: boolean } = {},
295+
): App {
296+
// configOnly mode: preserve `oldApp` (identity, slots, etc.) and deep-merge
297+
// only `newApp.config` on top.
298+
if (options.configOnly) {
299+
if (!newApp.config) {
300+
return oldApp;
301+
}
302+
return cloneAppDescriptors(oldApp, {
303+
config: merge({}, oldApp.config, newApp.config),
304+
});
305+
}
306+
307+
// Full mode: take `newApp` (identity, slots, etc.) and deep-merge `config`
308+
// and `provides` from `oldApp`. Other fields take `newApp`'s value verbatim.
309+
const deepMerged: Record<string, unknown> = {};
310+
if (oldApp.config !== undefined || newApp.config !== undefined) {
311+
deepMerged.config = merge({}, oldApp.config, newApp.config);
312+
}
313+
if (oldApp.provides !== undefined || newApp.provides !== undefined) {
314+
deepMerged.provides = merge({}, oldApp.provides, newApp.provides);
315+
}
316+
return cloneAppDescriptors(newApp, deepMerged);
317+
}
318+
319+
function cloneAppDescriptors(source: App, overrides: Record<string, unknown>): App {
320+
const descriptors = Object.getOwnPropertyDescriptors(source);
321+
for (const [key, value] of Object.entries(overrides)) {
322+
descriptors[key] = { value, writable: true, enumerable: true, configurable: true };
323+
}
324+
return Object.create(Object.getPrototypeOf(source), descriptors) as App;
325+
}
326+
258327
const appConfigs: Record<string, AppConfig> = {};
259328

260329
/**

0 commit comments

Comments
 (0)