From ebc88deef5966690907d885edd40441e55db78b2 Mon Sep 17 00:00:00 2001 From: Kai Gritun Date: Sun, 8 Feb 2026 03:25:33 -0500 Subject: [PATCH 1/2] fix(react): recreate SelectionArea instance when props change Fixes #226 The React SelectionArea component had an empty dependency array in its useEffect, which meant the component never reacted to prop changes. This is unlike the Vue implementation which uses watchEffect to auto-track dependencies. Changes: - Add configuration props (boundaries, document, selectables, startAreas, container, behaviour, features) to the useEffect dependency array - Use useRef for callback props to avoid unnecessary instance recreation when only callbacks change - Use useMemo with JSON.stringify for object props (behaviour, features) to provide stable dependency comparison - Forward remaining HTML attributes to the container div via rest props --- packages/preact/src/SelectionArea.tsx | 65 ++++++++++++++++++++------- packages/react/src/SelectionArea.tsx | 61 ++++++++++++++++++------- 2 files changed, 94 insertions(+), 32 deletions(-) diff --git a/packages/preact/src/SelectionArea.tsx b/packages/preact/src/SelectionArea.tsx index b435eb4..07b6762 100644 --- a/packages/preact/src/SelectionArea.tsx +++ b/packages/preact/src/SelectionArea.tsx @@ -1,7 +1,7 @@ import VanillaSelectionArea from '@viselect/vanilla'; import {SelectionEvents, PartialSelectionOptions} from '@viselect/vanilla'; -import {createContext, createRef, FunctionalComponent, JSX} from 'preact'; -import {useEffect, useContext, useState} from 'preact/hooks'; +import {createContext, JSX, FunctionalComponent} from 'preact'; +import {useEffect, useContext, useState, useMemo, useRef} from 'preact/hooks'; export interface SelectionAreaProps extends PartialSelectionOptions, JSX.HTMLAttributes { id?: string; @@ -18,23 +18,54 @@ const SelectionContext = createContext(undefin export const useSelection = () => useContext(SelectionContext); export const SelectionArea: FunctionalComponent = props => { + const { + boundaries, + document: doc, + selectables, + startAreas, + container, + behaviour, + features, + onBeforeStart, + onBeforeDrag, + onStart, + onMove, + onStop, + className, + id, + children, + ...rest + } = props; + const [instance, setInstance] = useState(undefined); - const root = createRef(); + const root = useRef(null); + + // Use refs for callbacks to avoid recreating the instance when only callbacks change + const callbacksRef = useRef({onBeforeStart, onBeforeDrag, onStart, onMove, onStop}); + callbacksRef.current = {onBeforeStart, onBeforeDrag, onStart, onMove, onStop}; + + // Stable serialization of object props for dependency comparison + const behaviourKey = useMemo(() => JSON.stringify(behaviour), [behaviour]); + const featuresKey = useMemo(() => JSON.stringify(features), [features]); useEffect(() => { - /* eslint-disable @typescript-eslint/no-unused-vars */ - const {boundaries = root.current, onBeforeStart, onBeforeDrag, onStart, onMove, onStop, ...opt} = props; + const boundaryElement = boundaries ?? root.current; const selection = new VanillaSelectionArea({ - boundaries: boundaries as HTMLElement, - ...opt + boundaries: boundaryElement as HTMLElement, + document: doc, + selectables, + startAreas, + container, + behaviour, + features }); - selection.on('beforestart', evt => props.onBeforeStart?.(evt)); - selection.on('beforedrag', evt => props.onBeforeDrag?.(evt)); - selection.on('start', evt => props.onStart?.(evt)); - selection.on('move', evt => props.onMove?.(evt)); - selection.on('stop', evt => props.onStop?.(evt)); + selection.on('beforestart', evt => callbacksRef.current.onBeforeStart?.(evt)); + selection.on('beforedrag', evt => callbacksRef.current.onBeforeDrag?.(evt)); + selection.on('start', evt => callbacksRef.current.onStart?.(evt)); + selection.on('move', evt => callbacksRef.current.onMove?.(evt)); + selection.on('stop', evt => callbacksRef.current.onStop?.(evt)); setInstance(selection); @@ -42,15 +73,15 @@ export const SelectionArea: FunctionalComponent = props => { selection.destroy(); setInstance(undefined); }; - }, []); + }, [boundaries, doc, selectables, startAreas, container, behaviourKey, featuresKey]); return ( - {props.boundaries ? ( - props.children + {boundaries ? ( + children ) : ( -
- {props.children} +
+ {children}
)} diff --git a/packages/react/src/SelectionArea.tsx b/packages/react/src/SelectionArea.tsx index 57d5ff9..05827ee 100644 --- a/packages/react/src/SelectionArea.tsx +++ b/packages/react/src/SelectionArea.tsx @@ -1,6 +1,6 @@ import VanillaSelectionArea from '@viselect/vanilla'; import {SelectionEvents, PartialSelectionOptions} from '@viselect/vanilla'; -import React, {useEffect, createContext, useContext, useRef, useState} from 'react'; +import React, {useEffect, createContext, useContext, useRef, useState, useMemo} from 'react'; export interface SelectionAreaProps extends PartialSelectionOptions, React.HTMLAttributes { id?: string; @@ -17,23 +17,54 @@ const SelectionContext = createContext(undefin export const useSelection = () => useContext(SelectionContext); export const SelectionArea: React.FunctionComponent = props => { + const { + boundaries, + document: doc, + selectables, + startAreas, + container, + behaviour, + features, + onBeforeStart, + onBeforeDrag, + onStart, + onMove, + onStop, + className, + id, + children, + ...rest + } = props; + const [instance, setInstance] = useState(undefined); const root = useRef(null); + // Use refs for callbacks to avoid recreating the instance when only callbacks change + const callbacksRef = useRef({onBeforeStart, onBeforeDrag, onStart, onMove, onStop}); + callbacksRef.current = {onBeforeStart, onBeforeDrag, onStart, onMove, onStop}; + + // Stable serialization of object props for dependency comparison + const behaviourKey = useMemo(() => JSON.stringify(behaviour), [behaviour]); + const featuresKey = useMemo(() => JSON.stringify(features), [features]); + useEffect(() => { - /* eslint-disable @typescript-eslint/no-unused-vars */ - const {boundaries = root.current, onBeforeStart, onBeforeDrag, onStart, onMove, onStop, ...opt} = props; + const boundaryElement = boundaries ?? root.current; const selection = new VanillaSelectionArea({ - boundaries: boundaries as HTMLElement, - ...opt + boundaries: boundaryElement as HTMLElement, + document: doc, + selectables, + startAreas, + container, + behaviour, + features }); - selection.on('beforestart', evt => props.onBeforeStart?.(evt)); - selection.on('beforedrag', evt => props.onBeforeDrag?.(evt)); - selection.on('start', evt => props.onStart?.(evt)); - selection.on('move', evt => props.onMove?.(evt)); - selection.on('stop', evt => props.onStop?.(evt)); + selection.on('beforestart', evt => callbacksRef.current.onBeforeStart?.(evt)); + selection.on('beforedrag', evt => callbacksRef.current.onBeforeDrag?.(evt)); + selection.on('start', evt => callbacksRef.current.onStart?.(evt)); + selection.on('move', evt => callbacksRef.current.onMove?.(evt)); + selection.on('stop', evt => callbacksRef.current.onStop?.(evt)); setInstance(selection); @@ -41,15 +72,15 @@ export const SelectionArea: React.FunctionComponent = props selection.destroy(); setInstance(undefined); }; - }, []); + }, [boundaries, doc, selectables, startAreas, container, behaviourKey, featuresKey]); return ( - {props.boundaries ? ( - props.children + {boundaries ? ( + children ) : ( -
- {props.children} +
+ {children}
)} From 4fc6b8b1518a34fabb102f8850b0f2a679d483a3 Mon Sep 17 00:00:00 2001 From: Simon Reinisch Date: Sun, 15 Feb 2026 16:31:11 +0100 Subject: [PATCH 2/2] fix(core): use coalesce operator for default values --- packages/vanilla/src/index.ts | 44 +++++++++++++++-------------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/packages/vanilla/src/index.ts b/packages/vanilla/src/index.ts index 16f9237..2e676aa 100644 --- a/packages/vanilla/src/index.ts +++ b/packages/vanilla/src/index.ts @@ -65,45 +65,39 @@ export default class SelectionArea extends EventTarget { super(); this._options = { - selectionAreaClass: 'selection-area', - selectionContainerClass: undefined, - selectables: [], - document: window.document, - startAreas: ['html'], - boundaries: ['html'], - container: 'body', - ...opt, + selectionAreaClass: opt.selectionAreaClass ?? 'selection-area', + selectionContainerClass: opt.selectionContainerClass ?? undefined, + selectables: opt.selectables ?? [], + document: opt.document ?? window.document, + startAreas: opt.startAreas ?? ['html'], + boundaries: opt.boundaries ?? ['html'], + container: opt.container ?? 'body', behaviour: { - overlap: 'invert', - intersect: 'touch', - triggers: [0], - ...opt.behaviour, + overlap: opt.behaviour?.overlap ?? 'invert', + intersect: opt.behaviour?.intersect ?? 'touch', + triggers: opt.behaviour?.triggers ?? [0], startThreshold: opt.behaviour?.startThreshold ? typeof opt.behaviour.startThreshold === 'number' ? opt.behaviour.startThreshold : {x: 10, y: 10, ...opt.behaviour.startThreshold} : {x: 10, y: 10}, scrolling: { - speedDivider: 10, - manualSpeed: 750, - ...opt.behaviour?.scrolling, + speedDivider: opt.behaviour?.scrolling?.speedDivider ?? 10, + manualSpeed: opt.behaviour?.scrolling?.manualSpeed ?? 750, startScrollMargins: { - x: 0, - y: 0, - ...opt.behaviour?.scrolling?.startScrollMargins, + x: opt.behaviour?.scrolling?.startScrollMargins?.x ?? 0, + y: opt.behaviour?.scrolling?.startScrollMargins?.y ?? 0 } } }, features: { - range: true, - touch: true, - deselectOnBlur: false, - ...opt.features, + range: opt.features?.range ?? true, + touch: opt.features?.touch ?? true, + deselectOnBlur: opt.features?.deselectOnBlur ?? false, singleTap: { - allow: true, - intersect: 'native', - ...opt.features?.singleTap, + allow: opt.features?.singleTap?.allow ?? true, + intersect: opt.features?.singleTap?.intersect ?? 'native', } } };