Skip to content

Commit

Permalink
fix(QrcodeStream): ignore structually equal prop changes
Browse files Browse the repository at this point in the history
Props like `constraints` and `formats` which carry non-primitive values
might receive structurally equal updates. For example, let `constraints`
be the variable that is passed to `QrcodeStream`:

    <qrcode-stream :constraints="constraints" />

and imagine the `script` section looks like this:

   const constraints = ref({})

   setInterval(() => {
     constraints.value = { deviceId: 'whatever' }
   }, 100)

This would keep triggering updates in `QrcodeStream` although the
constraints don't actually change. This is because the assigned object
is referencially different every time and Vue only checks referencial
equality. A less contrived example where this happens, is when the
template looks like this:

    <qrcode-stream :constraints="{ deviceId: 'whatever' }" />

Whenever Vue re-evaluates the passed object it creates a referencially
different copy. To avoid this problem we now maintain "cached" versions
of these props and only update them when we detect strucural changes.
  • Loading branch information
gruhn committed Feb 15, 2024
1 parent 920d159 commit f49174c
Show file tree
Hide file tree
Showing 4 changed files with 64 additions and 13 deletions.
4 changes: 2 additions & 2 deletions .lintstagedrc
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"**/*.{vue,js,jsx,cjs,mjs,ts,tsx,cts,mts}": ["pnpm lint"],
"**/*.{vue,js,jsx,cjs,mjs,ts,tsx,cts,mts,json,md}": ["pnpm format"]
"**/*.{vue,ts}": ["pnpm lint"],
"**/*.{vue,ts,json,md}": ["pnpm format"]
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs",
"lint": "eslint **/*.{vue,js,ts} --fix --ignore-path .gitignore",
"format": "prettier **/*.{vue,js,jsx,cjs,mjs,ts,tsx,cts,mts,json,md} --write --ignore-path .gitignore --ignore-path docs/.gitignore",
"format": "prettier **/*.{vue,ts,json,md} --write --ignore-path .gitignore --ignore-path docs/.gitignore",
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
"prepack": "pnpm run build",
"prepare": "husky install"
Expand Down
3 changes: 2 additions & 1 deletion shell.nix
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pkgs.mkShell {
nodejs_18
nodePackages.pnpm
nodePackages.typescript-language-server
nodePackages.volar
];

}
68 changes: 59 additions & 9 deletions src/components/QrcodeStream.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ import {
computed,
onMounted,
ref,
toRefs,
watch,
type PropType,
type CSSProperties
Expand All @@ -55,12 +54,14 @@ import type { Point } from '../types/types'
import { assert } from '../misc/util'
const props = defineProps({
// in this file: don't use `props.constraints` directly. Use `constraintsCached`.
constraints: {
type: Object as PropType<MediaTrackConstraints>,
default() {
return { facingMode: 'environment' } as MediaTrackConstraints
}
},
// in this file: don't use `props.formats` directly. Use `formatsCached`.
formats: {
type: Array as PropType<BarcodeFormat[]>,
default: () => ['qr_code'] as BarcodeFormat[]
Expand All @@ -80,6 +81,56 @@ const props = defineProps({
const emit = defineEmits(['detect', 'camera-on', 'camera-off', 'error'])
// Props like `constraints` and `formats` which carry non-primitive values might receive
// structurally equal updates. For example, let `constraints` be the variable that is
// passed to `QrcodeStream`:
//
// <qrcode-stream :constraints="constraints" />
//
// and imagine the `script` section looks like this:
//
// const constraints = ref({})
//
// setInterval(() => {
// constraints.value = { deviceId: 'whatever' }
// }, 100)
//
// This would keep triggering updates in `QrcodeStream` although the constraints don't
// actually change. This is because the assigned object is referencially different every
// time and Vue only checks referencial equality. A less contrived example where this
// happens is when the template looks like this:
//
// <qrcode-stream :constraints="{ deviceId: 'whatever' }" />
//
// Whenever Vue re-evaluates the passed object it creates a referencially different copy.
//
// To avoid this problem we maintain "cached" versions of these props and only update
// them when we detect strucural changes.
const constraintsCached = ref(props.constraints)
const formatsCached = ref(props.formats)
watch(
() => props.constraints,
(newConstraints, oldConstraints) => {
// Only update `constraintsCached` if the new constraints object is strucurally different.
if (JSON.stringify(newConstraints) !== JSON.stringify(oldConstraints)) {
constraintsCached.value = newConstraints
}
},
{ deep: true }
)
watch(
() => props.formats,
(newFormats, oldFormats) => {
// Only update `formatsCached` if the new formats object is strucurally different.
if (JSON.stringify(newFormats) !== JSON.stringify(oldFormats)) {
formatsCached.value = newFormats
}
},
{ deep: true }
)
// DOM refs
const pauseFrameRef = ref<HTMLCanvasElement>()
const trackingLayerRef = ref<HTMLCanvasElement>()
Expand Down Expand Up @@ -112,14 +163,14 @@ onUnmounted(() => {
const cameraSettings = computed(() => {
return {
torch: props.torch,
constraints: props.constraints,
constraints: constraintsCached.value,
shouldStream: isMounted.value && !props.paused
}
})
watch(
cameraSettings,
async (cameraSettings) => {
async (newSettings) => {
const videoEl = videoRef.value
assert(
videoEl !== undefined,
Expand All @@ -135,14 +186,14 @@ watch(
const ctx = canvas.getContext('2d')
assert(ctx !== null, 'if cavnas is defined, canvas 2d context should also be non-null')
if (cameraSettings.shouldStream) {
if (newSettings.shouldStream) {
// When a camera is already loaded and then the `constraints` prop is changed, then
// => both `cameraActive.value` and `cameraSettings.shouldStream` stay `true`
// => so `shouldScan` does not change and thus
// => and thus the watcher on `shouldScan` is not triggered
// => and finally we don't start a new scanning process
// So in this interaction scanning breaks. To prevent that we explicitly set `cameraActive`
// to `false` here. That is not just a hack but also makes semantically sense, because
// to `false` here. That is not just a hack but also makes semantically sense, because
// the camera is briefly inactive right before requesting a new camera.
cameraController.stop()
cameraActive.value = false
Expand All @@ -152,7 +203,7 @@ watch(
// Usually, when the component is destroyed the `onUnmounted` hook takes care of stopping the camera.
// However, if the component is destroyed while we are in the middle of starting the camera, then
// the `onUnmounted` hook might fire before the following promise resolves ...
const capabilities = await cameraController.start(videoEl, cameraSettings)
const capabilities = await cameraController.start(videoEl, newSettings)
// ... thus we check whether the component is still alive right after the promise resolves and stop
// the camera otherwise.
if (!isMounted.value) {
Expand Down Expand Up @@ -181,8 +232,7 @@ watch(
)
// Set formats will create a new BarcodeDetector instance with the given formats.
const { formats: propFormats } = toRefs(props)
watch(propFormats, (formats) => {
watch(formatsCached, (formats) => {
if (isMounted.value) {
setScanningFormats(formats)
}
Expand Down Expand Up @@ -223,7 +273,7 @@ watch(shouldScan, (shouldScan) => {
)
keepScanning(videoRef.value, {
detectHandler: (detectedCodes: DetectedBarcode[]) => emit('detect', detectedCodes),
formats: props.formats,
formats: formatsCached.value,
locateHandler: onLocate,
minDelay: scanInterval()
})
Expand Down

0 comments on commit f49174c

Please sign in to comment.