Skip to content

fix(vue): write useVModel through the scope, sync by content, copy with structuredClone - #1361

Open
leo-millennial wants to merge 3 commits into
effector:release/vue/23_3_0from
leo-millennial:vue/v-model-scope-write
Open

leo-millennial wants to merge 3 commits into
effector:release/vue/23_3_0from
leo-millennial:vue/v-model-scope-write

Conversation

@leo-millennial

@leo-millennial leo-millennial commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Conventions

Relates to #975
Relates to #1340

Part 5 of 6 of the effector-vue 23.3.0 series. Merge order: scope resolution, subscriptions, useGate, useStoreMap, useVModel, readonly refs. Parts 1 to 4 are merged, so this PR holds only its own 3 commits and goes next.

useVModel now writes user input into the scope, tells the two directions of the sync apart by content instead of a pair of flags, and copies the state with structuredClone. Before, an edit under EffectorScopePlugin went to the global store, an edit in the same tick as a store update could be lost, and Map and Set lost their content (#975). It also takes {scope, forceScope, deep} as its second argument; a Vue EffectScope there keeps working.

What is broken

Seven defects in src/vue/useVModel.ts on master:

  1. The local copy is a hand-written deepCopy. Map, Set, RegExp and typed arrays become plain objects, so Map and Set lose their content on the first edit (Effector-vue supports only primitive types #975). A Date is returned by reference, as it has been since Update deepCopy vue lib #1228, so the form and the store share the instance.
  2. The write back to the store calls the private store.setState directly, which writes into the ambient scope of the effector kernel, never into the scope of the component. Under EffectorScopePlugin a user edit does not reach scope.getState or serialize(scope).
  3. The two directions of sync are told apart by a pair of flags, isSelfUpdate and fromEvent, reset inside the Vue watcher callback (flush: 'pre', so asynchronously). An update of the store and an edit of the form in the same tick can flip the flags in the wrong order and drop the edit.
  4. The shape form fills its reactive object after the form-to-store watchers exist, so a mount writes every store once.
  5. A server render subscribes to the store and never disposes the subscription.
  6. The second argument is typed as a Vue EffectScope only, so scope, forceScope and deep have no way into the call, and an effector Scope passed there fails on vueScope.run.
  7. A derived store is accepted without a warning, though the next update of its source overwrites any edit.

What this does

  • New src/vue/lib/clone.ts: structuredClone(toRaw(v)) in a guarded try, falling back to the existing deepCopy when structuredClone is missing or throws on a value it cannot serialize (a function, a DOM node).
  • New src/vue/lib/isEqual.ts: structural comparison for the types a form carries, dates, regexps, maps, sets, array buffers and their views, arrays and plain objects. Anything else, an Error or a boxed primitive for one, is compared by reference. src/vue/lib/deepEqual.ts, the comparison of the Vue 3 Options API in src/vue/optionsApi.ts, is untouched.
  • The write back to the store now goes through launch({target: store, params, scope, defer: true}), the public form of setState, so it writes into the scope passed to useVModel or resolved from the plugin.
  • The pair of flags is replaced with three guards:
    • the store-to-form watch raises a flag for exactly the assignment it makes, and the form-to-store watcher skips while it is up;
    • the store-to-form watch skips the updates this call has written itself; it keeps every write still pending, since inside a computation of effector a form edited twice in one pass queues two;
    • the form-to-store watcher launches only when isEqual finds a difference from the state the store will hold once the pending writes land.
  • The form-to-store watcher runs with {deep: Boolean(deep), flush: 'sync'} instead of {deep: Boolean(true)} on the default flush: 'pre'. Boolean() keeps the fix(vue): keep the deep watch option a boolean in the build #1345 fix for a deep that is now configurable; the sync flush removes the same-tick race the flags lost.
  • The second argument is UseVModelOptions | EffectScope, told apart by duck typing: an object with run and stop is the legacy EffectScope and keeps working. Options: scope and forceScope, both from the ScopeOptions shared with useUnit, and deep, which defaults to true.
  • With deep: false the ref holds the state object of the store itself, and an assignment hands it back without a copy, so only an assignment of the whole value is written back. A write into a nested field changes the state of the store in place, and nothing reads that as an update; the Shallow binding section of useVModel.mdx says so.
  • An effector Scope passed as the second argument has neither run nor stop, so duck typing would take it for an options object and drop it. The call refuses it with a message that names the options form.
  • A derived store, the result of map or combine, now gets a warning through is.targetable: an edit lands in it, and the next update of its source overwrites it. A store from restore is writable and gets none.
  • For the shape form, shape[key] is filled for every store before the form-to-store watchers are created, so filling it is not read back as user input and writes nothing on mount.
  • On the server neither the store-to-form watch nor the form-to-store watcher is created: there is nothing to unsubscribe and no input to send back. Every read takes the state of the scope instead, copied once per state, so data loaded in onServerPrefetch reaches the form and hydration matches.
  • packages/effector-vue/composition.d.ts: UseVModelOptions is exported. The options overloads come first, and the EffectScope overloads stay, so existing call sites still compile.
  • Docs: useVModel.mdx documents the options, the scope, what the copy keeps and loses, and the EffectScope argument, which keeps working.

Backward compatibility

Unchanged: the return shape (a Ref for a single store, a reactive object for a shape of stores), deep: true as the default for editing nested fields.

Changed:

  • User input reaches the scope of the call or of the plugin instead of the global store. That matches the read side, which already goes through the scope in useStore and useUnit.
  • Where structuredClone works, Map, Set, RegExp and typed arrays keep their content through an edit, and a Date is copied instead of shared. Elsewhere the old deepCopy stays.
  • The form-to-store watcher is synchronous: two assignments in one tick launch the store twice, where flush: 'pre' wrote once.
  • An assignment equal by content to the state of the store launches nothing, so $store.updates does not fire for it.
  • The shape form no longer writes every store once on mount.
  • A server render subscribes to nothing.
  • A derived store prints a warning.
  • An effector Scope as the second argument throws a named error.

Tests

src/vue/__tests__/useVModel.test.ts, by group:

  • scope: user input under EffectorScopePlugin reaches scope.getState and serialize(scope) and leaves the global store alone; the scope option is read outside of a component;
  • the two directions of the sync: the shape form does not write to its stores on mount; an update of the store is not written back to it; two edits inside one pass of the kernel both reach the store; an edit that reverts the previous one in the same pass is written; an update and an edit in the same tick keep the edit;
  • the copy of the state: Map, Set and Date survive a round trip;
  • options: deep: false writes an assignment of the whole value; a store updated twice in one pass settles; a state the copy cannot reproduce, an Error, is not written back; a state that refers to itself is compared without recursing; an effector Scope as the second argument is refused; a derived store is reported; a Vue EffectScope as the second argument still works and prints nothing.

Other files:

  • clone.test.ts: both branches of clone, with and without structuredClone, including the fallback on a value structuredClone cannot serialize. The jsdom environment of jest has no structuredClone, so the test stands it in with the value serializer of node.
  • isEqual.test.ts: one case per branch (primitives, plain objects, arrays, dates, regexps, maps, sets, array buffers and their views, instances of other classes by reference), nested dates, sets and maps, and a value that refers to itself, directly and through a Set.
  • ssr.test.ts: useVModel renders the state of the scope and binds nothing; the data loaded in onServerPrefetch reaches the single store and the shape form.
  • src/types/__tests__/effector-vue3/composition.test.ts: the UseVModelOptions overload, the EffectScope overload, and rejection of an unknown option.

Against the pre-PR code nine runtime cases fail: eight in useVModel.test.ts (the two scope cases, the shape form on mount, the same-tick edit, the round trip of Map, Set and Date, deep: false, the refused effector scope and the derived store) and the ssr.test.ts case that binds nothing. The 13 cases that were in the file pass on both sides, and so do seven new ones that pin behavior the rewrite keeps: the EffectScope second argument, no echo of an update, two edits in one pass, an edit that reverts the previous one, two updates in one pass, the Error state and the state that refers to itself. The onServerPrefetch case passes on both sides too: it pins that the server path without watchers still shows the loaded data. The two new type tests, the options overload and the rejected unknown option, fail against the old typings. clone.test.ts and isEqual.test.ts cover new code.

Changelog entry

  • Fix useVModel: user input reaches the scope instead of the global store, and an edit in the same tick as a store update is no longer lost
  • Copy the state of useVModel with structuredClone where the runtime has it, so Map, Set, RegExp and typed arrays keep their content and a Date is no longer shared with the store (issue #975)
  • Add the options of useVModel: scope, forceScope and deep. The EffectScope second argument keeps working

Bundle size

The comparison and the copy are new code in the composition entry. Built before and after, gzip at level 9:

file before after
composition.mjs 3214 4182 +968 bytes, +30.1%
composition.cjs.js 3211 4179 +968 bytes, +30.1%

The root entry does not change.

How to verify

yarn test --selectProjects vue
yarn test --selectProjects types
yarn build effector-vue && yarn test src/vue/__tests__/bundle.test.ts --selectProjects vue

@bolt-new-by-stackblitz

Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

Under EffectorScopePlugin user input never reached the application
scope. The composable wrote through the private store.setState, which
launches into the ambient scope of the kernel, so scope.getState and
serialize(scope) kept the state the render started with. The write is a
launch with the scope of the call now, the public form of the same
thing.

Two more defects of the same sync:

- the copy of the state was a hand-written deepCopy. Map and Set became
  plain objects, so their content was lost (effector#975), and a Date was shared
  with the store by reference, as deepCopy has returned it since effector#1228.
  lib/clone.ts copies with structuredClone behind a runtime guard, over
  the raw value, and falls back to deepCopy when the runtime has none or
  the state holds a value it cannot serialize.
- the two directions were told apart by a pair of flags reset in an
  asynchronous callback, so an update of the store and an edit of the
  form in the same tick dropped the edit. The store-to-form direction
  raises a flag for exactly the assignment it makes, which the
  synchronous watcher of the form reads, and it ignores the updates the
  call has made itself. It keeps every write of the call that is still
  pending: inside a computation of effector a form edited twice in one
  pass queues two. The form-to-store direction compares the copy by
  content with the state the store will hold (lib/isEqual.ts, which
  knows the types a form carries and stops on a value that refers to
  itself; lib/deepEqual.ts, the comparison of the Vue 3 Options API,
  stays untouched).

The shape form filled its reactive object after the form-to-store
watchers were created, so mounting it wrote every store once. It is
filled first now.

An edit of a derived store lands in it and is overwritten on the next
update of the store it is computed from, so the call reports it. A
server render creates no watchers, and every read takes the state of the
scope, so the data an onServerPrefetch loads reaches the form.

The second argument is read by duck typing. A Vue EffectScope keeps
working, anything else is the options object with scope, forceScope and
deep. An effector Scope passed there has neither run nor stop, so it
would pass for options and be dropped with no error: the call refuses it
with a message instead.

With deep: false the ref holds the state object of the store, and an
assignment hands that object back without a copy, so the mode keeps its
promise of binding the state as it is.

The composition entry grows by 968 bytes gzip, from 3214 to 4182, mostly
the comparison.
The second argument was typed as a Vue EffectScope only, so the scope of
effector, forceScope and deep had no way into the call. The options
object comes first in the overloads and the EffectScope keeps its own: a
weak type has no property in common with a Vue scope, so the compiler
picks the EffectScope overload for it and the existing call sites stay
green.
The page documented one argument and said nothing about the scope, so a
reader had no way to learn that the form now writes into the scope of
the plugin. It also names what the copy of the state loses, why a
derived store gets a warning and that the EffectScope argument still
works.
@leo-millennial
leo-millennial force-pushed the vue/v-model-scope-write branch from 6d6f58a to aa4b9b3 Compare October 25, 2026 15:11

This branch has not been deployed

No deployments
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.

1 participant