effector / effector/effector

RFC: fix scope, SSR and lifecycle bugs in effector-vue/composition without breaking changes

Open
#1,340 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
4.9k
Forks
280
Avg merge
2d 6h
Merged PRs (30d)
7

Description

## Proposal

`effector-vue/composition` has not changed since 23.1.1 and does not work correctly under `fork()`. Every item below was checked against the source (effector 23.3, Vue 3.4.38).

- `useUnit(fx)` with a single Effect returns `{unit: fn}` instead of a function. `src/vue/useUnit.ts:72` checks `is.event` only, and an Effect has `kind === 'effect'`.
- Without a provided scope, every unit is wrapped in `scopeBind(unit, {scope: undefined, safe: true})`. The wrapper captures `scope || forkPage` at bind time and calls `setForkPage(captured)` on each call (`src/effector/fork/scopeBind.ts:11,22`). Called from an effect handler under `allSettled(fx, {scope})`, it pushes the event out of the scope into the global graph. effector-react (`apiBase.ts:85`) and effector-solid (`base.ts:60`) return the raw unit instead.
- `forceScope` is declared seven times in the typings and never read. The scope is provided under a string key from `app.config.globalProperties.scopeName` and resolved through `getCurrentInstance()`. Nested apps collide, `app.runWithContext` does not work, Vapor components fall back to global mode with no warning.
- Cleanup uses `onUnmounted`. It never runs on the server, inside `effectScope()` or after `await` in a hand-written async `setup()`. Each server render leaks nodes into `scope.additionalLinks`.
- `useStoreMap` wraps the state in `shallowReactive(state)`. It throws on `null`, mutates the store object, shares the proxy between components, never removes keys that disappeared, and applies `updateFilter` to the raw state instead of the selector result. `useStoreMap($store, fn)` is documented but does not exist.
- `useVModel` writes through the private `store.setState` (`createUnit.ts:291-297`: `launch` into the ambient `forkPage`). Under `EffectorScopePlugin` user input never reaches `scope.getState` or `serialize(scope)`. The shape form writes on mount. `deepCopy` returns `Date` by reference, so the core drops the update in its reference check, and turns `Map` and `Set` into `{}` (#975).
- `useGate` calls `open`, `close` and `set` unbound and fires `set` before `open`. Under the plugin the gate opens in the global stores.
- Stores come back as `readonly(shallowRef)` typed `DeepReadonly>` (#1130). `useUnit($s).value !== $s.getState()`, the deep proxy leaks back into stores through events and breaks `Map` and `WeakMap` keys and getters over private fields.

Users cannot fix this on their side. `@effector/router-vue` already works around part of it: `createRequire('effector-vue')` for the plugin, casts away from `DeepReadonly`, no `forceScope`. A production Vue 3.5 app we maintain hit the same list: the Vite build fails on the root entry with rollup's "default is not exported by vue", `useGate` opens the gate in the global scope while the component's scope stays closed, and the team banned `useGate` and `useVModel` and copied `EffectorScopePlugin` into the codebase.

## Use case

Vue 3.5 apps with `fork()` per request (SSR), per test or per embedded app. `` with top-level `await`. Composables inside `effectScope()` and `app.runWithContext`. `<KeepAlive>`. `@effector/router-vue`. Unit tests that mount with a forked scope and assert `scope.getState(...)`. Options API users on Vue 3 who cannot import `effector-vue/options-vue3` typings (#1186).

## API sketch (additions only)

```ts
import {EffectorScopePlugin, EffectorScopeKey, useProvidedScope, useUnit, useStoreMap, useVModel, useGate} from 'effector-vue/composition'

app.use(EffectorScopePlugin({scope, forceScope?: boolean, ssr?: boolean, scopeName?: string}))
type ScopeOptions = {scope?: Scope; forceScope?: boolean}

useUnit($store, opts?: ScopeOptions): Readonly<Ref<State>> // was DeepReadonly, see #1130
useUnit(fx, opts?): (params) => Promise<Done> // now a function
useStoreMap({store, keys?: MaybeRefOrGetter<Keys>, fn, updateFilter?, defaultValue?, ...ScopeOptions}): ComputedRef<Result>
useStoreMap($store, fn, opts?) // documented, now implemented
useVModel($store, opts?: ScopeOptions & {deep?: boolean}): Ref<T> // writes through launch({scope}); EffectScope arg still accepted
useGate(Gate, props?: MaybeRefOrGetter<Props>, opts?: ScopeOptions) // events bound to scope, open before set
useProvidedScope(opts?: {forceScope?: boolean}): Scope | null // same as effector-react
EffectorScopeKey: InjectionKey<Scope> // the plugin also provides the legacy string key
```

Internals:

- Scope resolution: explicit `{scope}`, then `inject(EffectorScopeKey)` behind `hasInjectionContext()`, then global mode.
- Events and effects: `scope ? scopeBind(unit, {scope}) : unit`.
- Stores: one `createWatch({unit: stores, scope, batch: true})` per call. The core dedupes the shared sampler barrier, so the callback runs once per `launch`. Snapshots live in `shallowRef` and are re-read from the scope; the refs are exposed through `shallowReadonly`.
- Cleanup: `onScopeDispose` behind `getCurrentScope()`, with a dev warning when there is no scope.
- Server (detected through `ssrContextKey`): no subscriptions, lazy `customRef` reads after `onServerPrefetch`, no gate open. Same as React.
- `useVModel`: `launch({target: store, params, scope, defer: true})`, the public form of `setState`, with a content-based dirty check instead of flags. `structuredClone(toRaw(v))` behind a runtime guard with a `deepCopy` fallback (#975).
- Errors: prefix `[effector-vue] useX: ...` plus a hint.

## Backward compatibility

Every 23.x change is additive or fixes behaviour that contradicts the package's own typings or docs. These call forms keep working: `useUnit(shape)`, `useStoreMap(config, scope)`, `useGate(Gate, getter)`, `useVModel($store, effectScope)`, `EffectorScopePlugin({scope, scopeName})`, `import {EffectorScopePlugin} from 'effector-vue'` (the root entry gets fixed for Vue 3 ESM, #1280).

Observable runtime changes: `shallowReadonly` instead of deep `readonly` (agreed in #1130; nested fields are no longer frozen; fallback: deep readonly behind a plugin option), `updateFilter` receives the selector result, gate `open` runs before `set`, no gate `set` during SSR, `useVModel` skips writes whose content equals the store state.

Typings: `Readonly<Ref<T>>` is assignable to the old `DeepReadonly<Ref<T>>`. `EventCallable` in the `useUnit` overloads, same as effector-react. `Reactive` (vue 3.4.30+) is replaced with `UnwrapNestedRefs`. Public typings keep importing from 'vue' (5a087be); `composition.d.ts` moves from `@vue/reactivity` to 'vue'. A second type-runner pass with Vue 3 typings compiles these files in CI for the first time. Deprecations and removals follow #1104, #1100, #1086.

## Follow-up, separate additive PRs after this series

`useServerPrefetch(unit, params, opts)`. Reactive shape for `useUnit`, needed by `@effector/router-vue`. `useVModel({store, update: EventCallable<T>})`. `keepAlive` options for `useUnit` and `useGate`. Opening the gate on the server, opt-in.

## Out of scope

Moving hooks to the root module (#1104), removing `effector-vue/ssr` and UMD (#1086), new subpaths, a Nuxt module, DevTools, HMR helpers, core changes, Options API and Vue 2 behaviour beyond the ESM import fix.

## Related issues

#1280, #1131, #1186, #1130, #975, #598, #1104, #1100, #1086, #1155; PRs #1281, #1306 (adopted with credit).

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.