Skip to content

Commit 3217c90

Browse files
committed
Merge branch 'feature/structural-sharing-cache-pr-2' into feature/structural-sharing-cache-pr-3
2 parents ccccbf1 + 4e70f38 commit 3217c90

2 files changed

Lines changed: 150 additions & 11 deletions

File tree

lib/utils.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ function mergeObject<TObject extends Record<string, unknown>>(
8989

9090
const targetObject = isMergeableObject(target) ? target : undefined;
9191

92+
// Track whether the merge actually changed anything compared to target.
93+
// If nothing changed, we return the original target reference for reference stability.
94+
let hasChanged = !targetObject;
95+
9296
// First we want to copy over all keys from the target into the destination object,
9397
// in case "target" is a mergable object.
9498
// If "shouldRemoveNestedNulls" is true, we want to remove null values from the merged object
@@ -103,6 +107,7 @@ function mergeObject<TObject extends Record<string, unknown>>(
103107
const shouldOmitNullishProperty = options.shouldRemoveNestedNulls && (targetProperty === null || sourceProperty === null);
104108

105109
if (targetProperty === undefined || shouldOmitNullishProperty) {
110+
hasChanged = true;
106111
continue;
107112
}
108113

@@ -125,6 +130,9 @@ function mergeObject<TObject extends Record<string, unknown>>(
125130

126131
// If the source value is not a mergable object, we need to set the key directly.
127132
if (!isMergeableObject(sourceProperty)) {
133+
if (destination[key] !== sourceProperty) {
134+
hasChanged = true;
135+
}
128136
destination[key] = sourceProperty;
129137
continue;
130138
}
@@ -134,6 +142,7 @@ function mergeObject<TObject extends Record<string, unknown>>(
134142
// To achieve this, we first mark these nested objects with an internal flag.
135143
// When calling fastMerge again with "mark" removal mode, the marked objects will be removed.
136144
if (options.objectRemovalMode === 'mark' && targetProperty === null) {
145+
hasChanged = true;
137146
targetProperty = {[ONYX_INTERNALS__REPLACE_OBJECT_MARK]: true};
138147
metadata.replaceNullPatches.push([[...basePath, key], {...sourceProperty}]);
139148
}
@@ -142,6 +151,7 @@ function mergeObject<TObject extends Record<string, unknown>>(
142151
// has the internal flag set, we replace the entire destination object with the source one and remove
143152
// the flag.
144153
if (options.objectRemovalMode === 'replace' && sourceProperty[ONYX_INTERNALS__REPLACE_OBJECT_MARK]) {
154+
hasChanged = true;
145155
// We do a spread here in order to have a new object reference and allow us to delete the internal flag
146156
// of the merged object only.
147157
const sourcePropertyWithoutMark = {...sourceProperty};
@@ -150,10 +160,14 @@ function mergeObject<TObject extends Record<string, unknown>>(
150160
continue;
151161
}
152162

153-
destination[key] = fastMerge(targetProperty, sourceProperty, options, metadata, [...basePath, key]).result;
163+
const merged = fastMerge(targetProperty, sourceProperty, options, metadata, [...basePath, key]).result;
164+
if (merged !== targetProperty) {
165+
hasChanged = true;
166+
}
167+
destination[key] = merged;
154168
}
155169

156-
return destination as TObject;
170+
return hasChanged ? (destination as TObject) : (targetObject as TObject);
157171
}
158172

159173
/** Checks whether the given object is an object and not null/undefined. */
@@ -170,35 +184,36 @@ function isMergeableObject<TObject extends Record<string, unknown>>(value: unkno
170184
return isNonNullObject && !(value instanceof RegExp) && !(value instanceof Date) && !Array.isArray(value);
171185
}
172186

173-
/** Deep removes the nested null values from the given value. */
187+
/** Deep removes the nested null values from the given value. Returns the original reference if no nulls were found. */
174188
function removeNestedNullValues<TValue extends OnyxInput<OnyxKey> | null>(value: TValue): TValue {
175-
if (value === null || value === undefined || typeof value !== 'object') {
189+
if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value)) {
176190
return value;
177191
}
178192

179-
if (Array.isArray(value)) {
180-
return [...value] as TValue;
181-
}
182-
193+
let hasChanged = false;
183194
const result: Record<string, unknown> = {};
184195

185196
// eslint-disable-next-line no-restricted-syntax, guard-for-in
186197
for (const key in value) {
187198
const propertyValue = value[key];
188199

189200
if (propertyValue === null || propertyValue === undefined) {
201+
hasChanged = true;
190202
continue;
191203
}
192204

193205
if (typeof propertyValue === 'object' && !Array.isArray(propertyValue)) {
194-
const valueWithoutNestedNulls = removeNestedNullValues(propertyValue);
195-
result[key] = valueWithoutNestedNulls;
206+
const cleaned = removeNestedNullValues(propertyValue);
207+
if (cleaned !== propertyValue) {
208+
hasChanged = true;
209+
}
210+
result[key] = cleaned;
196211
} else {
197212
result[key] = propertyValue;
198213
}
199214
}
200215

201-
return result as TValue;
216+
return hasChanged ? (result as TValue) : value;
202217
}
203218

204219
/** Formats the action name by uppercasing and adding the key if provided. */
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,5 +209,129 @@ describe('fastMerge', () => {
209209
const result = utils.fastMerge<unknown>(data, testObject);
210210
expect(result.result).toEqual(testObject);
211211
});
212+
213+
it('should return the same reference when source values match target', () => {
214+
const target = {a: 1, b: 'hello', c: true};
215+
const source = {a: 1, b: 'hello'};
216+
const result = utils.fastMerge(target, source);
217+
expect(result.result).toBe(target);
218+
});
219+
220+
it('should return a new reference when source adds a key', () => {
221+
const target = {a: 1};
222+
const source = {a: 1, b: 2};
223+
const result = utils.fastMerge(target, source);
224+
expect(result.result).not.toBe(target);
225+
expect(result.result).toEqual({a: 1, b: 2});
226+
});
227+
228+
it('should return a new reference when source changes a value', () => {
229+
const target = {a: 1, b: 2};
230+
const source = {b: 3};
231+
const result = utils.fastMerge(target, source);
232+
expect(result.result).not.toBe(target);
233+
expect(result.result).toEqual({a: 1, b: 3});
234+
});
235+
236+
it('should preserve nested object references when unchanged', () => {
237+
const nested = {x: 1, y: 2};
238+
const target = {a: 'hello', b: nested};
239+
const source = {a: 'hello'};
240+
const result = utils.fastMerge<GenericDeepRecord>(target, source);
241+
expect(result.result).toBe(target);
242+
expect(result.result.b).toBe(nested);
243+
});
244+
245+
it('should preserve unchanged nested references when sibling changes', () => {
246+
const nested = {x: 1, y: 2};
247+
const target = {a: nested, b: 'old'};
248+
const source = {b: 'new'};
249+
const result = utils.fastMerge<GenericDeepRecord>(target, source);
250+
expect(result.result).not.toBe(target);
251+
expect(result.result.a).toBe(nested);
252+
});
253+
254+
it('should return a new reference when nested object changes', () => {
255+
const target = {a: {x: 1, y: 2}, b: 'hello'};
256+
const source = {a: {x: 99}};
257+
const result = utils.fastMerge(target, source);
258+
expect(result.result).not.toBe(target);
259+
expect(result.result.a).not.toBe(target.a);
260+
expect(result.result.a).toEqual({x: 99, y: 2});
261+
});
262+
263+
it('should return a new reference when shouldRemoveNestedNulls removes a key', () => {
264+
const target = {a: 1, b: null};
265+
const source = {a: 1};
266+
const result = utils.fastMerge(target, source, {shouldRemoveNestedNulls: true});
267+
expect(result.result).not.toBe(target);
268+
expect(result.result).toEqual({a: 1});
269+
});
270+
271+
it('should return the same reference when merging with empty source keys', () => {
272+
const target = {a: 1, b: 2};
273+
const source = {};
274+
const result = utils.fastMerge(target, source);
275+
expect(result.result).toBe(target);
276+
});
277+
});
278+
279+
describe('removeNestedNullValues', () => {
280+
it('should return the same reference when no nulls exist', () => {
281+
const value = {a: 1, b: 'hello', c: true};
282+
const result = utils.removeNestedNullValues(value);
283+
expect(result).toBe(value);
284+
});
285+
286+
it('should return the same reference for nested objects without nulls', () => {
287+
const nested = {x: 1, y: 2};
288+
const value = {a: 'hello', b: nested};
289+
const result = utils.removeNestedNullValues(value);
290+
expect(result).toBe(value);
291+
expect((result as Record<string, unknown>).b).toBe(nested);
292+
});
293+
294+
it('should return a new reference when a null property is removed', () => {
295+
const value = {a: 1, b: null};
296+
const result = utils.removeNestedNullValues(value);
297+
expect(result).not.toBe(value);
298+
expect(result).toEqual({a: 1});
299+
});
300+
301+
it('should return a new reference when an undefined property is removed', () => {
302+
const value = {a: 1, b: undefined};
303+
const result = utils.removeNestedNullValues(value);
304+
expect(result).not.toBe(value);
305+
expect(result).toEqual({a: 1});
306+
});
307+
308+
it('should return a new reference when a deeply nested null is removed', () => {
309+
const value = {a: {b: {c: null, d: 1}}};
310+
const result = utils.removeNestedNullValues(value);
311+
expect(result).not.toBe(value);
312+
expect(result).toEqual({a: {b: {d: 1}}});
313+
});
314+
315+
it('should preserve sibling references when a nested null is removed', () => {
316+
const sibling = {x: 1};
317+
const value = {a: sibling, b: {c: null}};
318+
const result = utils.removeNestedNullValues(value);
319+
expect(result).not.toBe(value);
320+
expect((result as Record<string, unknown>).a).toBe(sibling);
321+
});
322+
323+
it('should return the same array reference', () => {
324+
const arr = [1, 2, 3];
325+
const result = utils.removeNestedNullValues(arr);
326+
expect(result).toBe(arr);
327+
});
328+
329+
it('should return the same reference for objects containing arrays', () => {
330+
const arr = ['a', 'b'];
331+
const value = {items: arr, count: 2};
332+
const result = utils.removeNestedNullValues(value);
333+
expect(result).toBe(value);
334+
expect((result as Record<string, unknown>).items).toBe(arr);
335+
});
212336
});
213337
});

0 commit comments

Comments
 (0)