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:
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);
}
}
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();
...