Vue Code Review — PR #47
CultivaDash · Módulo de Comentarios en Tiempo Real · Nuxt 3 + Vue 3 + Pinia
Vue 3 Nuxt 3 Pinia Composition API 4 archivos
Veredicto
BLOCK
2
Críticos
🛑 BLOCK
5
Altos
⚠️ BLOCK
4
Medios
ℹ️ WARN
4
Archivos
📂 revisados
Critical 2 issues — require corrección inmediata
🛑
v-html con input no saneado — XSS directo
CRITICAL · Seguridad Vue
CampaignComments.vue:5
v-html="comment.body" renderiza HTML arbitrario del servidor sin ninguna sanitización. Cualquier payload <script> o evento inline que llegue en comment.body se ejecutará en el navegador del usuario.
Es el equivalente a dangerouslySetInnerHTML de React. Un atacante con acceso a la API (SSRF, inyección en BBDD, comentario malicioso) puede robar tokens de sesión, redirigir a usuarios o instalar keyloggers persistentes.
Instalar dompurify y sanitizar en el mismo call-site. O, si no se necesita HTML, usar {{ comment.body }} (interpolación segura).
- <span v-html="comment.body"></span>
+ <span v-html="sanitize(comment.body)"></span>

// en script setup:
// import DOMPurify from 'dompurify'
// const sanitize = (html: string) => DOMPurify.sanitize(html, { ALLOWED_TAGS: ['b','i','em','strong'] })
🛑
:src sin validación de esquema URL — ejecución de javascript: URIs
CRITICAL · Seguridad Vue
CampaignComments.vue:6
:src="comment.authorAvatar" pasa el valor directamente al atributo sin validar el esquema. Una URL del tipo javascript:alert(1) o data:text/html,<script>... puede ejecutar código.
Los bindings de URL dinámicos en :href, :src y :action son vectores clásicos de XSS cuando el contenido proviene de usuarios externos. Afecta especialmente a plataformas multi-tenant como CultivaDash.
Validar que la URL empiece por https:// o usar un proxy de imágenes propio. Nunca confiar en URLs externas sin allowlist de dominio.
- <img :src="comment.authorAvatar" />
+ <img :src="safeAvatarUrl(comment.authorAvatar)" alt="avatar" />

// const safeAvatarUrl = (url: string) =>
//   url?.startsWith('https://') ? url : '/img/default-avatar.png'

High 5 issues — bloquean merge
⚠️
v-if + v-for en el mismo elemento — error de lógica de renderizado
HIGH · Template Correctness
CampaignComments.vue:7–9
v-if="isLoading" y v-for="item in 3" coexisten en el mismo <div>. Vue evalúa v-if una vez y no por iteración, causando comportamiento impredecible.
El skeleton loader puede no renderizarse o renderizarse solo una vez según el estado. La intención es mostrar 3 skeletons mientras carga, lo que requiere separar las directivas.
Envolver con <template v-if> exterior y poner el v-for en el hijo, o usar un <template v-for> con v-if en el elemento.
- <div v-if="isLoading" v-for="item in 3" :key="item">
+ <template v-if="isLoading">
+   <div v-for="item in 3" :key="item">
+     <div class="skeleton"></div>
+   </div>
+ </template>
⚠️
:key con índice en v-for — identidad DOM incorrecta en reordenaciones
HIGH · Template Correctness
CampaignComments.vue:4
:key="index" en el listado de comentarios. Si se elimina o inserta un comentario intermedio, Vue reutilizará nodos DOM erróneos causando contenido o animaciones en la posición equivocada.
Los comentarios en tiempo real llegan en streams; inserciones frecuentes harán que la UI muestre el avatar o el texto del comentario equivocado si se usa el índice como key.
Usar el ID estable de la API: :key="comment.id".
- <div v-for="(comment, index) in comments" :key="index">
+ <div v-for="comment in comments" :key="comment.id">
⚠️
Destructuring de props reactivos (Vue < 3.5) — snapshot, no reactivo
HIGH · Reactivity
CampaignComments.vue:34
const { campaignId, userId } = props captura una copia del valor en el momento de ejecución. Si el padre cambia campaignId, el componente no reacciona.
CultivaDash navega entre campañas sin recargar página. Si el componente hijo recibe un nuevo campaignId vía prop, mantendrá los comentarios de la campaña anterior.
Usar toRefs(props) o acceder siempre como props.campaignId. Migrar a <script setup> donde Vue 3.5 reactiva el destructuring automáticamente.
- const { campaignId, userId } = props
+ const { campaignId, userId } = toRefs(props)
+ // ahora campaignId.value es reactivo
⚠️
Composable con estado en scope de módulo y sin cleanup — memory leak
HIGH · Composables
useCommentsFeed.ts:4–6
const comments = ref([]) y el setInterval están a nivel de módulo. Cada instancia del componente comparte el mismo estado y el intervalo nunca se limpia, acumulándose con cada montaje.
Después de navegar por 5 campañas distintas habrá 5 intervalos corriendo en paralelo, multiplicando las peticiones y causando actualizaciones fantasma en la UI activa.
Mover comments y el intervalo dentro de la función del composable, y limpiar con onUnmounted.
- const comments = ref([])          // módulo global
- let pollInterval = setInterval(..., 5000)  // nunca limpiado
+ export function useCommentsFeed() {
+   const comments = ref([])         // por instancia
+   const pollInterval = setInterval(() => { ... }, 5000)
+   onUnmounted(() => clearInterval(pollInterval))
+   return { comments, ... }
+ }
⚠️
Store action sin error boundary — estado inconsistente en fallo de red
HIGH · Pinia · State Management
commentsStore.ts:11–15
loadComments hace await fetch(...) sin try/catch. Un 500 del servidor lanza una excepción no capturada que deja el store en estado parcial y crashea el componente silenciosamente.
En producción, el usuario verá una pantalla en blanco o un spinner infinito. Además, la función es llamada sin await en la página, por lo que los errores no burbujean al nivel correcto.
Añadir try/catch con estado de error, y hacer await desde el caller o usar useAsyncData de Nuxt.
+ const error = ref<string | null>(null)
+
  async function loadComments(campaignId: string) {
+   error.value = null
+   try {
      const res = await fetch(`/api/campaigns/${campaignId}/comments`)
+     if (!res.ok) throw new Error(`HTTP ${res.status}`)
      const data = await res.json()
      comments.value = data
+   } catch (e) {
+     error.value = (e as Error).message
+   }
  }

Medium 4 issues — merge con precaución
ℹ️
Options API en proyecto Vue 3 — usar Composition API con <script setup>
MEDIUM · Composition · Estilo
CampaignComments.vue
El componente usa export default { ... setup() {} } (Options API híbrida) en lugar de <script setup>. El resto del proyecto (páginas, composables) ya usa Composition API.
Inconsistencia en el codebase, peor soporte de TypeScript, y varias de las issues CRITICAL/HIGH de este review son directamente consecuencia de no usar <script setup>.
Migrar a <script setup lang="ts">. Los props pasan a defineProps, el nombre a defineOptions o convención de fichero.
ℹ️
Manipulación directa del DOM con document.querySelector — rompe encapsulación
MEDIUM · Component Architecture
CampaignComments.vue:43
document.querySelector('.comments-scroll') busca por clase CSS en el DOM global. Si hay dos instancias del componente (modal + página), se actualizará la primera que encuentre.
Usar el template ref ya declarado: scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight.
- document.querySelector('.comments-scroll').scrollTop = 9999
+ if (scrollContainer.value) {
+   scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight
+ }
ℹ️
Dato no serializable en Pinia (Date) — rompe SSR y devtools
MEDIUM · Pinia · SSR
commentsStore.ts:19
timestamp: new Date() almacena un objeto Date en el estado de Pinia. No sobrevive a JSON.stringify/parse, lo que rompe la hidratación SSR de Nuxt y el time-travel de devtools.
Guardar el timestamp como ISO string: timestamp: new Date().toISOString(). Convertir a Date solo en la capa de presentación.
ℹ️
<KeepAlive> sin :max — caché crece indefinidamente
MEDIUM · Render Performance
dashboard/campaign/[id].vue:18
<KeepAlive> sin prop :max mantiene vivas todas las instancias de activeTab visitadas. En CultivaDash, donde se puede navegar por docenas de campañas, el footprint de memoria crece sin límite.
Añadir un límite razonable: <KeepAlive :max="5">.
- <KeepAlive>
+ <KeepAlive :max="5">

📋 Resumen del Review
Severidad Conteo Estado Detalles
CRITICAL 2 BLOCK v-html sin sanitizar, :src con URL no validada
HIGH 5 BLOCK v-if+v-for, :key=index, props destructuring, composable leak, store sin error boundary
MEDIUM 4 WARN Options API, querySelector, Date en Pinia, KeepAlive sin max
🚫

Verdict: BLOCK — No mergear hasta resolver CRITICAL y HIGH

7 issues de severidad CRITICAL/HIGH impiden el merge. Las dos issues CRITICAL son vulnerabilidades de seguridad activas (XSS vía v-html y URL injection) que en producción permitirían ejecución de código en el contexto de cualquier usuario de CultivaDash. Las 5 issues HIGH incluyen un memory leak por composable mal construido, reactivity bugs que causarán datos obsoletos en la UI, y estado de Pinia inconsistente ante fallos de red. Corregir todas antes de solicitar re-review.

🤖
revisor-de-codigo-vue · CULTIVA IA
Senior Vue.js Reviewer Agent — Composition API, Seguridad, Rendimiento
18 Jun 2026 · PR #47 · CultivaDash v2