javascriptvue.jsbindingvuejs3vue-component

Why is property not updating in view when item is removed and added back in v-for


I'm making a feature wherein the user can add product images and it has an undo-redo functionality. I kept it as minimal as possible to show my issue.

I Created a minimal example of my issue in the Vue Playground: See it here

There's a component called ImageItem and it is rendered in a v-for scope. It gets a prop imageModel containing an object, and internally it uses that imageModel prop to access property description and shows/changes this using a textarea. When an item to the list is added, removed and added back, it seems that the binding to the textarea's value attribute is broken for some reason.

Try the following in my example app:

  1. Click the "Add image" button. A row with a textarea is added.
  2. Type some text in the textarea (the image description).
  3. Click "Undo" button. The description change is undone (textarea becomes empty).
  4. Click "Undo" button again. The image item is removed (The "add" action is undone).
  5. Until now the behavior is as intended.
  6. Now, click "Redo" button. The item is added back to where it was. The textarea containing the description is still empty, since typing in it is a separate action. Still intended behavior.
  7. Click "Redo" again, the description in the textarea should appear now, but it isn't. That's is the issue.

I included the code in this question for completeness. But it's the same code as in the Vue Playground.

App.vue

<script setup>
  import { ref } from 'vue'
  import ImageItem from './ImageItem.vue';
  import { ProductEditModel } from './ProductEditModel';
  const productEditModel = ref(new ProductEditModel());
  function addImage() { 
    productEditModel.value.addImage(); 
  }
  function handleChangeImageDescription(imageModel, description) { 
    productEditModel.value.changeImageDescription(imageModel, description);
  }
  function undo() { productEditModel.value.editHistory.undo(); }
  function redo() { productEditModel.value.editHistory.redo(); }
</script>

<template>
  <div v-for="productImage of productEditModel.images" :key="productImage.key">
    <ImageItem :imageModel="productImage" @changeImageDescription="handleChangeImageDescription"></ImageItem>
  </div>
  <button @click="undo">Undo</button>
  <button @click="redo">Redo</button>
  <button @click="addImage">Add image</button>
</template>

ProductImage.js

export class ProductImage {
  constructor()
  {
    this.key = Math.random();
    this.description = '';
  }
}

ImageItem.vue

<script setup>
import { defineProps, defineEmits } from 'vue';
const emit = defineEmits(['removeImage', 'changeImage', 'changeImageDescription']);
const props = defineProps({
    imageModel: {
        type: Object,
        required: true
    }
});
function handleDescriptionChange(description)
{
  emit('changeImageDescription', props.imageModel, description);
}
</script>

<template>
  <div class="image-item">
    <img src="" alt="Example image here..." />
    Description:
    <textarea :value="props.imageModel.description" rows="4"
      @change="(event) => handleDescriptionChange(event.target.value)" />
  </div>
</template>

<style>
.image-item {
  border: 1px solid #aaa;
  padding: 10px;
}
.image-item * {
  vertical-align: middle;
}
img {
  min-width: 100px;
  min-height: 100px;
  border: 1px solid #d0d0d0;
}
</style>

EditHistory.js

export class EditHistory
{
    constructor()
    {
        this.undoStack = [];
        this.redoStack = [];
    }

    do(action)
    {
        action.execute(); 
        this.undoStack.push(action);
        this.redoStack = [];
    }

    undo(count = 1)
    {
        count = Math.min(this.undoStack.length, count);
        for (let i = count; i > 0; i--)
        {
            const undoAction = this.undoStack.pop();
            undoAction.unExecute();
            this.redoStack.push(undoAction);
        }
    }

    redo(count = 1)
    {
        count = Math.min(this.redoStack.length, count);
        for (let i = count; i > 0; i--)
        {
            const redoAction = this.redoStack.pop();
            redoAction.execute();
            this.undoStack.push(redoAction);
        }
    }

    canUndo() 
    {
        return this.undoStack.length > 0;
    }

    canRedo() 
    {
        return this.redoStack.length > 0;
    }
}

ProductEditModel.js

import { AddImageAction } from "./AddImageAction";
import { ChangeImageDescriptionAction } from "./ChangeImageDescriptionAction";
import { EditHistory } from "./EditHistory";

export class ProductEditModel
{
  constructor()
  {
    this.images = []; // Array of ProductImage instances.
    this.editHistory = new EditHistory();
  }
  addImage()
  {
    this.editHistory.do(new AddImageAction(this));
  }
  changeImageDescription(imageModel, description)
  {
    this.editHistory.do(new ChangeImageDescriptionAction(this, imageModel, description));
  }
}

AddImageAction.js

import { ProductImage } from './ProductImage.js';
export class AddImageAction
{
    constructor(productEditModel) 
    {
        this.productEditModel = productEditModel;
        this.addedImageModel = null;
    }

    getDescription()
    {
        return "Afbeelding toevoegen";
    }

    execute()
    {
        this.addedImageModel = new ProductImage();
        this.productEditModel.images.push(this.addedImageModel);
    }

    unExecute()
    {
        const index = this.productEditModel.images.indexOf(this.addedImageModel);
        console.assert(index > -1);
        this.productEditModel.images.splice(index, 1);
    }
}

ChangeImageDescriptionAction.js

export class ChangeImageDescriptionAction
{
    constructor(productEditModel, productImageEditModel, description) 
    {
        this.productEditModel = productEditModel;
        this.productImageEditModel = productImageEditModel;
        this.description = description;

        this.originalDescription = this.productImageEditModel.description;
    }
    getDescription()
    {
        return "Afbeelding omschrijving veranderen";
    }
    execute()
    {
        this.productImageEditModel.description = this.description;
        console.log("Changed description to: " + this.description);
    }
    unExecute()
    {
        this.productImageEditModel.description = this.originalDescription;
        console.log("Changed description back to original: " + this.originalDescription);
    }
}

Solution

  • There are known problems with reactive classes in Vue that limit a way a class needs to be written but none are applicable here. this isn't always a reactive object in all classes, but they operate on reactive data, so no problems are expected with reactivity.

    The problem is that the history stores class instances (this is design restriction that could be avoided with immutable state) but it doesn't reuse image instance on redo, so description is changed on old instance.

    The fix is:

    execute()
    {
      if (!this.addedImageModel)
        this.addedImageModel = new ProductImage();
        ...