Code review · Node.js

Review a Vue Comment List

Medium Code review

This Vue 3 single-file component shows the comments on a blog post and lets a moderator hide a comment. Comments are written by site visitors.

Review it: list the problems, why they matter, and how to fix each one.

The code to review

There are 5 issues to find.

<template>
  <ul>
    <li v-for="comment in comments" v-if="!comment.hidden">
      <strong>{{ comment.author }}</strong>
      <div v-html="comment.body"></div>
      <button @click="hide(comment)">Hide</button>
    </li>
  </ul>
  <p>{{ comments.filter(c => !c.hidden).length }} visible comments</p>
</template>

<script setup>
const props = defineProps({ comments: Array });

function hide(comment) {
  comment.hidden = true;
  fetch(`/api/comments/${comment.id}/hide`, { method: "POST" });
}
</script>

Write your review

Loading the editor… (JavaScript is required to run code in your browser.)

The issues and how to fix them

Show the issues

1. Stored XSS through v-html

Critical, line 5

v-html inserts the comment as raw HTML. A visitor can post <img src=x onerror=...> and run script in every reader's browser, including moderators, which can steal sessions or act as them.

Fix: Render text with {{ comment.body }} (Vue escapes it). If formatting is needed, convert Markdown on the server and sanitise the HTML with an allow-list (DOMPurify) before using v-html.

2. Mutating a prop

Major, line 16

comment.hidden = true changes an object owned by the parent. It happens to work because objects are passed by reference, but it bypasses one-way data flow, so the parent's state can change without the parent knowing.

Fix: Emit an event (emit('hide', comment.id)) and let the owner update its data, or keep local state.

3. Optimistic update without error handling

Major, line 16-17

The comment disappears even if the request fails (or the user is not authorised), so the moderator thinks it is hidden when it is not.

Fix: Await the request, check response.ok, and roll back or show an error on failure. Also send the CSRF token the backend expects.

4. v-if and v-for on the same element

Major, line 3

In Vue 3, v-if is evaluated before v-for, so comment is undefined in the condition (an error), and the pattern is discouraged in both Vue 2 and 3.

Fix: Filter in a computed property (visibleComments) and loop over that, or move v-if to an inner <template>.

5. Missing :key in v-for

Minor, line 3

Without a key, Vue patches list items in place by position, which can keep the wrong DOM state when comments are hidden or reordered.

Fix: Add :key="comment.id".

Any time user content meets v-html (or React's dangerouslySetInnerHTML, or jQuery's .html()), XSS is the first finding. After that, interviewers look for Vue-specific knowledge: one-way data flow, v-if/v-for precedence, and keys.

Fixed version

<template>
  <ul>
    <li v-for="comment in visibleComments" :key="comment.id">
      <strong>{{ comment.author }}</strong>
      <p>{{ comment.body }}</p>
      <button :disabled="pending === comment.id" @click="hide(comment.id)">Hide</button>
    </li>
  </ul>
  <p>{{ visibleComments.length }} visible comments</p>
</template>

<script setup>
import { computed, ref } from "vue";

const props = defineProps({ comments: { type: Array, required: true } });
const emit = defineEmits(["hidden"]);
const pending = ref(null);

const visibleComments = computed(() => props.comments.filter((c) => !c.hidden));

async function hide(id) {
  pending.value = id;
  try {
    const r = await fetch(`/api/comments/${id}/hide`, { method: "POST", headers: { "X-CSRF-Token": csrfToken() } });
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    emit("hidden", id); // the parent owns the data
  } catch (e) {
    alert("Could not hide the comment. Please try again.");
  } finally {
    pending.value = null;
  }
}
</script>

Follow-up questions

  • What would a Content Security Policy add here?
  • How does computed differ from a method call in the template?