Skip to content

fix(core): prevent duplicate setInterval in dispatchEvent#683

Merged
ENvironmentSet merged 2 commits intomainfrom
analyze-corestore
Mar 12, 2026
Merged

fix(core): prevent duplicate setInterval in dispatchEvent#683
ENvironmentSet merged 2 commits intomainfrom
analyze-corestore

Conversation

@ENvironmentSet
Copy link
Collaborator

Summary

  • dispatchEvent가 호출될 때마다 새 setInterval이 생성되지만 이전 인터벌을 정리하지 않아, 빠른 연속 액션 시 여러 인터벌이 동시 실행되며 불필요한 aggregate() + isEqual() 호출이 선형 증가하는 성능 문제 수정
  • intervalRunning 플래그를 추가하여 이미 인터벌이 돌고 있으면 새로 생성하지 않도록 변경
  • 기존 인터벌이 동일한 events.value를 aggregate하므로 새 이벤트도 자동 처리됨

Resolves FEP-1958

Test plan

  • 기존 makeCoreStore 테스트 84개 전체 통과
  • typecheck 통과

🤖 Generated with Claude Code

When dispatchEvent was called rapidly (e.g., push → push → pop),
each call created a new setInterval without clearing the previous one.
This caused N concurrent intervals running aggregate + isEqual every
16ms, linearly increasing CPU load during transitions.

Fix by adding an `intervalRunning` flag so only one interval exists
at a time. Since all intervals read from the same `events.value`,
a single interval handles all queued events correctly.

Resolves FEP-1958

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@changeset-bot
Copy link

changeset-bot bot commented Mar 11, 2026

🦋 Changeset detected

Latest commit: e723f02

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@stackflow/core Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link

coderabbitai bot commented Mar 11, 2026

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Prevented overlapping interval processing during event dispatch, eliminating duplicate background updates and improving stability.
  • Chores

    • Added a changeset documenting a patch release for the fix; no public API changes.

Walkthrough

Adds a concurrency guard for the dispatch interval by tracking the active interval in currentInterval: any existing interval is cleared before starting a new one, the new interval is stored, and when the interval becomes idle it clears itself and resets currentInterval if it is still the active interval.

Changes

Cohort / File(s) Summary
Core store interval guard
core/src/makeCoreStore.ts
Adds currentInterval tracking: clears any existing interval before creating a new one, stores the new interval handle, and clears/reset currentInterval when the interval detects idle state to prevent overlapping interval processing.
Release notes / changeset
.changeset/fix-duplicate-interval.md
Adds a patch changeset documenting the fix that prevents duplicate setInterval invocations in the dispatch flow.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main fix: preventing duplicate setInterval calls in dispatchEvent. It is concise, specific, and directly reflects the core problem being solved.
Description check ✅ Passed The description is related to the changeset, explaining the performance problem being fixed, the solution approach, and including test results. It provides meaningful context about the changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch analyze-corestore

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 11, 2026

commit: e794bcf

@cloudflare-workers-and-pages
Copy link

cloudflare-workers-and-pages bot commented Mar 11, 2026

Deploying stackflow-demo with  Cloudflare Pages  Cloudflare Pages

Latest commit: e723f02
Status: ✅  Deploy successful!
Preview URL: https://22a3c921.stackflow-demo.pages.dev
Branch Preview URL: https://analyze-corestore.stackflow-demo.pages.dev

View logs

@cloudflare-workers-and-pages
Copy link

cloudflare-workers-and-pages bot commented Mar 11, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
stackflow-docs e723f02 Commit Preview URL Mar 12 2026, 07:12 AM

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
core/src/makeCoreStore.ts (1)

109-118: ⚠️ Potential issue | 🟠 Major

Use the latest stack state before clearing the only interval.

nextStackValue is computed before setStackValue(), but setStackValue() can synchronously run post-effect hooks that call dispatchEvent() again. With this new guard, that nested dispatch will reuse the current timer instead of starting a new one. If the old nextStackValue was "idle", Line 116 can still clear that timer even though stack.value has already moved back to a non-idle transition, leaving the store stuck until another external event arrives.

Suggested fix
       const interval = setInterval(() => {
         const nextStackValue = aggregate(events.value, new Date().getTime());

         if (!isEqual(stack.value, nextStackValue)) {
           setStackValue(nextStackValue);
         }

-        if (nextStackValue.globalTransitionState === "idle") {
+        if (stack.value.globalTransitionState === "idle") {
           clearInterval(interval);
           intervalRunning = false;
         }
       }, INTERVAL_MS);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/makeCoreStore.ts` around lines 109 - 118, The interval callback
computes nextStackValue then calls setStackValue and immediately clears the
timer if nextStackValue.globalTransitionState === "idle", but setStackValue can
synchronously trigger nested dispatches that change stack.value; update the
guard to read the latest stack state after calling setStackValue (e.g., examine
stack.value or a fresh value derived after setStackValue) before calling
clearInterval(interval) and flipping intervalRunning, so you only clear the
timer when the current stack.globalTransitionState is still "idle"; references:
aggregate, events.value, setStackValue, stack.value, nextStackValue, interval,
intervalRunning, dispatchEvent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@core/src/makeCoreStore.ts`:
- Around line 109-118: The interval callback computes nextStackValue then calls
setStackValue and immediately clears the timer if
nextStackValue.globalTransitionState === "idle", but setStackValue can
synchronously trigger nested dispatches that change stack.value; update the
guard to read the latest stack state after calling setStackValue (e.g., examine
stack.value or a fresh value derived after setStackValue) before calling
clearInterval(interval) and flipping intervalRunning, so you only clear the
timer when the current stack.globalTransitionState is still "idle"; references:
aggregate, events.value, setStackValue, stack.value, nextStackValue, interval,
intervalRunning, dispatchEvent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: eae45893-bf6b-44fc-bc31-6a2882de1f22

📥 Commits

Reviewing files that changed from the base of the PR and between 56c34dd and 4d6334d.

📒 Files selected for processing (1)
  • core/src/makeCoreStore.ts

orionmiz

This comment was marked as outdated.

Copy link
Collaborator

@orionmiz orionmiz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Request Changes

The problem being solved is real

Duplicate setInterval accumulation on rapid successive dispatchEvent calls is a valid performance issue.

However, the intervalRunning flag introduces a regression

onChanged is the one post-effect hook that fires from inside the interval callback (via setStackValuetriggerPostEffectHooks). When a plugin dispatches an event (e.g. pop()) from onChanged, the following occurs:

1. Interval tick → aggregate() detects idle → setStackValue()
2. → triggerPostEffectHooks() → onChanged()
3. → Plugin calls pop() → dispatchEvent("Popped")
4. → Event pushed + setStackValue() executes normally
5. → intervalRunning === true → early return (no new interval created)
6. Control returns to original interval tick → stale nextStackValue is still "idle"
7. → clearInterval() + intervalRunning = false
8. No interval exists to poll the pop transition → globalTransitionState permanently stuck in "loading"

Impact

  • usePreventTouchDuringTransition: Touch events are preventDefault-ed while globalTransitionState !== "idle"touch input permanently blocked on mobile
  • SerialNavigationProcess: captureNavigationOpportunity returns [] when not idle → history navigation queue blocked
  • basicUIPlugin: computedTransitionDuration stays at full duration instead of 0msCSS transition timing errors

Reproduction

Unit test (add to makeCoreStore.spec.ts):

test("dispatchEvent from onChanged (interval reentry) must reach idle", async () => {
  let popTriggered = false;

  const { actions } = makeCoreStore({
    initialEvents: [
      makeEvent("Initialized", { transitionDuration: 150, eventDate: enoughPastTime() }),
      makeEvent("ActivityRegistered", { activityName: "hello", eventDate: enoughPastTime() }),
      makeEvent("Pushed", { activityId: "a1", activityName: "hello", activityParams: {}, eventDate: enoughPastTime() }),
    ],
    plugins: [
      () => ({
        key: "reentrancy-test",
        onChanged({ actions: { getStack, pop } }) {
          const stack = getStack();
          if (!popTriggered && stack.activities.length === 2 && stack.globalTransitionState === "idle") {
            popTriggered = true;
            pop();
          }
        },
      }),
    ],
  });

  actions.push({ activityId: "a2", activityName: "hello", activityParams: {} });
  await new Promise((res) => setTimeout(res, 600));

  expect(popTriggered).toBe(true);
  expect(actions.getStack().globalTransitionState).toBe("idle"); // FAIL: received "loading"
});

Visual reproduction in demo (step by step):

  1. Add this plugin to demo/src/stackflow/Stack.tsx as the first plugin:
(() => {
  let popTriggered = false;
  return () => ({
    key: "reentrancy-bug-repro",
    onChanged({ actions: { getStack, pop } }) {
      const stack = getStack();
      (window as any).__state = stack.globalTransitionState;
      if (
        !popTriggered &&
        stack.activities.length === 2 &&
        stack.globalTransitionState === "idle"
      ) {
        popTriggered = true;
        console.log("[bug-repro] push idle detected → calling pop()");
        pop();
        const monitor = setInterval(() => {
          const s = getStack().globalTransitionState;
          console.log(`[monitor] globalTransitionState: ${s}`);
          if (s === "idle") {
            console.log("OK: recovered to idle");
            clearInterval(monitor);
          }
        }, 16);
      }
    },
  });
})(),
  1. Run yarn dev
  2. Open the demo in a browser and open DevTools console
  3. Click any feed card
  4. Observe the console: [monitor] globalTransitionState: loading repeats indefinitely — it never reaches "idle"
  5. Type window.__state in the console to confirm it is "loading"

Suggested fix: local interval reference with guard

let currentInterval: ReturnType<typeof setInterval> | null = null;

dispatchEvent(name, params) {
  // ...
  events.value.push(newEvent);
  setStackValue(nextStackValue);

  if (currentInterval !== null) {
    clearInterval(currentInterval);
  }

  const interval = setInterval(() => {
    const nextStackValue = aggregate(events.value, new Date().getTime());

    if (!isEqual(stack.value, nextStackValue)) {
      setStackValue(nextStackValue);
    }

    if (nextStackValue.globalTransitionState === "idle") {
      clearInterval(interval);
      if (currentInterval === interval) {
        currentInterval = null;
      }
    }
  }, INTERVAL_MS);
  currentInterval = interval;
},

Key points:

  • Prevents interval accumulation: clears previous interval before creating a new one
  • clearInterval(interval) uses a local variable — only clears itself, never kills a newer interval created by a reentrant call
  • currentInterval === interval guard — if reentrancy already replaced currentInterval, don't null it out

The intervalRunning flag caused a regression when onChanged hooks
triggered reentrant dispatchEvent calls: the nested dispatch would
skip interval creation, but the original interval would clear itself
on stale "idle" state, leaving no interval to poll the new transition.

Switch to clearInterval(currentInterval) + create new approach, which
safely handles reentrancy by always replacing the previous interval.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
core/src/makeCoreStore.ts (1)

87-121: Add a nested-dispatch regression test.

This handoff is subtle: a plugin hook can synchronously reenter dispatchEvent(), and correctness now depends on the interval replacement plus the currentInterval === interval guard. Since that path already regressed once, I’d lock it down with a test that dispatches again from a post-effect hook and verifies the newer timer remains the active one until idle.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/makeCoreStore.ts` around lines 87 - 121, Add a regression test that
reproduces a nested/synchronous reentry into dispatchEvent from a plugin
post-effect hook: create a test that wires a post-effect (or equivalent hook
used by your plugins) to call dispatchEvent synchronously during the first
dispatch, use fake timers (or control time via INTERVAL_MS) to advance timers,
and assert that after the nested dispatch the newer timer is the active one
(i.e., the original interval was cleared and currentInterval now references the
new interval) and that the stack eventually reaches globalTransitionState ===
"idle" only after the newer timer completes. Target the dispatchEvent
implementation and the currentInterval behavior so this catches regressions in
the interval-replacement + currentInterval === interval guard.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@core/src/makeCoreStore.ts`:
- Around line 87-121: Add a regression test that reproduces a nested/synchronous
reentry into dispatchEvent from a plugin post-effect hook: create a test that
wires a post-effect (or equivalent hook used by your plugins) to call
dispatchEvent synchronously during the first dispatch, use fake timers (or
control time via INTERVAL_MS) to advance timers, and assert that after the
nested dispatch the newer timer is the active one (i.e., the original interval
was cleared and currentInterval now references the new interval) and that the
stack eventually reaches globalTransitionState === "idle" only after the newer
timer completes. Target the dispatchEvent implementation and the currentInterval
behavior so this catches regressions in the interval-replacement +
currentInterval === interval guard.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 75c49007-95a3-456a-9a66-7a3df6d64c17

📥 Commits

Reviewing files that changed from the base of the PR and between 4d6334d and e723f02.

📒 Files selected for processing (2)
  • .changeset/fix-duplicate-interval.md
  • core/src/makeCoreStore.ts
✅ Files skipped from review due to trivial changes (1)
  • .changeset/fix-duplicate-interval.md

@ENvironmentSet ENvironmentSet merged commit 4d3b294 into main Mar 12, 2026
8 checks passed
@ENvironmentSet ENvironmentSet deleted the analyze-corestore branch March 12, 2026 07:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants