Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion docs/guides/migration.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -1035,7 +1035,7 @@ for each option it deselected and reporting the shortened value to the form cont

### 18. Component review (20.3.0)

Ten components went through a full review in 20.3.0: notification-center, popover, search-expandable, select, split-button, title, toast, tooltip, tree and tree-select. Each review closed the members that were never part of the component's contract, moved inputs to signals where that was the point of it, and fixed the behavior it uncovered along the way. Only the changes that reach a consumer are listed here.
Components went through a full review in 20.3.0, in two waves. The first covered notification-center, popover, search-expandable, select, split-button, title, toast, tooltip, tree and tree-select; the second is the one each subsection below belongs to. Each review closed the members that were never part of the component's contract, moved inputs to signals where that was the point of it, and fixed the behavior it uncovered along the way. Only the changes that reach a consumer are listed here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sentence contradicts the subsections it points at, and main has already replaced the whole paragraph.

§18 contains exactly four #### subsections: Code block (1052), Search expandable (1078), Split button (1100), Title (1122). The sentence lists search-expandable, split-button and title as first wave and then says "the second is the one each subsection below belongs to" — so a reader concludes those three were reviewed twice in 20.3.0. migration.ru.md:1042 repeats it verbatim.

Meanwhile origin/main already retitled the section to ### 18. Component review (21.0.0) and rewrote the intro to:

Components went through a full review in 21.0.0. … Only the changes that reach a consumer are listed here, so a component whose review changed nothing a consumer can see has no subsection below.

which solves the same problem properly — so this hunk is both a merge conflict and a regression on top of it. Rebasing onto main's rewrite drops the wave framing entirely.

One more inaccuracy in the same hunk: it says softWrap, viewAll, canDownload, activeFileIndex and files "stay accessor inputs". At the merge base four of the five were plain public fields (@Input({ transform: booleanAttribute }) softWrap: boolean = false;) — they became accessors here, which moves them off the instance (Object.keys, spread, JSON.stringify) and makes a subclass's override softWrap = true shadow the accessor under a consumer's default useDefineForClassFields: true.


Every schematic named below runs automatically:

Expand All @@ -1049,6 +1049,32 @@ Most of them report rather than rewrite: what replaces a removed member or a sig
ng g @koobiq/components:<schematic-name> --project <your project>
```

#### Code block

`maxHeight` was published as `InputSignal<number>` over an `undefined!` default, so a code block with no `[maxHeight]` binding reported `undefined` from a non-nullable type:

```ts
const height: number = codeBlock.maxHeight(); // held undefined
if (codeBlock.maxHeight() > 0) { … } // NaN comparison, never true
```

It reports `number | undefined` now. Nothing about the runtime value changed — the call sites that were quietly wrong now fail to compile.

`KbqCodeBlockHighlight.file` was a write-only required input: a setter with no getter that kicked off highlighting as a side effect. It is a required signal input driven by an effect now, so it can finally be read — and a programmatic write no longer compiles.

| Pattern | Manual migration |
| ---------------------------------------- | ------------------------------------------------------------------- |
| `.maxHeight()` | `?? 0` for the common reading, or handle the unset state explicitly |
| `.file = …` on a `KbqCodeBlockHighlight` | Bind `[file]`; the value is readable as `file()` now |

**The `max-height` applied while `viewAll` is off is a `computed`.** It was a getter read from a `[style.max-height.px]` binding, so it only re-evaluated when something else marked the view dirty.

`softWrap`, `viewAll`, `canDownload`, `activeFileIndex` and `files` are backed by signals. They stay accessor inputs with the same types and the same two-way outputs — they are written by the component as well as by the binding, and a `model()` cannot carry the `booleanAttribute` / `numberAttribute` transform they need. No call site changes.

**Shrinking `files` so that `activeFileIndex` equals the new length now resets the active file to 0.** The guard compared `files.length < activeFileIndex`, which left the first out-of-range index in place, and the block then rendered from an undefined file.

Reported by `code-block-optional-max-height`.

#### Search expandable

Step 4 already renames the `kbq-filter-search` element to `kbq-search-expandable`. That rewrite only ever touched the tag, so the inputs of the removed `KbqFilterBarSearch` survived as attributes the new component does not have — silently, because an unknown attribute on a component is not an error. `v20-upgrade` renames them too now:
Expand Down
28 changes: 27 additions & 1 deletion docs/guides/migration.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -1039,7 +1039,7 @@ ng g @koobiq/components:list-tree-multiple-input --project <your project>

### 18. Ревью компонентов (20.3.0)

В 20.3.0 полное ревью прошли десять компонентов: notification-center, popover, search-expandable, select, split-button, title, toast, tooltip, tree и tree-select. Каждое ревью закрывало члены, которые никогда не были частью контракта компонента, переводило входы на сигналы там, где в этом и был его смысл, и попутно исправляло найденные ошибки поведения. Ниже перечислено только то, что доходит до потребителя.
В 20.3.0 полное ревью прошло в две волны. В первую вошли notification-center, popover, search-expandable, select, split-button, title, toast, tooltip, tree и tree-select; ко второй относится каждый из подразделов ниже. Каждое ревью закрывало члены, которые никогда не были частью контракта компонента, переводило входы на сигналы там, где в этом и был его смысл, и попутно исправляло найденные ошибки поведения. Ниже перечислено только то, что доходит до потребителя.

Все схематики, названные ниже, запускаются автоматически:

Expand All @@ -1053,6 +1053,32 @@ ng update @koobiq/components@20
ng g @koobiq/components:<schematic-name> --project <your project>
```

#### Code block

`maxHeight` был опубликован как `InputSignal<number>` поверх значения по умолчанию `undefined!`, поэтому блок кода без привязки `[maxHeight]` возвращал `undefined` из ненулевого типа:

```ts
const height: number = codeBlock.maxHeight(); // содержал undefined
if (codeBlock.maxHeight() > 0) { … } // сравнение с NaN, никогда не истинно
```

Теперь он возвращает `number | undefined`. В рантайме ничего не изменилось — просто места вызова, которые и так были неправильными, перестали компилироваться.

`KbqCodeBlockHighlight.file` был входом только на запись: сеттер без геттера, который побочным эффектом запускал подсветку. Теперь это обязательный сигнальный вход, которым управляет `effect`, поэтому его наконец можно прочитать — а программная запись больше не компилируется.

| Что было | Как мигрировать вручную |
| ------------------------------------- | -------------------------------------------------------------------- |
| `.maxHeight()` | `?? 0` для обычного чтения либо явная обработка незаданного значения |
| `.file = …` у `KbqCodeBlockHighlight` | Привязать `[file]`; значение теперь читается как `file()` |

**`max-height`, который применяется, пока `viewAll` выключен, стал `computed`.** Это был геттер, читаемый из привязки `[style.max-height.px]`, поэтому он пересчитывался только тогда, когда представление помечал грязным кто-то другой.

`softWrap`, `viewAll`, `canDownload`, `activeFileIndex` и `files` теперь опираются на сигналы. Они остались входами на геттере и сеттере с теми же типами и теми же двусторонними событиями — в них пишет и сам компонент, и привязка, а `model()` не умеет нести нужную им трансформацию `booleanAttribute` / `numberAttribute`. Места вызова не меняются.

**Если `files` укоротить так, что `activeFileIndex` станет равен новой длине, активный файл теперь сбрасывается на 0.** Проверка сравнивала `files.length < activeFileIndex` и оставляла первый выходящий за границы индекс, после чего блок показывал содержимое неопределённого файла.

Сообщается схематиком `code-block-optional-max-height`.

#### Search expandable

Шаг 4 уже переименовывает элемент `kbq-filter-search` в `kbq-search-expandable`. Но та замена трогала только тег, поэтому входы удалённого `KbqFilterBarSearch` оставались в разметке атрибутами, которых у нового компонента нет, — и молча, потому что неизвестный атрибут на компоненте не является ошибкой. Теперь `v20-upgrade` переименовывает и их:
Expand Down
50 changes: 39 additions & 11 deletions packages/components/code-block/code-block-highlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { DOCUMENT } from '@angular/common';
import {
booleanAttribute,
Directive,
effect,
inject,
InjectionToken,
Input,
input,
isDevMode,
numberAttribute,
Expand Down Expand Up @@ -95,6 +95,7 @@ export class KbqCodeBlockHighlight {
private readonly window = inject(KBQ_WINDOW);
private readonly config = inject(KBQ_CODE_BLOCK_HIGHLIGHT_JS_CONFIG, { optional: true });
private hljs: HLJSApi | null = null;
private hljsLoading: Promise<void> | null = null;
private readonly _pending = signal(false);

/**
Expand All @@ -105,17 +106,32 @@ export class KbqCodeBlockHighlight {
readonly pending = this._pending.asReadonly();

/** The code file. */
// TODO: Skipped for migration because:
// Accessor inputs cannot be migrated as they are too complex.
@Input({ required: true })
set file(file: KbqCodeBlockFile) {
if (!this.window) return;
readonly file = input.required<KbqCodeBlockFile>();

if (!this.hljs) {
this.load(this.config ?? {}).then(() => this.highlight(file));
} else {
this.highlight(file);
}
constructor() {
effect((onCleanup) => {
const file = this.file();

if (!this.window) return;

if (this.hljs) {
this.highlight(file);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The effect's dependency set is history-dependent: startFrom/singleLine are tracked here but not on the .then() path.

highlight() reads this.startFrom() (line 206) and this.singleLine() (line 207). Called here it runs inside the effect's consumer, so both become dependencies; called from loadOnce().then(…) (line 131) it runs in a microtask and neither is.

Verified by counting highlight() calls on <code kbqCodeBlockHighlight [file]="f" [startFrom]="n">:

step base 4bb1c9927 this PR
after initial load 1 1
startFrom 1 → 10 1 1 (dead)
f changes 2 2
startFrom 10 → 20 2 3

hljs is null on every instance's first run, so the first pass always takes the untracked path and the set stays {file}; once a file change routes through this branch it is rebuilt as {file, startFrom, singleLine}. Same input change, two behaviours depending on what happened earlier.

Once live, each gutter-only step also costs a full hljs.highlight() re-parse, a DomSanitizer.sanitize pass, a lineNumbersValue() DOM rebuild and a whole-subtree innerHTML write.

KbqCodeBlockHighlight is exported from public-api.ts with both inputs public, so this is consumer-reachable even though code-block.html:111 binds only [file]. Pick one: read both inputs in the effect body, or wrap this call in untracked().


return;
}

// A `file` that arrives while highlight.js is still loading must not start a second load,
// and must not highlight through a directive that has gone away in the meantime.
let cancelled = false;

onCleanup(() => (cancelled = true));

this.loadOnce().then(() => {
if (!cancelled) {
this.highlight(file);
}
});
});
}

/** The starting line number. */
Expand All @@ -124,6 +140,18 @@ export class KbqCodeBlockHighlight {
/** Whether to display line numbers for single line code block. */
readonly singleLine = input<boolean, unknown>(false, { transform: booleanAttribute });

/** Loads highlight.js at most once, so overlapping `file` changes share a single import. */
private loadOnce(): Promise<void> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadOnce() de-duplicates per directive instance, but the work it guards is application-global.

Verified: a host with three code[kbqCodeBlockHighlight] elements produces loadCalls = 3 and 3 new <style> elements in <head>.

load() ends in initLineNumbersPlugin (line 172), which does this.window['hljs'] = instance (line 227 — already global) and then runs highlightJSLineNumbersPlugin, whose body unconditionally calls addStyles() (line 394, head.appendChild(css)) and d.addEventListener('copy', …) (line 360). There is no ngOnDestroy/DestroyRef/removeEventListener in the file, so the listeners outlive their directives: a docs component page renders 10–30 code blocks and every "show source" toggle adds another, so the count grows monotonically with clicking and SPA navigation, and all of them run preventDefault() + setData() on a single copy gesture.

The intra-instance race this fixes is real and the fix works (3 rapid file changes now yield 1 load and 1 highlight of the last file — verified), but it is the rare case. A providedIn: 'root' loader holding the promise and running initLineNumbersPlugin once would kill both duplications and give the copy listener an owner with a lifetime.

this.hljsLoading ??= this.load(this.config ?? {}).finally(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A failed load un-latches hljsLoading but never _pending, so pending() stays true forever.

load() sets this._pending.set(true) at line 156 and only highlight() clears it at line 213.

Verified with a rejecting core loader: after the first failure pending() is true; a later file change does retry (attempts 1 → 2, so the hljsLoading reset works) but pending() is still true afterwards, and the rejection escapes this.loadOnce().then(…) (line 129, no .catch) through zone.js as an uncaught error on every attempt.

Consequences in the host:

  • KbqCodeBlock.scrollTo() takes the highlight?.pending() branch and subscribes to filter((pending) => !pending), take(1), which never emits — toggleViewAll()'s collapse-and-scroll-to-top and onSelectedTabChange()'s scroll reset are silently dead for the rest of the page, and each call leaks another toObservable effect + ReplaySubject.
  • setupContentOverflowDetection's post-load checkOverflow never runs.

Triggers: a 404 on the lazy import('highlight.js') chunk after a deploy, offline, or one rejecting entry in kbqCodeBlockHighlightJsConfigProvider({ languages }).

The comment on the next line says the failure is not latched — the load is not, pending is. Clearing it where it is set covers both halves:

private async load({ core, languages }: KbqCodeBlockHighlightJsConfig): Promise<void> {
    this._pending.set(true);

    try {
        // …
    } catch (error) {
        this._pending.set(false);
        throw error;
    }
}

…plus a rejection handler on the loadOnce() call at line 129 so the failure does not escape as an uncaught error.

if (!this.hljs) {
// The load failed; let a later file change try again rather than latching the failure.
this.hljsLoading = null;
}
});

return this.hljsLoading;
}

private async load({ core, languages }: KbqCodeBlockHighlightJsConfig): Promise<void> {
this._pending.set(true);

Expand Down
2 changes: 1 addition & 1 deletion packages/components/code-block/code-block.html
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
kbqOverflowShadowContainer
kbqTabNavPanel
class="kbq-code-block__main"
[style.max-height.px]="calculatedMaxHeight"
[style.max-height.px]="calculatedMaxHeight()"
[tabIndex]="codeContentTabIndex"
>
<pre
Expand Down
77 changes: 77 additions & 0 deletions packages/components/code-block/code-block.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -939,4 +939,81 @@ describe(KbqCodeBlock.name, () => {
expect(scrollSpy).toHaveBeenCalledWith({ top: 100 });
}));
});
it('should report undefined for an unbound maxHeight', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maxHeight is bound here, not unbound — the path the name describes stays untested.

BaseCodeBlock declares [maxHeight]="maxHeight" (line 102) over maxHeight: number | undefined = undefined (line 137), so the transform runs and takes its new value == null branch. That is the interesting assertion — at the merge base the same binding produced numberAttribute(undefined)NaN — but it is not what the name says.

A genuinely unbound <kbq-code-block [files]="files" /> never invokes the transform: Angular uses the declared default, which was undefined! before and is undefined now, so that path returned undefined on both sides and nothing covers it.

Rename to say the input is bound to undefined, or add the unbound host so the name is true.

const fixture = createComponent(BaseCodeBlock);
const codeBlock = geCodeBlockDebugElement(fixture.debugElement).componentInstance as KbqCodeBlock;

fixture.detectChanges();

expect(codeBlock.maxHeight()).toBeUndefined();
});

it('should drop the calculated max height once viewAll is on', () => {
const fixture = createComponent(BaseCodeBlock);
const { componentInstance } = fixture;
const codeBlock = geCodeBlockDebugElement(fixture.debugElement).componentInstance as KbqCodeBlock;

componentInstance.maxHeight = 200;
fixture.detectChanges();

const main = fixture.nativeElement.querySelector('.kbq-code-block__main') as HTMLElement;

expect(codeBlock.maxHeight()).toBe(200);
expect(main.style.maxHeight).toBe('200px');

codeBlock.toggleViewAll();
fixture.detectChanges();

expect(main.style.maxHeight).toBe('');
});

it('should expose the highlighted file on the highlight directive', () => {
const fixture = createComponent(BaseCodeBlock);

fixture.detectChanges();

const highlight = fixture.debugElement
.query(By.directive(KbqCodeBlockHighlight))
.injector.get(KbqCodeBlockHighlight);

expect(highlight.file().filename).toBe('index.html');

fixture.componentInstance.activeFileIndex = 1;
fixture.detectChanges();

expect(highlight.file().filename).toBe('main.ts');
});
it('should follow a maxHeight that changes after init', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This duplicates the first half of the test above it and pins nothing the PR changed.

Lines 987–995 are statement for statement lines 952–962: same createComponent(BaseCodeBlock), same maxHeight = 200, same querySelector('.kbq-code-block__main'), same expect(main.style.maxHeight).toBe('200px'). Only the 400 step is new — and maxHeight was already an input() signal at the merge base, so writing the binding marks the OnPush view for refresh and the old protected get calculatedMaxHeight() is re-read and returns 400. The test goes green on pre-PR code-block.ts/code-block.html, so it pins none of the computed change. 'should drop the calculated max height once viewAll is on' (line 951) is what actually pins it, because toggleViewAll() wrote a plain field before and dirtied nothing.

It is also the test that runs entirely inside the dead zone of setupContentOverflowDetection (see the comment on code-block.ts:531), so it documents the half-fixed state as correct.

Suggest folding the 400 step into the test above and spending the slot on what currently has no coverage at all: loadOnce()'s de-duplication, the failed-load path, and the effect's cancelled guard.

const fixture = createComponent(BaseCodeBlock);
const { componentInstance } = fixture;

componentInstance.maxHeight = 200;
fixture.detectChanges();

const main = fixture.nativeElement.querySelector('.kbq-code-block__main') as HTMLElement;

expect(main.style.maxHeight).toBe('200px');

componentInstance.maxHeight = 400;
fixture.detectChanges();

expect(main.style.maxHeight).toBe('400px');
});

it('should reset the active file when it falls outside a shorter file list', () => {
const fixture = createComponent(BaseCodeBlock);
const { componentInstance } = fixture;
const codeBlock = geCodeBlockDebugElement(fixture.debugElement).componentInstance as KbqCodeBlock;

componentInstance.activeFileIndex = 2;
fixture.detectChanges();

expect(codeBlock.activeFileIndex).toBe(2);

// Two files leave 0 and 1 valid, so the index has to come back into range.
componentInstance.files = componentInstance.files.slice(0, 2);
fixture.detectChanges();

expect(codeBlock.activeFileIndex).toBe(0);
});
});
Loading