Repository navigation
fix(vue): write useVModel through the scope, sync by content, copy with structuredClone - #1361
Open
leo-millennial wants to merge 3 commits into
Open
leo-millennial wants to merge 3 commits into
leo-millennial wants to merge 3 commits into
Conversation
|
|
leo-millennial
force-pushed
the
vue/v-model-scope-write
branch
6 times, most recently
from
October 25, 2026 10:06
2d67070 to
6d6f58a
Compare
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
force-pushed
the
vue/v-model-scope-write
branch
from
October 25, 2026 15:11
6d6f58a to
aa4b9b3
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
useVModelnow 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 withstructuredClone. Before, an edit underEffectorScopePluginwent to the global store, an edit in the same tick as a store update could be lost, andMapandSetlost their content (#975). It also takes{scope, forceScope, deep}as its second argument; a VueEffectScopethere keeps working.What is broken
Seven defects in
src/vue/useVModel.tson master:deepCopy.Map,Set,RegExpand typed arrays become plain objects, soMapandSetlose their content on the first edit (Effector-vue supports only primitive types #975). ADateis returned by reference, as it has been since Update deepCopy vue lib #1228, so the form and the store share the instance.store.setStatedirectly, which writes into the ambient scope of the effector kernel, never into the scope of the component. UnderEffectorScopePlugina user edit does not reachscope.getStateorserialize(scope).isSelfUpdateandfromEvent, 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.EffectScopeonly, soscope,forceScopeanddeephave no way into the call, and an effectorScopepassed there fails onvueScope.run.What this does
src/vue/lib/clone.ts:structuredClone(toRaw(v))in a guardedtry, falling back to the existingdeepCopywhenstructuredCloneis missing or throws on a value it cannot serialize (a function, a DOM node).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, anErroror a boxed primitive for one, is compared by reference.src/vue/lib/deepEqual.ts, the comparison of the Vue 3 Options API insrc/vue/optionsApi.ts, is untouched.launch({target: store, params, scope, defer: true}), the public form ofsetState, so it writes into the scope passed touseVModelor resolved from the plugin.isEqualfinds a difference from the state the store will hold once the pending writes land.{deep: Boolean(deep), flush: 'sync'}instead of{deep: Boolean(true)}on the defaultflush: 'pre'.Boolean()keeps the fix(vue): keep the deep watch option a boolean in the build #1345 fix for adeepthat is now configurable; the sync flush removes the same-tick race the flags lost.UseVModelOptions | EffectScope, told apart by duck typing: an object withrunandstopis the legacyEffectScopeand keeps working. Options:scopeandforceScope, both from theScopeOptionsshared withuseUnit, anddeep, which defaults totrue.deep: falsethe 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 ofuseVModel.mdxsays so.Scopepassed as the second argument has neitherrunnorstop, 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.maporcombine, now gets a warning throughis.targetable: an edit lands in it, and the next update of its source overwrites it. A store fromrestoreis writable and gets none.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.onServerPrefetchreaches the form and hydration matches.packages/effector-vue/composition.d.ts:UseVModelOptionsis exported. The options overloads come first, and theEffectScopeoverloads stay, so existing call sites still compile.useVModel.mdxdocuments the options, the scope, what the copy keeps and loses, and theEffectScopeargument, which keeps working.Backward compatibility
Unchanged: the return shape (a
Reffor a single store, a reactive object for a shape of stores),deep: trueas the default for editing nested fields.Changed:
useStoreanduseUnit.structuredCloneworks,Map,Set,RegExpand typed arrays keep their content through an edit, and aDateis copied instead of shared. Elsewhere the olddeepCopystays.flush: 'pre'wrote once.$store.updatesdoes not fire for it.Scopeas the second argument throws a named error.Tests
src/vue/__tests__/useVModel.test.ts, by group:EffectorScopePluginreachesscope.getStateandserialize(scope)and leaves the global store alone; thescopeoption is read outside of a component;Map,SetandDatesurvive a round trip;deep: falsewrites an assignment of the whole value; a store updated twice in one pass settles; a state the copy cannot reproduce, anError, is not written back; a state that refers to itself is compared without recursing; an effectorScopeas the second argument is refused; a derived store is reported; a VueEffectScopeas the second argument still works and prints nothing.Other files:
clone.test.ts: both branches ofclone, with and withoutstructuredClone, including the fallback on a valuestructuredClonecannot serialize. The jsdom environment of jest has nostructuredClone, 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 aSet.ssr.test.ts:useVModelrenders the state of the scope and binds nothing; the data loaded inonServerPrefetchreaches the single store and the shape form.src/types/__tests__/effector-vue3/composition.test.ts: theUseVModelOptionsoverload, theEffectScopeoverload, 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 ofMap,SetandDate,deep: false, the refused effector scope and the derived store) and thessr.test.tscase 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: theEffectScopesecond argument, no echo of an update, two edits in one pass, an edit that reverts the previous one, two updates in one pass, theErrorstate and the state that refers to itself. TheonServerPrefetchcase 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.tsandisEqual.test.tscover new code.Changelog entry
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 lostuseVModelwithstructuredClonewhere the runtime has it, soMap,Set,RegExpand typed arrays keep their content and aDateis no longer shared with the store (issue #975)useVModel:scope,forceScopeanddeep. TheEffectScopesecond argument keeps workingBundle size
The comparison and the copy are new code in the
compositionentry. Built before and after, gzip at level 9:The root entry does not change.
How to verify