diff --git a/src/runtime/test/immediate-reentrancy.spec.tsx b/src/runtime/test/immediate-reentrancy.spec.tsx
new file mode 100644
index 00000000000..d80c38ddd7e
--- /dev/null
+++ b/src/runtime/test/immediate-reentrancy.spec.tsx
@@ -0,0 +1,87 @@
+import { BUILD } from '@app-data';
+import { Component, h, Prop, State } from '@stencil/core';
+import { newSpecPage } from '@stencil/core/testing';
+
+/**
+ * Regression test for the `taskQueue: 'immediate'` reentrancy hole.
+ *
+ * The re-render de-dup / reentrancy guard relies on `HOST_FLAGS.isQueuedForUpdate`.
+ * Previously BOTH its set (scheduleUpdate) and clear (callRender) were gated behind
+ * `BUILD.taskQueue`, so under `immediate` (BUILD.taskQueue === false) the flag was
+ * never set. Because scheduleUpdate dispatches synchronously in that mode, a state
+ * write during render() (e.g. the common "measure the DOM, then setState" pattern)
+ * re-entered the render synchronously and unbounded — and could corrupt an in-flight
+ * vdom patch.
+ *
+ * The guard is now gated on `BUILD.updatable` only, so a single external update
+ * triggers a single follow-up render in BOTH scheduling modes.
+ */
+describe("scheduleUpdate reentrancy guard (taskQueue: 'immediate')", () => {
+ const CAP = 60;
+ let renderCount = 0;
+
+ @Component({ tag: 'cmp-reentry' })
+ class CmpReentry {
+ // external re-render trigger
+ @Prop() trigger = 0;
+ // simulates "measure the DOM, then set state during render" (truncation pattern)
+ @State() measured = 0;
+
+ render() {
+ renderCount++;
+ // Ask for another render from within render(). The guard must coalesce this;
+ // the CAP only prevents a hang if the guard is broken.
+ if (renderCount <= CAP) {
+ this.measured++;
+ }
+ return
{this.measured}
;
+ }
+ }
+
+ const originalTaskQueue = BUILD.taskQueue;
+
+ beforeEach(() => {
+ renderCount = 0;
+ });
+
+ afterEach(() => {
+ BUILD.taskQueue = originalTaskQueue;
+ });
+
+ it('async: a state write during render() is coalesced — 1 render per external update', async () => {
+ BUILD.taskQueue = true;
+
+ const { root, waitForChanges } = await newSpecPage({
+ components: [CmpReentry],
+ html: ``,
+ });
+ expect(renderCount).toBe(1);
+
+ (root as any).trigger = 1;
+ await waitForChanges();
+
+ expect(renderCount).toBe(2);
+ });
+
+ it('immediate: a state write during render() is coalesced — does NOT re-enter unbounded', async () => {
+ // mount in async mode so the initial render behaves identically
+ BUILD.taskQueue = true;
+ const { root, waitForChanges } = await newSpecPage({
+ components: [CmpReentry],
+ html: ``,
+ });
+ await waitForChanges();
+ expect(renderCount).toBe(1);
+
+ // switch the platform to 'immediate' and fire a single external update
+ BUILD.taskQueue = false;
+ renderCount = 0;
+
+ (root as any).trigger = 1;
+ await waitForChanges();
+
+ // With the guard active, one external update == one follow-up render.
+ // Before the fix this was CAP + 1 (runaway synchronous reentrancy).
+ expect(renderCount).toBe(1);
+ });
+});
diff --git a/src/runtime/update-component.ts b/src/runtime/update-component.ts
index 3718cfecb03..48a14c65ea0 100644
--- a/src/runtime/update-component.ts
+++ b/src/runtime/update-component.ts
@@ -24,7 +24,13 @@ export const attachToAncestor = (hostRef: d.HostRef, ancestorComponent?: d.HostE
};
export const scheduleUpdate = (hostRef: d.HostRef, isInitialLoad: boolean): Promise | void => {
- if (BUILD.taskQueue && BUILD.updatable) {
+ if (BUILD.updatable) {
+ // Mark this host as queued/rendering. This must NOT be gated on
+ // `BUILD.taskQueue`: under `taskQueue: 'immediate'` scheduleUpdate dispatches
+ // synchronously, so this flag is the only thing that stops a state write made
+ // during render() (e.g. a measure-then-setState pattern) from synchronously
+ // re-entering the render and corrupting the in-flight vdom patch. It is
+ // cleared in `callRender` once render() has run.
hostRef.$flags$ |= HOST_FLAGS.isQueuedForUpdate;
}
if (BUILD.asyncLoading && hostRef.$flags$ & HOST_FLAGS.isWaitingForChildren) {
@@ -284,7 +290,6 @@ const callRender = (hostRef: d.HostRef, instance: any, elm: HTMLElement, isIniti
// https://rollupjs.org/guide/en/#treeshake tryCatchDeoptimization
const allRenderFn = BUILD.allRenderFn ? true : false;
const lazyLoad = BUILD.lazyLoad ? true : false;
- const taskQueue = BUILD.taskQueue ? true : false;
const updatable = BUILD.updatable ? true : false;
try {
@@ -295,7 +300,10 @@ const callRender = (hostRef: d.HostRef, instance: any, elm: HTMLElement, isIniti
*/
instance = allRenderFn ? instance.render() : instance.render && instance.render();
- if (updatable && taskQueue) {
+ if (updatable) {
+ // Clear the queued/rendering flag now that render() has produced the new
+ // tree. Not gated on `BUILD.taskQueue` so the reentrancy guard set in
+ // `scheduleUpdate` is balanced under `taskQueue: 'immediate'` too.
hostRef.$flags$ &= ~HOST_FLAGS.isQueuedForUpdate;
}