From 00e074a77a901fc16c60de8385722729142a47cb Mon Sep 17 00:00:00 2001 From: Myasnikov Daniil Date: Thu, 27 Aug 2026 17:46:42 +0500 Subject: [PATCH] fix(dashboard): materialise an immutable ancestor only when a leaf exists The guard keyed on the intermediate ancestor being present in the original rather than on the immutable leaf being restorable, so an explicit null the user submitted at that ancestor was traded for an empty object with nothing put back into it. Confirm the leaf over the remaining path first, wildcards included. Assisted-by: LLM Signed-off-by: Myasnikov Daniil --- .../console/src/lib/immutable-paths.test.ts | 11 ++++++ .../apps/console/src/lib/immutable-paths.ts | 37 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.test.ts b/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.test.ts index 63f4bb66a..5a993c4db 100644 --- a/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.test.ts +++ b/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.test.ts @@ -415,6 +415,17 @@ describe("overlayImmutable", () => { expect(result).toEqual({ spec: { storage: "keep-me" } }) }) + it("leaves a null ancestor alone when original has no leaf to restore", () => { + // Materialising on the ancestor merely existing would trade the user's + // explicit null for an empty object with nothing put back into it. + const submitted = { spec: null } as Record + const original = { spec: { storage: { size: "10Gi" } } } + const result = overlayImmutable(submitted, original, [ + ["spec", "storage", "storageClass"], + ]) + expect(result).toEqual({ spec: null }) + }) + it("array reordering by the user with index-aligned overlay re-anchors source values to the new index (pinned current behaviour)", () => { // Tracked in cozystack/cozystack-ui#10. // The overlay walks by index, not by content identity. Reordering an diff --git a/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.ts b/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.ts index 2f6d1bc31..45a6ea6c9 100644 --- a/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.ts +++ b/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.ts @@ -153,6 +153,36 @@ function wildcardArrayLengthChanged( return false } +/** + * Whether source still holds a value at the end of the remaining path. + * Materialising a missing intermediate object is only justified when there is + * a leaf to restore into it — keyed on the ancestor merely existing, an + * explicit null the user parked at that ancestor would be replaced by an empty + * object with nothing put back. + */ +function sourceHasLeaf( + source: unknown, + path: ImmutablePath, + depth: number, +): boolean { + if (depth === path.length) return source !== undefined + const seg = path[depth] + if (seg === "*") { + // A trailing wildcard freezes the whole collection, so the collection + // being present is itself the value to restore. + if (depth === path.length - 1) return source !== undefined + if (Array.isArray(source)) { + return source.some((v) => sourceHasLeaf(v, path, depth + 1)) + } + if (isPlainObject(source)) { + return Object.values(source).some((v) => sourceHasLeaf(v, path, depth + 1)) + } + return false + } + if (!isPlainObject(source)) return false + return sourceHasLeaf(source[seg], path, depth + 1) +} + function overlayPath( target: unknown, source: unknown, @@ -175,8 +205,11 @@ function overlayPath( const targetObj = isPlainObject(target) ? (target as Record) : // A YAML edit can drop the whole intermediate object; materialise it so - // the immutable leaf underneath is still restored from source. - sourceVal !== undefined && (target === undefined || target === null) + // the immutable leaf underneath is still restored from source. Only when + // source actually has that leaf, so a null the user parked here is not + // traded for an empty object holding nothing. + (target === undefined || target === null) && + sourceHasLeaf(sourceVal, path, depth + 1) ? {} : null if (!targetObj) return target