feat(deploy): production deployment setup and fixes
- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma binaryTargets for openssl 3.0 - nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal cron, http->https redirect - prisma: add origin_goods.delisted migration, sync missing schema (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz - api: CORS wildcard reflection, helmet CORP cross-origin, price backfill in persistProductDetail, categoryIcon ancestor fallback, mediaByColor per-color gallery in public goods detail - admin: /admin base path (vite + router) - import-data.mjs: udt_name casting, serial sequence advance fix
This commit is contained in:
@@ -1,180 +1,180 @@
|
||||
---
|
||||
title: Clean Up Async Operations in Watchers to Prevent Race Conditions
|
||||
impact: HIGH
|
||||
impactDescription: Stale async requests can overwrite newer data, causing incorrect UI state and hard-to-debug issues
|
||||
type: capability
|
||||
tags: [vue3, watch, watchers, async, cleanup, race-condition, abort]
|
||||
---
|
||||
|
||||
# Clean Up Async Operations in Watchers to Prevent Race Conditions
|
||||
|
||||
**Impact: HIGH** - When a watched value changes rapidly, multiple async operations run concurrently. Without cleanup, a slow earlier request can complete after a faster later request, overwriting current data with stale results.
|
||||
|
||||
Always use `onWatcherCleanup` or the `onCleanup` callback parameter to cancel pending async operations when the watcher re-runs or the component unmounts.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use `onWatcherCleanup()` or `onCleanup` parameter in async watchers
|
||||
- [ ] Use `AbortController` to cancel pending fetch requests
|
||||
- [ ] Cancel any setTimeout/setInterval calls in cleanup
|
||||
- [ ] Invalidate previous async operation results with flags
|
||||
- [ ] Consider debouncing rapid changes before fetching
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const searchQuery = ref('')
|
||||
const results = ref([])
|
||||
|
||||
// BAD: Race condition - slow request for "a" can finish after fast request for "ab"
|
||||
watch(searchQuery, async (query) => {
|
||||
if (query) {
|
||||
const response = await fetch(`/api/search?q=${query}`)
|
||||
results.value = await response.json() // May overwrite newer results!
|
||||
}
|
||||
})
|
||||
|
||||
// BAD: No cleanup for timeouts
|
||||
watch(searchQuery, (query) => {
|
||||
// Previous timeout keeps running even when query changes
|
||||
setTimeout(() => {
|
||||
performExpensiveSearch(query)
|
||||
}, 500)
|
||||
})
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { ref, watch, onWatcherCleanup } from 'vue'
|
||||
|
||||
const searchQuery = ref('')
|
||||
const results = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
// CORRECT: Using onWatcherCleanup (Vue 3.5+)
|
||||
watch(searchQuery, async (query) => {
|
||||
if (!query) {
|
||||
results.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
// Register cleanup to abort on re-run or unmount
|
||||
onWatcherCleanup(() => {
|
||||
controller.abort()
|
||||
})
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await fetch(`/api/search?q=${query}`, {
|
||||
signal: controller.signal
|
||||
})
|
||||
results.value = await response.json()
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Search failed:', err)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Using onCleanup Parameter
|
||||
|
||||
```javascript
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const userId = ref(1)
|
||||
const userData = ref(null)
|
||||
|
||||
// CORRECT: Using onCleanup callback parameter
|
||||
watch(userId, (newId, oldId, onCleanup) => {
|
||||
const controller = new AbortController()
|
||||
|
||||
fetch(`/api/users/${newId}`, { signal: controller.signal })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
userData.value = data
|
||||
})
|
||||
.catch(err => {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error(err)
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
controller.abort()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Cleanup with Timeouts
|
||||
|
||||
```javascript
|
||||
import { ref, watch, onWatcherCleanup } from 'vue'
|
||||
|
||||
const input = ref('')
|
||||
|
||||
// CORRECT: Cancel previous timeout on new input
|
||||
watch(input, (value) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
performExpensiveOperation(value)
|
||||
}, 300)
|
||||
|
||||
onWatcherCleanup(() => {
|
||||
clearTimeout(timeoutId)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Invalidation Flag Pattern
|
||||
|
||||
```javascript
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const id = ref(1)
|
||||
const data = ref(null)
|
||||
|
||||
// CORRECT: Invalidation flag for non-abortable operations
|
||||
watch(id, async (newId, oldId, onCleanup) => {
|
||||
let cancelled = false
|
||||
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
})
|
||||
|
||||
const result = await someNonAbortableAsyncOperation(newId)
|
||||
|
||||
// Check if this watch run is still valid
|
||||
if (!cancelled) {
|
||||
data.value = result
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## watchEffect Cleanup
|
||||
|
||||
```javascript
|
||||
import { ref, watchEffect, onWatcherCleanup } from 'vue'
|
||||
|
||||
const resourceId = ref('abc')
|
||||
|
||||
watchEffect(async () => {
|
||||
const id = resourceId.value
|
||||
const controller = new AbortController()
|
||||
|
||||
onWatcherCleanup(() => {
|
||||
controller.abort()
|
||||
})
|
||||
|
||||
const data = await fetchResource(id, { signal: controller.signal })
|
||||
processData(data)
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Watchers - Callback Flush Timing](https://vuejs.org/guide/essentials/watchers.html#callback-flush-timing)
|
||||
- [Vue.js Watchers - Side Effect Cleanup](https://vuejs.org/api/reactivity-core.html#watcheffect)
|
||||
---
|
||||
title: Clean Up Async Operations in Watchers to Prevent Race Conditions
|
||||
impact: HIGH
|
||||
impactDescription: Stale async requests can overwrite newer data, causing incorrect UI state and hard-to-debug issues
|
||||
type: capability
|
||||
tags: [vue3, watch, watchers, async, cleanup, race-condition, abort]
|
||||
---
|
||||
|
||||
# Clean Up Async Operations in Watchers to Prevent Race Conditions
|
||||
|
||||
**Impact: HIGH** - When a watched value changes rapidly, multiple async operations run concurrently. Without cleanup, a slow earlier request can complete after a faster later request, overwriting current data with stale results.
|
||||
|
||||
Always use `onWatcherCleanup` or the `onCleanup` callback parameter to cancel pending async operations when the watcher re-runs or the component unmounts.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use `onWatcherCleanup()` or `onCleanup` parameter in async watchers
|
||||
- [ ] Use `AbortController` to cancel pending fetch requests
|
||||
- [ ] Cancel any setTimeout/setInterval calls in cleanup
|
||||
- [ ] Invalidate previous async operation results with flags
|
||||
- [ ] Consider debouncing rapid changes before fetching
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const searchQuery = ref('')
|
||||
const results = ref([])
|
||||
|
||||
// BAD: Race condition - slow request for "a" can finish after fast request for "ab"
|
||||
watch(searchQuery, async (query) => {
|
||||
if (query) {
|
||||
const response = await fetch(`/api/search?q=${query}`)
|
||||
results.value = await response.json() // May overwrite newer results!
|
||||
}
|
||||
})
|
||||
|
||||
// BAD: No cleanup for timeouts
|
||||
watch(searchQuery, (query) => {
|
||||
// Previous timeout keeps running even when query changes
|
||||
setTimeout(() => {
|
||||
performExpensiveSearch(query)
|
||||
}, 500)
|
||||
})
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { ref, watch, onWatcherCleanup } from 'vue'
|
||||
|
||||
const searchQuery = ref('')
|
||||
const results = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
// CORRECT: Using onWatcherCleanup (Vue 3.5+)
|
||||
watch(searchQuery, async (query) => {
|
||||
if (!query) {
|
||||
results.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
// Register cleanup to abort on re-run or unmount
|
||||
onWatcherCleanup(() => {
|
||||
controller.abort()
|
||||
})
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await fetch(`/api/search?q=${query}`, {
|
||||
signal: controller.signal
|
||||
})
|
||||
results.value = await response.json()
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Search failed:', err)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Using onCleanup Parameter
|
||||
|
||||
```javascript
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const userId = ref(1)
|
||||
const userData = ref(null)
|
||||
|
||||
// CORRECT: Using onCleanup callback parameter
|
||||
watch(userId, (newId, oldId, onCleanup) => {
|
||||
const controller = new AbortController()
|
||||
|
||||
fetch(`/api/users/${newId}`, { signal: controller.signal })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
userData.value = data
|
||||
})
|
||||
.catch(err => {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error(err)
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
controller.abort()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Cleanup with Timeouts
|
||||
|
||||
```javascript
|
||||
import { ref, watch, onWatcherCleanup } from 'vue'
|
||||
|
||||
const input = ref('')
|
||||
|
||||
// CORRECT: Cancel previous timeout on new input
|
||||
watch(input, (value) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
performExpensiveOperation(value)
|
||||
}, 300)
|
||||
|
||||
onWatcherCleanup(() => {
|
||||
clearTimeout(timeoutId)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Invalidation Flag Pattern
|
||||
|
||||
```javascript
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const id = ref(1)
|
||||
const data = ref(null)
|
||||
|
||||
// CORRECT: Invalidation flag for non-abortable operations
|
||||
watch(id, async (newId, oldId, onCleanup) => {
|
||||
let cancelled = false
|
||||
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
})
|
||||
|
||||
const result = await someNonAbortableAsyncOperation(newId)
|
||||
|
||||
// Check if this watch run is still valid
|
||||
if (!cancelled) {
|
||||
data.value = result
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## watchEffect Cleanup
|
||||
|
||||
```javascript
|
||||
import { ref, watchEffect, onWatcherCleanup } from 'vue'
|
||||
|
||||
const resourceId = ref('abc')
|
||||
|
||||
watchEffect(async () => {
|
||||
const id = resourceId.value
|
||||
const controller = new AbortController()
|
||||
|
||||
onWatcherCleanup(() => {
|
||||
controller.abort()
|
||||
})
|
||||
|
||||
const data = await fetchResource(id, { signal: controller.signal })
|
||||
processData(data)
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Watchers - Callback Flush Timing](https://vuejs.org/guide/essentials/watchers.html#callback-flush-timing)
|
||||
- [Vue.js Watchers - Side Effect Cleanup](https://vuejs.org/api/reactivity-core.html#watcheffect)
|
||||
|
||||
Reference in New Issue
Block a user