clarity-js 0.6.30 → 0.6.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clarity-js",
3
- "version": "0.6.30",
3
+ "version": "0.6.33",
4
4
  "description": "An analytics library that uses web page interactions to generate aggregated insights",
5
5
  "author": "Microsoft Corp.",
6
6
  "license": "MIT",
@@ -0,0 +1,8 @@
1
+ import { Constant } from "@clarity-types/core";
2
+
3
+ export default function api(method: string): string {
4
+ // Zone.js, a popular package for Angular, overrides native browser APIs which can lead to inconsistent state for single page applications.
5
+ // Example issue: https://github.com/angular/angular/issues/31712
6
+ // As a work around, we ensuring Clarity access APIs outside of Zone (and use native implementation instead)
7
+ return window[Constant.Zone] && Constant.Symbol in window[Constant.Zone] ? window[Constant.Zone][Constant.Symbol](method) : method;
8
+ }
@@ -9,8 +9,7 @@ let config: Config = {
9
9
  mask: [],
10
10
  unmask: [],
11
11
  regions: [],
12
- metrics: [],
13
- dimensions: [],
12
+ extract: [],
14
13
  cookies: [],
15
14
  report: null,
16
15
  upload: null,
package/src/core/event.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { BrowserEvent } from "@clarity-types/core";
1
+ import { BrowserEvent, Constant } from "@clarity-types/core";
2
+ import api from "./api";
2
3
  import measure from "./measure";
3
4
 
4
5
  let bindings: BrowserEvent[] = [];
@@ -8,7 +9,7 @@ export function bind(target: EventTarget, event: string, listener: EventListener
8
9
  // Wrapping following lines inside try / catch to cover edge cases where we might try to access an inaccessible element.
9
10
  // E.g. Iframe may start off as same-origin but later turn into cross-origin, and the following lines will throw an exception.
10
11
  try {
11
- target.addEventListener(event, listener, capture);
12
+ target[api(Constant.AddEventListener)](event, listener, capture);
12
13
  bindings.push({ event, target, listener, capture });
13
14
  } catch { /* do nothing */ }
14
15
  }
@@ -18,7 +19,7 @@ export function reset(): void {
18
19
  for (let binding of bindings) {
19
20
  // Wrapping inside try / catch to avoid situations where the element may be destroyed before we get a chance to unbind
20
21
  try {
21
- binding.target.removeEventListener(binding.event, binding.listener, binding.capture);
22
+ binding.target[api(Constant.RemoveEventListener)](binding.event, binding.listener, binding.capture);
22
23
  } catch { /* do nothing */ }
23
24
  }
24
25
  bindings = [];
@@ -1,2 +1,2 @@
1
- let version = "0.6.30";
1
+ let version = "0.6.33";
2
2
  export default version;
@@ -9,6 +9,7 @@ import * as ping from "@src/data/ping";
9
9
  import * as summary from "@src/data/summary";
10
10
  import * as upgrade from "@src/data/upgrade";
11
11
  import * as variable from "@src/data/variable";
12
+ import * as extract from "@src/data/extract";
12
13
  import { queue, track } from "./upload";
13
14
 
14
15
  export default function(event: Event): void {
@@ -105,5 +106,13 @@ export default function(event: Event): void {
105
106
  queue(tokens, false);
106
107
  }
107
108
  break;
109
+ case Event.Extract:
110
+ let extractKeys = extract.keys;
111
+ for (let e of extractKeys) {
112
+ tokens.push(e);
113
+ tokens.push(extract.data[e]);
114
+ }
115
+ extract.reset();
116
+ queue(tokens, false);
108
117
  }
109
118
  }
@@ -0,0 +1,143 @@
1
+ import { ExtractSource, Syntax, Type } from "@clarity-types/core";
2
+ import { Event, Setting, ExtractData } from "@clarity-types/data";
3
+ import config from "@src/core/config";
4
+ import encode from "./encode";
5
+ import * as internal from "@src/diagnostic/internal";
6
+ import { Code, Constant, Severity } from "@clarity-types/data";
7
+
8
+ export let data: ExtractData = {};
9
+ export let keys: (number | string)[] = [];
10
+
11
+ let variables : { [key: number]: Syntax[] } = {};
12
+ let selectors : { [key: number]: string } = {};
13
+ export let fragments: string[] = [];
14
+
15
+ export function start(): void {
16
+ try {
17
+ let e = config.extract;
18
+ if (!e) { return; }
19
+ for (let i = 0; i < e.length; i+=3) {
20
+ let source = e[i] as ExtractSource;
21
+ let key = e[i+1] as number;
22
+ switch (source) {
23
+ case ExtractSource.Javascript:
24
+ let variable = e[i+2] as string;
25
+ variables[key] = parse(variable);
26
+ break;
27
+ case ExtractSource.Cookie:
28
+ /*Todo: Add cookie extract logic*/
29
+ break;
30
+ case ExtractSource.Text:
31
+ let match = e[i+2] as string;
32
+ selectors[key] = match;
33
+ break;
34
+ case ExtractSource.Fragment:
35
+ fragments = e[i+2] as string[];
36
+ break;
37
+ }
38
+ }
39
+ }
40
+ catch(e) {
41
+ internal.log(Code.Config, Severity.Warning, e ? e.name : null);
42
+ }
43
+ }
44
+
45
+ export function clone(v: Syntax[]): Syntax[] {
46
+ return JSON.parse(JSON.stringify(v));
47
+ }
48
+
49
+ export function compute(): void {
50
+ try {
51
+ for (let v in variables) {
52
+ let value = str(evaluate(clone(variables[v])));
53
+ if (value) { update(v, value); }
54
+ }
55
+
56
+ for (let s in selectors) {
57
+ let node = document.querySelector(selectors[s] as string) as HTMLElement;
58
+ if (node) { update(s, node.innerText); }
59
+ }
60
+ }
61
+ catch (e) { internal.log(Code.Selector, Severity.Warning, e ? e.name : null); }
62
+
63
+ encode(Event.Extract);
64
+ }
65
+
66
+ export function reset(): void {
67
+ keys = [];
68
+ }
69
+
70
+ export function update(key: string, value: string | number, force: boolean = false): void {
71
+ if (!(key in data) || (key in data && data[key] !== value) || force ) {
72
+ data[key] = value;
73
+ keys.push(key);
74
+ }
75
+ }
76
+
77
+ export function stop(): void {
78
+ data = {};
79
+ keys = [];
80
+ variables = {};
81
+ selectors = {};
82
+ }
83
+
84
+ function parse(variable: string): Syntax[] {
85
+ let syntax: Syntax[] = [];
86
+ let parts = variable.split(Constant.Dot);
87
+ while (parts.length > 0) {
88
+ let part = parts.shift();
89
+ let arrayStart = part.indexOf(Constant.ArrayStart);
90
+ let conditionStart = part.indexOf(Constant.ConditionStart);
91
+ let conditionEnd = part.indexOf(Constant.ConditionEnd);
92
+ syntax.push({
93
+ name : arrayStart > 0 ? part.substring(0, arrayStart) : (conditionStart > 0 ? part.substring(0, conditionStart) : part),
94
+ type : arrayStart > 0 ? Type.Array : (conditionStart > 0 ? Type.Object : Type.Simple),
95
+ condition : conditionStart > 0 ? part.substring(conditionStart + 1, conditionEnd) : null
96
+ });
97
+ }
98
+
99
+ return syntax;
100
+ }
101
+
102
+ // The function below takes in a variable name in following format: "a.b.c" and safely evaluates its value in javascript context
103
+ // For instance, for a.b.c, it will first check window["a"]. If it exists, it will recursively look at: window["a"]["b"] and finally,
104
+ // return the value for window["a"]["b"]["c"].
105
+ function evaluate(variable: Syntax[], base: Object = window): any {
106
+ if (variable.length == 0) { return base; }
107
+ let part = variable.shift();
108
+ let output;
109
+ if (base && base[part.name]) {
110
+ let obj = base[part.name];
111
+ if (part.type !== Type.Array && match(obj, part.condition)) {
112
+ output = evaluate(variable, obj);
113
+ }
114
+ else if (Array.isArray(obj)) {
115
+ let filtered = [];
116
+ for (var value of obj) {
117
+ if (match(value, part.condition)) {
118
+ let op = evaluate(variable, value)
119
+ if (op) { filtered.push(op); }
120
+ }
121
+ }
122
+ output = filtered;
123
+ }
124
+
125
+ return output;
126
+ }
127
+
128
+ return null;
129
+ }
130
+
131
+ function str(input: string): string {
132
+ // Automatically trim string to max of Setting.ExtractLimit to avoid fetching long strings
133
+ return input ? JSON.stringify(input).substring(0, Setting.ExtractLimit) : input;
134
+ }
135
+
136
+ function match(base: Object, condition: string): boolean {
137
+ if (condition) {
138
+ let prop = condition.split(":");
139
+ return prop.length > 1 ? base[prop[0]] == prop[1] : base[prop[0]]
140
+ }
141
+
142
+ return true;
143
+ }
package/src/data/index.ts CHANGED
@@ -11,12 +11,13 @@ import * as summary from "@src/data/summary";
11
11
  import * as upgrade from "@src/data/upgrade";
12
12
  import * as upload from "@src/data/upload";
13
13
  import * as variable from "@src/data/variable";
14
+ import * as extract from "@src/data/extract";
14
15
  export { event } from "@src/data/custom";
15
16
  export { consent, metadata } from "@src/data/metadata";
16
17
  export { upgrade } from "@src/data/upgrade";
17
18
  export { set, identify } from "@src/data/variable";
18
19
 
19
- const modules: Module[] = [baseline, dimension, variable, limit, summary, metadata, envelope, upload, ping, upgrade];
20
+ const modules: Module[] = [baseline, dimension, variable, limit, summary, metadata, envelope, upload, ping, upgrade, extract];
20
21
 
21
22
  export function start(): void {
22
23
  // Metric needs to be initialized before we can start measuring. so metric is not wrapped in measure
@@ -40,4 +41,5 @@ export function compute(): void {
40
41
  metric.compute();
41
42
  summary.compute();
42
43
  limit.compute();
44
+ extract.compute();
43
45
  }
@@ -42,7 +42,7 @@ export function queue(tokens: Token[], transmit: boolean = true): void {
42
42
  let now = time();
43
43
  let type = tokens.length > 1 ? tokens[1] : null;
44
44
  let event = JSON.stringify(tokens);
45
-
45
+
46
46
  switch (type) {
47
47
  case Event.Discover:
48
48
  discoverBytes += event.length;
@@ -118,12 +118,12 @@ async function upload(final: boolean = false): Promise<void> {
118
118
 
119
119
  let p = sendPlaybackBytes ? `[${playback.join()}]` : Constant.Empty;
120
120
  let encoded: EncodedPayload = {e, a, p};
121
-
121
+
122
122
  // Get the payload ready for sending over the wire
123
123
  // We also attempt to compress the payload if it is not the last payload and the browser supports it
124
124
  // In all other cases, we continue to send back string value
125
125
  let payload = stringify(encoded);
126
- let zipped = last ? null : await compress(payload)
126
+ let zipped = last ? null : await compress(payload)
127
127
  metric.sum(Metric.TotalBytes, zipped ? zipped.length : payload.length);
128
128
  send(payload, zipped, envelope.data.sequence, last);
129
129
 
@@ -151,8 +151,11 @@ function send(payload: string, zipped: Uint8Array, sequence: number, beacon: boo
151
151
  // However, we don't want to rely on it for every payload, since we have no ability to retry if the upload failed.
152
152
  // Also, in case of sendBeacon, we do not have a way to alter HTTP headers and therefore can't send compressed payload
153
153
  if (beacon && "sendBeacon" in navigator) {
154
- dispatched = navigator.sendBeacon(url, payload);
155
- if (dispatched) { done(sequence); }
154
+ try {
155
+ // Navigator needs to be bound to sendBeacon before it is used to avoid errors in some browsers
156
+ dispatched = navigator.sendBeacon.bind(navigator)(url, payload);
157
+ if (dispatched) { done(sequence); }
158
+ } catch { /* do nothing - and we will automatically fallback to XHR below */ }
156
159
  }
157
160
 
158
161
  // Before initiating XHR upload, we check if the data has already been uploaded using sendBeacon
@@ -189,7 +192,7 @@ function check(xhr: XMLHttpRequest, sequence: number): void {
189
192
  if (xhr && xhr.readyState === XMLReadyState.Done && transitData) {
190
193
  // Attempt send payload again (as configured in settings) if we do not receive a success (2XX) response code back from the server
191
194
  if ((xhr.status < 200 || xhr.status > 208) && transitData.attempts <= Setting.RetryLimit) {
192
- // We re-attempt in all cases except when server explicitly rejects our request with 4XX error
195
+ // We re-attempt in all cases except when server explicitly rejects our request with 4XX error
193
196
  if (xhr.status >= 400 && xhr.status < 500) {
194
197
  // In case of a 4XX response from the server, we bail out instead of trying again
195
198
  limit.trigger(Check.Server);
@@ -212,7 +215,7 @@ function check(xhr: XMLHttpRequest, sequence: number): void {
212
215
  // Handle response if it was a 200 response with a valid body
213
216
  if (xhr.status === 200 && xhr.responseText) { response(xhr.responseText); }
214
217
  // If we exhausted our retries then trigger Clarity's shutdown for this page since the data will be incomplete
215
- if (xhr.status === 0) {
218
+ if (xhr.status === 0) {
216
219
  // And, right before we terminate the session, we will attempt one last time to see if we can use
217
220
  // different transport option (sendBeacon vs. XHR) to get this data to the server for analysis purposes
218
221
  send(transitData.data, null, sequence, true);
@@ -233,7 +236,7 @@ function done(sequence: number): void {
233
236
 
234
237
  function delay(): number {
235
238
  // Progressively increase delay as we continue to send more payloads from the client to the server
236
- // If we are not uploading data to a server, and instead invoking UploadCallback, in that case keep returning configured value
239
+ // If we are not uploading data to a server, and instead invoking UploadCallback, in that case keep returning configured value
237
240
  let gap = config.lean === false && discoverBytes > 0 ? Setting.MinUploadDelay : envelope.data.sequence * config.delay;
238
241
  return typeof config.upload === Constant.String ? Math.max(Math.min(gap, Setting.MaxUploadDelay), Setting.MinUploadDelay) : config.delay;
239
242
  }
@@ -1,7 +1,5 @@
1
- import { Code, Constant, Event, Severity } from "@clarity-types/data";
1
+ import { Code, Event, Severity } from "@clarity-types/data";
2
2
  import { LogData } from "@clarity-types/diagnostic";
3
- import config from "@src/core/config";
4
- import { bind } from "@src/core/event";
5
3
  import encode from "./encode";
6
4
 
7
5
  let history: { [key: number]: string[] } = {};
@@ -9,7 +7,6 @@ export let data: LogData;
9
7
 
10
8
  export function start(): void {
11
9
  history = {};
12
- bind(document, "securitypolicyviolation", csp);
13
10
  }
14
11
 
15
12
  export function log(code: Code, severity: Severity, name: string = null, message: string = null, stack: string = null): void {
@@ -26,16 +23,6 @@ export function log(code: Code, severity: Severity, name: string = null, message
26
23
  encode(Event.Log);
27
24
  }
28
25
 
29
- function csp(e: SecurityPolicyViolationEvent): void {
30
- let upload = config.upload as string;
31
- let parts = upload ? upload.substr(0, upload.indexOf("/", Constant.HTTPS.length)).split(Constant.Dot) : []; // Look for first "/" starting after initial "https://" string
32
- let domain = parts.length >= 2 ? parts.splice(-2).join(Constant.Dot) : null;
33
- // Capture content security policy violation only if disposition value is not explicitly set to "report"
34
- if (domain && e.blockedURI && e.blockedURI.indexOf(domain) >= 0 && e["disposition"] !== Constant.Report) {
35
- log(Code.ContentSecurityPolicy, Severity.Warning, e.blockedURI);
36
- }
37
- }
38
-
39
26
  export function stop(): void {
40
27
  history = {};
41
28
  }
package/src/layout/dom.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import { Privacy } from "@clarity-types/core";
2
2
  import { Code, Setting, Severity } from "@clarity-types/data";
3
- import { Constant, NodeInfo, NodeValue, SelectorInput, Source } from "@clarity-types/layout";
3
+ import { Constant, NodeInfo, NodeValue, Selector, SelectorInput, Source } from "@clarity-types/layout";
4
4
  import config from "@src/core/config";
5
5
  import hash from "@src/core/hash";
6
6
  import * as internal from "@src/diagnostic/internal";
7
- import * as extract from "@src/layout/extract";
8
7
  import * as region from "@src/layout/region";
9
8
  import selector from "@src/layout/selector";
10
-
9
+ import * as mutation from "@src/layout/mutation";
10
+ import * as extract from "@src/data/extract";
11
11
  let index: number = 1;
12
12
 
13
13
  // Reference: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Input#%3Cinput%3E_types
@@ -19,6 +19,9 @@ let nodes: Node[] = [];
19
19
  let values: NodeValue[] = [];
20
20
  let updateMap: number[] = [];
21
21
  let hashMap: { [hash: string]: number } = {};
22
+ let override = [];
23
+ let unmask = [];
24
+ let updatedFragments: { [fragment: number]: string } = {};
22
25
 
23
26
  // The WeakMap object is a collection of key/value pairs in which the keys are weakly referenced
24
27
  let idMap: WeakMap<Node, number> = null; // Maps node => id.
@@ -27,7 +30,7 @@ let privacyMap: WeakMap<Node, Privacy> = null; // Maps node => Privacy (enum)
27
30
 
28
31
  export function start(): void {
29
32
  reset();
30
- parse(document);
33
+ parse(document, true);
31
34
  }
32
35
 
33
36
  export function stop(): void {
@@ -40,6 +43,8 @@ function reset(): void {
40
43
  values = [];
41
44
  updateMap = [];
42
45
  hashMap = {};
46
+ override = [];
47
+ unmask = [];
43
48
  idMap = new WeakMap();
44
49
  iframeMap = new WeakMap();
45
50
  privacyMap = new WeakMap();
@@ -47,18 +52,19 @@ function reset(): void {
47
52
 
48
53
  // We parse new root nodes for any regions or masked nodes in the beginning (document) and
49
54
  // later whenever there are new additions or modifications to DOM (mutations)
50
- export function parse(root: ParentNode): void {
55
+ export function parse(root: ParentNode, init: boolean = false): void {
51
56
  // Wrap selectors in a try / catch block.
52
57
  // It's possible for script to receive invalid selectors, e.g. "'#id'" with extra quotes, and cause the code below to fail
53
58
  try {
59
+ // Parse unmask configuration into separate query selectors and override tokens as part of initialization
60
+ if (init) { config.unmask.forEach(x => x.indexOf(Constant.Bang) < 0 ? unmask.push(x) : override.push(x.substr(1))); }
61
+
54
62
  // Since mutations may happen on leaf nodes too, e.g. text nodes, which may not support all selector APIs.
55
63
  // We ensure that the root note supports querySelectorAll API before executing the code below to identify new regions.
56
64
  if ("querySelectorAll" in root) {
57
- extract.regions(root, config.regions);
58
- extract.metrics(root, config.metrics);
59
- extract.dimensions(root, config.dimensions);
65
+ config.regions.forEach(x => root.querySelectorAll(x[1]).forEach(e => region.observe(e, `${x[0]}`))); // Regions
60
66
  config.mask.forEach(x => root.querySelectorAll(x).forEach(e => privacyMap.set(e, Privacy.TextImage))); // Masked Elements
61
- config.unmask.forEach(x => root.querySelectorAll(x).forEach(e => privacyMap.set(e, Privacy.None))); // Unmasked Elements
67
+ unmask.forEach(x => root.querySelectorAll(x).forEach(e => privacyMap.set(e, Privacy.None))); // Unmasked Elements
62
68
  }
63
69
  } catch (e) { internal.log(Code.Selector, Severity.Warning, e ? e.name : null); }
64
70
  }
@@ -80,19 +86,19 @@ export function add(node: Node, parent: Node, data: NodeInfo, source: Source): v
80
86
  let previousId = getPreviousId(node);
81
87
  let privacy = config.content ? Privacy.Sensitive : Privacy.Text;
82
88
  let parentValue = null;
83
- let parentTag = Constant.Empty;
84
89
  let regionId = region.exists(node) ? id : null;
90
+ let fragmentId = null;
85
91
 
86
92
  if (parentId >= 0 && values[parentId]) {
87
93
  parentValue = values[parentId];
88
- parentTag = parentValue.data.tag;
89
94
  parentValue.children.push(id);
90
95
  regionId = regionId === null ? parentValue.region : regionId;
96
+ fragmentId = parentValue.fragment;
91
97
  privacy = parentValue.metadata.privacy;
92
98
  }
93
99
 
94
100
  // Check to see if this particular node should be masked or not
95
- privacy = getPrivacy(node, data, parentTag, privacy);
101
+ privacy = getPrivacy(node, data, parentValue, privacy);
96
102
 
97
103
  // If there's an explicit region attribute set on the element, use it to mark a region on the page
98
104
  if (data.attributes && Constant.RegionData in data.attributes) {
@@ -110,12 +116,13 @@ export function add(node: Node, parent: Node, data: NodeInfo, source: Source): v
110
116
  selector: null,
111
117
  hash: null,
112
118
  region: regionId,
113
- metadata: { active: true, suspend: false, privacy, position: null, size: null }
119
+ metadata: { active: true, suspend: false, privacy, position: null, size: null },
120
+ fragment: fragmentId,
114
121
  };
115
122
 
116
123
  updateSelector(values[id]);
117
124
  size(values[id], parentValue);
118
- track(id, source);
125
+ track(id, source, values[id].fragment);
119
126
  }
120
127
 
121
128
  export function update(node: Node, parent: Node, data: NodeInfo, source: Source): void {
@@ -169,9 +176,14 @@ export function update(node: Node, parent: Node, data: NodeInfo, source: Source)
169
176
  }
170
177
  }
171
178
 
179
+ // track node if it is a part of scheduled fragment mutation
180
+ if(value.fragment && updatedFragments[value.fragment]) {
181
+ changed = true;
182
+ }
183
+
172
184
  // Update selector
173
185
  updateSelector(value);
174
- track(id, source, changed, parentChanged);
186
+ track(id, source, values[id].fragment, changed, parentChanged);
175
187
  }
176
188
  }
177
189
 
@@ -198,13 +210,27 @@ export function iframe(node: Node): HTMLIFrameElement {
198
210
  return doc && iframeMap.has(doc) ? iframeMap.get(doc) : null;
199
211
  }
200
212
 
201
- function getPrivacy(node: Node, data: NodeInfo, parentTag: string, privacy: Privacy): Privacy {
213
+ function getPrivacy(node: Node, data: NodeInfo, parent: NodeValue, privacy: Privacy): Privacy {
202
214
  let attributes = data.attributes;
203
215
  let tag = data.tag.toUpperCase();
204
216
 
205
217
  // If this node was explicitly configured to contain sensitive content, use that information and return the value
206
218
  if (privacyMap.has(node)) { return privacyMap.get(node); }
207
219
 
220
+ // If it's a text node belonging to a STYLE or TITLE tag;
221
+ // Or, the text node belongs to one of SCRUB_EXCEPTIONS
222
+ // then reset the privacy setting to ensure we capture the content
223
+ if (tag === Constant.TextTag && parent && parent.data) {
224
+ let path = parent.selector ? parent.selector[Selector.Stable] : Constant.Empty;
225
+ privacy = parent.data.tag === Constant.StyleTag || parent.data.tag === Constant.TitleTag ? Privacy.None : privacy;
226
+ for (let entry of override) {
227
+ if (path.indexOf(entry) >= 0) {
228
+ privacy = Privacy.None;
229
+ break;
230
+ }
231
+ }
232
+ }
233
+
208
234
  // Do not proceed if attributes are missing for the node
209
235
  if (attributes === null || attributes === undefined) { return privacy; }
210
236
 
@@ -243,10 +269,6 @@ function getPrivacy(node: Node, data: NodeInfo, parentTag: string, privacy: Priv
243
269
  if (Constant.MaskData in attributes) { privacy = Privacy.TextImage; }
244
270
  if (Constant.UnmaskData in attributes) { privacy = Privacy.None; }
245
271
 
246
- // If it's a text node belonging to a STYLE or TITLE tag; then reset the privacy setting to ensure we capture the content
247
- let cTag = tag === Constant.TextTag ? parentTag : tag;
248
- if (cTag === Constant.StyleTag || cTag === Constant.TitleTag) { privacy = Privacy.None; }
249
-
250
272
  return privacy;
251
273
  }
252
274
 
@@ -281,6 +303,9 @@ function updateSelector(value: NodeValue): void {
281
303
  value.selector = [selector(s), selector(s, true)];
282
304
  value.hash = value.selector.map(x => x ? hash(x) : null) as [string, string];
283
305
  value.hash.forEach(h => hashMap[h] = value.id);
306
+ if (value.hash.some(h => extract.fragments.indexOf(h) !== -1)) {
307
+ value.fragment = value.id;
308
+ }
284
309
  }
285
310
 
286
311
  export function getNode(id: number): Node {
@@ -316,6 +341,11 @@ export function updates(): NodeValue[] {
316
341
  if (id in values) { output.push(values[id]); }
317
342
  }
318
343
  updateMap = [];
344
+ for (let id in updatedFragments) {
345
+ extract.update(updatedFragments[id], id, true)
346
+ }
347
+
348
+ updatedFragments = {}
319
349
  return output;
320
350
  }
321
351
 
@@ -353,7 +383,19 @@ function getPreviousId(node: Node): number {
353
383
  return id;
354
384
  }
355
385
 
356
- function track(id: number, source: Source, changed: boolean = true, parentChanged: boolean = false): void {
386
+ function track(id: number, source: Source, fragment: number = null, changed: boolean = true, parentChanged: boolean = false): void {
387
+ // if updated node is a part of fragment and the fragment is not being tracked currently, schedule a mutation on the fragment node
388
+ if (fragment && !updatedFragments[fragment]) {
389
+ let node = getNode(fragment)
390
+ let value = getValue(fragment);
391
+ if (node && value) {
392
+ mutation.schedule(node, true);
393
+ value.hash.forEach(h => {
394
+ if(extract.fragments.indexOf(h) !== -1) { updatedFragments[fragment] = h;}
395
+ });
396
+ }
397
+ }
398
+
357
399
  // Keep track of the order in which mutations happened, they may not be sequential
358
400
  // Edge case: If an element is added later on, and pre-discovered element is moved as a child.
359
401
  // In that case, we need to reorder the pre-discovered element in the update list to keep visualization consistent.
@@ -1,6 +1,7 @@
1
1
  import { Priority, Task, Timer } from "@clarity-types/core";
2
2
  import { Code, Event, Metric, Severity } from "@clarity-types/data";
3
3
  import { Constant, MutationHistory, MutationQueue, Setting, Source } from "@clarity-types/layout";
4
+ import api from "@src/core/api";
4
5
  import { bind } from "@src/core/event";
5
6
  import measure from "@src/core/measure";
6
7
  import * as task from "@src/core/task";
@@ -68,11 +69,8 @@ export function observe(node: Node): void {
68
69
  // For this reason, we need to wire up mutations every time we see a new shadow dom.
69
70
  // Also, wrap it inside a try / catch. In certain browsers (e.g. legacy Edge), observer on shadow dom can throw errors
70
71
  try {
71
- // In an edge case, it's possible to get stuck into infinite Mutation loop within Angular applications
72
- // This appears to be an issue with Zone.js package, see: https://github.com/angular/angular/issues/31712
73
- // As a temporary work around, ensuring Clarity can invoke MutationObserver outside of Zone (and use native implementation instead)
74
- let api: string = window[Constant.Zone] && Constant.Symbol in window[Constant.Zone] ? window[Constant.Zone][Constant.Symbol](Constant.MutationObserver) : Constant.MutationObserver;
75
- let observer = api in window ? new window[api](measure(handle) as MutationCallback) : null;
72
+ let m = api(Constant.MutationObserver);
73
+ let observer = m in window ? new window[m](measure(handle) as MutationCallback) : null;
76
74
  if (observer) {
77
75
  observer.observe(node, { attributes: true, childList: true, characterData: true, subtree: true });
78
76
  observers.push(observer);
@@ -145,6 +143,7 @@ async function process(): Promise<void> {
145
143
  let target = mutation.target;
146
144
  let type = track(mutation, timer);
147
145
  if (type && target && target.ownerDocument) { dom.parse(target.ownerDocument); }
146
+ if (type && target && target.nodeType == Node.DOCUMENT_FRAGMENT_NODE && (target as ShadowRoot).host) { dom.parse(target as ShadowRoot); }
148
147
  switch (type) {
149
148
  case Constant.Attributes:
150
149
  processNode(target, Source.Attributes);
@@ -219,7 +218,7 @@ async function processNodeList(list: NodeList, source: Source, timer: Timer): Pr
219
218
  }
220
219
  }
221
220
 
222
- function schedule(node: Node): Node {
221
+ export function schedule(node: Node, fragment: boolean = false): Node {
223
222
  // Only schedule manual trigger for this node if it's not already in the queue
224
223
  if (queue.indexOf(node) < 0) { queue.push(node); }
225
224
 
@@ -227,17 +226,20 @@ function schedule(node: Node): Node {
227
226
  // It's common for a webpage to call multiple synchronous "insertRule" / "deleteRule" calls.
228
227
  // And in those cases we do not wish to monitor changes multiple times for the same node.
229
228
  if (timeout) { clearTimeout(timeout); }
230
- timeout = setTimeout(trigger, Setting.LookAhead);
229
+ timeout = setTimeout(() => { trigger(fragment) }, Setting.LookAhead);
231
230
 
232
231
  return node;
233
232
  }
234
233
 
235
- function trigger(): void {
234
+ function trigger(fragment: boolean): void {
236
235
  for (let node of queue) {
237
- let shadowRoot = node && node.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
238
- // Skip re-processing shadowRoot if it was already discovered
239
- if (shadowRoot && dom.has(node)) { continue; }
240
- generate(node, shadowRoot ? Constant.ChildList : Constant.CharacterData);
236
+ // Generate a mutation for this node only if it still exists
237
+ if (node) {
238
+ let shadowRoot = node.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
239
+ // Skip re-processing shadowRoot if it was already discovered
240
+ if (shadowRoot && dom.has(node)) { continue; }
241
+ generate(node, shadowRoot || fragment ? Constant.ChildList : Constant.CharacterData);
242
+ }
241
243
  }
242
244
  queue = [];
243
245
  }
@@ -46,6 +46,7 @@ export default function (node: Node, source: Source): Node {
46
46
  case Node.DOCUMENT_FRAGMENT_NODE:
47
47
  let shadowRoot = (node as ShadowRoot);
48
48
  if (shadowRoot.host) {
49
+ dom.parse(shadowRoot);
49
50
  let type = typeof (shadowRoot.constructor);
50
51
  if (type === Constant.Function && shadowRoot.constructor.toString().indexOf(Constant.NativeCode) >= 0) {
51
52
  observe(shadowRoot);