Skip to content

Commit 778507b

Browse files
committed
⚗️ Add partial view updates foundation
- Add PARTIAL_VIEW_UPDATES to ExperimentalFeature enum - Add VIEW_UPDATE to RumEventType, RawRumViewUpdateEvent, and RawRumEvent union - Add viewDiff.ts: isEqual and diffMerge utilities implementing MERGE / REPLACE / APPEND strategies for computing minimal diffs between assembled view events
1 parent 8349a59 commit 778507b

5 files changed

Lines changed: 292 additions & 0 deletions

File tree

packages/core/src/tools/experimentalFeatures.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export enum ExperimentalFeature {
2525
LCP_SUBPARTS = 'lcp_subparts',
2626
INP_SUBPARTS = 'inp_subparts',
2727
TOO_MANY_REQUESTS_INVESTIGATION = 'too_many_requests_investigation',
28+
PARTIAL_VIEW_UPDATES = 'partial_view_updates',
29+
PARTIAL_VIEW_UPDATES_NO_CHECKPOINT = 'partial_view_updates_no_checkpoint',
2830
}
2931

3032
const enabledExperimentalFeatures: Set<ExperimentalFeature> = new Set()
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { isEqual, diffMerge } from './viewDiff'
2+
3+
describe('isEqual', () => {
4+
it('should return true for identical primitives', () => {
5+
expect(isEqual(1, 1)).toBe(true)
6+
expect(isEqual('a', 'a')).toBe(true)
7+
expect(isEqual(true, true)).toBe(true)
8+
expect(isEqual(null, null)).toBe(true)
9+
expect(isEqual(undefined, undefined)).toBe(true)
10+
})
11+
12+
it('should return false for different primitives', () => {
13+
expect(isEqual(1, 2)).toBe(false)
14+
expect(isEqual('a', 'b')).toBe(false)
15+
expect(isEqual(true, false)).toBe(false)
16+
expect(isEqual(null, undefined)).toBe(false)
17+
})
18+
19+
it('should return true for deeply equal objects', () => {
20+
expect(isEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } })).toBe(true)
21+
})
22+
23+
it('should return false for objects with different values', () => {
24+
expect(isEqual({ a: 1 }, { a: 2 })).toBe(false)
25+
})
26+
27+
it('should return false for objects with different keys', () => {
28+
expect(isEqual({ a: 1 }, { b: 1 })).toBe(false)
29+
})
30+
31+
it('should return true for equal arrays', () => {
32+
expect(isEqual([1, 2, 3], [1, 2, 3])).toBe(true)
33+
})
34+
35+
it('should return false for arrays with different lengths', () => {
36+
expect(isEqual([1, 2], [1, 2, 3])).toBe(false)
37+
})
38+
39+
it('should return false for arrays with different values', () => {
40+
expect(isEqual([1, 2, 3], [1, 2, 4])).toBe(false)
41+
})
42+
43+
it('should return false when comparing array to non-array', () => {
44+
expect(isEqual([1], { 0: 1 })).toBe(false)
45+
})
46+
47+
it('should return false for type mismatch', () => {
48+
expect(isEqual(1, '1')).toBe(false)
49+
})
50+
})
51+
52+
describe('diffMerge', () => {
53+
it('should return undefined when there are no changes', () => {
54+
const result = diffMerge({ a: 1, b: 'x' }, { a: 1, b: 'x' })
55+
expect(result).toBeUndefined()
56+
})
57+
58+
it('should return changed primitive fields', () => {
59+
const result = diffMerge({ a: 1, b: 2 }, { a: 1, b: 1 })
60+
expect(result).toEqual({ b: 2 })
61+
})
62+
63+
it('should include new fields not present in lastSent', () => {
64+
const result = diffMerge({ a: 1, b: 2 }, { a: 1 })
65+
expect(result).toEqual({ b: 2 })
66+
})
67+
68+
it('should set null for deleted keys', () => {
69+
const result = diffMerge({ a: 1 }, { a: 1, b: 2 })
70+
expect(result).toEqual({ b: null })
71+
})
72+
73+
it('should recursively diff nested objects', () => {
74+
const result = diffMerge({ nested: { x: 1, y: 2 } }, { nested: { x: 1, y: 1 } })
75+
expect(result).toEqual({ nested: { y: 2 } })
76+
})
77+
78+
it('should return undefined for unchanged nested objects', () => {
79+
const result = diffMerge({ nested: { x: 1 } }, { nested: { x: 1 } })
80+
expect(result).toBeUndefined()
81+
})
82+
83+
it('should include new nested objects', () => {
84+
const result = diffMerge({ nested: { x: 1 } }, {})
85+
expect(result).toEqual({ nested: { x: 1 } })
86+
})
87+
88+
describe('replaceKeys option', () => {
89+
it('should use full replace strategy for specified keys', () => {
90+
const result = diffMerge({ arr: [1, 2, 3] }, { arr: [1, 2] }, { replaceKeys: new Set(['arr']) })
91+
expect(result).toEqual({ arr: [1, 2, 3] })
92+
})
93+
94+
it('should not include replace key if unchanged', () => {
95+
const result = diffMerge({ arr: [1, 2] }, { arr: [1, 2] }, { replaceKeys: new Set(['arr']) })
96+
expect(result).toBeUndefined()
97+
})
98+
})
99+
100+
describe('appendKeys option', () => {
101+
it('should append only new trailing elements for array keys', () => {
102+
const result = diffMerge({ items: [1, 2, 3] }, { items: [1, 2] }, { appendKeys: new Set(['items']) })
103+
expect(result).toEqual({ items: [3] })
104+
})
105+
106+
it('should include full array when it first appears', () => {
107+
const result = diffMerge({ items: [1, 2] }, {}, { appendKeys: new Set(['items']) })
108+
expect(result).toEqual({ items: [1, 2] })
109+
})
110+
111+
it('should not include append key if array has not grown', () => {
112+
const result = diffMerge({ items: [1, 2] }, { items: [1, 2] }, { appendKeys: new Set(['items']) })
113+
expect(result).toBeUndefined()
114+
})
115+
})
116+
})
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { isEmptyObject } from '@datadog/browser-core'
2+
3+
/**
4+
* Compare two values for deep equality
5+
*/
6+
export function isEqual(a: unknown, b: unknown): boolean {
7+
// Reference equality
8+
if (a === b) {
9+
return true
10+
}
11+
12+
// Handle null/undefined
13+
if (a === null || b === null || a === undefined || b === undefined) {
14+
return a === b
15+
}
16+
17+
// Type mismatch
18+
if (typeof a !== typeof b) {
19+
return false
20+
}
21+
22+
// Primitives
23+
if (typeof a !== 'object') {
24+
return a === b
25+
}
26+
27+
// Arrays
28+
if (Array.isArray(a) && Array.isArray(b)) {
29+
if (a.length !== b.length) {
30+
return false
31+
}
32+
return a.every((val, idx) => isEqual(val, b[idx]))
33+
}
34+
35+
// One is array, other is not
36+
if (Array.isArray(a) || Array.isArray(b)) {
37+
return false
38+
}
39+
40+
// Objects
41+
const aObj = a as Record<string, unknown>
42+
const bObj = b as Record<string, unknown>
43+
const aKeys = Object.keys(aObj)
44+
const bKeys = Object.keys(bObj)
45+
46+
if (aKeys.length !== bKeys.length) {
47+
return false
48+
}
49+
50+
return aKeys.every((key) => bKeys.includes(key) && isEqual(aObj[key], bObj[key]))
51+
}
52+
53+
/**
54+
* Options for controlling diff merge behavior
55+
*/
56+
export interface DiffMergeOptions {
57+
replaceKeys?: Set<string>
58+
appendKeys?: Set<string>
59+
}
60+
61+
/**
62+
* MERGE strategy: compare two objects and return an object with only changed fields.
63+
* Returns undefined if no changes.
64+
*/
65+
export function diffMerge(
66+
current: Record<string, unknown>,
67+
lastSent: Record<string, unknown>,
68+
options?: DiffMergeOptions
69+
): Record<string, unknown> | undefined {
70+
const result: Record<string, unknown> = {}
71+
const replaceKeys = options?.replaceKeys || new Set<string>()
72+
const appendKeys = options?.appendKeys || new Set<string>()
73+
74+
// Check all keys in current
75+
for (const key of Object.keys(current)) {
76+
const currentVal = current[key]
77+
const lastSentVal = lastSent[key]
78+
79+
// REPLACE strategy for specific keys
80+
if (replaceKeys.has(key)) {
81+
if (!isEqual(currentVal, lastSentVal)) {
82+
result[key] = currentVal
83+
}
84+
continue
85+
}
86+
87+
// APPEND strategy for array keys
88+
if (appendKeys.has(key)) {
89+
if (Array.isArray(currentVal) && Array.isArray(lastSentVal)) {
90+
if (currentVal.length > lastSentVal.length) {
91+
// Include only new trailing elements
92+
result[key] = currentVal.slice(lastSentVal.length)
93+
}
94+
} else if (Array.isArray(currentVal) && !lastSentVal) {
95+
// Array appeared for the first time
96+
result[key] = currentVal
97+
}
98+
continue
99+
}
100+
101+
// Primitive comparison
102+
if (currentVal !== null && typeof currentVal !== 'object') {
103+
if (currentVal !== lastSentVal) {
104+
result[key] = currentVal
105+
}
106+
continue
107+
}
108+
109+
// Handle null explicitly
110+
if (currentVal === null) {
111+
if (currentVal !== lastSentVal) {
112+
result[key] = currentVal
113+
}
114+
continue
115+
}
116+
117+
// Array comparison (not in appendKeys)
118+
if (Array.isArray(currentVal)) {
119+
if (!isEqual(currentVal, lastSentVal)) {
120+
result[key] = currentVal
121+
}
122+
continue
123+
}
124+
125+
// Object comparison - recurse (no options propagation: replaceKeys/appendKeys apply only at top level)
126+
if (typeof currentVal === 'object' && lastSentVal && typeof lastSentVal === 'object') {
127+
const nestedDiff = diffMerge(currentVal as Record<string, unknown>, lastSentVal as Record<string, unknown>)
128+
if (nestedDiff && !isEmptyObject(nestedDiff)) {
129+
result[key] = nestedDiff
130+
}
131+
} else if (typeof currentVal === 'object' && !lastSentVal) {
132+
// New object appeared
133+
result[key] = currentVal
134+
}
135+
}
136+
137+
// Check for deleted keys (present in lastSent but not in current)
138+
for (const key of Object.keys(lastSent)) {
139+
if (!(key in current)) {
140+
result[key] = null
141+
}
142+
}
143+
144+
return Object.keys(result).length > 0 ? result : undefined
145+
}

packages/rum-core/src/rawRumEvent.types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {
1818
RumLongTaskEvent,
1919
RumResourceEvent,
2020
RumViewEvent,
21+
RumViewUpdateEvent,
2122
RumVitalEvent,
2223
} from './rumEvent.types'
2324

@@ -26,6 +27,7 @@ export const RumEventType = {
2627
ERROR: 'error',
2728
LONG_TASK: 'long_task',
2829
VIEW: 'view',
30+
VIEW_UPDATE: 'view_update',
2931
RESOURCE: 'resource',
3032
VITAL: 'vital',
3133
} as const
@@ -34,6 +36,7 @@ export type RumEventType = (typeof RumEventType)[keyof typeof RumEventType]
3436

3537
export type AssembledRumEvent = (
3638
| RumViewEvent
39+
| RumViewUpdateEvent
3740
| RumActionEvent
3841
| RumResourceEvent
3942
| RumErrorEvent
@@ -181,6 +184,19 @@ export interface RawRumViewEvent {
181184
}
182185
}
183186

187+
export interface RawRumViewUpdateEvent {
188+
date: TimeStamp
189+
type: typeof RumEventType.VIEW_UPDATE
190+
view: Partial<RawRumViewEvent['view']>
191+
_dd: Partial<RawRumViewEvent['_dd']> & {
192+
document_version: number
193+
}
194+
display?: Partial<ViewDisplay>
195+
privacy?: RawRumViewEvent['privacy']
196+
device?: RawRumViewEvent['device']
197+
feature_flags?: Context
198+
}
199+
184200
interface ViewDisplay {
185201
scroll: {
186202
max_depth?: number
@@ -407,6 +423,7 @@ export type RawRumEvent =
407423
| RawRumErrorEvent
408424
| RawRumResourceEvent
409425
| RawRumViewEvent
426+
| RawRumViewUpdateEvent
410427
| RawRumLongTaskEvent
411428
| RawRumLongAnimationFrameEvent
412429
| RawRumActionEvent

packages/rum-core/test/fixtures.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,18 @@ export function createRawRumEvent(type: RumEventType, overrides?: Context): RawR
119119
},
120120
overrides
121121
)
122+
case RumEventType.VIEW_UPDATE:
123+
return combine(
124+
{
125+
type,
126+
date: 0 as TimeStamp,
127+
view: {},
128+
_dd: {
129+
document_version: 1,
130+
},
131+
},
132+
overrides
133+
)
122134
}
123135
}
124136

0 commit comments

Comments
 (0)