1

I have a tooltip element floating above the cursor. The size of the tooltip is obviously dependent on its content. I also need the tooltip to be completely visible inside the viewport, thus I set the tooltip's position in relation to its size and the cursor's location.

Here's the template:

@if (cursorLocation()) {
  <div #tooltipContainer
       [style.top]="'max(' + (tooltipContainer.offsetHeight + 40) + 'px,' + cursorLocation()!.top + 'px)'"
       [style.left]="'min(100vw - ' + (10 + tooltipContainer.offsetWidth / 2) + 'px, max(' + (10 + tooltipContainer.offsetWidth / 2) + 'px,' + cursorLocation()!.left + 'px))'">
    <div class="mat-mdc-dialog-content">
      @if (dataHandler.selectedWord()) {
        {{dataHandler.selectedWord()?.translation}}
      } @else {
        <wb-loading-screen></wb-loading-screen>
      }
    </div>
  </div>
}

The whole thing is slightly more complex but the important information is the #tooltipContainer and @if content section.

Here's the important bits of the components:

export class BaseTooltipComponent {
  public readonly cursorLocation: ModelSignal<{ top: number, left: number } | undefined> = model.required();
}

export class WordTooltipComponent extends BaseTooltipComponent {
  public readonly dataHandler: WordHandler = inject(WordHandler);
  public readonly ids: InputSignal<{ languageId: string,
                                     wordId: string } | undefined> = input.required();

  constructor() {
    super();
    effect(this._initWords.bind(this), { allowSignalWrites: true });
  }

  private _initWords(): void {
    if (!this.ids()) { return; }
    this.dataHandler.read(this.ids()!.wordId, this.ids()!.languageId);
  }
}

If the tooltip is to be shown, it receives the cursor's current location. Then, in case of the specific tooltip for a word, it also receives two IDs, one for the language and one for the word, then asks a data handler to read this information, which it either takes from its internal cache or requests from the database. Once complete, the dataHandler updates its signal property selectedWord. This then calls to update the content within the tooltip, i.e. what's inside of the @if. Once it does, the following error is thrown:

ERROR RuntimeError: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value for 'top': 'max(166px, 442px)'. Current value: 'max(154px, 442px)'

The way I understand this is that only the content of the tooltip gets re-rendered when the selectedWord is updated. But, the changing content changes the #tooltipContainer's size and this then updates the [style.top] and [style.left] values outside of the corresponding angular lifecycle step as the container itself isn't being re-rendered, which obviously complains (though the tooltip still renders at the correct position).

What I do not understand is why this only happens for offsetHeight. If I remove that from the [style.top] binding despite offsetWidth still being present and updated, no exception is thrown. I've also tried various combinations of the top and left argument order, tried to switch the bindings for top and left, so left would contain the offsetHeight. For whatever reason, it's only an issue for offsetHeight.

Thus I'm actually not sure if my assessment of this issue is correct, otherwise it should also happen for the offsetWidth, shouldn't it? But, in case it is, is there a way to tell angular to re-render the parent container component when selectedWord is being set and the container's content changes without having to wrap the whole container in the @if-statement?

1 Answer 1

1

It could be happening only for offsetHeight, because that is the computation that actually had a different value before and after change detection ran.

To Learn more about this error, visit Error Encyclopedia - Expression Changed After Checked | Angular.dev

Angular throws an ExpressionChangedAfterItHasBeenCheckedError when an expression value has been changed after change detection has completed. Angular only throws this error in development mode. In development mode, Angular performs an additional check after each change detection run, to ensure the bindings haven't changed. This catches errors where the view is left in an inconsistent state. This can occur, for example, if a method or getter returns a different value each time it is called, or if a child component changes values on its parent. If either of these occurs, this is a sign that change detection is not stabilized. Angular throws the error to ensure data is always reflected correctly in the view, which prevents erratic UI behavior or a possible infinite loop.

To solve the problem, we can use afterRenderEffect which will:

Register an effect that, when triggered, is invoked when the application finishes rendering, during the mixedReadWrite phase.

Thus the computation is done after render and the value does not change in DEV mode.

export class BaseTooltipComponent {
  public readonly cursorLocation: ModelSignal<{ top: number, left: number } | undefined> = model.required();
}

export class WordTooltipComponent extends BaseTooltipComponent {
  public readonly dataHandler: WordHandler = inject(WordHandler);
  public readonly ids: InputSignal<{ languageId: string,
                                     wordId: string } | undefined> = input.required();

  constructor() {
    super();
    afterRenderEffect(this._initWords.bind(this), { allowSignalWrites: true });
  }

  private _initWords(): void {
    if (!this.ids()) { return; }
    this.dataHandler.read(this.ids()!.wordId, this.ids()!.languageId);
  }
}

Only thing I am certain is that dataHandler is somehow changing the state of signals that are used to do the computation of top and left styles.

Sign up to request clarification or add additional context in comments.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.