From f2932ce10ec80a1080befaee9e5c235bc41a1b16 Mon Sep 17 00:00:00 2001 From: Nikita Barsukov Date: Fri, 20 Sep 2024 15:16:42 +0300 Subject: [PATCH] fix(react): race condition when `options` are changed before long element predicate is resolved (#1651) --- .../angular-wrapper.tsx | 0 .../react-app.tsx | 30 +++++++++++++++++++ .../react-async-predicate-options-race.cy.ts | 24 +++++++++++++++ .../async-predicates-race/angular-wrapper.tsx | 14 +++++++++ .../async-predicates-race}/react-app.tsx | 21 +++---------- .../react-async-predicates-race.cy.ts} | 0 .../component-testing/react/awesome-input.tsx | 16 ++++++++++ projects/react/src/lib/useMaskito.ts | 13 +++++--- 8 files changed, 97 insertions(+), 21 deletions(-) rename projects/demo-integrations/src/tests/component-testing/{react-async-predicate => react/async-predicate-options-race}/angular-wrapper.tsx (100%) create mode 100644 projects/demo-integrations/src/tests/component-testing/react/async-predicate-options-race/react-app.tsx create mode 100644 projects/demo-integrations/src/tests/component-testing/react/async-predicate-options-race/react-async-predicate-options-race.cy.ts create mode 100644 projects/demo-integrations/src/tests/component-testing/react/async-predicates-race/angular-wrapper.tsx rename projects/demo-integrations/src/tests/component-testing/{react-async-predicate => react/async-predicates-race}/react-app.tsx (81%) rename projects/demo-integrations/src/tests/component-testing/{react-async-predicate/react-async-predicate.cy.ts => react/async-predicates-race/react-async-predicates-race.cy.ts} (100%) create mode 100644 projects/demo-integrations/src/tests/component-testing/react/awesome-input.tsx diff --git a/projects/demo-integrations/src/tests/component-testing/react-async-predicate/angular-wrapper.tsx b/projects/demo-integrations/src/tests/component-testing/react/async-predicate-options-race/angular-wrapper.tsx similarity index 100% rename from projects/demo-integrations/src/tests/component-testing/react-async-predicate/angular-wrapper.tsx rename to projects/demo-integrations/src/tests/component-testing/react/async-predicate-options-race/angular-wrapper.tsx diff --git a/projects/demo-integrations/src/tests/component-testing/react/async-predicate-options-race/react-app.tsx b/projects/demo-integrations/src/tests/component-testing/react/async-predicate-options-race/react-app.tsx new file mode 100644 index 000000000..878b76657 --- /dev/null +++ b/projects/demo-integrations/src/tests/component-testing/react/async-predicate-options-race/react-app.tsx @@ -0,0 +1,30 @@ +import type {MaskitoElementPredicate, MaskitoOptions} from '@maskito/core'; +import {useMaskito} from '@maskito/react'; +import type {ComponentType} from 'react'; +import {useEffect, useState} from 'react'; + +import {AwesomeInput} from '../awesome-input'; + +export const SWITCH_OPTIONS_TIME = 1_000; +export const PREDICATE_RESOLVING_TIME = 2_000; + +const numberOptions: MaskitoOptions = {mask: /^\d+$/}; +const engLettersOptions: MaskitoOptions = {mask: /^[a-z]+$/i}; + +const elementPredicate: MaskitoElementPredicate = async (element) => + new Promise((resolve) => { + setTimeout(() => resolve(element.querySelector('.real-input') as HTMLInputElement), PREDICATE_RESOLVING_TIME); + }); + +export const App: ComponentType = () => { + const [options, setOptions] = useState(numberOptions); + const maskRef = useMaskito({options, elementPredicate}); + + useEffect(() => { + setTimeout(() => { + setOptions(engLettersOptions); + }, SWITCH_OPTIONS_TIME); + }, []); + + return ; +}; diff --git a/projects/demo-integrations/src/tests/component-testing/react/async-predicate-options-race/react-async-predicate-options-race.cy.ts b/projects/demo-integrations/src/tests/component-testing/react/async-predicate-options-race/react-async-predicate-options-race.cy.ts new file mode 100644 index 000000000..10f415010 --- /dev/null +++ b/projects/demo-integrations/src/tests/component-testing/react/async-predicate-options-race/react-async-predicate-options-race.cy.ts @@ -0,0 +1,24 @@ +import {TestWrapper} from './angular-wrapper'; +import {PREDICATE_RESOLVING_TIME, SWITCH_OPTIONS_TIME} from './react-app'; + +describe('React async predicate + maskitoOptions race', () => { + beforeEach(() => { + cy.clock(); + cy.mount(TestWrapper); + cy.get('.real-input').should('be.visible').as('textfield'); + }); + + it('can enter any value before no predicate is resolved', () => { + cy.get('@textfield').focus().type('12abc3').should('have.value', '12abc3'); + }); + + it('enabling of the first mask should be skipped if `options` were changed during resolving of element predicate', () => { + cy.smartTick(PREDICATE_RESOLVING_TIME); // predicate is resolved only once for digit cases + cy.get('@textfield').focus().type('12abc3').should('have.value', '12abc3'); + }); + + it('only the last mask should be applied if [maskitoOptions] were changed during resolving of element predicates', () => { + cy.smartTick(SWITCH_OPTIONS_TIME + PREDICATE_RESOLVING_TIME); // enough time to resolve element predicated for both cases + cy.get('@textfield').focus().type('12abc3').should('have.value', 'abc'); + }); +}); diff --git a/projects/demo-integrations/src/tests/component-testing/react/async-predicates-race/angular-wrapper.tsx b/projects/demo-integrations/src/tests/component-testing/react/async-predicates-race/angular-wrapper.tsx new file mode 100644 index 000000000..60f575b00 --- /dev/null +++ b/projects/demo-integrations/src/tests/component-testing/react/async-predicates-race/angular-wrapper.tsx @@ -0,0 +1,14 @@ +import {isPlatformBrowser} from '@angular/common'; +import {ChangeDetectionStrategy, Component, ElementRef, inject, PLATFORM_ID} from '@angular/core'; +import {createRoot} from 'react-dom/client'; + +import {App} from './react-app'; + +@Component({standalone: true, selector: 'test-wrapper', template: '', changeDetection: ChangeDetectionStrategy.OnPush}) +export class TestWrapper { + constructor() { + if (isPlatformBrowser(inject(PLATFORM_ID))) { + createRoot(inject(ElementRef).nativeElement).render(); + } + } +} diff --git a/projects/demo-integrations/src/tests/component-testing/react-async-predicate/react-app.tsx b/projects/demo-integrations/src/tests/component-testing/react/async-predicates-race/react-app.tsx similarity index 81% rename from projects/demo-integrations/src/tests/component-testing/react-async-predicate/react-app.tsx rename to projects/demo-integrations/src/tests/component-testing/react/async-predicates-race/react-app.tsx index 6b8face48..f4ed4c936 100644 --- a/projects/demo-integrations/src/tests/component-testing/react-async-predicate/react-app.tsx +++ b/projects/demo-integrations/src/tests/component-testing/react/async-predicates-race/react-app.tsx @@ -2,8 +2,10 @@ import type {MaskitoElementPredicate, MaskitoOptions} from '@maskito/core'; import {maskitoInitialCalibrationPlugin} from '@maskito/core'; import {maskitoTimeOptionsGenerator} from '@maskito/kit'; import {useMaskito} from '@maskito/react'; -import type {ComponentType, InputHTMLAttributes} from 'react'; -import {forwardRef, useEffect, useState} from 'react'; +import type {ComponentType} from 'react'; +import {useEffect, useState} from 'react'; + +import {AwesomeInput} from '../awesome-input'; const timeOptions = maskitoTimeOptionsGenerator({ mode: 'HH:MM', @@ -34,21 +36,6 @@ const fastValidPredicate: MaskitoElementPredicate = async (host) => setTimeout(() => resolve(correctPredicate(host)), 500); }); -const hiddenInputStyles = { - display: 'none', -}; - -export const AwesomeInput = forwardRef>((props, ref) => ( -
- - - -
-)); - export const App: ComponentType = () => { const [useCorrectPredicate, setUseCorrectPredicate] = useState(false); const inputRef2sec = useMaskito({options, elementPredicate: longCorrectPredicate}); diff --git a/projects/demo-integrations/src/tests/component-testing/react-async-predicate/react-async-predicate.cy.ts b/projects/demo-integrations/src/tests/component-testing/react/async-predicates-race/react-async-predicates-race.cy.ts similarity index 100% rename from projects/demo-integrations/src/tests/component-testing/react-async-predicate/react-async-predicate.cy.ts rename to projects/demo-integrations/src/tests/component-testing/react/async-predicates-race/react-async-predicates-race.cy.ts diff --git a/projects/demo-integrations/src/tests/component-testing/react/awesome-input.tsx b/projects/demo-integrations/src/tests/component-testing/react/awesome-input.tsx new file mode 100644 index 000000000..0e617a543 --- /dev/null +++ b/projects/demo-integrations/src/tests/component-testing/react/awesome-input.tsx @@ -0,0 +1,16 @@ +import {forwardRef, type InputHTMLAttributes} from 'react'; + +const hiddenInputStyles = { + display: 'none', +}; + +export const AwesomeInput = forwardRef>((props, ref) => ( +
+ + + +
+)); diff --git a/projects/react/src/lib/useMaskito.ts b/projects/react/src/lib/useMaskito.ts index cfbda2912..d296072e3 100644 --- a/projects/react/src/lib/useMaskito.ts +++ b/projects/react/src/lib/useMaskito.ts @@ -45,27 +45,31 @@ export const useMaskito = ({ ); const latestPredicateRef = useRef(elementPredicate); + const latestOptionsRef = useRef(options); latestPredicateRef.current = elementPredicate; + latestOptionsRef.current = options; useIsomorphicLayoutEffect(() => { if (!hostElement) { return; } - const predicate = elementPredicate; - const elementOrPromise = predicate(hostElement); + const elementOrPromise = elementPredicate(hostElement); if (isThenable(elementOrPromise)) { void elementOrPromise.then((el) => { - if (latestPredicateRef.current === predicate) { + if ( + latestPredicateRef.current === elementPredicate && + latestOptionsRef.current === options + ) { setElement(el); } }); } else { setElement(elementOrPromise); } - }, [hostElement, elementPredicate, latestPredicateRef]); + }, [hostElement, elementPredicate, latestPredicateRef, options, latestOptionsRef]); useIsomorphicLayoutEffect(() => { if (!element || !options) { @@ -76,6 +80,7 @@ export const useMaskito = ({ return () => { maskedElement.destroy(); + setElement(null); }; }, [options, element]);