glimmerjs / glimmer-vm

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Infinite revalidation error is incorrect

NullVoxPopuli opened this issue · comments

Fwiw, I'm using ember-source@3.28.0, so.. I don't know exactly what version of GlimmerVM that's using, but:

This was first reported to me by @nag5000 on the Ember Discord. (Many thanks, @nag5000!)

The reproduction is basic:

// controllers/application.js
import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class ApplicationController extends Controller {
  @tracked fooIsRunning = false;
  @tracked isShown = true;

  @action fooFunc() {
    this.fooIsRunning = true;
  }

  @action hide() {
    this.isShown = false;
  }
}
{{!-- templates/application.hbs --}}
{{#if this.isShown}}
  <button
    disabled={{this.fooIsRunning}}
    {{on "click" this.fooFunc}}
    {{on "blur" this.hide}}
  >
    button
  </button>
{{/if}}

Here is the error printed in Chrome's console (after clicking the button):

Uncaught Error: Assertion Failed: You attempted to update `isShown` on `<ApplicationController:ember155>`, but it had already been used previously in the same computation.  Attempting to update a value after using it in a computation can cause logical errors, infinite revalidation bugs, and performance issues, and is not supported.

`isShown` was first used:

- While rendering:
  -top-level
    application

Stack trace for the update:
    at dirtyTagFor (validator.js:575)
    at setter (validator.js:861)

but, what's fun is that if I comment out this.fooIsRunning = true, and click the button again, let blur trigger, etc, the infinite revalidation assertion never prints!
and, the same thing happens when I comment out this.isShown = false -- no error.

This has something to do with the combination of both action handlers synchronously setting these two tracked properties.

of course, adding await Promise.resolve() to the top of both functions "solves the problem" (of getting the assertion at all), but the assertion does not help anyone come to that resolution, nor actually reveal what the root problem is (imo)
I also opened emberjs/rfcs#769 for discussion.

For extra fun, this error doesn't happen in firefox. 🙃

Just for the additional context, the code below is a real case where I encountered the bug:

<DropDownMenu @side="bottom-end" as |dd|>
  <dd.toggler>
    <button data-active={{dd.isExpanded}} type="button">Toggle</button>
  </dd.toggler>

  <dd.menu>
    <li class="menu-item">
      <button
        type="button"
        disabled={{this.fooTask.isRunning}}
        {{on "click" (perform this.fooTask)}}
      >
        perform foo
      </button>
    </li>
  </dd.menu>
</DropDownMenu>

DropDownMenu under the hood has addEventListener('focusout', ...) to close the drop down menu.
If I click "perform foo" button, I get Uncaught Error: Assertion Failed: You attempted to update isExpanded ...
UPD, a little clarification here: fooTask.isRunning == true -> button[disabled] -> dropdown focusout -> dropdown.isExpanded = false.

But it works if I remove the line disabled={{this.fooTask.isRunning}}.

ember 3.25.4

@nag5000 I think in that case you actually do want the yield Promise.resolve() at the start of your task.

  • isRunning changes to true
  • isRunning is re-consumed in the template
  • your task finishes (I assume no await/yield?)
  • isRunning changes to false
    ----> Infinite revalidation assertion

@NullVoxPopuli My task already has yield (a modal dialog is opened, and promise waits until it is closed - implementation mostly the same as https://github.com/simplabs/ember-promise-modals)

@task({ drop: true })
*fooTask() {
  const confirmResult = yield this.modalDialog.open('confirm', { text });
  ...
}

does the behavior change if you change that to this;

@task({ drop: true })
*fooTask() {
  yield Promise.resolve();
  const confirmResult = yield this.modalDialog.open('confirm', { text });  // I assume state changes occur in here
  ...
}

Unfortunately, that didn't help.

ok, after more discussing in Discord (and some history from @runspired), we have this situation:

The order of operations

  • click
    (async)
    • update fooIsRunning to true

(async (later, as the changes from the click propagate))

  • render isShown (still true, so DOM preserved) (READ)
  • update disabled to true
  • sync blur callback hide sets isShown to false (WRITE)
  • BOOM!

This has to do with Chrome emitting the blur event synchronously, (and FireFox emits this event async??, docs: https://developer.mozilla.org/en-US/docs/Web/API/Element/blur_event).

So adding await Promise.resolve() to the blur handler, with an isDestroyed check (or use a task to do this for you) is probably the way to go.


Note, I'd still like to keep this ticket open, because the error message seems misleading based on the action taken to cause the error. :-\
I'm not sure how we could improve it though -- unless we kept a stack of reasons why async behavior initiates.

Basically what I was saying is the behavior here (rendering invalidation error) is correct and also expected, its a point of education that folks generally aren't aware that blur and focus callbacks are synchronous, and because of this any value updating during render that causes a focus change runs the risk of triggering these handlers and invalidating the render.

There appears to be a nuance to Firefox whereby Firefox is not synchronously invoking blur/focus handlers when the element is changing to a disabled state from an enabled state, though in most cases Firefox will also synchronously invoke such handlers (as is spec).

As a side-note, this particular code example removes the button from DOM entirely any time it is disabled, so it should likely be refactored to do so more directly if that's the true intent. The real-world case could likely be refactored similarly.

So adding await Promise.resolve() to the blur handler, with an isDestroyed check (or use a task to do this for you) is probably the way to go.

I can confirm that it works in my real-world case.