Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions src/runtime/test/immediate-reentrancy.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 <div>{this.measured}</div>;
}
}

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: `<cmp-reentry></cmp-reentry>`,
});
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: `<cmp-reentry></cmp-reentry>`,
});
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);
});
});
14 changes: 11 additions & 3 deletions src/runtime/update-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ export const attachToAncestor = (hostRef: d.HostRef, ancestorComponent?: d.HostE
};

export const scheduleUpdate = (hostRef: d.HostRef, isInitialLoad: boolean): Promise<void> | 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) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
}

Expand Down