From 999d7ec2a2428f2c7b661f58540e63cfae5907ee Mon Sep 17 00:00:00 2001 From: Boris Besemer Date: Fri, 16 Sep 2022 17:08:04 +0200 Subject: [PATCH] fix(abode): move code to separate functions and clean up code First step to clean up the codebase and make it more readable: * Moving main functions, helpers and constants to separate files * More consistent function api around setters for element lists * Use proper `Map` instead of using `Object` to create maps --- src/abode.ts | 263 +++----------------------------------------- src/constants.ts | 13 +++ src/helpers.ts | 216 ++++++++++++++++++++++++++++++++++++ src/index.ts | 11 +- src/types.d.ts | 38 +++++++ test/abode.test.tsx | 71 +++++++----- 6 files changed, 329 insertions(+), 283 deletions(-) create mode 100644 src/constants.ts create mode 100644 src/helpers.ts create mode 100644 src/types.d.ts diff --git a/src/abode.ts b/src/abode.ts index 577570b..007f62b 100644 --- a/src/abode.ts +++ b/src/abode.ts @@ -1,251 +1,6 @@ -import { getCurrentScript } from 'tiny-current-script'; -import { createElement, FC } from 'react'; -import { createRoot, Root } from 'react-dom/client'; - -interface RegisteredComponents { - [key: string]: { - module: Promise; - options?: { propParsers?: PropParsers }; - }; -} - -interface Props { - [key: string]: string; -} - -interface Options { - propParsers?: PropParsers; -} -interface PropParsers { - [key: string]: ParseFN; -} - -interface HTMLElementAttributes { - [key: string]: string; -} - -interface PopulateOptions { - attributes?: HTMLElementAttributes; - callback?: () => void; -} - -export type RegisterPromise = () => Promise; -export type RegisterComponent = () => FC; -export type RegisterFN = RegisterPromise | RegisterComponent; -export type ParseFN = (rawProp: string) => any; - -export let componentSelector = 'data-component'; -export let components: RegisteredComponents = {}; -export let unPopulatedElements: Element[] = []; - -export const register = (name: string, fn: RegisterFN, options?: Options) => { - components[name] = { module: retry(fn, 10, 20), options }; -}; - -export const unRegisterAllComponents = () => { - components = {}; -}; - -export const delay = (ms: number) => new Promise(res => setTimeout(res, ms)); - -const retry = async ( - fn: () => any, - times: number, - delayTime: number -): Promise => { - try { - return await fn(); - } catch (err) { - if (times > 1) { - await delay(delayTime); - return retry(fn, times - 1, delayTime * 2); - } else { - throw new Error(err as string); - } - } -}; - -export const setComponentSelector = (selector: string) => { - componentSelector = selector; -}; - -export const getRegisteredComponents = () => { - return components; -}; - -export const getActiveComponents = () => { - return Array.from( - new Set(getAbodeElements().map(el => el.getAttribute(componentSelector))) - ); -}; - -// start prop logic -export const getCleanPropName = (raw: string): string => { - return raw.replace('data-prop-', '').replace(/-./g, x => x.toUpperCase()[1]); -}; - -export const getElementProps = ( - el: Element | HTMLScriptElement, - options?: Options -): Props => { - const props: { [key: string]: string } = {}; - - if (el?.attributes) { - const rawProps = Array.from(el.attributes).filter(attribute => - attribute.name.startsWith('data-prop-') - ); - rawProps.forEach(prop => { - const componentName = getComponentName(el) ?? ''; - const propName = getCleanPropName(prop.name); - const propParser = - options?.propParsers?.[propName] ?? - components[componentName]?.options?.propParsers?.[propName]; - if (propParser) { - // custom parse function for prop - props[propName] = propParser(prop.value); - } else { - // default json parsing - if (/^0+\d+$/.test(prop.value)) { - /* - ie11 bug fix; - in ie11 JSON.parse will parse a string with leading zeros followed - by digits, e.g. '00012' will become 12, whereas in other browsers - an exception will be thrown by JSON.parse - */ - props[propName] = prop.value; - } else { - try { - props[propName] = JSON.parse(prop.value); - } catch (e) { - props[propName] = prop.value; - } - } - } - }); - } - - return props; -}; - -export const getScriptProps = (options?: Options) => { - const element = getCurrentScript(); - if (element === null) { - throw new Error('Failed to get current script'); - } - return getElementProps(element, options); -}; -// end prop logic - -// start element logic -export const getAbodeElements = (): Element[] => { - return Array.from(document.querySelectorAll(`[${componentSelector}]`)).filter( - el => { - const component = el.getAttribute(componentSelector); - - // It should exist in registered components - return component && components[component]; - } - ); -}; - -export const setUnpopulatedElements = () => { - unPopulatedElements = getAbodeElements().filter( - el => !el.getAttribute('react-abode-populated') - ); -}; - -export const setAttributes = ( - el: Element, - attributes: HTMLElementAttributes -) => { - Object.entries(attributes).forEach(([k, v]) => el.setAttribute(k, v)); -}; - -// end element logic - -function getComponentName(el: Element) { - return Array.from(el.attributes).find(at => at.name === componentSelector) - ?.value; -} - -export const renderAbode = async (el: Element, root: Root) => { - const props = getElementProps(el); - - const componentName = getComponentName(el); - - if (!componentName || componentName === '') { - throw new Error( - `not all react-abode elements have a value for ${componentSelector}` - ); - } - - const module = await components[componentName]?.module; - if (!module) { - throw new Error(`no component registered for ${componentName}`); - } - - const element = module.default || module; - - root.render(createElement(element, props)); -}; - -export const trackPropChanges = (el: Element, root: Root) => { - if (MutationObserver) { - const observer = new MutationObserver(() => { - renderAbode(el, root); - }); - observer.observe(el, { attributes: true }); - } -}; - -function unmountOnNodeRemoval(element: any, root: Root) { - const observer = new MutationObserver(function() { - function isDetached(el: any): any { - if (el.parentNode === document) { - return false; - } else if (el.parentNode === null) { - return true; - } else { - return isDetached(el.parentNode); - } - } - - if (isDetached(element)) { - observer.disconnect(); - root.unmount(); - } - }); - - observer.observe(document, { - childList: true, - subtree: true, - }); -} - -export const update = async ( - elements: Element[], - options?: PopulateOptions -) => { - // tag first, since adding components is a slow process and will cause components to get iterated multiple times - elements.forEach(el => el.setAttribute('react-abode-populated', 'true')); - elements.forEach(el => { - const root = createRoot(el); - if (options?.attributes) setAttributes(el, options.attributes); - renderAbode(el, root); - trackPropChanges(el, root); - unmountOnNodeRemoval(el, root); - }); -}; - -const checkForAndHandleNewComponents = async (options?: PopulateOptions) => { - setUnpopulatedElements(); - - if (unPopulatedElements.length) { - await update(unPopulatedElements, options); - unPopulatedElements = []; - if (options?.callback) options.callback(); - } -}; +import type { Options, PopulateOptions, RegisterFN } from 'types'; +import { components } from './constants'; +import { checkForAndHandleNewComponents, retry } from './helpers'; export const populate = async (options?: PopulateOptions) => { await checkForAndHandleNewComponents(options); @@ -254,3 +9,15 @@ export const populate = async (options?: PopulateOptions) => { checkForAndHandleNewComponents(options) ); }; + +/** + * Register adds a component to the list of available components + * + * The registered components will be used to populate a given DOM. + * @param name Name of the component + * @param fn The React component function + * @param options Options object + */ +export const register = (name: string, fn: RegisterFN, options?: Options) => { + components.set(name, { module: retry(fn, 10, 20), options }); +}; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..f9a5d68 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,13 @@ +import type { RegisteredComponents } from 'types'; + +export let componentSelector = 'data-component'; +export let components: RegisteredComponents = new Map(); +export let unpopulatedElements: Element[] = []; + +export const setComponentSelector = (selector: string) => { + componentSelector = selector; +}; + +export const setUnpopulatedElements = (value: Element[]) => { + unpopulatedElements = value; +}; diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 0000000..76206d5 --- /dev/null +++ b/src/helpers.ts @@ -0,0 +1,216 @@ +import { createElement } from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { getCurrentScript } from 'tiny-current-script'; +import { + components, + componentSelector, + setUnpopulatedElements, + unpopulatedElements, +} from './constants'; +import { + HTMLElementAttributes, + Options, + PopulateOptions, + Props, +} from './types'; + +export const unRegisterAllComponents = () => { + components.clear(); +}; + +export const delay = (ms: number) => new Promise((res) => setTimeout(res, ms)); + +export const retry = async ( + fn: () => any, + times: number, + delayTime: number +): Promise => { + try { + return await fn(); + } catch (err) { + if (times > 1) { + await delay(delayTime); + return retry(fn, times - 1, delayTime * 2); + } else { + throw new Error(err as string); + } + } +}; + +export const getRegisteredComponents = () => { + return components; +}; + +export const getActiveComponents = () => { + return Array.from( + new Set(getAbodeElements().map((el) => el.getAttribute(componentSelector))) + ); +}; + +// start prop logic +export const getCleanPropName = (raw: string): string => { + return raw + .replace('data-prop-', '') + .replace(/-./g, (x) => x.toUpperCase()[1]); +}; + +export const getElementProps = ( + el: Element | HTMLScriptElement, + options?: Options +): Props => { + const props: { [key: string]: string } = {}; + + if (el?.attributes) { + const rawProps = Array.from(el.attributes).filter((attribute) => + attribute.name.startsWith('data-prop-') + ); + rawProps.forEach((prop) => { + const componentName = getComponentName(el) ?? ''; + const propName = getCleanPropName(prop.name); + const propParser = + options?.propParsers?.[propName] ?? + components.get(componentName)?.options?.propParsers?.[propName]; + if (propParser) { + // custom parse function for prop + props[propName] = propParser(prop.value); + } else { + // default json parsing + if (/^0+\d+$/.test(prop.value)) { + /* + ie11 bug fix; + in ie11 JSON.parse will parse a string with leading zeros followed + by digits, e.g. '00012' will become 12, whereas in other browsers + an exception will be thrown by JSON.parse + */ + props[propName] = prop.value; + } else { + try { + props[propName] = JSON.parse(prop.value); + } catch (e) { + props[propName] = prop.value; + } + } + } + }); + } + + return props; +}; + +export const getScriptProps = (options?: Options) => { + const element = getCurrentScript(); + if (element === null) { + throw new Error('Failed to get current script'); + } + return getElementProps(element, options); +}; +// end prop logic + +// start element logic +export const getAbodeElements = (): Element[] => { + return Array.from(document.querySelectorAll(`[${componentSelector}]`)).filter( + (el) => { + const component = el.getAttribute(componentSelector); + + // It should exist in registered components + return component && components.get(component); + } + ); +}; + +export const setAttributes = ( + el: Element, + attributes: HTMLElementAttributes +) => { + Object.entries(attributes).forEach(([k, v]) => el.setAttribute(k, v)); +}; + +// end element logic + +function getComponentName(el: Element) { + return Array.from(el.attributes).find((at) => at.name === componentSelector) + ?.value; +} + +export const renderAbode = async (el: Element, root: Root) => { + const props = getElementProps(el); + + const componentName = getComponentName(el); + + if (!componentName || componentName === '') { + throw new Error( + `not all react-abode elements have a value for ${componentSelector}` + ); + } + + const module = await components.get(componentName)?.module; + if (!module) { + throw new Error(`no component registered for ${componentName}`); + } + + const element = module.default || module; + + root.render(createElement(element, props)); +}; + +export const trackPropChanges = (el: Element, root: Root) => { + if (MutationObserver) { + const observer = new MutationObserver(() => { + renderAbode(el, root); + }); + observer.observe(el, { attributes: true }); + } +}; + +function unmountOnNodeRemoval(element: any, root: Root) { + const observer = new MutationObserver(function () { + function isDetached(el: any): any { + if (el.parentNode === document) { + return false; + } else if (el.parentNode === null) { + return true; + } else { + return isDetached(el.parentNode); + } + } + + if (isDetached(element)) { + observer.disconnect(); + root.unmount(); + } + }); + + observer.observe(document, { + childList: true, + subtree: true, + }); +} + +export const update = async ( + elements: Element[], + options?: PopulateOptions +) => { + // tag first, since adding components is a slow process and will cause components to get iterated multiple times + elements.forEach((el) => el.setAttribute('react-abode-populated', 'true')); + elements.forEach((el) => { + const root = createRoot(el); + if (options?.attributes) setAttributes(el, options.attributes); + renderAbode(el, root); + trackPropChanges(el, root); + unmountOnNodeRemoval(el, root); + }); +}; + +export const checkForAndHandleNewComponents = async ( + options?: PopulateOptions +) => { + setUnpopulatedElements( + getAbodeElements().filter((el) => !el.getAttribute('react-abode-populated')) + ); + + if (unpopulatedElements.length) { + await update(unpopulatedElements, options); + setUnpopulatedElements([]); + if (options?.callback) options.callback(); + } +}; diff --git a/src/index.ts b/src/index.ts index 827064e..45060fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,7 @@ +export { populate, register } from './abode'; +export { setComponentSelector } from './constants'; export { - populate, - setComponentSelector, - register, - getScriptProps, - getRegisteredComponents, getActiveComponents, -} from './abode'; + getRegisteredComponents, + getScriptProps, +} from './helpers'; diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..b60dc99 --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,38 @@ +import { FC } from 'react'; + +// interface RegisteredComponents { +// [key: string]: { +// module: Promise; +// options?: { propParsers?: PropParsers }; +// }; +// } + +type RegisteredComponents = Map< + string, + { module: Promise; options?: { propParsers?: PropParsers } } +>; + +interface Props { + [key: string]: string; +} + +interface Options { + propParsers?: PropParsers; +} +interface PropParsers { + [key: string]: ParseFN; +} + +interface HTMLElementAttributes { + [key: string]: string; +} + +interface PopulateOptions { + attributes?: HTMLElementAttributes; + callback?: () => void; +} + +export type RegisterPromise = () => Promise; +export type RegisterComponent = () => FC; +export type RegisterFN = RegisterPromise | RegisterComponent; +export type ParseFN = (rawProp: string) => any; diff --git a/test/abode.test.tsx b/test/abode.test.tsx index 0cbe1d5..84b11a6 100644 --- a/test/abode.test.tsx +++ b/test/abode.test.tsx @@ -3,26 +3,28 @@ */ import * as fc from 'fast-check'; +import { populate, register } from '../src/abode'; import { - getCleanPropName, + delay, getAbodeElements, - getRegisteredComponents, - unPopulatedElements, - setUnpopulatedElements, + getCleanPropName, getElementProps, - setAttributes, + getRegisteredComponents, renderAbode, - register, + setAttributes, unRegisterAllComponents, - components, - populate, - delay, -} from '../src/abode'; +} from '../src/helpers'; + // @ts-ignore -import TestComponent from './TestComponent'; -import TestComponentProps, { util } from './TestComponentProps'; import 'mutationobserver-shim'; import { createRoot } from 'react-dom/client'; +import { + components, + setUnpopulatedElements, + unpopulatedElements, +} from '../src/constants'; +import TestComponent from './TestComponent'; +import TestComponentProps, { util } from './TestComponentProps'; global.MutationObserver = window.MutationObserver; describe('helper functions', () => { @@ -54,14 +56,22 @@ describe('helper functions', () => { abodeElement.setAttribute('data-component', 'TestComponent'); document.body.appendChild(abodeElement); - setUnpopulatedElements(); + setUnpopulatedElements( + getAbodeElements().filter( + (el) => !el.getAttribute('react-abode-populated') + ) + ); - expect(unPopulatedElements).toHaveLength(0); + expect(unpopulatedElements).toHaveLength(0); register('TestComponent', () => TestComponent); - setUnpopulatedElements(); + setUnpopulatedElements( + getAbodeElements().filter( + (el) => !el.getAttribute('react-abode-populated') + ) + ); - expect(unPopulatedElements).toHaveLength(1); + expect(unpopulatedElements).toHaveLength(1); }); it('getElementProps', () => { @@ -103,7 +113,7 @@ describe('helper functions', () => { it('getElementProps parses JSON', () => { fc.assert( - fc.property(fc.json({ maxDepth: 10 }), data => { + fc.property(fc.json({ maxDepth: 10 }), (data) => { const abodeElement = document.createElement('div'); abodeElement.setAttribute('data-prop-test-prop', JSON.stringify(data)); const props = getElementProps(abodeElement); @@ -115,12 +125,12 @@ describe('helper functions', () => { it('getElementProps does not parse strings with leading zeros followed by other digits', () => { const strWithLeadingZeros = fc .tuple(fc.integer({ min: 1, max: 10 }), fc.integer()) - .map(t => { + .map((t) => { const [numberOfZeros, integer] = t; return '0'.repeat(numberOfZeros) + integer.toString(); }); fc.assert( - fc.property(strWithLeadingZeros, data => { + fc.property(strWithLeadingZeros, (data) => { const abodeElement = document.createElement('div'); abodeElement.setAttribute('data-prop-test-prop', data); const props = getElementProps(abodeElement); @@ -178,21 +188,23 @@ describe('exported functions', () => { it('register', async () => { register('TestComponent', () => import('./TestComponent')); - expect(Object.keys(components)).toEqual(['TestComponent']); - expect(Object.values(components).length).toEqual(1); - let promise = Object.values(components)[0].module; + console.log(components); + expect(Array.from(components.keys())).toEqual(['TestComponent']); + expect(components.size).toEqual(1); + const iterator = components.values(); + let promise = iterator.next().value.module; expect(typeof promise.then).toEqual('function'); let module = await promise; expect(typeof module).toEqual('object'); expect(Object.keys(module)).toEqual(['default']); register('TestComponent2', () => TestComponent); - expect(Object.keys(components)).toEqual([ + expect(Array.from(components.keys())).toEqual([ 'TestComponent', 'TestComponent2', ]); - expect(Object.values(components).length).toEqual(2); - promise = Object.values(components)[1].module; + expect(components.size).toEqual(2); + promise = iterator.next().value.module; expect(typeof promise.then).toEqual('function'); module = await promise; expect(typeof module).toEqual('function'); @@ -227,7 +239,7 @@ describe('exported functions', () => { const registeredComponents = getRegisteredComponents(); - expect(Object.keys(registeredComponents).length).toEqual(2); + expect(registeredComponents.size).toEqual(2); }); it('uses custom prop parsers', async () => { @@ -267,8 +279,9 @@ describe('exported functions', () => { const abodeElement = document.createElement('div'); abodeElement.setAttribute('data-component', 'TestComponentWithUnmount'); document.body.appendChild(abodeElement); - register('TestComponentWithUnmount', () => - import('./TestComponentWithUnmount') + register( + 'TestComponentWithUnmount', + () => import('./TestComponentWithUnmount') ); await populate(); await delay(20); @@ -287,7 +300,7 @@ describe('exported functions', () => { abodeElement.setAttribute('data-component', 'TestComponentProps'); document.body.appendChild(abodeElement); fc.assert( - fc.property(fc.json(), data => { + fc.property(fc.json(), (data) => { abodeElement.setAttribute('data-prop-anything', JSON.stringify(data)); register('TestComponentProps', () => TestComponentProps, { propParsers: {