fix: detect non-native thenables in isPromiseLike#143
Conversation
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
There was a problem hiding this comment.
💡 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") |
There was a problem hiding this comment.
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 👍 / 👎.
The bug
isPromiseLike(src/lib/utils/isPromiseLike.ts) ended with:The
typeofis missing, so the last condition compares thecatchmethod itself to the string"function"— which is always false for a real thenable. The whole structural branch is therefore dead code, andisPromiseLikeonly ever returns true forvalue instanceof Promise.How to observe it
Any promise-like value that is not a native
Promiseinstance (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 publicuseSubscribeanduseSubscribeEffecthooks and is itself exported — then wraps the thenable inof(...)instead offrom(...), so subscribers receive the raw thenable object instead of its resolved value:As a bonus, the buggy comparison also returned
truefor a nonsense object like{ then: () => {}, catch: "function" }(wherecatchis literally the string"function").The fix
One-character-class fix:
value.catch === "function"→typeof value.catch === "function". No other behavior changed.Verification
src/lib/utils/isPromiseLike.test.ts: 3 of 5 tests fail on the previous code (thenable detection, the string-catchfalse positive, and themakeObservableintegration emitting the raw thenable) and all pass after the fix.npm run check(biome) clean,npm run build(tsc + vite) clean — the same gates CI runs.Other findings (not addressed in this PR)
usePersistSignalspipesentriesSubjectthroughconcatMap(persistSignals), but the inner persistence stream never completes, so a changedentriesarray 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 itsuseSyncExternalStoresubscriber. 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