Skip to content

bug: taskQueue: 'immediate' disables the render reentrancy/dedup guard → unbounded re-render & vdom corruption #6787

Description

@erlanzhou

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:

  1. re-renders unbounded (a single external update fans out into an unbounded synchronous cascade), and
  2. 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

Not related to OS

Steps to Reproduce

  1. Open the reproduction: https://stackblitz.com/github/erlanzhou/stencil-immediate-reentrancy-repro
    (or clone it and run npm install && npm start).

  2. Note the config: stencil.config.ts sets taskQueue: 'immediate'.

  3. 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.

  1. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions