Repository: effector/effector (ts)

Issue:
No-throw mode for `scopeBind`
<!--
Please fill out this entire template so that we can evaluate your proposal as quickly as possible.
-->

## Proposal

Add an option for `scopeBind` to supress its exception in case, when there is no active scope.

```ts
const maybeBinded = scopeBind(event, { safe: true })
```
In this case, if `scopeBind` called with `safe: true` and there is no scope, then `scopeBind` just returns original event or a function, which calls original event (just like when scope is found)

<!--
  Explain why you need this change and why this cannot be addressed with effector's current set of features.
-->

## Use case

`scopeBind` is meant to be used to bind event or effect to a scope to be called later and the most common use case is to build a bridge of effector events to other reactive source, like websocket, browser api, etc

It works quite well, when used like this:
```ts
const connectSocketFx = attach({
 source: $socket,
 effect(socket) {
   socket.on("message", scopeBind(messageReceived))
 }
})

sample({
 clock: appStarted,
 target: connectSocketFx
})

// later
const scope = fork()

await allSettled(appStarted, {scope})
```
This way it is possible to set scope once, when app is initialized and all related subscribtions will also work in correct scope, which is also useful for tests

But if app doesn't use scopes and only needs Fork API for tests, then `scopeBind` usage is problematic because it throws exception, when scope is not found

And to allow usage both with and without scope users are required to write something like this:
```ts
const safeBind = (event) => {
 try {
   return scopeBind(event)
 } catch(e) {
   return event
 }
}

// later
const connectSocketFx = attach({
 source: $socket,
 effect(socket) {
   socket.on("message", safeBind(messageReceived))
 }
})
```

Built-in option would allow to solve these cases without custom helpers

Pull request description:
`safe` mode for `scopeBind`
Closes #666

- Added a new option to `scopeBind` - `safe: boolean`


Patch:
diff --git a/packages/effector/index.d.ts b/packages/effector/index.d.ts
index 7b40dc73..29ad4e58 100644
--- a/packages/effector/index.d.ts
+++ b/packages/effector/index.d.ts
@@ -2999,7 +2999,7 @@ export function serialize(
  * @param unit event to bind
  * @returns function which will trigger an event in a given scope
  */
-export function scopeBind<T>(unit: Event<T>, opts?: {scope: Scope}): (payload: T) => void
+export function scopeBind<T>(unit: Event<T>, opts?: {scope?: Scope; safe?: boolean}): (payload: T) => void
 /**
  * Bind effect to a scope to be called later.
  *
@@ -3007,7 +3007,7 @@ export function scopeBind<T>(unit: Event<T>, opts?: {scope: Scope}): (payload: T
  * @param unit effect to bind
  * @returns function which will trigger an effect in a given scope and returns a promise with a result
  */
-export function scopeBind<P, D>(unit: Effect<P, D>, opts?: {scope: Scope}): (params: P) => Promise<D>
+export function scopeBind<P, D>(unit: Effect<P, D>, opts?: {scope?: Scope; safe?: boolean}): (params: P) => Promise<D>
 
 /**
  * Creates isolated instance of application. Primary purposes of this method are SSR and testing.
diff --git a/src/effector/fork/scopeBind.ts b/src/effector/fork/scopeBind.ts
index 541664d8..014216e7 100644
--- a/src/effector/fork/scopeBind.ts
+++ b/src/effector/fork/scopeBind.ts
@@ -6,9 +6,12 @@ import type {Scope} from '../unit.h'
 import type {Unit} from '../index.h'
 
 /** bind event to scope */
-export function scopeBind(unit: Unit, {scope}: {scope?: Scope} = {}) {
+export function scopeBind(
+  unit: Unit,
+  {scope, safe}: {scope?: Scope; safe?: true} = {},
+) {
   assert(
-    scope || forkPage,
+    scope || forkPage || safe,
     'scopeBind cannot be called outside of forked .watch',
   )
   const savedForkPage = scope || forkPage!


Test patch:
diff --git a/src/effector/__tests__/fork/scopeBind.test.ts b/src/effector/__tests__/fork/scopeBind.test.ts
index 68e301df..2b79ef2a 100644
--- a/src/effector/__tests__/fork/scopeBind.test.ts
+++ b/src/effector/__tests__/fork/scopeBind.test.ts
@@ -58,3 +58,46 @@ it('returns promise when used with effect', async () => {
   const req = scopeFx()
   expect(req instanceof Promise).toBe(true)
 })
+
+it('does not throw on calls without scope, if called in safe mode', () => {
+  const trigger = createEvent()
+
+  expect(() => {
+    scopeBind(trigger, {safe: true})
+  }).not.toThrow()
+})
+
+it('allows calls without scope, if called in safe mode', async () => {
+  const trigger = createEvent()
+  const inc = createEvent()
+  const $count = createStore(0).on(inc, x => x + 1)
+
+  let fn: () => void
+
+  trigger.watch(() => {
+    fn = scopeBind(inc, {safe: true})
+  })
+
+  trigger()
+  await Promise.resolve()
+  fn!()
+  expect($count.getState()).toBe(1)
+})
+
+it('catches scope, if called with scope in safe mode', async () => {
+  const trigger = createEvent()
+  const inc = createEvent()
+  const $count = createStore(0).on(inc, x => x + 1)
+
+  let fn: () => void
+
+  trigger.watch(() => {
+    fn = scopeBind(inc, {safe: true})
+  })
+
+  const scope = fork()
+  await allSettled(trigger, {scope})
+  fn!()
+  expect(scope.getState($count)).toBe(1)
+  expect($count.getState()).toBe(0)
+})