bug: undetected initial prop value on Angular
Prerequisites
- [x] I have read the Contributing Guidelines.
- [x] I agree to follow the Code of Conduct.
- [x] I have searched for existing issues that already report this problem, without success.
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:
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-issuebranch 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
- Refresh the page (on the test page)
- The
Watch()method is not called
If the stencil component is in the Angular root component (projects/angular-app/src/app/app.component.html), the prop is initialized on componentWillLoad.
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.
@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 cannot reproduce the issue on our setup:
@siemens/ix:build: [55:51.9] @stencil/core @siemens/ix:build: [55:52.0] v4.36.0 🎊
Also tested with v4.36.2 and angular 20
@mamillastre Maybe you can try to reproduce it based on the output-target repo.
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.
(@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 🎊
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 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 🎊
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.
It does seem to be an issue; Ionic users have also reported it: https://github.com/ionic-team/ionic-framework/issues/30613
@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
@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.
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?
I dont have a solution right now. My current assumption is:
Actual issue:
- lifecycle calls and initial rendering is called via queueMicrotask (safe time prior to control returning to the browser's event loop.)
- lifecycle result:
valueA = undefined - 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:
- initial rendering is called still via queueMicrotask
- Angular try to perform the angular bindings `[value-a]="'demo'"
- lifecycle calls are executed during requestAnimationFrame loop
- 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 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
@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?
@danielleroux did you try adjusting the taskQueue option before your PR? It is (or was?) async, perhaps you just wanted
immediateand 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.
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).
So I guess a workaround for right-now could be use the dist output instead with initializeNextTick.
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
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();
});
});
}
Is that a work-around or a full solution?
Would people still want initializeNextTick wholesale or per dist / dist-custom-elements outputs respectively?
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
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
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.
awesome - thanks for the investigation @kristilw
Hi @johnjenkins, the commit has been reverted on 4.37.1 Does fix issue is still fixed ?
yep @mamillastre - re-opened
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
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
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!
Thank you very much @johnjenkins for the explanations.
I will use the workarounds for now, waiting for the watcher fix 👍
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 ?
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.
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.
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
Also tested with v4.36.2 and angular 20