Blog

Debounce inside a Vue composable

In the Queerlit frontend, I keep a query object in vuex state, and there’s a helper function for modifying the query object and then immediately performing a search request. This helper lives in a composable, useSearch() in search.composable.js.

The search function is debounced, because it may be issued multiple times, quickly in succession. This happens for instance when using autocomplete. When selecting a suggested term:

  1. Clear the free-text input (update query and search!)
  2. Add the selected term object (update query and search!)

However, the debounced function is created (debounce invoked) inside the composable. When using the composable from different components/composables, they will each generate and use its own debounced function, and the debouncing has no effect. It will do the delay part, there is of course no way for one function to abort earlier invocations of another function.

To make sure that different composable users will use the same object, I had to move the object up to module scope. I couldn’t actually do the debouncing there, though, because the search function depends on other composables. Best practice seems to be to not use composables outside the setup function or other composables.

So this is the structure of my solution:

let debouncedSearch;

export default function useSearch() {
  const { commit } = useStore(); // This is used in doSearch()
  
  async function doSearch() {
    // await an API request
    // commit results to store
  }

  debouncedSearch = debounce(doSearch, 50);
 
  function setQuery(params) {
    // commit query modifications to store
    debouncedSearch();
  }
}

The debounced function is declared outside the composable, in module scope. But it is defined (given its value) inside the composable.

Each usage of the composable will redefine the function (invoke debounce) but then it will update the module-scope variable, so that all users will, in the end, use the same debounced function.

Does this help you? Do you know of another (better) way to use debounce inside a composable?

I have been adding Matomo tracking to the Queerlit frontend, using
Dennis Ruhe’s vue-matomo plugin. The plugin hooks nicely into the router and tracks page views for each route change. It has not, however, been tracking the page title.

My first attempt to fix this was by sending the setDocumentTitle event after async-fetching the data in the router view component. However, by the time we even get into the component <script setup>, vue-matomo has already tracked the page view. I wish Matomo had support for “amending” a tracked page view so that we could add the title afterwards, but it doesn’t seem that way.

My next attempt relied on fetching the data and then setting the route title, all in the vue-router beforeEnter hook. That way, when entering the route (and before running <script setup>), it suddenly has a title which vue-matomo can pick up. Copying an example in this Vue discussion, I implemented beforeRouteEnter in a non-setup <script> block next to the <script setup>. It was kind of working, but I had to make it even more complicated in the Term router view component, where it was expected that the route would update with different route parameters. I probably could have continued with this approach, but the complexity was bothering me.

The third time is the charm. It was clear that the vue-router integration of vue-matomo does not take dynamic page titles into account. Instead of submitting to its assumptions, I left the router option of the plugin undefined, and instead added invocations at the appropriate place in my code. I already had a little useTitle composable wrapping the useTitle of VueUse, so I added it there.

The logic is this:

  1. When the page title is set, track the page view to Matomo.
  2. When visiting a new path (including updating same route with new params), repeat from 1.

I am making assumptions now too, but they are based on the application at hand and not on a general case. Most importantly, I am assuming that each router view component will set the page title, i.e. invoke useTitle(). That’s just a pattern I’m committing to, and which I want to do anyway.

RDF in version control

TL;DR: Write as N-Triples and then sort the lines.

You have a RDF graph? You want to commit it to git in order to keep a history of changes? You’re generating it from another data source and want to see how changes in your script affects the output?

RDF as a data model is unordered. So when you dump the graph to a file, be it in Turtle, JSON or XML, you don’t really know whether the changes as seen by git will be meaningful to you.

The solution:

  1. Serialize to the N-Triples format
  2. Sort the lines

N-Triples is just lines of <subject> <predicate> <object> . There are no namespace prefixes and no header. This makes it easy to sort, and the sorting makes it easy to diff.

Yes, there’s a lot of ugly repetition, and this might be cumbersome if your data is large. You could maybe throw in some string replacements if size is an issue?

(Photo credit: Robin Mathlener)

Streams in Vite

I’m trying to use N3 to parse and query Turtle files in a Vite/Vue 3 project. The N3 readme says “N3.js seamlessly works in browsers via webpack or browserify” and I had trouble interpreting what that would mean in a Vite context. I made an attempt at properly learning all about bundling, but I did some trial and error in parallel, which in the end worked out fine:

First off, I had to add a shim for global in vite.config.js (thanks Richard Oliver Bray):

export default defineConfig({
   // ...
   define: {
     global: {},
   },
 });

Then I got problems when using N3.Store.match:

Uncaught TypeError: Cannot read properties of undefined (reading ‘call’)

The culprit was a line saying Stream.call(this), i.e. Stream is what’s undefined. Here’s where I got lost installing and aliasing various browserified forks, before I just did:

yarn add events

and voilà!

N3 uses the Node.js Stream class. In a browser environment, it instead uses EventEmitter from the events library. So we just had to get that in place.

Spinnrockstricks

Jag testar mammas spinnrock och har kommit på ett par tricks:

  • Spänningen på löptråden spelar roll. Man måste hålla emot lite för att få högre “spinntäthet”, och om löptråden är för spänd går den spunna tråden av.
  • Bra att öva på att hålla emot innan man börjar öva på att trampa. Man kan gott köra hjulet med handen för att öva på att hålla emot.
  • Håll ullen i vänster hand och tråden i höger. Då är det närmare för höger hand att driva hjulet.
  • När man börjar öva på trampet kan man sikta på att göra precis ett varv i taget. Dels får man en paus då man kan mata ull i lugn och ro. Dels får man in kraften i trampet.

Sort empty strings last

You want to sort a list of strings in JavaScript, but some of the strings are empty, and you want those to come last. localeCompare is your friend, but it places empty strings first.

The solution is a bit of boolean sweetness:

["foo", "", "bar"].sort((a, b) =>
  a && b
    ? a.localeCompare(b)
    : !a - !b
);
// [ "bar", "foo", "" ]

For instance, if a is empty but b is not, the else case in the ternary resolves to true - false1 - 01 which means: put b before a.

Comma, comma & and

I recently had to concatenate author names with commas and an ampersand in the following manner:

commaAnd(['Emir Jong']);
// becomes: "Emir Jong"

commaAnd(['Kristian Josefsen', 'Tetyana Bohuňková']);
// becomes: "Kristian Josefsen & Tetyana Bohuňková"

commaAnd(['Luana Ferreira Carvalho', 'Jian Tu', 'Ambessa Afwerki']);
// becomes: "Luana Ferreira Carvalho, Jian Tu & Ambessa Afwerki"

Here are a few implementations in JavaScript:

function commaAnd(strs, comma = ", ", and = " & ") {
  const glue = (i) => ["", and, comma][Math.min(strs.length - i - 1, 2)];
  return strs.reduce((res, str, i) => res + str + glue(i), "");
}
function commaAnd(strs, comma = ", ", and = " & ") {
  return (
    ((s) => (s ? s + and : ""))(strs.slice(0, -1).join(comma)) + strs.slice(-1)
  );
}
function commaAnd(strs, comma = ", ", and = " & ") {
  const init = strs.slice(0, -1).join(comma);
  return [init, strs.slice(-1)].filter((s) => s).join(and);
}

Vue

But then each author name needed a bit of markup as well. In Vue, we could concatenate HTML strings and use v-html, but we should avoid that if we can.

Here’s my method using slot scopes (much like that todo list example):

<template>
  <span>
    <span v-for="(item, i) in items" :key="i"
      ><slot name="item" :item="item">{{ item }}</slot
      >{{ [null, and, comma][Math.min(items.length - i - 1, 2)] }}</span
    >
  </span>
</template>

<script>
export default {
  name: "CommaAnd",
  props: {
    items: { type: Array, default: Array },
    comma: { type: String, default: () => ", " },
    and: { type: String, default: () => " & " },
  },
};
</script>

The usage is as follows:

<CommaAnd :items="authors">
  <template v-slot:item="{ item }">
    {{ fullName(item)
    }}<sup>{{
      affiliations.indexOf(item.affiliation) + 1
    }}</sup></template
  >
</CommaAnd>

From the CommaAnd component, we call out to the parent component to define how to render each item. Inside the component, we only define what to do in between the items.

Note that much of the whitespace between elements and interpolations ({{ }}) must be eliminated when we are dealing with inline text. Furthermore, forcing HTML/XML into short lines is difficult (Prettier is doings its best here…) and the Vue syntax for slot scopes is a bit entangled, so the result is not super readable.

The resulting output is also pretty loud. Lots of <span>s in <span>s. If you don’t want that you should probably go with v-html anyway.

GU Eduroam trouble solved

Connecting to Eduroam (at the University of Gothenburg) was tricky this time. The main problem was ensuring the CA Certificate was installed. Clicking the certificate link on the instructions page at Medarbetarportalen gave a message saying This certificate is already installed as a certificate authority. Yet, when configuring the WiFi connection, there was no option to choose that certificate (or any other certificate, for that matter). The trick turned out to be first saving the file, and then opening it.

You don’t have to bother with the crazy Eduroam CAT app.

So:

  1. Visit the Installationsguide Eduroam page.
  2. On Google Chrome, long-tap the certificate link at the bottom of the page and select Download link.
  3. Or, if you use another browser, save the file on a computer and transfer it to your phone 🤷‍♀️
  4. Install the certificate by either opening it through the file browser, or using Settings > Wi-Fi > Advanced > View more > Install network certificates.
  5. Finally, configure the network connection:
    EAP method: PEAP
    Phase 2 authentication: MSCHAPV2
    CA certificate: (whatever name you gave it, should be an option between Select certificate and Don’t validate)
    Identity: x…@gu.se or gus…@gu.se

This was with Android 8.0.0 on a Samsung Galaxy J3. Your mileage may vary. Good luck 🤞

Push to deploy Vue app

With this workflow, my web space contains a git remote which, whenever pushed to, builds the Vue project and puts it on a public path.

A few years ago I, working with Drupal 8, I was using Pantheon and I really enjoyed the workflow, where deploying comprised of simply git push‘ing to a certain remote. (I think maybe this model was made popular by Heroku?)

Nowadays I’m getting into JS development. A framework that I have been using recently is Vue. One of my current project ideas is using Vue to develop a small web app for crawling some websites and grabbing data on arbitrary movies. I want to do the development in short iterations, and I want it to be easy to publish each step of progress.

So today I set up a push-to-deploy workflow for my Vue app. I don’t want to give the prototype its own domain name, so I’m publishing it in a subdirectory: klavaro.se/filminfo

Workflow

With this workflow, my web space contains a git remote which, whenever pushed to, builds the Vue project and puts it on a public path.

The deploy procedure is reduced to a single command: git push deploy

Git setup on server

The web host I am using is NearlyFreeSpeech.net, which provides SSH access and git.

SSH into the server and create the remote repository:

cd /home/private
git init --bare /home/private/myproject.deploy

Clone it to get the local git repository:

git clone myproject.deploy myproject.checkout

Then add a post-receive hook script in the remote repository:

touch myproject.deploy/hooks/post-receive
chmod +x $_
# Bonus trick: That $_ expands to the last argument in the previous command, in this case the path of the new file.

Edit the script as follows (making changes where appropriate, depending on your Vue setup):

!/bin/bash
# -e exits as soon as any command fails
# -x prints each command being executed
set -ex
CHECKOUT=/home/private/myproject.checkout
SITE=/home/public/myproject
cd $CHECKOUT
git --git-dir .git fetch
git --git-dir .git reset --hard origin/master
yarn install --production
yarn build --dest $SITE

Local git configuration

Back on your local machine, all you have to do is add the new remote:

git remote add deploy user@example.com:/home/private/myproject.deploy

Give it a spin:

echo Hello World! > hello.txt
git add hello.txt
git commit -m 'Deploy test'
git push deploy
# Verify that the post-receive script output is showing.
# Clean up after yourself:
git reset --hard HEAD^
git push -f deploy

Configure Vue for a subdirectory

Vue is normally configured to serve the app at the root level of the host. I want to serve it at the root level locally, but in a subdirectory on the web server. I accomplished this using environment variables.

Locally, create .env containing:

PUBLIC_PATH=/

Edit vue.config.js like this:

module.exports = {
  publicPath: process.env.PUBLIC_PATH,
} 

If you use vue-router, configure it (in main.ts):

const router = new VueRouter({
  base: process.env.PUBLIC_PATH,
  // ...
})

Finally, in the local repository on the server (that’s an oxymoron, but you know what I mean), create .env.local, containing:

PUBLIC_PATH=/myproject

After that, commit and push the changes you made on your local machine.