Jump to content

Vue.js/Vuex to Pinia migration

From mediawiki.org

Introduction

[edit]

The intent of this article is to support mediawiki migration from Vuex to Pinia in internal projects that provide an opportunity to do so. Currently there is no need to upgrade existing project that are already in production and this update should just be performed on new project that have yet to be published or are at early stage of development.

The front end team has decided to use Pinia going forward, has it is now been recommended by the Vue core team as the successor of Vuex.

Migration in summary

[edit]

The migration between this two stores is quite simple and it can even happen in stages where both store co-exist in the same application while the migration is in place.

The migration steps are:

  • Add Pinia to your current Vue instance
  • Create a Pinia store for each Vuex module
  • Update other Vuex store to use Pinia
  • Update the components to use Pinia
  • Update store unit tests

In the following sections we are going to covering in details the step required to perform the migration. A multi-step migration has already been implemented in the foundation. You can see the first partial migration of just one module here, and the complete migration here.

Pinia's official website also include a great migration guide, and this article should just be used a supplement of that guide as there are parts specific to Mediawiki or not included in the official migration (like unit test)

Add Pinia to your current Vue instance

[edit]

Pinia has been added into core with the following task T326174. Pinia can be added to your existing Vue application by using the following code:

// Require the package
const Vue = require( 'vue' ),
    Pinia = require( 'pinia' );
    
// Initialize a new pinia instance
const pinia = Pinia.createPinia();

// Add it to your store
Vue.createMwApp( App )
	.use( pinia )
	.mount( '#main' );

Create a store for each module you had

[edit]

The main difference between Vuex and Pinia is that the latter expects you to create multiple stores. Do not worry, you can still create the nested structure you previously could achieve with Vuex.

Create folders and files

[edit]

Create a folder called stores (as Pinia expects multiple stores) and add files within that folder as shown below. The difference in name Store for Vuex and Stores for Pinia also help to us to be able to clearly achieve a "step migration".

// Vuex structure
- /resources
-- /store
--- index.js
--- actions.js
--- mutations.js
--- getters.js
--- state.js
--- /modules
---- Module1.js

// Pinia structure
- /resources
-- /stores
--- Root.js
--- Module1.js

Update the store module

[edit]

I suggest to update a single module at the time and start from the smaller one (do not start from root). The main documentation includes step by step tutorial on how to update the module, so I am not going to repeat that here, but just mention the most important points.

  1. Remove "context" from all Actions
  2. Get rid of mutations (they will just become private functions or actions)
  3. Make sure actions are NOT arrow functions. This will make the "this" undefined!
  4. change the function name to align with pinia standards useModuleNameStore

The following link showcase the migration of a single module by showing the comparison of the two files: Event module file migration

Update other Vuex modules to use Pinia

[edit]

While achieving a step migration, you will find yourself in situation where one store (Vuex) need to call the other (Pinia). In this section we are going to see how to achieve this.

Call Pinia from a Vuex store

[edit]
//  Actions.js example

myAction( context ) {
    ...
    context.dispatch( 'myModule/myNestedModuleAction', true );
}

To update the above action call, we need to fist import the newly created store and then call it. The code after a step migration will look like this:

// Actions.js example after migrating myModule to Pinia
const useMyModuleStore = require( '../stores/myModule.js' );

myAction( context ) {
    ...
	const myModule = useMyModuleStore();
    myModule.myNestedModuleAction( true );
}

Update the components to use Pinia

[edit]

Pinia is ready to be used within our component as it has been initialised in our Vue instance and exposes a module ready to be used. As with before, i would first read the official documentation and use this as additional support. The steps that should be followed for the migration in your components are:

  1. Search and replace all instances of "mapActions", "mapGetters", "mapState", "mapMutations" to "mapVuexActions", "mapVuexGetters" and so on..
  2. Import the Pinia store in the component that require it
  3. Import Pinia's methods as required mapPiniaActions, mapPiniaGetters...
  4. Replace the code appropriately to use the correct store.

An example migration can be seen in this patch.

Let's create a simple example to demonstrate the migration steps:

const mapActions = require( 'vuex' ).mapActions,
	mapGetters = require( 'vuex' ).mapGetters;

// @vue/component
module.exports = exports = {
    computed: $.extend( {},
        mapGetters( gettersWithNoModule ),
        mapGetters( 'anotherModule', [ anotherModule ] ),
        mapGetters( 'myModule', [ items ] )
    ),
    methods: $.extend( {},
        mapActions( myRootAction ),
        mapActions( 'myModule', myAction )
    ),
    mounted() {
        this.myAction();
    }
}


The above code is going to be changed with the following:

const mapVuexActions = require( 'vuex' ).mapActions,
	mapVuexGetters = require( 'vuex' ).mapGetters,
	mapPiniaActions = require( 'pinia' ).mapActions,
	mapPiniaGetters = require( 'pinia' ).mapGetters,
	useMyModuleStore = require( '../stores/myModule.js' );

// @vue/component
module.exports = exports = {
    computed: $.extend( {},
        mapVuexGetters( gettersWithNoModule ),
        mapVuexGetters( 'anotherModule', [ anotherModule ] ),
        mapPiniaGetters( useMyModuleStore, [ items ] )
    ),
    methods: $.extend( {},
        mapVuexActions( myRootAction ),
        mapPiniaActions( useMyModuleStore, myAction )
    ),
    mounted() {
        this.myAction();
    }
}

Update store unit tests

[edit]

Updating unit tests can be tricky if your modules require each other. This section explains how to migrate unit tests from Vuex to Pinia and also explain how to handle situation in which your Vuex store depends on your Pinia store (situation that happen during a module by module migration). It is also important to note that due to the fact that Pinia architecture support the creation of multiple modules, you may find yourself creating more dependencies that when you had a single vuex store (eg with vuex you may just have a module, but with Pinia you may create one for the sidebar, one for the requestStatus, etc).

The section is going to be divided into different scenarios that will hopefully help you update your unit test with no issues at all

Vuex that depends on Pinia store

[edit]

This is a simple example that is going to be very common as you work on your migration. In this case we have Vuex store that require a Pinia store. First we need to mock our Pinia store. To do this we use Jest.mock. So for our example we would create the following:

// mocks/MyModule.js

const store = {
	myStoreAction: jest.fn(),
	myName: 'test'
};

jest.mock( '../resources/stores/MyModule.js', () => () => store );

module.exports = store;

Then we would use this mock in our test. This mock will automatically be used in our test.

const getters = require( '../resources/store/getters.js' ),
	mockMyModuleStore = require( '../mocks/myModule.js' );

describe( 'Getters', () => {
	it( 'test require a differnt value', () => {
	    // Simply change the value that has to be different
	    const testName = 'My new Name';
	    mockMyModuleStore.myName = testName;
	    // behind the scene this getter is actually dependent on our Pinia store
	    const currentName = getters.currentName( {} );
	    expect( currentName ).toEqual( testName );
	} );
} );

Vuex Action that require Pinia store

[edit]

Following up from the above example, also in this case we require a mock of the store to be created, then the usage is very simple and follow Jest standards:

const getters = require( '../resources/store/getters.js' ),
	mockMyModuleStore = require( '../mocks/myModule.js' );

describe( 'Action', () => {
	it( 'test that calls a pinia action', () => {
	    action.currentName( {} );
	    // remember we mocked this in the mock/myModule.js file
	    expect( mockMyModuleStore.myAction ).toHaveBeenCalled();
	} );
} );

Pinia store that require Pinia store

[edit]

During the course of your migration you will also encounter situation in which your Pinia module may require another module. The code required in that case is the following.

NOTE: you can still use the mock methodology above if you prefer to have less of an integration test.

Let's start by setting our test up. In the following test we have 2 store beind setup. The first is the one we are going to test, the second is the dependent one.

const Pinia = require( 'pinia' ),
	useCurrentStore = require( '../resources/stores/currentStore.js' ),
	useOtherStore = require( '../resources/stores/otherStore.js' );

beforeEach( () => {
	Pinia.setActivePinia( Pinia.createPinia() );
} );

describe( 'Query store', () => {
	let currentStore;
	let otherStore;
	beforeEach( () => {
		currentStore = useStore();
		otherStore = useOtherStore();
	} );
} );

Now it is time to see how we would use both actions and state/getters from the dependent store:

// all the setup above...

it( 'Action that depends on a specific value on the pinia store', () => {
    // We are able to change the store as we wish.
    otherStore.myValue = 'new value';
    
    currentStore.myActionThatUsesTheOtherAction();
    
    expect( currentStore.something ).toBeTruthy();
} );

it( 'Use dependent action', () => {
    // If there is a specific aciton needed, we can mock it.
    // of course if needed you can also change this to return a specific value or implementation
    otherStore.myAction = jest.fn();
    
    currentStore.myActionThatUsesTheOtherAction();
    
    expect( otherStore.myAction ).toHavebeenCalled();
} );

it( 'Use dependent action', () => {
    // Remember because we initialised both store you have access to all its methods
    otherStore.$patch( {
        name: 'john',
        surname: 'smith'
    } );
    otherStore.$reset();
    
    currentStore.test();
    
    expect( currentStore.name ).toEqual( 'dummy');
} );

Circular dependencies issue

[edit]

Unfortunately, due to the "dependence architecture" proposed by Pinia, you may find yourself with a circular dependence. This happen when there is a never ending circle when one resource require another that require another that require the first one. The resource loader is not currently able to handle this and would require us to work around this.


There is no correct way in solving this issue, but if you encounter this issue the solutions could be:

  • Move the shared value into its own store (even if it is going to be a simple store that has one value and one action)
  • Instead than depend on values, pass them when calling actions as paramethers.
  • Decouple the dependencies on that value all together