javascriptvue.jshtml5-canvasrace-conditionvue-props

VueJS race condition calling a child's function after setting its props


UPDATE

This was solved using nextTick() as proposed by @Estus Flask.

import { ref, useTemplateRef, nextTick } from 'vue'

...

const handleClick = () => {
  color.value = 'blue'
  size.value = 50
  nextTick(() => {
    childRef.value?.render()
  })
}

I have Child.vue that draws to canvas. From Parent.vue, I want to be able to change various props of Child.vue, then run its render() function for a redraw.

The problem is when I run handleClick() in Parent.vue, the props for Child.vue have not finished propagating before the render() function is called. This is ascertained by clicking a second time and getting the redraw, and/or by using the hack of adding a delay before calling the render function. Like so:

// Poor person's fix using a delay.
const delay = (ms:number) => new Promise(res => setTimeout(res, ms))
const handleClick = async () => {
  color.value = 'blue'
  size.value = 50
  await delay(10)
  childRef.value?.render()
}

In my real app, I have many more props that will change before calling render(). Sometimes only one changes, sometimes five, sometimes two, etc... Never the same ones.

Is there a proper way to solve this race condition?

<!-- Child.vue -->
<script setup lang="ts">
  import { onMounted, useTemplateRef } from 'vue'

  interface Props {
    color?: string
    size?: number
  }

  const {
    color = 'red',
    size = 10,
  } = defineProps<Props>()

  const canvasRef = useTemplateRef('canvas-ref')
  let canvas:HTMLCanvasElement
  let ctx:CanvasRenderingContext2D

  onMounted(() => {
    canvas = canvasRef.value as HTMLCanvasElement
    ctx = canvas.getContext("2d") as CanvasRenderingContext2D
    render()
  })

  const render = () => {
    ctx.clearRect(0, 0, 100, 100)
    ctx.fillStyle = color
    ctx.fillRect(0, 0, size, size)
  }

  defineExpose({ render })
</script>

<template>
  <canvas ref="canvas-ref" :width="100" :height="100"></canvas>
</template>
<!-- Parent.vue -->
<script setup lang="ts">
  import Child from './Child.vue'
  import { ref, useTemplateRef } from 'vue'

  const childRef = useTemplateRef('child-ref')

  const color = ref()
  const size = ref()

  const handleClick = () => {
    color.value = 'blue'
    size.value = 50
    childRef.value?.render()
  }
</script>

<template>
  <Child
    :color=color
    :size=size
    ref="child-ref"
  />
  <button @click=handleClick>button</button>
</template>

Solution

  • Send overall changes, not each value individually.
    Something like this:

    /* App.vue */
    <script setup>
    import { ref } from 'vue';
    import Child from './Child.vue'
    
    const canvasProps = ref()
    
    const handleClick = () => {
      canvasProps.value = {
        color: 'blue',
        size:  50
      }
    }
    </script>
    
    <template>
      <button @click="handleClick">handleClick</button>
      <Child v-bind="canvasProps"></Child>
    </template>
    
    /* Child.vue */
    <script setup lang="ts">
    import { onMounted, ref, watch } from 'vue'
    
    interface Props {
      color?: string
      size?: number
    }
    
    const props = withDefaults(defineProps<Props>(), {
      color: () => 'red',
      size: () => 100,
    });
    
    const canvasRef = ref();
    
    const render = () => {
      if(!canvasRef.value){
        return;
      }
      const ctx = canvasRef.value.getContext('2d');
      ctx.clearRect(0, 0, 100, 100)
      ctx.fillStyle = props.color
      ctx.fillRect(0, 0, props.size, props.size)
    }
    
    onMounted(render);
    watch(props, render);
    </script>
    
    <template>
      <canvas ref="canvasRef" :width="100" :height="100"></canvas>
    </template>
    

    Vue SFC Playground