Skip to content

fix(BEE-11886): strict mode race condition#16

Merged
mattweberio merged 1 commit into
mainfrom
fix/BEE-11886-Race-Fix
Jun 4, 2026
Merged

fix(BEE-11886): strict mode race condition#16
mattweberio merged 1 commit into
mainfrom
fix/BEE-11886-Race-Fix

Conversation

@mattweberio

Copy link
Copy Markdown
Contributor

Description

Fixes an intermittent Error: Bee is not started thrown from a passive
effect when <Builder> mounts under <React.StrictMode> — the default in
Next.js (reactStrictMode: true) and Create React App. Without this fix,
the wrapper is effectively unusable out-of-the-box in modern React apps
unless StrictMode is disabled app-wide.

Root cause

Builder.tsx constructs the SDK during render and flips editorReady
from an unawaited .then():

  if (instanceRef.current === null && token) {
    instanceRef.current = new BeefreeSDK(token, {...})                                                                                                  
    void beeInstance.start(...).then(() => {
      setEditorReady(true)   // no generation / disposed guard                                                                                          
    })                                                                                   
  }                                                                                                                                                     

Under StrictMode's mount → cleanup → re-mount cycle:

  1. Render constructs Bee A, calls start() (async, unresolved).
  2. StrictMode cleanup runs: instanceRef.current = null, setEditorReady(false).
  3. StrictMode re-runs effects but not render, so instanceRef.current stays null.
  4. Bee A's start() resolves → setEditorReady(true).
  5. Re-render sees instanceRef.current === null and constructs Bee B,
    calling start() again (still unresolved).
  6. The loadConfig effect fires because editorReady changed false → true,
    calling loadConfig(callbacks) on Bee B, whose underlying SDK instance
    isn't started → executeAction throws "Bee is not started".

Two amplifiers make it fatal:

  • The start().then(...) has no guard that this instance is still current.
  • executeAction throws synchronously, so the .catch() on
    loadConfig().catch(...) never attaches — the error escapes the effect
    and hits the nearest error boundary.

The fix

src/Builder.tsx — three discrete changes, no reordering:

  1. Move construct/start out of render into a useEffect with a
    disposed flag captured in closure. The .then() and .catch()
    handlers if (disposed) return so a stale resolution from a prior
    mount cannot mutate state for a different mount cycle. The cleanup
    from the old standalone useEffect(() => { ... }, [container])
    folds into this effect's cleanup.
  2. Add startedRef decoupled from editorReady. Set true inside
    the .then() after the disposed check. The loadConfig effect then
    gates on startedRef.current — distinguishing "flag flipped" from
    "this instance actually observed to start." A stale resolution can't
    trick the gate.
  3. Wrap loadConfig() in try/catch. executeAction throws
    synchronously, so a chained .catch() is insufficient.

Total diff: +60 / -41 in src/Builder.tsx. Nothing else touched.

Behavior changes

The construct effect uses [token, container] deps. v1.0.3's render-time
guard (instanceRef.current === null && token) silently ignored
token-prop changes after first construct. With this fix, a new token
prop tears down the old instance and constructs a new one. This is
arguably the more correct React-idiomatic behavior; consumers who refresh
tokens should already prefer useBuilder().updateToken() and won't see a
difference. Flag if this is a concern.

Test plan

  • yarn lint — clean
  • yarn test:ci — 25 / 25 pass, no changes to existing test
    expectations
  • Manual repro harness verified — bouncing mount/unmount
    under <React.StrictMode> for 5+ cycles never triggers the error
    boundary; trace logs show the disposed flag suppressing stale
    resolutions on every StrictMode re-mount

Repro harness (not in this PR): a clone of this repo at v1.0.3 with
<React.StrictMode> added in example/index.tsx and a Home/Builder
toggle + bounce-stress button in example/App.tsx. Reliably reproduces
the bug against the unpatched Builder.tsx and reliably does NOT
reproduce against this patch.

Out of scope (potential follow-up)

  • The underlying @beefree.io/sdk should expose dispose() / destroy()
    so the wrapper doesn't have to reach into DOM (innerHTML = '') to
    tear an instance down. Worth its own ticket against the SDK package.

Type of Change

  • 🐛 Bug fix (non-breaking change which fixes an issue)

Related Issues

https://growens.atlassian.net/browse/BEE-11886

Fixes #

How Has This Been Tested?

  • Unit tests
  • Integration tests
  • Manual testing

Checklist

  • My code follows the project's code style guidelines
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings or errors
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published
  • I have updated the CHANGELOG.md file under [Unreleased]

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Fixes a StrictMode-induced "Bee is not started" error by moving SDK construction out of render and adding guards against stale async resolutions across mount/unmount cycles.

Changes:

  • Move new BeefreeSDK(...) and start()/join() from render-time into a useEffect, with a disposed closure flag that suppresses stale .then()/.catch() callbacks from a prior mount.
  • Add startedRef decoupled from editorReady, so the loadConfig effect only fires when an instance was actually observed to start (not just when the flag flipped).
  • Wrap loadConfig() in try/catch because executeAction throws synchronously, so a chained .catch() alone wouldn't capture the error.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@mattweberio mattweberio merged commit af2f04c into main Jun 4, 2026
6 checks passed
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.

3 participants