Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Errors are not caught/handled within value changed callbacks #711

Open
lb- opened this issue Aug 3, 2023 · 1 comment
Open

Errors are not caught/handled within value changed callbacks #711

lb- opened this issue Aug 3, 2023 · 1 comment

Comments

@lb-
Copy link
Contributor

lb- commented Aug 3, 2023

I have recently set up a controller where we want to ensure that a specific target exists and found what could be a bug in how the error handling works in Stimulus.

According to the documentation on error handling;

All calls from Stimulus to your application’s code are wrapped in a try ... catch block.
If your code throws an error, it will be caught by Stimulus and logged to the browser console, including extra detail such as the controller name and event or lifecycle function being called. If you use an error tracking system that defines window.onerror, Stimulus will also pass the error on to it.

Problem

Errors in the <some>valueChanged callbacks are not being caught like all other errors and result in code that is hard to debug, log and test.

Steps to reproduce (basic)

  1. Set up a simple controller with a value and throw any error inside the value changed callback.
// reveal-controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
  static values = { closed: Boolean };

  closedValueChanged() {
    throw new Error('Is this error caught?');
  }
}
  1. Add the HTML to connect to the controller & load in a browser.
<div class="container" data-controller="reveal"></div>
  1. Expected: Error should be handled and captured similar to errors in other methods. Actual: Error is not caught.
// console result
stimulus.js:2023 application #starting
stimulus.js:2023 reveal #initialize
reveal-controller.js:7 Uncaught (in promise) Error: Is this error caught?
    at t.value (reveal-controller.js:7:11)
    at M.invokeChangedCallback (stimulus.js:1115:31)
    at M.invokeChangedCallbacksForDefaultValues (stimulus.js:1100:22)
    at M.start (stimulus.js:1057:14)
    at B.connect (stimulus.js:1376:28)
    at D.connectContextForScope (stimulus.js:1550:17)
    at H.scopeConnected (stimulus.js:1924:20)
    at j.elementMatchedValue (stimulus.js:1840:27)
    at A.tokenMatched (stimulus.js:946:27)
    at S.tokenMatched (stimulus.js:877:23)
value @ reveal-controller.js:7
invokeChangedCallback @ stimulus.js:1115
invokeChangedCallbacksForDefaultValues @ stimulus.js:1100
start @ stimulus.js:1057
connect @ stimulus.js:1376
  1. As a comparison, using this HTML and clicking the button will result in a caught error.
// reveal-controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
  static values = { closed: Boolean };

  close() {
    throw new Error('Is this error caught?');
  }
}
<div class="container" data-controller="reveal">
  <button data-action="click->reveal#close" type="button">Close</button>
</div>
// Observe the handleError catches this error
stimulus.js:2023 application #starting
stimulus.js:2023 reveal #initialize
stimulus.js:2023 reveal #connect
stimulus.js:2023 application #start
stimulus.js:2018 Error invoking action "click->reveal#close"

Error: Is this error caught?
    at t.value (reveal-controller.js:7:11)
    at f.invokeWithEvent (stimulus.js:363:25)
    at f.handleEvent (stimulus.js:329:18)
    at a.handleEvent (stimulus.js:31:25)

{identifier: 'reveal', controller: t, element: div.container, index: 0, event: PointerEvent}
handleError @ stimulus.js:2018
handleError @ stimulus.js:1424
invokeWithEvent @ stimulus.js:369

Steps to reproduce (full details)

Consider this controller code.

// reveal-controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
  static values = { closed: Boolean };
  static targets = ['content', 'toggle'];

  connect() {
    this.closedValue ? this.close() : this.open();
  }

  close() {
    this.toggleTarget.setAttribute('aria-expanded', 'false');
    this.contentTarget.hidden = true;
    this.closedValue = true;
  }

  open() {
    this.toggleTarget.setAttribute('aria-expanded', 'true');
    this.contentTarget.hidden = false;
    this.closedValue = false;
  }

  toggle() {
    this.closedValue ? this.open() : this.close();
  }
}

And this HTML.

  <body>
    <section class="section">
      <div class="container" data-controller="reveal">
        <h2>Information</h2>
        <button data-action="click->reveal#toggle" type="button">Toggle</button>
        <p class="content" data-target="content">Additional content...</p>
      </div>
    </section>
  </body>

Observe that there is no toggle target set in the HTML, accessing this in the controller will cause a caught error (as expected).

stimulus.js:2023 application #starting
stimulus.js:2023 reveal #initialize
stimulus.js:2018 Error connecting controller

Error: Missing target element "toggle" for "reveal" controller
    at t.get (stimulus.js:2154:27)
    at t.value (reveal-controller.js:18:10)
    at t.value (reveal-controller.js:8:44)
    at B.connect (stimulus.js:1380:29)
    at D.connectContextForScope (stimulus.js:1550:17)
    at H.scopeConnected (stimulus.js:1924:20)
    at j.elementMatchedValue (stimulus.js:1840:27)
    at A.tokenMatched (stimulus.js:946:27)
    at S.tokenMatched (stimulus.js:877:23)
    at stimulus.js:871:40

{identifier: 'reveal', controller: t, element: div.container}
handleError @ stimulus.js:2018
handleError @ stimulus.js:1424
connect @ stimulus.js:1384
connectContextForScope @ stimulus.js:1550
...

Now, refactor this same controller to use value changed callbacks.

import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
  static values = { closed: Boolean };
  static targets = ['content', 'toggle'];

  /**
   * Will cause uncaught error if a target does not exist.
   */
  closedValueChanged(shouldClose) {
    if (shouldClose) {
      this.toggleTarget.setAttribute('aria-expanded', 'false');
      this.contentTarget.hidden = true;
    } else {
      this.toggleTarget.setAttribute('aria-expanded', 'true');
      this.contentTarget.hidden = false;
    }
  }

  close() {
    this.closedValue = true;
  }

  open() {
    this.closedValue = false;
  }

  toggle() {
    this.closedValue = !this.closedValue;
  }
}

With the same HTML, we now get an uncaught error, this does not get handled by Stimulus, not logged as expected and also makes it very difficult to unit test this scenario. The message is there but there is no way for this to be caught by any code easily.

stimulus.js:2023 application #starting
stimulus.js:2023 reveal #initialize
stimulus.js:2154 Uncaught (in promise) Error: Missing target element "toggle" for "reveal" controller
    at t.get (stimulus.js:2154:27)
    at t.value (reveal-controller.js:15:12)
    at M.invokeChangedCallback (stimulus.js:1115:31)
    at M.invokeChangedCallbacksForDefaultValues (stimulus.js:1100:22)
    at M.start (stimulus.js:1057:14)
    at B.connect (stimulus.js:1376:28)
    at D.connectContextForScope (stimulus.js:1550:17)
    at H.scopeConnected (stimulus.js:1924:20)
    at j.elementMatchedValue (stimulus.js:1840:27)
    at A.tokenMatched (stimulus.js:946:27)
get @ stimulus.js:2154
value @ reveal-controller.js:15
invokeChangedCallback @ stimulus.js:1115
invokeChangedCallbacksForDefaultValues @ stimulus.js:1100
start @ stimulus.js:1057
connect @ stimulus.js:1376
connectContextForScope @ stimulus.js:1550
scopeConnected @ stimulus.js:1924

I can kind of work around this by doing a check of targets that we care about in the initalize callback (called before the first changed callback).

  initialize() {
    // attempt to 'fail early' if the controller is misconfigured
    this.toggleTarget;
  }

We still get the uncaught error though, but at least we get a caught one just before it. However, this is not going to help when unit testing this case as the process will still fail.

Potential solution

I think the issue is that the private method invokeChangedCallback does not pass the error to handleError. Instead it just throws it, but I cannot see any places where this method is called that catch this error, causing it to bubble up.

} catch (error) {
if (error instanceof TypeError) {
error.message = `Stimulus Value "${this.context.identifier}.${descriptor.name}" - ${error.message}`
}
throw error

Maybe we can avoid this by passing the error to the handleError instead as this pattern is used by other code.

-      } catch (error) {
+      } catch (error: any) {
        if (error instanceof TypeError) {
          error.message = `Stimulus Value "${this.context.identifier}.${descriptor.name}" - ${error.message}`
        }
-        throw error
+        this.context.handleError(error, `calling ${changedMethodName} with ${JSON.stringify([rawValue, rawOldValue])}`, {/* detail... */})

Note: I have not tested this but just a guess.

@lb-
Copy link
Contributor Author

lb- commented Feb 11, 2024

This appears to be an issue for target connected / disconnected callbacks also.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

No branches or pull requests

1 participant