From 81a7a982c7e231f36408a1a8d81055129350cb57 Mon Sep 17 00:00:00 2001 From: "Erlan (Erxuan) Zhou" Date: Thu, 16 Jul 2026 16:15:09 -0700 Subject: [PATCH] fix(runtime): keep isQueuedForUpdate guard active under taskQueue: 'immediate' Both the set (scheduleUpdate) and clear (callRender) of HOST_FLAGS.isQueuedForUpdate were gated on BUILD.taskQueue, so under taskQueue: 'immediate' the render reentrancy guard was inert. Because immediate dispatches synchronously, a state write during render() (e.g. measure-then-setState) re-entered the render unbounded and could corrupt an in-flight vdom patch. Gate the flag on BUILD.updatable only; async/congestionAsync behavior is unchanged. Complements #6673. --- .../test/immediate-reentrancy.spec.tsx | 87 +++++++++++++++++++ src/runtime/update-component.ts | 14 ++- 2 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 src/runtime/test/immediate-reentrancy.spec.tsx 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; }