Vue Array Class Proxy Not Reacting - javascript

I have a class that extends Array, and as part of it, I want to intercept changes that are made to its properties, so I use Proxy, which is what I return from its constructor. It works just fine until I try to use it in my Vue component. See this example.
When the page first loads, you'll see the console log for Collection 1 in the watchEffect, which is the expected result. Then when you click the Add Filter button, you'll see that the display doesn't update and the watchEffect doesn't fire... expectation is that we'd get the console log like when the page loaded. However, if you inspect collection1, you'll see that the value was added.
Does anyone know why this doesn't work and how I can fix it? It feels like maybe my proxy is being tripped up with Vue's proxy wrapper, but I don't know enough about the internals to say that confidently.
Collection.js
export class MyCollection extends Array {
constructor(data) {
super();
this.add(data);
return new Proxy(this, {
set(target, prop, value) {
target[prop] = value;
if (prop === 'filters') {
const add = []
target.records.forEach((item) => {
if (item.id === target.filters) {
add.push(item)
}
})
target.add(add);
}
return true;
}
})
}
addFilters() {
this.filters = 1
}
add(items) {
this.length = 0;
items = Array.isArray(items) ? items : [items];
this.records = items;
console.log('here', this.records, this);
items.forEach((item) => this.push(item))
}
}
App.vue
<script setup>
import {watchEffect, computed, ref, toRaw} from "vue";
import {MyCollection} from "./Collection.js";
const collection1 = $ref(new MyCollection([{id: 1, display: 'one'}, {id: 2, display: 'two'}]));
watchEffect(() => {
console.log("wow", collection1);
});
const onClickUpdate1 =() => {
collection1.addFilters();
}
</script>
<template>
<div>
Collection 1
<button #click='onClickUpdate1'>
Add Filter
</button>
</div>
<div v-for="item in collection1" :key="item.id">
{{item.display}}
</div>
</template>

Try this edit I made to your code.
I have changed a few things:
changed to script setup for better readability
changed computed to reactive using the $red syntax from reactivity transform
You were adding items with name: 'three' and displaying item.display. I changed that bit to add with display: 'three'.
It works now and I suspect the difference is in having changed from computed to reactive though I'm not sure. I'm going to read about it a bit more and update the answer accordingly.

I think I found a solution, but I may have also found a bug in Vue, which I've reported. What I had to change was calling the receiver's method instead of the target's method in the set trap of MyCollection. Fiddle
MyCollection.js
export class MyCollection extends Array {
constructor(data) {
super();
this.add(data);
return new Proxy(this, {
set(target, prop, value, receiver) {
target[prop] = value;
if (prop === 'filters') {
const add = []
target.records.forEach((item) => {
if (item.id === target.filters) {
add.push(item)
}
})
// IMPORTANT: Had to use receiver here instead of target
receiver.add(add);
}
return true;
}
})
}
addFilters() {
this.filters = 1
}
add(items) {
this.length = 0;
items = Array.isArray(items) ? items : [items];
this.records = items;
items.forEach((item) => this.push(item))
}
}
The second issue, which I think is the bug, is that I still can't use a computed method for this. However, I can use a ref and watchEffect to achieve the same thing.
App.vue
<script setup>
import {watchEffect, computed, ref, toRaw} from "vue";
const props = defineProps({
options: {
type: Array,
default: [{id: 1, display: 'one'}, {id: 2, display: 'two'}]
}
})
import {MyCollection} from "./Collection.js";
const collection1 = ref(null);
const collection2 = computed(() => new MyCollection(props.options))
// Workaround for not being able to use computed
watchEffect(() => {
collection1.value = new MyCollection(props.options)
})
watchEffect(() => {
console.log("collection1", collection1.value.length);
});
// THIS WILL NOT FIRE WHEN ADD FILTER IS CLICKED
watchEffect(() => {
console.log("collection2", collection2.value.length);
});
const onClickUpdate1 =() => {
collection1.value.addFilters();
collection2.value.addFilters();
}
</script>
<template>
<div>
<button #click='onClickUpdate1'>
Add Filter
</button>
</div>
<div style="display: flex">
<div style="margin-right: 1rem;">
Collection 1
<div v-for="item in collection1" :key="item.id">
{{item.display}}
</div>
</div>
<div>
Collection 2
<div v-for="item in collection2" :key="item.id">
{{item.display}}
</div>
</div>
</div>
</template>

Related

Vue.js 3 - watching props in child component does not work if props comes with a member of array in parent component

I am working with Vue.js 3. I got a problem, let us see the code first.
Code
ChildComponent.vue
<template>
<div>
{{ modelValue }}
<input v-model="resultString"/>
<button #click="showModelValue">show model value</button>
</div>
</template>
<script lang="ts">
import {defineComponent, PropType, ref, watch} from "vue";
export default defineComponent({
props: {
modelValue: {
type: Object as PropType<number>,
required: true,
}
},
emits:['update:modelValue'],
setup(props) {
const resultString = ref<string>("");
watch(() => props.modelValue, (newVal:number, oldVal:number) => {
if (newVal % 2 == 0) {
resultString.value = 'even';
} else {
resultString.value = 'odd';
}
}, {deep: true});
const showModelValue = () => {
console.log(props.modelValue);
}
return { resultString, showModelValue }
}
})
</script>
<style scoped>
</style>
ParentComponent.vue
<template>
<div class="main-container">
<child-component v-model="test1" />
<button #click="increaseTest1">increase test1</button>
<hr/>
Cannot use v-model within v-for!
<!--
<div v-for="(testNum, index) in test2">
<child-component v-model="testNum" /> <button #click="increaseTest2(index)">increase test2</button>
</div>
-->
<hr/>
<div v-for="(testNumWrapper, index) in test3">
<child-component v-model="testNumWrapper.val" /> <button #click="increaseTest3(index)">increase test3</button>
</div>
</div>
</template>
<script lang="ts">
import {defineComponent, onMounted, ref} from "vue";
import ChildComponent from "#/main/components/pc/ChildComponent.vue";
export default defineComponent({
components: {ChildComponent},
setup() {
const test1 = ref<number>(1);
const increaseTest1 = () => test1.value++;
/*
const test2 = ref<number[]>([3,1,4,1,5,9]);
const increaseTest2 = (index:number) => test2.value[index]++;
const updateTest2 = (e:any) => {
console.log(e);
};
*/
const test3 = ref<{val:number}[]>([]);
const increaseTest3 = (index:number) => test3.value[index].val++;
onMounted(() => {
// This triggers watch() in childComponent.
test1.value = 4;
// But these do NOT trigger watch() in childComponent.
test3.value = [{val: 3},{val: 1},{val: 4},{val: 1},{val: 5},{val: 9}];
});
return {
test1, increaseTest1,
//test2, increaseTest2, updateTest2,
test3, increaseTest3,
}
}
});
</script>
<style scoped>
</style>
The above codes are modified for sharing my problem, let me explain.
The ChildComponent decides if the value of modelValue is odd or even, automatically.
The ParentComponent...
binds a ref variable, test1 to ChildComponent,
tries to bind each primitive typed member of ref array variable, test2 to ChildComponents but this is not compiled because v-model cannot be used within v-for, so that I commented out the code lines and try the next test,
binds each non-primitive typed member of ref array variable, test3 to ChildComponent.
And it initiates the variables in onMounted().
However, I've found that watch() in ChildCompoent works fine for test1 but not for test3. The watch() also does not work for test3 when I push a value into test3 or delete a value from test3. (It works when I click increase button.)
Please, show me a way to trigger the watch() function for test3.
Thank you.
Of course the watch in child component is not triggered by pushing or deleting elements from the array. Child component is not watching whole array but just single element (it's val property).
If you push new element into the array, child component for that element does not exist yet. If you delete an element, the child component rendered for that element is destroyed immediately. Only thing that can trigger that watch is indeed mutation of the val property...
Problem with test2 is that testNum is local temporary variable - instead of v-model="testNum", use v-model="test2[index]"
Anyway your ChildComponent.vue does not need watch at all. Just use computed:
const resultString = computed(() => props.modelValue % 2 === 0 ? 'even' : 'odd')
...and btw you should be using key with v-for - docs

this.$emit() is not a function while trying to change state in parent component from child in composition API in vue 3

//Parent component
<template>
<childComp #onchangeData='changeData' />
</template>
<script>
setup() {
const state = reactive({
data: 'anything
});
function changeData(v){
state.data = v
}
return { changeData}
},
</script>
//Child
<template>
<button #click='change('hello')' />
</template>
<script>
setup() {
function change(v){
this.$emit('onchangeData', v)
}
return{change}
},
</script>
I am struggling to change the parents' reactive state from the child's button click. It's saying this.$emit is not a function. I tried many ways like using #onchangeData='changeData()' instead of #onchangeData='changeData', using arrow functions etc. But nothing works. Here, I wrote an example and minimal code to keep it simple. But I hope my problem is clear.
Look at following snippet, this is not the same in composition as in options API, so you need to use emit passed to setup function:
const { reactive } = Vue
const app = Vue.createApp({
setup() {
const state = reactive({
data: 'anything'
});
function changeData(v){
state.data = v
}
return { changeData, state }
},
})
app.component("ChildComp", {
template: `
<div>
<button #click="change('hello')">click</button>
</div>
`,
setup(props, {emit}) {
function change(v){
emit('onchangeData', v)
}
return { change }
},
})
app.mount('#demo')
<script src="https://unpkg.com/vue#3/dist/vue.global.prod.js"></script>
<div id="demo">
<child-comp #onchange-data='changeData'></child-comp>
<p>{{ state.data }}</p>
</div>

VueJS - prop / var assignment / when / how

I'm new to Vue and having trouble with when / how to assign a prop value calculated using the a store object. I want to grab the id from the url (e.g. /location?locationid) and compare that against the locations object in the store to find the correct location (by location_id) and pass that matching object as prop to the child location component.
I'm not sure when to do the curLoc calculation currently in created(). I've tried doing it as a computed prop, with no luck. I've tried putting the calculation into a method and calling it on created() and update() and it works with a webpack update but not a page refresh.
<template>
<div class="location-wrap">
<main id="main" aria-label="content">
<h2 class="c-section__title">{{ this.curLoc.location_name // get get location_name of undefined }}</h2>
<div class="location">
<Location v-bind:loc="curLoc" /> // I want curLoc to be accessible to this child Location component.
</div>
</main>
</div>
</template>
<script>
import Location from "#/components/Location";
import { mapState } from "vuex";
export default {
name: "location",
components: {
Location,
},
computed: mapState(["locations"]),
data() {
return {
curLoc: {},
locationId: "",
locationName: ""
};
},
created() {
// curLoc calculation
let ref = location.href;
this.locationId = ref.substring(ref.indexOf("?") + 1);
this.locations.forEach(loc => {
if (loc.location_id === this.locationId) {
this.curLoc = loc;
console.log(this.curLoc); // nope
}
});
},
updated() {},
methods: {}
};
</script>
You can create a computed property like this
currLoc() {
/* remove currLoc from data */
let currLoc = {};
let ref = location.href;
this.locationId = ref.substring(ref.indexOf("?") + 1);
/* put check when initially locations isn't defined and can even use break */
this.locations.forEach(loc => {
if (loc.location_id === this.locationId) {
curLoc = loc;
console.log(curLoc); // should print your currLoc
}
});
return currLoc;
}
By making currrLoc a computed property we've ensured that it runs everytime the location changes. You can now use computed property in the template (currLoc and pass it as a prop)

Watch Vuex state change not working as expected with v-model

I've created a global error state with Vuex, i'ts an array with objects of all current errors.
const store = new Vuex.Store({
state: {
errors: []
},
getters: {
getErrors: state => state.errors
},
mutations: {
setError: (state, message) => {
state.errors.push({ error: true, message });
},
removeError: (state, i) => {
state.errors.splice(i, 1);
}
}
});
I have a component that shows all the errors dynamically using Vuex state and what i'm trying to do is removing all the objects that have the error property set to false, the error property state is being handled by the setError mutation and the v-model property inside the component.
I'm trying to do that by watching for changes and removing the desired items from the array, but it is not removing right when the property changes to false, how can i achieve that?
Here is the live demo https://codesandbox.io/s/vue-template-h5hf7
<template>
<div id="snackbar">
<v-snackbar
v-for="(error, index) in getErrors"
:key="index"
v-model="error.error"
color="red"
:right="true"
:timeout="2000"
:top="true"
>
{{ error.message }}
<v-btn dark text #click="removeError(index)">Close</v-btn>
</v-snackbar>
</div>
</template>
<script>
import { mapGetters, mapMutations } from "vuex";
export default {
name: "ErrorSnackbar",
computed: mapGetters(["getErrors"]),
methods: {
...mapMutations(["removeError"]),
removeError(i) {
this.$store.commit("removeError", i);
}
},
watch: {
getErrors: {
handler(newErrors) {
if (newErrors.length > 0) {
newErrors.forEach((error, i) => {
if (error.error === false) {
newErrors.splice(i, 1);
}
});
}
},
}
}
};
</script>
Your watcher will only respond to mutations of the array directly (such as an item being added or removed from it). In order to observe changes to the items within the array too, you need to use a deep watcher.
Also whenever you are looping over an array and removing items from the array at the same time, you should iterate in reverse order otherwise you will miss some elements.
watch: {
getErrors: {
deep: true,
handler(newErrors) {
for (let i = newErrors.length - 1; i >= 0; i--) {
if (!newErrors[i].error) {
newErrors.splice(i, 1)
}
}
}
}
}
Note that this may trigger another call to the handler since you're mutating the thing you are observing.
EDIT
Thanks for the codesandbox.
The issue has to do with <v-snackbar> not updating the model. I'm not completely sure how <v-snackbar> is implemented, but it seems that when the component is reused then its timeout gets cancelled and it will not emit an input event. Some of the components are getting reused as a result of adding and removing multiple errors at the same time.
What you need to do is to key each <v-snackbar> correctly to the same error object. Right now you have them keyed by the index in the array, but this will change as elements are removed from the array. So we have to come up with our own unique ID for each error object.
Here's an excerpt of the code changes you need to make:
// Define this at file-level
let nextKey = 1
mutations: {
setError: (state, message) => {
state.errors.push({
key: nextKey++,
error: true,
message,
})
}
}
<v-snackbar
v-for="error in getErrors"
:key="error.key"
>

Using external callback function data inside vuejs component

Hello and Happy holidays !!
I need advice on how I could get data from an external function that generate a zipfile into my vuejs component, to create a progress bar for JSZip plugin: https://stuk.github.io/jszip/documentation/api_jszip/generate_async.html
I import my file :
import { generateZIP } from "#/utils/downloader.js";
and call it inside vuejs from a method trigger by a button:
<template>
...
<div v-for="result of results" :key="result.item.refID">
<section class="row" #click="selectByRow(result.item)">
<input
type="checkbox"
:id="result.item.refID"
:value="result.item.refID"
v-model="checkedItems"
class="checkbox"
/>
</div>
<!-- FOUND RESULTS -->
<div class="name">{{ result.item.marketingName }}</div>
</section>
</div>
<!-- Download all checked items -->
<div>
<button
v-if="checkedItems.length > 1"
#click="downloadAll(checkedItems)"
class="button"
>
Download Selection
</button>
</template>
...
<script>
import { mapState, mapActions, mapMutations, mapGetters } from "vuex";
import { generateZIP } from "#/utils/downloader.js";
...
export default {
data() {
return {
// Path to point to pictures storage
domainName: this.$domainName,
// Array to gather search results
searchArray: [],
checkedItems: [],
// make a special array for row selection
checkedRow: []
};
},
methods:
downloadAll(files) {
// Prepare path
const fullPath = `${this.domainName}/files/${this.reqPath}/`;
const filesArray = [];
files.forEach(fileID => {
let obj = this.results.find(value => value.item.refID == fileID);
if (obj.item.images !== undefined) {
filesArray.push(obj.item.images);
}
});
generateZIP(filesArray.flat(), fullPath);
},
selectByRow(resultID) {
// Check if select resultID.refID is already in checkedItems and store it in variable if its present.
const isInArray = this.checkedItems.find(name => name === resultID.refID);
// if the ref not in array, add it
if (!isInArray) {
this.checkedItems.push(resultID.refID);
// Add checkedRow full information object
this.checkedRow.push(resultID);
} else {
// if already in array, remove it
this.checkedItems = this.checkedItems.filter(
name => name !== resultID.refID
);
this.checkedRow = this.checkedRow.filter(
name => name.refID !== resultID.refID
);
}
...
Everything working, now I add some feedback showing the zip progress. There is an available callback function "updateCallback" that I'll call in my downloader.js
zip.generateAsync({type:"blob"}, function updateCallback(metadata) {
console.log("progression: " + metadata.percent.toFixed(2) + " %");
if(metadata.currentFile) {
console.log("current file = " + metadata.currentFile);
}
})
...
export {
generateZIP
}
Cool it displays progression in my console log.
But HOW could I import this metadata object into vue to display it in my app ?
Thanks a lot !
Use data properties in your vue component.
Inside the callback, link your instance (this) to local var, to pass value between callback data and reactive property. For example: let var=this

Categories