Skip to content

Commit 222fc0e

Browse files
authored
Merge pull request #9963 from ever-co/develop
release(stage): Documents hub main-thread wedge fix (facet option recreation)
2 parents 6691b68 + 4ed0550 commit 222fc0e

3 files changed

Lines changed: 156 additions & 17 deletions

File tree

packages/plugins/docs-ui/src/lib/components/filter-bar/docs-filter-bar.component.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,32 +44,57 @@ export class DocsFilterBarComponent extends TranslationBaseComponent {
4444
}
4545

4646
// ─── Facet buckets (fall back to full enums when facets are unloaded) ───
47+
//
48+
// 🛑 These are consumed as `[buckets]="kindBuckets"` template bindings, which Angular
49+
// re-evaluates on EVERY change-detection cycle. They must therefore return a STABLE array
50+
// reference while `facets` is unchanged — a fresh `Object.values(...).map(...)` each cycle
51+
// fed the downstream `<nb-select>`/`FacetMultiselectComponent` a new identity every tick,
52+
// which recreated its `<nb-option>` children and self-retriggered change detection (the
53+
// Documents-hub main-thread wedge). The cache below is keyed on the `facets` INPUT REFERENCE:
54+
// the parent (browse page) replaces `facets` wholesale on each load, so identity equality is
55+
// the correct and cheap invalidation signal.
56+
57+
private bucketsCache: { source: IDocumentFacets | null; buckets: Record<string, IDocumentFacetBucket[]> } = {
58+
source: undefined as unknown as IDocumentFacets | null,
59+
buckets: {}
60+
};
61+
62+
private facetBuckets(key: string, compute: () => IDocumentFacetBucket[]): IDocumentFacetBucket[] {
63+
if (this.bucketsCache.source !== this.facets) {
64+
this.bucketsCache = { source: this.facets, buckets: {} };
65+
}
66+
return (this.bucketsCache.buckets[key] ??= compute());
67+
}
4768

4869
get kindBuckets(): IDocumentFacetBucket[] {
49-
return this.bucketsOrEnum(this.facets?.kind, Object.values(DocumentKindEnum));
70+
return this.facetBuckets('kind', () => this.bucketsOrEnum(this.facets?.kind, Object.values(DocumentKindEnum)));
5071
}
5172

5273
get statusBuckets(): IDocumentFacetBucket[] {
5374
// UPLOADED folds into PROCESSING — filters offer only READY/PROCESSING/FAILED,
5475
// and the Processing count carries the still-UPLOADED rows with it (R-STA-02).
5576
const values = [DocumentStatusEnum.READY, DocumentStatusEnum.PROCESSING, DocumentStatusEnum.FAILED];
56-
return this.bucketsOrEnum(foldStatusFacetBuckets(this.facets?.status), values);
77+
return this.facetBuckets('status', () => this.bucketsOrEnum(foldStatusFacetBuckets(this.facets?.status), values));
5778
}
5879

5980
get knowledgeBuckets(): IDocumentFacetBucket[] {
60-
return this.bucketsOrEnum(this.facets?.knowledgeStatus, Object.values(DocumentKnowledgeStatusEnum));
81+
return this.facetBuckets('knowledge', () =>
82+
this.bucketsOrEnum(this.facets?.knowledgeStatus, Object.values(DocumentKnowledgeStatusEnum))
83+
);
6184
}
6285

6386
get sourceBuckets(): IDocumentFacetBucket[] {
64-
return this.bucketsOrEnum(this.facets?.source, Object.values(DocumentSourceEnum));
87+
return this.facetBuckets('source', () =>
88+
this.bucketsOrEnum(this.facets?.source, Object.values(DocumentSourceEnum))
89+
);
6590
}
6691

6792
get categoryBuckets(): IDocumentFacetBucket[] {
68-
return this.facets?.categories ?? [];
93+
return this.facetBuckets('categories', () => this.facets?.categories ?? []);
6994
}
7095

7196
get tagBuckets(): IDocumentFacetBucket[] {
72-
return this.facets?.tags ?? [];
97+
return this.facetBuckets('tags', () => this.facets?.tags ?? []);
7398
}
7499

75100
kindLabel = (value: string): string => this.getTranslation(`DOCS.KIND.${value}`);

packages/plugins/docs-ui/src/lib/components/filter-bar/facet-multiselect.component.ts

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { IDocumentFacetBucket } from '../../models/docs-api.model';
1616
[selected]="selected"
1717
(selectedChange)="onSelectedChange($event)"
1818
>
19-
<nb-option *ngFor="let option of options" [value]="option.value">
19+
<nb-option *ngFor="let option of options; trackBy: trackByValue" [value]="option.value">
2020
{{ option.label }}
2121
<span class="docs-facet-count" *ngIf="option.count !== undefined">({{ option.count }})</span>
2222
</nb-option>
@@ -46,20 +46,51 @@ export class FacetMultiselectComponent implements OnChanges {
4646

4747
public options: { value: string; label: string; count?: number }[] = [];
4848

49+
/** Content fingerprint of the last-built `options`, so a same-content rebuild is skipped. */
50+
private optionsSignature = '';
51+
52+
/**
53+
* 🛑 `options` MUST keep a STABLE reference across change-detection cycles whose content has
54+
* not changed. The filter bar binds `[buckets]` to getters (`get kindBuckets()` …) that return
55+
* a NEW array of NEW objects on every evaluation, and `[selected]="value?.kind || []"` mints a
56+
* fresh `[]` every cycle — so `ngOnChanges` fires on essentially every change detection. If we
57+
* rebuilt `options` unconditionally, `*ngFor` (even with `trackBy`) would receive a new array
58+
* each cycle; combined with `<nb-select>` re-querying its `<nb-option>` ContentChildren, that
59+
* recreated every option, whose `ngAfterViewInit` + the resulting content-query change
60+
* retriggered change detection — a self-sustaining loop that pegged the main thread on the
61+
* Documents hub (silent, zero HTTP, before the list even loaded). Rebuilding only when the
62+
* fingerprint changes keeps the reference stable and breaks the cycle; `trackByValue` is the
63+
* second line of defense for when the content genuinely does change.
64+
*/
4965
ngOnChanges(): void {
5066
const buckets = this.buckets ?? [];
67+
const selected = this.selected ?? [];
5168
const known = new Set(buckets.map((bucket) => bucket.value));
52-
this.options = buckets.map((bucket) => ({
53-
value: bucket.value,
54-
label: this.resolveLabel(bucket.value, bucket.label),
55-
count: bucket.count
56-
}));
57-
// Keep stale selected values visible as appended options.
58-
for (const value of this.selected ?? []) {
59-
if (!known.has(value)) {
60-
this.options.push({ value, label: this.resolveLabel(value) });
61-
}
69+
const stale = selected.filter((value) => !known.has(value));
70+
71+
const signature = JSON.stringify([
72+
buckets.map((bucket) => [bucket.value, bucket.label, bucket.count]),
73+
stale
74+
]);
75+
if (signature === this.optionsSignature) {
76+
return;
6277
}
78+
this.optionsSignature = signature;
79+
80+
this.options = [
81+
...buckets.map((bucket) => ({
82+
value: bucket.value,
83+
label: this.resolveLabel(bucket.value, bucket.label),
84+
count: bucket.count
85+
})),
86+
// Keep stale selected values (deep links) visible as appended options.
87+
...stale.map((value) => ({ value, label: this.resolveLabel(value) }))
88+
];
89+
}
90+
91+
/** Stable identity for `*ngFor` so unchanged options are never destroyed/recreated. */
92+
trackByValue(_index: number, option: { value: string }): string {
93+
return option.value;
6394
}
6495

6596
onSelectedChange(values: string[]): void {
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { FacetMultiselectComponent } from './facet-multiselect.component';
2+
import { IDocumentFacetBucket } from '../../models/docs-api.model';
3+
4+
/**
5+
* 🛑 Regression guard for the Documents-hub main-thread wedge.
6+
*
7+
* The filter bar binds `[buckets]` to getters and `[selected]="value?.x || []"`, both of which
8+
* yield a NEW array identity on every change-detection cycle. `FacetMultiselectComponent` used to
9+
* rebuild `options` (new array + new objects) on every resulting `ngOnChanges`, and its
10+
* `*ngFor` had no `trackBy` — so `<nb-select>` recreated every `<nb-option>` each cycle, whose
11+
* `ngAfterViewInit` + the content-query change retriggered change detection. That self-sustaining
12+
* loop pegged the main thread the moment the hub rendered (silent, zero HTTP).
13+
*
14+
* The load-bearing invariant is: **when the bucket/selection CONTENT is unchanged, `options` keeps
15+
* the same array reference across `ngOnChanges` calls** — even when the inputs are new arrays.
16+
*/
17+
describe('FacetMultiselectComponent — option reference stability', () => {
18+
const bucket = (value: string, count?: number, label?: string): IDocumentFacetBucket =>
19+
({ value, count, label } as IDocumentFacetBucket);
20+
21+
let component: FacetMultiselectComponent;
22+
23+
beforeEach(() => {
24+
component = new FacetMultiselectComponent();
25+
});
26+
27+
/** Feed inputs the way the template does: a brand-new array identity every cycle. */
28+
const applyInputs = (values: string[], selected: string[] = []): void => {
29+
component.buckets = values.map((v) => bucket(v, 1));
30+
component.selected = [...selected];
31+
component.ngOnChanges();
32+
};
33+
34+
it('keeps the SAME options reference when the content has not changed across cycles', () => {
35+
applyInputs(['FOLDER', 'PAGE', 'FILE']);
36+
const first = component.options;
37+
38+
// Three more cycles with fresh input identities but identical content.
39+
applyInputs(['FOLDER', 'PAGE', 'FILE']);
40+
applyInputs(['FOLDER', 'PAGE', 'FILE']);
41+
applyInputs(['FOLDER', 'PAGE', 'FILE']);
42+
43+
expect(component.options).toBe(first);
44+
});
45+
46+
it('rebuilds options (new reference) only when the content actually changes', () => {
47+
applyInputs(['FOLDER', 'PAGE']);
48+
const first = component.options;
49+
50+
applyInputs(['FOLDER', 'PAGE', 'FILE']); // a real change
51+
const second = component.options;
52+
53+
expect(second).not.toBe(first);
54+
expect(second.map((o) => o.value)).toEqual(['FOLDER', 'PAGE', 'FILE']);
55+
});
56+
57+
it('treats a changed count as a content change (facet counts arriving from the API)', () => {
58+
component.buckets = [bucket('READY', undefined)];
59+
component.selected = [];
60+
component.ngOnChanges();
61+
const before = component.options;
62+
63+
component.buckets = [bucket('READY', 12)];
64+
component.ngOnChanges();
65+
66+
expect(component.options).not.toBe(before);
67+
expect(component.options[0].count).toBe(12);
68+
});
69+
70+
it('appends stale selected values as options and stays stable while they persist', () => {
71+
applyInputs(['FOLDER'], ['ARCHIVED_KIND_NOT_IN_BUCKETS']);
72+
const first = component.options;
73+
74+
expect(first.map((o) => o.value)).toEqual(['FOLDER', 'ARCHIVED_KIND_NOT_IN_BUCKETS']);
75+
76+
applyInputs(['FOLDER'], ['ARCHIVED_KIND_NOT_IN_BUCKETS']);
77+
expect(component.options).toBe(first);
78+
});
79+
80+
it('exposes a value-based trackBy so unchanged options are never recreated', () => {
81+
expect(component.trackByValue(0, { value: 'FILE' })).toBe('FILE');
82+
});
83+
});

0 commit comments

Comments
 (0)