Prerequisites
Stencil Version
@stencil/core 4.x
Current Behavior
The re-render de-dup / reentrancy guard uses HOST_FLAGS.isQueuedForUpdate. Both the set (scheduleUpdate) and clear (callRender) of this flag are gated behind BUILD.taskQueue:
src/runtime/update-component.ts scheduleUpdate: if (BUILD.taskQueue && BUILD.updatable) { flags |= isQueuedForUpdate }
src/runtime/update-component.ts callRender: if (updatable && taskQueue) { flags &= ~isQueuedForUpdate }
Under taskQueue: 'immediate', BUILD.taskQueue === false, so the flag is never set. Because scheduleUpdate also dispatches synchronously in immediate mode (BUILD.taskQueue ? writeTask(dispatch) : dispatch()), the guard in setValue (if (!(flags & isQueuedForUpdate)) scheduleUpdate(...)) and in forceUpdate no longer protects against a state write performed during render().
Result:
Any state/prop write during render() / componentWillRender / a @Watch handler / componentDidRender synchronously re-enters the render. This:
- re-renders unbounded (a single external update fans out into an unbounded synchronous cascade), and
- can run a nested render while the outer
renderVdom patch is in flight, corrupting it and crashing with Cannot read properties of null (reading 'nodeType') in the browser.
This is the same class of failure that #6673 (fix(runtime): skip setter when removing reflected bool attr) patched at one entry point (reflected boolean attribute removal). The underlying hole affects every "write state during render" path, most commonly when we need to measure intermediate DOM then write final DOM (text truncation, sizing) that complicated web components rely on.
Expected Behavior
A single external update should trigger a single follow-up render in all scheduling modes, exactly as it does under async.
Minimal reproduction
A component that sets @State during render() (mirrors measure-then-render):
@Component({ tag: 'cmp-reentry' })
class CmpReentry {
@Prop() trigger = 0;
@State() measured = 0;
render() {
// e.g. this.measured = this.el.offsetWidth (measure, then set state)
this.measured++;
return <div>{this.measured}</div>;
}
}
Driving one external trigger change:
taskQueue: 'async' → 1 follow-up render (guard coalesces the in-render write). ✅
taskQueue: 'immediate' → unbounded synchronous re-entry (only bounded by an artificial render cap in the test). ❌
(The runtime emits its own dev warning here: "The state/prop … changed during rendering. This can potentially lead to infinite-loops…" where async neutralizes but immediate lets run away.)
System Info
Steps to Reproduce
-
Open the reproduction: https://stackblitz.com/github/erlanzhou/stencil-immediate-reentrancy-repro
(or clone it and run npm install && npm start).
-
Note the config: stencil.config.ts sets taskQueue: 'immediate'.
-
Open the browser DevTools console and look at the rendered <repro-cmp>.
The component (src/components/repro-cmp/repro-cmp.tsx) sets @State inside
render() — the common "measure the DOM, then setState" pattern — and fires ONE
external update from componentDidLoad.
Expected: that single update causes one follow-up render (counter = 2), exactly as
it does under taskQueue: 'async'.
Actual (taskQueue: 'immediate'): render() re-enters itself synchronously and
unbounded. The on-screen counter shoots to 501 (an artificial cap in the repro to
avoid hanging the tab) and the console logs:
[repro] taskQueue:'immediate' — render() r SINGLE update.
- To confirm it is the scheduling mode: chc'
instencil.config.ts`, restart, and the counter is 2 (correct).
Code Reproduction URL
https://stackblitz.com/github/erlanzhou/stencil-immediate-reentrancy-repro
Additional Information
No response
Prerequisites
Stencil Version
@stencil/core 4.x
Current Behavior
The re-render de-dup / reentrancy guard uses
HOST_FLAGS.isQueuedForUpdate. Both the set (scheduleUpdate) and clear (callRender) of this flag are gated behindBUILD.taskQueue:src/runtime/update-component.tsscheduleUpdate:if (BUILD.taskQueue && BUILD.updatable) { flags |= isQueuedForUpdate }src/runtime/update-component.tscallRender:if (updatable && taskQueue) { flags &= ~isQueuedForUpdate }Under
taskQueue: 'immediate',BUILD.taskQueue === false, so the flag is never set. BecausescheduleUpdatealso dispatches synchronously in immediate mode (BUILD.taskQueue ? writeTask(dispatch) : dispatch()), the guard insetValue(if (!(flags & isQueuedForUpdate)) scheduleUpdate(...)) and inforceUpdateno longer protects against a state write performed duringrender().Result:
Any state/prop write during
render()/componentWillRender/ a@Watchhandler /componentDidRendersynchronously re-enters the render. This:renderVdompatch is in flight, corrupting it and crashing withCannot read properties of null (reading 'nodeType')in the browser.This is the same class of failure that #6673 (
fix(runtime): skip setter when removing reflected bool attr) patched at one entry point (reflected boolean attribute removal). The underlying hole affects every "write state during render" path, most commonly when we need to measure intermediate DOM then write final DOM (text truncation, sizing) that complicated web components rely on.Expected Behavior
A single external update should trigger a single follow-up render in all scheduling modes, exactly as it does under
async.Minimal reproduction
A component that sets
@Stateduringrender()(mirrors measure-then-render):Driving one external
triggerchange:taskQueue: 'async'→ 1 follow-up render (guard coalesces the in-render write). ✅taskQueue: 'immediate'→ unbounded synchronous re-entry (only bounded by an artificial render cap in the test). ❌(The runtime emits its own dev warning here: "The state/prop … changed during rendering. This can potentially lead to infinite-loops…" where
asyncneutralizes butimmediatelets run away.)System Info
Steps to Reproduce
Open the reproduction: https://stackblitz.com/github/erlanzhou/stencil-immediate-reentrancy-repro
(or clone it and run
npm install && npm start).Note the config:
stencil.config.tssetstaskQueue: 'immediate'.Open the browser DevTools console and look at the rendered
<repro-cmp>.The component (
src/components/repro-cmp/repro-cmp.tsx) sets@Stateinsiderender()— the common "measure the DOM, then setState" pattern — and fires ONEexternal update from
componentDidLoad.Expected: that single update causes one follow-up render (counter = 2), exactly as
it does under
taskQueue: 'async'.Actual (
taskQueue: 'immediate'):render()re-enters itself synchronously andunbounded. The on-screen counter shoots to 501 (an artificial cap in the repro to
avoid hanging the tab) and the console logs:
[repro] taskQueue:'immediate' — render() r SINGLE update.instencil.config.ts`, restart, and the counter is 2 (correct).Code Reproduction URL
https://stackblitz.com/github/erlanzhou/stencil-immediate-reentrancy-repro
Additional Information
No response