Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

kristilw
Copy link
Contributor

@kristilw kristilw commented Sep 12, 2025

customElements.whenDefined always returns the answer asynchronously. Making the check synchronous when possible resolves the issue

fixes: #6373

BREAKING CHANGE: Watchers will fire earlier than before, but this is the expected behavior

What is the current behavior?

GitHub Issue Number: #6373

What is the new behavior?

Fixes so that watched methods are triggering as expected when an element is already defined.

Documentation

Does this introduce a breaking change?

  • Yes
  • No

I do not think i adds a breaking change as to how it was before 3.36.0, so I belive that no migration is nessecary.

Testing

I had planned to add a unit test to this code but found it very difficult. Looking at the initialize-component.spec.tsx file didnt give me much confidence either. Instead I tested it in a private professional project with many many components and verified that the code reproduction of the error in the github issue (https://github.com/mamillastre/stencil-demo/tree/angular-issue) also now works as expected.

Other information

@kristilw kristilw requested a review from a team as a code owner September 12, 2025 14:04
@johnjenkins
Copy link
Contributor

johnjenkins commented Sep 12, 2025

thanks @kristilw ! Can you run npm run prettier and I'll re-run the workflows :)

…lement is already defined

customElements.whenDefined always returns the answer asynchronously. Making the check synchronous when possible resolves the issue

fixes: 6373

BREAKING CHANGE: Watchers will fire earlier than before, but this is the expected behavior
@kristilw
Copy link
Contributor Author

Done 👍

@johnjenkins johnjenkins changed the title fix(runtime): make sure watchers can fire immediately if the custom e… fix(dist-custom-elements): make watchers fire immediately when element already defined Sep 12, 2025
@johnjenkins johnjenkins added this pull request to the merge queue Sep 12, 2025
Merged via the queue into stenciljs:main with commit 4fb9140 Sep 12, 2025
69 checks passed
@exorex
Copy link

exorex commented Sep 18, 2025

@johnjenkins This change introduces a breaking behavior in how @Watch works.

Previously, watchers only fired when a prop actually changed after the component had mounted. That meant lifecycle guarantees were respected — DOM refs existed, and methods like present() or dismiss() could safely access elements.

With this change, watchers now fire immediately on initial prop assignment, before componentDidLoad or even before refs are set.

This breaks a fundamental expectation in Stencil’s lifecycle and will cause regressions across many components. For example, in Ionic we have patterns like:

@Watch('isOpen')
onIsOpenChange(newValue: boolean, oldValue: boolean) {
  if (newValue === true && oldValue === false) {
    this.present(); // requires DOM refs
  } else if (newValue === false && oldValue === true) {
    this.dismiss();
  }
}

Before:

  • This watcher would only run when isOpen was toggled after the component was mounted.
  • By that time, refs existed and methods like present() worked reliably.

After:

  • The watcher is invoked immediately on the initial prop assignment.
  • If present() depends on refs, they are still undefined.
  • This leads to runtime errors and breaks many existing components.

This essentially changes the semantics of watchers from "respond to prop changes" → to "also run eagerly at init time", which was never the contract before.

This will:

  • Break Ionic components (which rely heavily on watchers for isOpen, disabled, etc.).
  • Break userland components that assume DOM refs exist when a watcher fires.
  • Force developers to add guards and lifecycle hacks in many places.

Suggestion: This should be reconsidered or at least gated behind a flag/major version bump, since it’s a breaking change that alters Stencil’s watcher lifecycle in a way that existing code cannot safely adapt without regressions.

@johnjenkins
Copy link
Contributor

@exorex fair enough!
tests passed so thought that would be ok: we obviously need more coverage on the dist-custom-elements bundle. Can you raise a new issue and we can get it sorted

@exorex
Copy link

exorex commented Sep 18, 2025

@johnjenkins done #6388

Thank you for your efforts.

@kristilw
Copy link
Contributor Author

If postponing watchers until it has rendered is the desired behaviour, why not just move the "isWatchReady" flag check to the updateComponent method (which calls the renderer)? It has a parameter for isInitialLoad, so should be easy to do. I was wondering about it when writing the branch but dismissed it because lazy loaded components seem to set the flag immediately. Could it be that the code for "isWatchReady" was added to the initializeComponent method, as a hack, because of the async nature of customElements.whenDefined?

@johnjenkins
Copy link
Contributor

@kristilw
I have a vague idea around the development history of Stencil - I think dist (lazy loader) was the main / primary idea and dist-custom-elements was an afterthought - shoe-horned in later.

I will initially roll-back this change, but I'd love for you to try moving isWatchReady into updateComponent - in my head it sounds like a very sensible idea.

In the mean-time, I think we're gonna try and run the whole browser-based test suite in the Stencil codebase to pass over both dist and dist-custom-elements outputs (atm they mainly run over dist with some exceptions)

@kristilw
Copy link
Contributor Author

kristilw commented Sep 19, 2025

After doing a small test I can at least verify that a lazy loaded component does indeed fire the watchers before the first rendrer (even before componentWillLoad).

I think moving the isWatchReady check into updateComponent won't be that difficult, can also test it out in our projects to verify if it will work there. It will introduce a breaking change compare to how lazy components currently work, but I don't know Stencil well enough to predict other possible breaking changes.

@johnjenkins
Copy link
Contributor

johnjenkins commented Sep 19, 2025

After doing a small test I can at least verify that a lazy loaded component does indeed fire the watchers before the first rendrer (even before componentWillLoad).

fascinating!

@exorex does this code:

@Watch('isOpen')
onIsOpenChange(newValue: boolean, oldValue: boolean) {
  if (newValue === true && oldValue === false) {
    this.present(); // requires DOM refs
  } else if (newValue === false && oldValue === true) {
    this.dismiss();
  }
}

work without error in the dist output?

edit

@kristilw - I see what's happening, in the dist / lazy-loader the incoming, initial value is set (and retained) internally before the component is even loaded. It means when the actual component module is fetched / loaded there's no changes on the value so no watchers are called.
It protects against the module's default values overriding already set values using the isConstructingInstance flag - the dist-custom-elements output doesn't use that flag

@exorex
Copy link

exorex commented Sep 19, 2025

@johnjenkins You’re absolutely right — thanks for catching that. I should have clarified earlier: this issue only happens in the dist-custom-elements output. The same code works fine in the regular dist output without errors.

@kristilw
Copy link
Contributor Author

kristilw commented Sep 19, 2025

In my test of the dist output I did the following:

  1. Created a component by putting its tag in static html, setting the watched value with js (or with attribute, made no difference)
  2. Waited for it to be fully loaded (a set timeout of 1000ms)
  3. Created another instance of that component in js and set the watched value immediately

The watched method is not triggered on the first instance of the component. On the second component the watch is triggered immediately, before comoponentWillLoad.

Moving the isWatchReady flag to the end of the updateComponent method it behaves more consistently, only firing the watched method after it has rendered once. In our larger project (using dist-custom-elements), manually testing our components and wathcing for any errors in the console, it seems like every thing still works.

Do you know why the customElements.whenDefined check is even needed? I see that for the dist-output there is no such check, and for the dist-custom-elements output, as far as I can tell, the element already has to be defined before entering initializeComponent, beause initializeComponent is triggered by the connectedCallback set up by proxyCustomElement. Looking at the original issue doesn't give much insight. I see that initializeComponent can also be triggered by some HMR stuff, maybe it is because of that.

@kristilw
Copy link
Contributor Author

kristilw commented Sep 19, 2025

After moving isWatchReady to the end of updateComponent it breaks two unit tests that verifies that the watched method is triggered even when changing the state inside componentWillLoad. So either this lifecycle overview is wrong, or the tests are wrong? 🫤

@johnjenkins
Copy link
Contributor

johnjenkins commented Sep 19, 2025

Do you know why the customElements.whenDefined check is even needed?

Absolutely none :D - my guess is, you are correct in your guess - a 'hack' to give it similarity to the lazy variant.

So either this lifecycle overview is wrong, or the tests are wrong? 🫤

I'd assume the tests are wrong. The diagram seems pretty definitive.

With all that being said, however - idk how best to proceed as people come to rely on 'broken' behaviour like this :|
It may be that it should wait until a major release

Any thoughts @exorex ?

(i'm leaning toward 'fixing' as making changes inside componentWillLoad seems weird to me)

johnjenkins pushed a commit that referenced this pull request Sep 19, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

bug: undetected initial prop value on Angular
3 participants