Vue router – how to change URL without loosing parent context

I’m currently developing an app, using the vue stack – vue, vue router and vuex. The user is browsing articles which are open in modal windows. You can open article while you are browsing a folder (url “/folder/name”) or while you are browing search results (url “/search/term”). If the user opens the modal URL /article/{id} he should see the article open in the main <router-view> without the modal.

This looks like a simple problem but trying to implement it I stumbled upon a simple vue router limitation – you cannot keep the context (component) when navigating to different URL. There is a discussion in github from 2016 without any “good” solution to this “simple” problem – https://github.com/vuejs/vue-router/issues/703. Since this is very important for me I started to try to find a solution to the problem and I will try to share my findings in this blog post.

The articles modal is triggered from an url like “/article/3a9c6e7e97fd73c1-discord-has-a-new-problem-revenge-porn”. So how do I keep the current browsed/searched components and open modal on top !?

At first I developed the BrowseComponent so I found an easy solution – add the route with the same component and add some logic to the BrowseComponent. This was the first iteration of routes :

{
  path: '/folder/:name',
  component: BrowseComponent,
  name: 'folder'
},
{
  path: '/article/:article',
  component: BrowseComponent,
  name: 'article_view',
}

This worked well until I implemented the SearchComponent and got stuck :). How was I supposed to open the modal and keep the context !? Some ideas went through my head:

  • make BrowseComponent the base of search and browse pages and implement some logic inside to change the component depending on the route – this become very complex to handle
  • dynamically add a route “/article/:id” when opening search or browse component with the corresponding component in the route definition – this was no go since you cannot check for existing routes and you cannot modify/delete routes
  • add logic to open the modal without changing the URL and change it via history.pushstate({}, null, url) to the desired format “/article/{id}” – these caused some errors thrown from vue router about missing routes and there were some problems with the navigation back/forward … and it doesnt look write. This was proposed by some of the comments in github and is closest to a real solution
  • another solution was this https://github.com/vuejs/vue-router/issues/703#issuecomment-389894487 – but I find it way too “hack”-y and i’m not sure how this can work with more than one parent context

So I posted a comment in the issue section and started waiting for “someone” to find a soltuion. As I was writing the comment an idea came to me

app-idea.gif

What about we create a functional component – that checks what is the current viewed component and return it in the render function – but execute the logic to open the modal. I thought that the Vue cache logic would keep the instance so nothing in the parent will change – and it worked 🙂

Here is

//ArticleViewBase.js
let parentComponent = null

export default {
  functional: true,
  render (createElement, context) {
    return createElement(
      parentComponent || BrowseComponent,
      context.data,
      context.children
    )
  },
  beforeRouteEnter (to, from, next) {
    parentComponent = router.getMatchedComponents()[0]
    next()
  },
}

I’ll try to explain what is happening here.

First I create a functional component which I set in the router for the modal view:

{
  path: '/article/:article',
  component: ArticleViewBase,
  name: 'article_view'
},

In the component I create beforeRouteEnter hook in order to get the current rendered component (the context) that I need to preserve and save it to local var parentComponent.

Then in the render function I just return the saved componet and Vue is smart enough to detect this component and reuse it – so the state is preserved.

Finally in the render function I use:

parentComponent || BrowseComponent,

because if you open the link directly vue router still needs to render something in the <router-view> and this plays role as a default component.

Hope this helps someone. May be there are better solutions – i’m open for discussion if you find something wrong or may be another solution.

NOTE: This solutions is not working with nested routes yet. I’m still trying to find a solution with no luck. If you have any idea please share it in the comments below.

Using Laravel Mix in Yii2 project

I’m fan of Yii framework but the Mix plugin of Laravel provides very good and easy way to integrate webpack bundles in your project. I’m also a fan of Vue so laravel mix seems to be the tool I need. The good thing is that the library is not so tightly coupled to laravel so it can be used in other projects relatively easy.

First you need to create a npm package in your project root and then install the lib. Taken from the laravel mix docs:

npm init -y
npm install laravel-mix --save-dev
cp -r node_modules/laravel-mix/setup/webpack.mix.js ./

You should now have the following directory structure:

  • node_modules/
  • package.json
  • webpack.mix.js

webpack.mix.js is your configuration layer on top of webpack. Most of your time will be spent here.

So far so good. Now we need to setup the mix in order to work with Yii. This is my webpack.mix.js file:

let mix = require('laravel-mix');

mix.js('frontend/app.js', 'dist')
    .setPublicPath('web');

The directory root of my javascript files is frontend/ you can change it to whatever you want but you must update this line. I’ve also created app.js in that folder which is the entry point of the script. The second param tells mix to put the compiled app.js into dist subfolder of the public path. And finally you need to setup the public path to “web” which is the public folder in a typical Yii2 project. We do this by calling .setPublicPath(‘web’).

It’s a good idea to add the commands to package.json file as recommended by laravel. Just add these lines in the scripts section:

  "scripts": {
    "dev": "NODE_ENV=development webpack --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
    "watch": "NODE_ENV=development webpack --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
    "hot": "NODE_ENV=development webpack-dev-server --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
    "production": "NODE_ENV=production webpack --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js"
  }

We are almost done. Running npm run dev compiles the frontend files and put them in web\dist\app.js file in my case. All we need to do now is to include this file in your layout or view and you are ready. Laravel has a handy function mix which handles the paths of the js files and assets see Workflow with Laravel. Yii doesn’t have this so I made an Yii2 version of the file wrapped in a class I called Mix, here is the source code:

class Mix
{
    /**
     * Determine if a given string starts with a given substring.
     *
     * @param  string $haystack
     * @param  string|array $needles
     * @return bool
     */
    public static function starts_with($haystack, $needles)
    {
        foreach ((array)$needles as $needle) {
            if ($needle != '' && substr($haystack, 0, strlen($needle)) === (string)$needle) {
                return true;
            }
        }

        return false;
    }

    /**
     * Get the path to a versioned Mix file.
     *
     * @param  string $path
     * @param  string $manifestDirectory
     *
     * @return string
     * @throws \Exception
     */
    public static function mix($path, $manifestDirectory = '')
    {
        static $manifest;

        if (!self::starts_with($path, '/')) {
            $path = "/{$path}";
        }

        if ($manifestDirectory && !self::starts_with($manifestDirectory, '/')) {
            $manifestDirectory = "/{$manifestDirectory}";
        }

        if (file_exists(self::public_path($manifestDirectory . '/hot'))) {
            return "//localhost:8080{$path}";
        }

        if (!$manifest) {
            if (!file_exists($manifestPath = self::public_path($manifestDirectory . '/mix-manifest.json'))) {
                throw new \Exception('The Mix manifest does not exist.');
            }

            $manifest = json_decode(file_get_contents($manifestPath), true);
        }

        if (!array_key_exists($path, $manifest)) {
            throw new \Exception(
                "Unable to locate Mix file: {$path}. Please check your " .
                'webpack.mix.js output paths and try again.'
            );
        }

        return $manifestDirectory . $manifest[$path];
    }

    private static function public_path($string)
    {
        return Yii::getAlias('@webroot').$string;
    }
}

Now whenever you need to use something from the webpack bundle e.g. the app.js file you can include it easily with:

\app\helpers\Mix::mix('dist/app.js')

This function returns the proper path you can use to include the file in your Yii2 projects.

Enjoy !

PS: If you have any questions please ask them in the comments. This is a quick hack to use laravel mix in a Yii2 project – there might be a better way but this is my way. Also this is my first blog post so there might be mistakes of unclear things 🙂