stencil icon indicating copy to clipboard operation
stencil copied to clipboard

bug: undetected initial prop value on Angular

Open mamillastre opened this issue 7 months ago • 31 comments

Prerequisites

Stencil Version

=4.36.0

Current Behavior

Since Stencil 4.36.0, an initial prop value can be undetected on a dynamically set Angular component.

For example, I use the following code to emulate the dynamic first prop value:

<my-component [first]="'issue'"></my-component>

I have the following logs:

@Prop() first!: string;
@Watch('first')
firstChanged() {
  console.log('CHANGE', this.first);
}

connectedCallback() {
  console.log('connectedCallback', this.first);
}

componentWillLoad() {
  console.log('componentWillLoad', this.first);
}

componentDidLoad() {
  console.log('componentDidLoad', this.first);
}

render() {
  console.log('render', this.first);
  ...
}

In runtime:

Image

The value comes only at the second rendering, the prop has not been initialized before componentWillLoad and the Watch() method has not been called. The prop update seems to be undetected but fire a rerender.

Note: I reproduced this issue only when the component is on a child routing page and when the app is loaded on this page (See reproduction steps).

Expected Behavior

The prop is available on componentWillLoad OR the Watch() method is called.

System Info

System: node 20.18.3
Platform: darwin (24.6.0)
CPU Model: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz (12 cpus)
Compiler: /stencil-demo/packages/elements/node_modules/@stencil/core/compiler/stencil.js
Build: 1752598220
Stencil: 4.36.0 🎊
TypeScript: 5.5.4
Rollup: 4.34.9
Parse5: 7.2.1
jQuery: 4.0.0-pre
Terser: 5.37.0

Steps to Reproduce

  • Clone the angular-issue branch from https://github.com/mamillastre/stencil-demo/tree/angular-issue
  • In the root folder
  • npm i
  • npm run angular
  • Open the running Angular app
  • Open the dev console to see the logs
  • Click on the "TEST" link
  • Here the Watch() method is called
Image
  • Refresh the page (on the test page)
  • The Watch() method is not called
Image

If the stencil component is in the Angular root component (projects/angular-app/src/app/app.component.html), the prop is initialized on componentWillLoad.

Image

Code Reproduction URL

https://github.com/mamillastre/stencil-demo/tree/angular-issue

Additional Information

The issue seems to come from Stencil v3.36.0. Workaround: stay on the v3.35.3 I didn't try to reproduce on Vue or React.

mamillastre avatar Aug 22 '25 08:08 mamillastre

@danielleroux I'm pretty sure this is due to https://github.com/stenciljs/core/pull/6331 < do you have any insights? (@mamillastre I believe this stems from Stencil components now rendering much faster - perhaps Angular sets properties too slowly)

johnjenkins avatar Aug 22 '25 08:08 johnjenkins

@johnjenkins cannot reproduce the issue on our setup:

@siemens/ix:build: [55:51.9] @stencil/core @siemens/ix:build: [55:52.0] v4.36.0 🎊

Image

Also tested with v4.36.2 and angular 20

@mamillastre Maybe you can try to reproduce it based on the output-target repo.

danielleroux avatar Aug 22 '25 08:08 danielleroux

We're having a similar issue where in one particular place in our app componentWillLoad is called before the angular input has been set. We have other places with almost identical code using the same stencil component where it doesn't happen.

ben-hamida avatar Aug 22 '25 09:08 ben-hamida

(@mamillastre I believe this stems from Stencil components now rendering much faster - perhaps Angular sets properties too slowly)

It can be the issue. But in this case, the @Watch() method should be called when the Angular value is initialized.

@johnjenkins cannot reproduce the issue on our setup:

@siemens/ix:build: [55:51.9] @stencil/core @siemens/ix:build: [55:52.0] v4.36.0 🎊

Image Also tested with v4.36.2 and angular 20

@mamillastre Maybe you can try to reproduce it based on the output-target repo.

Do you reproduce on my repo ? https://github.com/mamillastre/stencil-demo/tree/angular-issue

mamillastre avatar Aug 25 '25 07:08 mamillastre

(@mamillastre I believe this stems from Stencil components now rendering much faster - perhaps Angular sets properties too slowly)

It can be the issue. But in this case, the @Watch() method should be called when the Angular value is initialized.

@johnjenkins cannot reproduce the issue on our setup:

@siemens/ix:build: [55:51.9] @stencil/core @siemens/ix:build: [55:52.0] v4.36.0 🎊

Image Also tested with v4.36.2 and angular 20 [@mamillastre](https://github.com/mamillastre) Maybe you can try to reproduce it based on the output-target repo.

Do you reproduce on my repo ? https://github.com/mamillastre/stencil-demo/tree/angular-issue

No there is to much stuff in there, I do not install any repo without knowing which dependencies in there. I would suggest use stencil/output-target fork to make a repo to show the issue.

danielleroux avatar Aug 25 '25 07:08 danielleroux

It does seem to be an issue; Ionic users have also reported it: https://github.com/ionic-team/ionic-framework/issues/30613

johnjenkins avatar Aug 25 '25 09:08 johnjenkins

@danielleroux, I forked the output-target repo and reproduced this issue in the issue-angular-prop-init branch: https://github.com/mamillastre/output-targets/tree/issue-angular-prop-init Here is the commit detail: https://github.com/mamillastre/output-targets/commit/358112cf888c5e84902fc1c43f414d538b5bf074

mamillastre avatar Aug 25 '25 10:08 mamillastre

@johnjenkins Do we have any angular dev's here who have some insights into change detection etc? The regular task queue uses requestAnimationFrame which works like desired.

The issue only happens with queueMicrotask. Same as providing taskQueue: 'immediate', inside the stencil.config.ts even without the changes of the queueMicrotask.

//Edit

Maybe a possible fix would be to put the lifecycle calls back into the event loop of the repaint frames.

export const safeCall = (instance: any, method: string, arg?: any, elm?: HTMLElement) => {
  if (instance && instance[method]) {
    try {
      return new Promise<void>((resolve) => {
        plt.raf(() => resolve(instance[method](arg)));
      });
    } catch (e) {
      consoleError(e, elm);
    }
  }
  return undefined;
};

//Edit 2

It looks promising If I put the lifecycle calls back into the repaint loop, but keep the initial rendering inside the microtask-loop.

danielleroux avatar Aug 25 '25 12:08 danielleroux

Do we have any angular dev's here who have some insights into change detection etc? The regular task queue uses requestAnimationFrame which works like desired.

@gratzl-dev maybe?

johnjenkins avatar Sep 05 '25 11:09 johnjenkins

I dont have a solution right now. My current assumption is:

Actual issue: Image

  1. lifecycle calls and initial rendering is called via queueMicrotask (safe time prior to control returning to the browser's event loop.)
  2. lifecycle result: valueA = undefined
  3. Angular try to perform the angular bindings `[value-a]="'demo'" ==> render dependent lifecycle functions will be execute before angular performs the binding

Putting lifecycle calls into requestAnimationFrame will not solve the issue: Image

  1. initial rendering is called still via queueMicrotask
  2. Angular try to perform the angular bindings `[value-a]="'demo'"
  3. lifecycle calls are executed during requestAnimationFrame loop
  4. lifecycle result `valueA = 'demo'

==> Problem componentWillRender and componentWillLoad has the following feature A promise can be returned, that can be used to wait for the upcoming render. This does not work for the initial rendering attempt.

Note: Graphics are super simplified and does not show 100% the execution order or architecture of stencil/angular. Theres are just my assumption of the issue. (Iam still no angular guy to understand every render detail there)

Solutions what I have in mind is, but not sure about:

  • Make the queue configuration part of the @Component-decorator or event a runtime flag. Stencil developers can decide If they need the "faster-initial-rendering" or "controlled-rendering" behaviour.

danielleroux avatar Sep 05 '25 15:09 danielleroux

@danielleroux did you try adjusting the taskQueue option before your PR? It is (or was?) async, perhaps you just wanted immediate and we can revert? idk - haven't looked much into it

johnjenkins avatar Sep 05 '25 16:09 johnjenkins

@danielleroux / @mamillastre - I've just noticed this hidden in Stencil's docs too ...

/**

  • When a component is first attached to the DOM, this setting will wait a single tick before
  • rendering. This works around an Angular issue, where Angular attaches the elements before
  • settings their initial state, leading to double renders and unnecessary event dispatches.
  • Defaults to false. */ initializeNextTick?: boolean;

sounds relevant ... can someone experiment?

johnjenkins avatar Sep 05 '25 16:09 johnjenkins

@danielleroux did you try adjusting the taskQueue option before your PR? It is (or was?) async, perhaps you just wanted immediate and we can revert? idk - haven't looked much into it

I dont think so because applying updates makes totally sense to do that only depending on the refresh rate and the initial render before the browser frame is shown. If you look at the if-branch of the initializeNextTick there are two closed angular tickets.

danielleroux avatar Sep 08 '25 06:09 danielleroux

I've just tried this out and initializeNextTick does indeed seem to fix this issue in Angular. The main issue with that however, is that initializeNextTick doesn't propagate within the dist-custom-elements build / output atm (I tested by manually editing the compiled code).

Image

So I guess a workaround for right-now could be use the dist output instead with initializeNextTick.

johnjenkins avatar Sep 08 '25 10:09 johnjenkins

I can't claim to know all the details on how Angular renders, but the initial Angular rendering cycle is split into two parts. The first part contains all of the static HTML content: tags (including custom elements), static text, static attributes. This is added to the DOM immediately after an Angular component is created. The other part includes all the dynamic stuff: interpolated variables {{}}, host bindings, dynamic attributes, HTML templates that's inside control flow (@if). This runs as part of the initial change detection cycle, which is triggered at the end of Angular component creation method but waits for a setTimeout/requestAnimationFrame (whatever comes first).

I do not know the internals of Stencil well enough to know why the @watch method is not triggered, and I guess as OP suggest it would kind of work for my project if the @watch methods are triggered properly (but I'll add that in our project we have some runtime checks on what we consider required properties which would log errors if not set).

It would however not resolve the core incompatibility between Stencil and Angular, which is that Angular differentiates between static and dynamic content. I think one has to decide on a trade-off on either 1) rendering the components before the inputs are set and then again afterwards, or 2) wait for the inputs to be set (ideally hook into the ngOnInit lifecycle) before trying to render the component. While setting the initializeNextTick works it would be equal for all builds and components alike, it would be better if it could be turned on/off for different output targets and even components.

Make the queue configuration part of the @Component-decorator or event a runtime flag. Stencil developers can decide If they need the "faster-initial-rendering" or "controlled-rendering" behaviour.

So basically this

kristilw avatar Sep 08 '25 21:09 kristilw

While setting the initializeNextTick works it would be equal for all builds and components alike, it would be better if it could be turned on/off for different output targets and even components.

You can do it on a 'per-component' basis now I think by returning a promise from componentWillLoad:

async componentWillLoad() {
    return new Promise<void>((resolve) => {
      requestAnimationFrame(() => {
        console.log('componentWillLoad', this.first);
        resolve();
      });
    });
  }
Image

Is that a work-around or a full solution? Would people still want initializeNextTick wholesale or per dist / dist-custom-elements outputs respectively?

johnjenkins avatar Sep 09 '25 14:09 johnjenkins

Seems more like a workaround, would it not be better to have a flag in the @Component decorator that better captures the intent? I also think the function with @watch should trigger as expected regardless, as that will probably resolve most of the use cases. Delaying rendering until input is set would then be more of a edge case that is needed if the component would render very differently depending on its input, if some inputs are "required", or the rendering method is very heavy

kristilw avatar Sep 10 '25 07:09 kristilw

Seems more like a workaround, would it not be better to have a flag in the @Component decorator that better captures the intent

Perhaps? However I'd prefer for Stencil to not have to carry the baggage of other frameworks :D - in 99% of use-cases it makes sense to render the component asap. Increasing Stencil's codebase; api, the static analysis, the different outputs, the test suites just doesn't seem fair. Probably happy to receive PRs though.

I also think the function with @watch should trigger as expected regardless

yeah - this is only an issue with dist-custom-elements output - the isWatchReady flag is switched on only after a component has been defined that's why the first example in this repro fires but on-refresh does not - Angular gets to the punch before the whenDefined callback.

I'm not sure around the implications of enabling watchers before whenDefined.. would take some investigation

johnjenkins avatar Sep 10 '25 08:09 johnjenkins

Took a closer look at when the callback of customElements.whenDefined fires. One would think it would fire immediately as a microtask if it has already been defined, but it isn't, it's actually a bit slower. Using customElements.get method instead it returns instantly. I tried a test like this, and it consistently returns the same result in both Chrome and Firefox:

const d = new Date().getTime();
console.log('test get before', new Date().getTime() - d, !!customElements.get(tagName));
customElements.define(tagName, Component);
customElements.whenDefined(tagName).then(() => {
    console.log('custom elements time:', new Date().getTime() - d);
    debugger;
});
console.log('test get after', new Date().getTime() - d, !!customElements.get(tagName));
Promise.resolve().then(() => {
    console.log('promise A time:', new Date().getTime() - d);
});
queueMicrotask(() => {
    console.log('queueMicrotask time:', new Date().getTime() - d);
});
Promise.resolve().then(() => {
    console.log('promise B time:', new Date().getTime() - d);
});

/* prints
test get before 0 false
test get after 1 true
promise A time: 54
queueMicrotask time: 55
promise B time: 55
custom elements time
*/

Its a bit surprising that the customElements.whenDefined returns after queueMicrotask (when the component has already been defined). So I'm wondering if rewriting

customElements.whenDefined(cmpTag).then(() => (hostRef.$flags$ |= HOST_FLAGS.isWatchReady));

to

const setWatchIsReady = () => (hostRef.$flags$ |= HOST_FLAGS.isWatchReady);
if (!!customElements.get(cmpTag)) {
  setWatchIsReady();
} else {
  customElements.whenDefined(cmpTag).then(setWatchIsReady);
}

would resolve the watch issue.

I dont have time to look at it further today, but I can try to make a branch and a test it tomorrow.

kristilw avatar Sep 11 '25 08:09 kristilw

awesome - thanks for the investigation @kristilw

johnjenkins avatar Sep 11 '25 08:09 johnjenkins

Hi @johnjenkins, the commit has been reverted on 4.37.1 Does fix issue is still fixed ?

mamillastre avatar Sep 23 '25 13:09 mamillastre

yep @mamillastre - re-opened

johnjenkins avatar Sep 23 '25 15:09 johnjenkins

however - this particular issue / repro is fixed or rather has consistent workarounds now.

per component:

async componentWillLoad() {
    return new Promise<void>((resolve) => {
      requestAnimationFrame(() => {
        console.log('componentWillLoad', this.first);
        resolve();
      });
    });
  }

or globally, via extras.initializeNextTick

johnjenkins avatar Sep 23 '25 16:09 johnjenkins

Even with the workarounds, I think that this is still a breaking change between the 4.35 and 4.36 versions.

The per component solution does not feel right since we have to remember to add this piece of code. The issue happens in particular cases, so it's easy to forget.

I am not familiar with the initializeNextTick extras configuration. I do not find it in the documentation. What is the impact of using this option? You quote this documentation that concerns me: settings their initial state, leading to double renders and unnecessary event dispatches.

Does the Ionic framework will not be impacted by this issue?

Thank you

mamillastre avatar Sep 23 '25 17:09 mamillastre

Even with the workarounds, I think that this is still a breaking change between the 4.35 and 4.36 versions.

The change between those versions is Stencil got much faster in initialising it's components - esp in the dist-custom-elements output - due to @danielleroux work.

Angular has an issue in that it attaches your components to the DOM then binds it's dynamic properties (e.g. yourElemen.prop1 = boundProp) < Stencil has already done it's first render - this is the reason for this new behaviour.

I am not familiar with the initializeNextTick extras configuration. I do not find it in the documentation. What is the impact of using this option?

There is no documentation for it (I guess as it's a hack for a niche, Angular-only buggy behaviour) - here's the declaration description in context - https://github.com/stenciljs/core/blob/main/src/declarations/stencil-public-compiler.ts#L369

Turning it to true will essentially recreate the old behaviour; throttling a components' initial render so Angular has had time to attach it's bound property. No more 'double renders' / watchers firing when the initial property is added.

This new behaviour won't be rolled back as I think the best default is for components to render ASAP.

We will be looking at watcher attaching though to make it more consistent.

Hope that all makes sense!

johnjenkins avatar Sep 23 '25 19:09 johnjenkins

Thank you very much @johnjenkins for the explanations.

I will use the workarounds for now, waiting for the watcher fix 👍

mamillastre avatar Sep 23 '25 20:09 mamillastre

preact switched back to requestAnimationFrame from queueMicrotask because of stability issues, there is a whole section in angular core about it why they still use requestAnimationFrame. Did you try to switch from queueMicrotask back to requestAnimationFrame in stencil to see if the issue still happens ?

pfteter avatar Oct 20 '25 11:10 pfteter

https://github.com/angular/angular/blob/c60ab336c9e862f6fc3b16cab4a8b2dd910712cf/packages/core/src/util/callback_scheduler.ts#L13

what angular is saying about it:

Both `setTimeout` and `rAF` are able to "coalesce" several events from a single user
 * interaction into a single change detection. Importantly, this reduces view tree traversals when
 * compared to an alternative timing mechanism like `queueMicrotask`, where change detection would
 * then be interleaves between each event.

pfteter avatar Oct 20 '25 11:10 pfteter

Experimentation / PRs most welcome!

RAF is still used for the majority of updating (if taskQueue is not set to immediate); queueMicrotask is only used for the first, initial render.

johnjenkins avatar Oct 20 '25 13:10 johnjenkins

however - this particular issue / repro is fixed or rather has consistent workarounds now.

per component:

async componentWillLoad() { return new Promise((resolve) => { requestAnimationFrame(() => { console.log('componentWillLoad', this.first); resolve(); }); }); } or globally, via extras.initializeNextTick

setting extras.initializeNextTick didn't work for us, while wrapping in a RAF promise did the job. Would have preferred to use the initializeNextTick option though

Cliffback avatar Nov 20 '25 14:11 Cliffback