Skip to content

fix: detect non-native thenables in isPromiseLike#143

Open
mbret wants to merge 1 commit into
mainfrom
fix/bug-hunt-2026-07-20
Open

fix: detect non-native thenables in isPromiseLike#143
mbret wants to merge 1 commit into
mainfrom
fix/bug-hunt-2026-07-20

Conversation

@mbret

@mbret mbret commented Jul 20, 2026

Copy link
Copy Markdown
Owner

The bug

isPromiseLike (src/lib/utils/isPromiseLike.ts) ended with:

"catch" in value &&
value.catch === "function"

The typeof is missing, so the last condition compares the catch method itself to the string "function" — which is always false for a real thenable. The whole structural branch is therefore dead code, and isPromiseLike only ever returns true for value instanceof Promise.

How to observe it

Any promise-like value that is not a native Promise instance (a cross-realm promise, a polyfill, or a lazy thenable such as an ORM query object) is misclassified as plain data. makeObservable — which backs the public useSubscribe and useSubscribeEffect hooks and is itself exported — then wraps the thenable in of(...) instead of from(...), so subscribers receive the raw thenable object instead of its resolved value:

const thenable = { then: (resolve) => resolve(42), catch: () => {} }
makeObservable(thenable)().subscribe(console.log)
// before: logs the thenable object itself
// after:  logs 42

As a bonus, the buggy comparison also returned true for a nonsense object like { then: () => {}, catch: "function" } (where catch is literally the string "function").

The fix

One-character-class fix: value.catch === "function"typeof value.catch === "function". No other behavior changed.

Verification

  • New regression suite src/lib/utils/isPromiseLike.test.ts: 3 of 5 tests fail on the previous code (thenable detection, the string-catch false positive, and the makeObservable integration emitting the raw thenable) and all pass after the fix.
  • Baseline before the fix: 22 files / 129 tests green. After the fix: 23 files / 134 tests green, npm run check (biome) clean, npm run build (tsc + vite) clean — the same gates CI runs.

Other findings (not addressed in this PR)

  • usePersistSignals pipes entriesSubject through concatMap(persistSignals), but the inner persistence stream never completes, so a changed entries array is queued forever and never hydrated/persisted. The hook's docs say a new entries reference "will start over the process once the current one is finished", so this may be a known design trade-off; flagging it since the restart can never actually happen while an adapter is active.
  • ObservableStore.subscribe (useObserve) re-subscribes the shared source after a synchronous error: share() resets on error, so the underlying cold observable's side effects execute a second time when React attaches its useSyncExternalStore subscriber. Not fixed here because the observable state semantics around errors look intentional and the blast radius of changing it is larger.

Generated by Claude Code

The check compared the catch method itself to the string "function"
instead of its typeof, so it could never be true. Any promise-like value
that is not a native Promise instance was treated as plain data and
makeObservable emitted the raw thenable object instead of subscribing
to its resolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Xfhen71E1NXj61srsKLaY

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 257778d99b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

typeof value.then === "function" &&
"catch" in value &&
value.catch === "function")
typeof value.catch === "function")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Accept then-only PromiseLike values

If callers pass a valid non-native thenable that implements the standard PromiseLike shape with then but no catch, this condition remains false and makeObservable still routes it through of(...) instead of from(...). In that scenario subscribers continue to receive the raw thenable object rather than its fulfilled value, so this fix still misses a standard class of promise-like inputs.

Useful? React with 👍 / 👎.

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