Skip to content

Commit

Permalink
Removed MutationObserver
Browse files Browse the repository at this point in the history
  • Loading branch information
smikhalevski committed Nov 11, 2023
1 parent 263b785 commit c2dec12
Show file tree
Hide file tree
Showing 4 changed files with 110 additions and 104 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -133,37 +133,36 @@ export function constraintValidationPlugin(): PluginInjector<ConstraintValidatio

const { ref } = field;

field.ref = element => {
if (field.element === element) {
ref?.(element);
field.ref = nextElement => {
const prevElement = field.element;

ref?.(nextElement);

if (prevElement === nextElement) {
return;
}

if (field.element !== null) {
field.element.removeEventListener('input', changeListener);
field.element.removeEventListener('change', changeListener);
field.element.removeEventListener('invalid', changeListener);
field.element = nextElement instanceof Element ? nextElement : null;

if (prevElement !== null) {
prevElement.removeEventListener('input', changeListener);
prevElement.removeEventListener('change', changeListener);
prevElement.removeEventListener('invalid', changeListener);
}

const events: Event[] = [];

if (isValidatable(element)) {
element.addEventListener('input', changeListener);
element.addEventListener('change', changeListener);
element.addEventListener('invalid', changeListener);

field.element = element;
field.validity = element.validity;

setError(field, element.validationMessage, 1, events);
if (isValidatable(nextElement)) {
nextElement.addEventListener('input', changeListener);
nextElement.addEventListener('change', changeListener);
nextElement.addEventListener('invalid', changeListener);
field.validity = nextElement.validity;
setError(field, nextElement.validationMessage, 1, events);
} else {
field.element = field.validity = null;

field.validity = null;
deleteError(field, 1, events);
}

ref?.(element);

dispatchEvents(events);
};

Expand Down
2 changes: 1 addition & 1 deletion packages/ref-plugin/src/main/refPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ export function refPlugin(): PluginInjector<RefPlugin> {
const { ref } = field;

field.ref = element => {
field.element = element instanceof Element ? element : null;
ref?.(element);
field.element = element instanceof Element ? element : null;
};

field.scrollIntoView = options => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ export function scrollToErrorPlugin(): PluginInjector<ScrollToErrorPlugin> {
const { ref } = field;

field.ref = element => {
field.element = element instanceof Element ? element : null;
ref?.(element);
field.element = element instanceof Element ? element : null;
};

field.scrollToError = (index = 0, options) => {
Expand Down
171 changes: 89 additions & 82 deletions packages/uncontrolled-plugin/src/main/uncontrolledPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,144 +1,151 @@
import { createEvent, dispatchEvents, Event, PluginInjector, Subscriber, Unsubscribe } from 'roqueform';
import { Field, PluginInjector } from 'roqueform';
import isDeepEqual from 'fast-deep-equal';
import { createElementValueAccessor, ElementValueAccessor } from './createElementValueAccessor';

const EVENT_CHANGE_OBSERVED_ELEMENTS = 'change:observedElements';

/**
* The default value accessor.
*/
const elementValueAccessor = createElementValueAccessor();

export type RefCallback = (element: Element | null) => void;

/**
* The plugin added to fields by the {@link uncontrolledPlugin}.
*/
export interface UncontrolledPlugin {
element: Element | null;

/**
* The array of elements that are used to derive the field value. Update this array by calling {@link observe} method.
* Elements are observed by the {@link !MutationObserver MutationObserver} and deleted from this array when they are
* removed from DOM.
* The array of elements that are used to derive the field value, and which are updated the field value is changed.
*
* @protected
*/
['observedElements']: Element[];
['elements']: Element[];

/**
* The accessor that reads and writes field value from and to {@link observedElements observed elements}.
* The map from {@link refFor a ref key} to a corresponding element.
*
* @protected
*/
['elementValueAccessor']: ElementValueAccessor;
['elementsMap']: Map<unknown, Element>;

/**
* Adds the DOM element to {@link observedElements observed elements}.
* The accessor that reads and writes the field value from and to {@link elements}.
*
* @param element The element to observe. No-op if the element is `null` or not connected to the DOM.
* @protected
*/
observe(element: Element | null): void;
['elementValueAccessor']: ElementValueAccessor;

/**
* Subscribes to updates of {@link observedElements observed elements}.
*
* @param eventType The type of the event.
* @param subscriber The subscriber that would be triggered.
* @returns The callback to unsubscribe the subscriber.
* Associates the field with {@link element the DOM element}.
*/
on(eventType: 'change:observedElements', subscriber: Subscriber<this, Element>): Unsubscribe;
ref(element: Element | null): void;

/**
* Associates the field with {@link element the DOM element}. This method is usually exposed by plugins that use DOM
* element references. This method is invoked when {@link observedElements the first observed element} is changed.
*
* @protected
* Returns a callback that associates the field with {@link element the DOM element} under
* {@link elementsMap the given key}.
*/
['ref']?(element: Element | null): void;
refFor(key: unknown): RefCallback;
}

/**
* Updates field value when the DOM element value is changed and vice versa.
*
* @param accessor The accessor that reads and writes values to and from the DOM elements that
* {@link UncontrolledPlugin.observedElements are observed by the filed}.
*/
export function uncontrolledPlugin(accessor = elementValueAccessor): PluginInjector<UncontrolledPlugin> {
return field => {
field.observedElements = [];
field.elements = [];
field.elementsMap = new Map();
field.elementValueAccessor = accessor;

const mutationObserver = new MutationObserver(mutations => {
const events: Event[] = [];
const { observedElements } = field;
const refs = new Map<unknown, RefCallback>();

for (const mutation of mutations) {
for (let i = 0; i < mutation.removedNodes.length; ++i) {
const elementIndex = observedElements.indexOf(mutation.removedNodes.item(i) as Element);

if (elementIndex === -1) {
continue;
}

const element = observedElements[elementIndex];

element.removeEventListener('input', changeListener);
element.removeEventListener('change', changeListener);

observedElements.splice(elementIndex, 1);
events.push(createEvent(EVENT_CHANGE_OBSERVED_ELEMENTS, field, element));
}
}

if (observedElements.length === 0) {
mutationObserver.disconnect();
field.ref?.(null);
} else {
field.ref?.(observedElements[0]);
}

dispatchEvents(events);
});
let lastValue: unknown;

const changeListener: EventListener = event => {
let value;
if (
field.observedElements.indexOf(event.currentTarget as Element) !== -1 &&
!isDeepEqual((value = field.elementValueAccessor.get(field.observedElements)), field.value)
field.elements.includes(event.target as Element) &&
!isDeepEqual((value = field.elementValueAccessor.get(field.elements)), field.value)
) {
field.setValue(value);
field.setValue((lastValue = value));
}
};

field.on('change:value', () => {
if (field.observedElements.length !== 0) {
field.elementValueAccessor.set(field.observedElements, field.value);
field.on('change:value', event => {
if (field.value !== lastValue && event.target === field && field.elements.length !== 0) {
console.trace('SET_TO_ELEMENT');
field.elementValueAccessor.set(field.elements, field.value);
}
});

field.observe = element => {
const { observedElements } = field;

if (
!(element instanceof Element) ||
!element.isConnected ||
element.parentNode === null ||
observedElements.includes(element)
) {
return;
}
const { ref } = field;

mutationObserver.observe(element.parentNode, { childList: true });
field.ref = nextElement => {
const prevElement = field.element;

element.addEventListener('input', changeListener);
element.addEventListener('change', changeListener);
ref?.(nextElement);

const elementCount = observedElements.push(element);
if (prevElement === nextElement) {
return;
}

field.elementValueAccessor.set(observedElements, field.value);
if (prevElement !== null) {
field.elements.splice(field.elements.indexOf(prevElement), 1);
prevElement.removeEventListener('input', changeListener);
prevElement.removeEventListener('change', changeListener);
}

if (elementCount === 1) {
field.ref?.(element);
if (nextElement instanceof Element) {
field.element = nextElement;
field.elements.push(nextElement);
nextElement.addEventListener('input', changeListener);
nextElement.addEventListener('change', changeListener);
} else {
field.element = null;
}

dispatchEvents([createEvent(EVENT_CHANGE_OBSERVED_ELEMENTS, field, element)]);
field.elementValueAccessor.set(field.elements, field.value);
};

field.refFor = key => getOrCreateRefCallback(field, key, refs, changeListener);
};
}

function getOrCreateRefCallback(
field: Field<UncontrolledPlugin>,
key: unknown,
refs: Map<unknown, RefCallback>,
changeListener: EventListener
): RefCallback {
let ref = refs.get(key);
if (ref !== undefined) {
return ref;
}

ref = nextElement => {
const prevElement = field.elementsMap.get(key);

if (prevElement === nextElement) {
return;
}
if (prevElement !== undefined) {
field.elementsMap.delete(key);
field.elements.splice(field.elements.indexOf(prevElement), 1);

prevElement.removeEventListener('input', changeListener);
prevElement.removeEventListener('change', changeListener);
}
if (nextElement instanceof Element) {
field.elementsMap.set(key, nextElement);
field.elements.push(nextElement);

nextElement.addEventListener('input', changeListener);
nextElement.addEventListener('change', changeListener);
}

field.elementValueAccessor.set(field.elements, field.value);
};

refs.set(key, ref);
return ref;
}

0 comments on commit c2dec12

Please sign in to comment.