├── .changeset ├── README.md ├── config.json └── perfect-taxis-report.md ├── .eslintrc.js ├── .github └── workflows │ ├── main.yml │ └── publish.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── .prettierignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── jest.config.ts ├── package.json ├── pnpm-lock.yaml ├── src ├── abode.ts └── index.ts ├── test ├── TestComponent.tsx ├── TestComponentProps.tsx ├── TestComponentWithUnmount.tsx └── abode.test.tsx └── tsconfig.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.1.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "master", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } -------------------------------------------------------------------------------- /.changeset/perfect-taxis-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'react-abode': patch 3 | --- 4 | 5 | Fix release process 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint'], 5 | env: { 6 | node: true, 7 | browser: true, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - name: Begin CI... 9 | uses: actions/checkout@v3 10 | 11 | - uses: pnpm/action-setup@v4 12 | name: Install pnpm 13 | id: pnpm-install 14 | 15 | - name: Use Node 20 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: '20' 19 | cache: 'pnpm' 20 | 21 | - name: Install dependencies 22 | run: pnpm install 23 | env: 24 | CI: true 25 | 26 | - name: Lint 27 | run: pnpm lint 28 | env: 29 | CI: true 30 | 31 | - name: Test 32 | run: pnpm test 33 | continue-on-error: true 34 | env: 35 | CI: true 36 | 37 | - name: Build 38 | run: pnpm build 39 | env: 40 | CI: true 41 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | release: 4 | types: [created, edited] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: pnpm/action-setup@v4 11 | name: Install pnpm 12 | id: pnpm-install 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: '20' 16 | registry-url: 'https://registry.npmjs.org' 17 | - name: Install dependencies 18 | run: pnpm install 19 | env: 20 | CI: true 21 | - name: Build 22 | run: pnpm build 23 | env: 24 | CI: true 25 | - run: npm publish 26 | env: 27 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | pnpm lint 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .vscode 4 | coverage 5 | pnpm-lock.yaml 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.0.0 (2022-08-04) 2 | 3 | - chore: update to react 18 4 | 5 | ## 1.1.0 (2022-03-25) 6 | 7 | - feature: unmount React components when DOM nodes are removed 8 | 9 | ## 1.0.0 (2021-06-18) 10 | 11 | - feature: Add option for passing custom prop parsers 12 | 13 | - fixed: getScriptProps returns props on IE11 14 | 15 | - improved: extend example based unit tests 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Bram Kaashoek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Abode 2 | 3 | React Abode is a simple React micro-frontend framework allowing you to host multiple react components by defining HTML. 4 | 5 | ## Features 6 | 7 | ### Prop passing 8 | 9 | React Abode allows you to pass props to your React components by using a `data-prop-prop-name` attribute. All props need to be prefixed by `data-prop-`. Props will automatically be converted from kebab-case to camelCase. 10 | 11 | ```html 12 |
13 | ``` 14 | 15 | ### Script props 16 | 17 | React Abode allows you to pass `data-prop-` props to the script. These can then be consumed inside your bundle by using `getScriptProps()`. This is useful when you need to have a prop available in every component. 18 | 19 | ```html 20 | 24 | ``` 25 | 26 | ```javascript 27 | const scriptProps = getScriptProps(); 28 | console.log(scriptProps.globalProp); 29 | ``` 30 | 31 | ### Prop parsing 32 | 33 | By default all supplied props will be parsed with `JSON.parse`. In case a prop should be parsed differently, custom parse functions can be provided to `register` or `getScriptProps`. 34 | 35 | ```js 36 | //
37 | register('Product', () => import('./modules/Product/Product'), { 38 | propParsers: { 39 | sku: prop => String(prop), 40 | isAvailable: prop => Boolean(prop), 41 | price: prop => Float(prop), 42 | }, 43 | }); 44 | // 45 | getScriptProps({ propParsers: { global: prop => String(prop) } }); 46 | ``` 47 | 48 | - default JSON.parse 49 | - custom prop parsing function 50 | 51 | ### Automatic DOM node detection 52 | 53 | When DOM nodes are added, for example when loading more products in a catalog on a SPA, React Abode will automatically detect them and populate them with your React components. 54 | When a DOM node containing a hosted React component is removed, the component is unmounted. 55 | 56 | ### Update on prop change 57 | 58 | When the props for your components change, React Abode will rerender these components. This can be very useful when nesting multiple layers of front-end frameworks. 59 | 60 | ## How to use 61 | 62 | Create a bundle with one or more abode registered components. This takes the place of the `App` component in a create-react-app, which links the top level react component to the html page. When all components are registered, call `populate`. Build and host this bundle on your platform of choice. 63 | 64 | ```javascript 65 | // src/modules/Cart/Cart.tsx 66 | const Cart = (): JSX.Element => { 67 | return

a shopping cart

; 68 | }; 69 | 70 | // src/App.tsx 71 | import { populate, register } from 'react-abode'; 72 | 73 | // Import can be used to register component 74 | register('Cart', () => import('./modules/Cart/Cart')); 75 | 76 | // Component can also be used directly 77 | import Cart from './modules/Cart/Cart'; 78 | 79 | register('Cart', () => React.memo(Cart)); 80 | 81 | populate({ attributes: { classname: 'some-class-name' } }); 82 | ``` 83 | 84 | Include a div with the selector in your HTML. Load the bundle in a script tag **inside the ` `**. On loading the page, React Abode will check for components with the matching selector, which is `data-component` by default. 85 | 86 | ```html 87 | 88 | 89 |
90 | This text wil be replaced by your react component 91 |
92 | 93 | 94 | 95 | ``` 96 | 97 | ## Options 98 | 99 | ### Utility functions 100 | 101 | #### setComponentSelector 102 | 103 | If you do not want to use `data-component` you can change the component selector by using `setComponentSelector('data-my-component-selector')`. 104 | 105 | #### getActiveComponents 106 | 107 | You can use `getActiveComponents` to get a list of all Abode elements currently in your DOM. 108 | 109 | #### getRegisteredComponents 110 | 111 | You can use `getRegisteredComponents` to get all registered components. 112 | 113 | ### Populate parameters 114 | 115 | The `populate` function can be passed an object with options. 116 | 117 | | name | type | purpose | example | 118 | | ---------- | -------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------- | 119 | | attributes | object | attributes which will be aplied to every react-abode container | `{attributes: { classname: "some-class-name"}}` | 120 | | callback | function | function which will be executed every time a new batch of react-abode elements is populated | `() => console.log('new abode elements added to page')` | 121 | 122 | ## Contributing 123 | 124 | After having commited your changes, run `pnpm changeset` and specify an appropriate bump type and a message. If you want to use your commit message(s) as the changeset message, run `pnpm get:changes` which copies all commit message(s) to your clipboard which you can then paste when running `pnpm changeset`. 125 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.1.1", 3 | "license": "MIT", 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/labd/react-abode" 7 | }, 8 | "packageManager": "pnpm@9.7.0", 9 | "main": "dist/index.js", 10 | "typings": "dist/index.d.ts", 11 | "files": [ 12 | "dist" 13 | ], 14 | "engines": { 15 | "node": ">=16.0.0", 16 | "pnpm": ">=7.5.2" 17 | }, 18 | "scripts": { 19 | "start": "tsup ./src/index.ts --dts --watch", 20 | "build": "tsup ./src/index.ts --dts --sourcemap --format esm,cjs", 21 | "changeset": "pnpm exec changeset", 22 | "get:changes": "git log --format=%B --no-merges HEAD ^master | pbcopy", 23 | "husky:install": "pnpm exec husky install", 24 | "lint": "eslint --ext .ts,.tsx src", 25 | "test": "jest", 26 | "test:watch": "jest --watch", 27 | "prepare": "pnpm build", 28 | "preinstall": "npx only-allow pnpm" 29 | }, 30 | "prettier": { 31 | "printWidth": 80, 32 | "semi": true, 33 | "singleQuote": true, 34 | "trailingComma": "es5" 35 | }, 36 | "name": "react-abode", 37 | "author": "Bram Kaashoek", 38 | "module": "dist/index.mjs", 39 | "devDependencies": { 40 | "@changesets/cli": "^2.24.2", 41 | "@types/jest": "^28.1.6", 42 | "@types/jsdom": "^20.0.0", 43 | "@types/react": "^18.0.15", 44 | "@types/react-dom": "^18.0.6", 45 | "@typescript-eslint/eslint-plugin": "^5.33.0", 46 | "@typescript-eslint/parser": "^5.33.0", 47 | "eslint": "^8.21.0", 48 | "fast-check": "^3.1.1", 49 | "husky": "^8.0.1", 50 | "jest": "^28.1.3", 51 | "jest-environment-jsdom": "^28.1.3", 52 | "mutationobserver-shim": "^0.3.7", 53 | "prettier": "^2.7.1", 54 | "react": "^18.2.0", 55 | "react-dom": "^18.2.0", 56 | "ts-jest": "^28.0.7", 57 | "ts-node": "^10.9.1", 58 | "tslib": "^2.0.0", 59 | "tsup": "^6.2.2", 60 | "typescript": "^4.7.4" 61 | }, 62 | "peerDependencies": { 63 | "react": ">=18", 64 | "react-dom": ">=18" 65 | }, 66 | "resolutions": { 67 | "serialize-javascript": "^3.1.0" 68 | }, 69 | "dependencies": { 70 | "tiny-current-script": "^1.0.0" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/abode.ts: -------------------------------------------------------------------------------- 1 | import { type FC, createElement } from 'react'; 2 | import { type Root, createRoot } from 'react-dom/client'; 3 | import { getCurrentScript } from 'tiny-current-script'; 4 | 5 | interface RegisteredComponents { 6 | [key: string]: { 7 | module: Promise; 8 | options?: { propParsers?: PropParsers }; 9 | }; 10 | } 11 | 12 | interface Props { 13 | [key: string]: string; 14 | } 15 | 16 | interface Options { 17 | propParsers?: PropParsers; 18 | } 19 | interface PropParsers { 20 | [key: string]: ParseFN; 21 | } 22 | 23 | interface HTMLElementAttributes { 24 | [key: string]: string; 25 | } 26 | 27 | interface PopulateOptions { 28 | attributes?: HTMLElementAttributes; 29 | callback?: () => void; 30 | } 31 | 32 | export type RegisterPromise = () => Promise; 33 | export type RegisterComponent = () => FC; 34 | export type RegisterFN = RegisterPromise | RegisterComponent; 35 | export type ParseFN = (rawProp: string) => any; 36 | 37 | export let componentSelector = 'data-component'; 38 | export let components: RegisteredComponents = {}; 39 | export let unPopulatedElements: Element[] = []; 40 | 41 | export const register = (name: string, fn: RegisterFN, options?: Options) => { 42 | components[name] = { module: retry(fn, 10, 20), options }; 43 | }; 44 | 45 | export const unRegisterAllComponents = () => { 46 | components = {}; 47 | }; 48 | 49 | export const delay = (ms: number) => new Promise((res) => setTimeout(res, ms)); 50 | 51 | const retry = async ( 52 | fn: () => any, 53 | times: number, 54 | delayTime: number 55 | ): Promise => { 56 | try { 57 | return await fn(); 58 | } catch (err) { 59 | if (times > 1) { 60 | await delay(delayTime); 61 | return retry(fn, times - 1, delayTime * 2); 62 | } else { 63 | throw new Error(err as string); 64 | } 65 | } 66 | }; 67 | 68 | export const setComponentSelector = (selector: string) => { 69 | componentSelector = selector; 70 | }; 71 | 72 | export const getRegisteredComponents = () => { 73 | return components; 74 | }; 75 | 76 | export const getActiveComponents = () => { 77 | return Array.from( 78 | new Set(getAbodeElements().map((el) => el.getAttribute(componentSelector))) 79 | ); 80 | }; 81 | 82 | // start prop logic 83 | export const getCleanPropName = (raw: string): string => { 84 | return raw 85 | .replace('data-prop-', '') 86 | .replace(/-./g, (x) => x.toUpperCase()[1]); 87 | }; 88 | 89 | export const getElementProps = ( 90 | el: Element | HTMLScriptElement, 91 | options?: Options 92 | ): Props => { 93 | const props: { [key: string]: string } = {}; 94 | 95 | if (el?.attributes) { 96 | const rawProps = Array.from(el.attributes).filter((attribute) => 97 | attribute.name.startsWith('data-prop-') 98 | ); 99 | for (const prop of rawProps) { 100 | const componentName = getComponentName(el) ?? ''; 101 | const propName = getCleanPropName(prop.name); 102 | const propParser = 103 | options?.propParsers?.[propName] ?? 104 | components[componentName]?.options?.propParsers?.[propName]; 105 | if (propParser) { 106 | // custom parse function for prop 107 | props[propName] = propParser(prop.value); 108 | } else { 109 | // default json parsing 110 | if (/^0+\d+$/.test(prop.value)) { 111 | /* 112 | ie11 bug fix; 113 | in ie11 JSON.parse will parse a string with leading zeros followed 114 | by digits, e.g. '00012' will become 12, whereas in other browsers 115 | an exception will be thrown by JSON.parse 116 | */ 117 | props[propName] = prop.value; 118 | } else { 119 | try { 120 | props[propName] = JSON.parse(prop.value); 121 | } catch (e) { 122 | props[propName] = prop.value; 123 | } 124 | } 125 | } 126 | } 127 | } 128 | 129 | return props; 130 | }; 131 | 132 | export const getScriptProps = (options?: Options) => { 133 | const element = getCurrentScript(); 134 | if (element === null) { 135 | throw new Error('Failed to get current script'); 136 | } 137 | return getElementProps(element, options); 138 | }; 139 | // end prop logic 140 | 141 | // start element logic 142 | export const getAbodeElements = (): Element[] => { 143 | return Array.from(document.querySelectorAll(`[${componentSelector}]`)).filter( 144 | (el) => { 145 | const component = el.getAttribute(componentSelector); 146 | 147 | // It should exist in registered components 148 | return component && components[component]; 149 | } 150 | ); 151 | }; 152 | 153 | export const setUnpopulatedElements = () => { 154 | unPopulatedElements = getAbodeElements().filter( 155 | (el) => !el.getAttribute('react-abode-populated') 156 | ); 157 | }; 158 | 159 | export const setAttributes = ( 160 | el: Element, 161 | attributes: HTMLElementAttributes 162 | ) => { 163 | for (const [k, v] of Object.entries(attributes)) { 164 | el.setAttribute(k, v); 165 | } 166 | }; 167 | 168 | // end element logic 169 | 170 | function getComponentName(el: Element) { 171 | return Array.from(el.attributes).find((at) => at.name === componentSelector) 172 | ?.value; 173 | } 174 | 175 | export const renderAbode = async (el: Element, root: Root) => { 176 | const props = getElementProps(el); 177 | 178 | const componentName = getComponentName(el); 179 | 180 | if (!componentName || componentName === '') { 181 | throw new Error( 182 | `not all react-abode elements have a value for ${componentSelector}` 183 | ); 184 | } 185 | 186 | const module = await components[componentName]?.module; 187 | if (!module) { 188 | throw new Error(`no component registered for ${componentName}`); 189 | } 190 | 191 | const element = module.default || module; 192 | 193 | root.render(createElement(element, props)); 194 | }; 195 | 196 | export const trackPropChanges = (el: Element, root: Root) => { 197 | if (MutationObserver) { 198 | const observer = new MutationObserver(() => { 199 | renderAbode(el, root); 200 | }); 201 | observer.observe(el, { attributes: true }); 202 | } 203 | }; 204 | 205 | function unmountOnNodeRemoval(element: any, root: Root) { 206 | const observer = new MutationObserver(() => { 207 | function isDetached(el: any): any { 208 | if (el.parentNode === document) { 209 | return false; 210 | } else if (el.parentNode === null) { 211 | return true; 212 | } else { 213 | return isDetached(el.parentNode); 214 | } 215 | } 216 | 217 | if (isDetached(element)) { 218 | observer.disconnect(); 219 | root.unmount(); 220 | } 221 | }); 222 | 223 | observer.observe(document, { 224 | childList: true, 225 | subtree: true, 226 | }); 227 | } 228 | 229 | export const update = async ( 230 | elements: Element[], 231 | options?: PopulateOptions 232 | ) => { 233 | // tag first, since adding components is a slow process and will cause components to get iterated multiple times 234 | for (const el of elements) { 235 | el.setAttribute('react-abode-populated', 'true'); 236 | } 237 | 238 | // TODO: Move this to requestAnimationFrame inside one loop to optimize 239 | for (const el of elements) { 240 | const root = createRoot(el); 241 | if (options?.attributes) setAttributes(el, options.attributes); 242 | renderAbode(el, root); 243 | trackPropChanges(el, root); 244 | unmountOnNodeRemoval(el, root); 245 | } 246 | }; 247 | 248 | const checkForAndHandleNewComponents = async (options?: PopulateOptions) => { 249 | setUnpopulatedElements(); 250 | 251 | if (unPopulatedElements.length) { 252 | await update(unPopulatedElements, options); 253 | unPopulatedElements = []; 254 | if (options?.callback) options.callback(); 255 | } 256 | }; 257 | 258 | export const populate = async (options?: PopulateOptions) => { 259 | await checkForAndHandleNewComponents(options); 260 | 261 | const observer = new MutationObserver(async (mutationList) => { 262 | for (const mutation of mutationList) { 263 | if (mutation.type === 'childList') { 264 | await checkForAndHandleNewComponents(options); 265 | } 266 | } 267 | }); 268 | 269 | observer.observe(document.body, { 270 | childList: true, 271 | subtree: true, 272 | }); 273 | }; 274 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | populate, 3 | setComponentSelector, 4 | register, 5 | getScriptProps, 6 | getRegisteredComponents, 7 | getActiveComponents, 8 | } from './abode'; 9 | -------------------------------------------------------------------------------- /test/TestComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const TestComponent = (): JSX.Element => { 4 | return
testing 1 2 3
; 5 | }; 6 | 7 | export default TestComponent; 8 | -------------------------------------------------------------------------------- /test/TestComponentProps.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface Props { 4 | number: number; 5 | boolean: boolean; 6 | numberAsString: string; 7 | float: number; 8 | } 9 | 10 | export const util = { 11 | getProps: (props: Props): Props => props, 12 | getScriptProps: (props: any) => props, 13 | }; 14 | 15 | const TestComponentProps = (props: Props): JSX.Element => { 16 | util.getProps(props); 17 | return
1 2 3
; 18 | }; 19 | 20 | export default TestComponentProps; 21 | -------------------------------------------------------------------------------- /test/TestComponentWithUnmount.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | 3 | const TestComponentWithUnmount = (): JSX.Element => { 4 | useEffect(() => { 5 | return () => { 6 | const unmountElement = document.createElement('unmounted'); 7 | document.body.appendChild(unmountElement); 8 | }; 9 | }, []); 10 | 11 | return

test component

; 12 | }; 13 | 14 | export default TestComponentWithUnmount; 15 | -------------------------------------------------------------------------------- /test/abode.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import * as fc from 'fast-check'; 6 | import { 7 | getCleanPropName, 8 | getAbodeElements, 9 | getRegisteredComponents, 10 | unPopulatedElements, 11 | setUnpopulatedElements, 12 | getElementProps, 13 | setAttributes, 14 | renderAbode, 15 | register, 16 | unRegisterAllComponents, 17 | components, 18 | populate, 19 | delay, 20 | } from '../src/abode'; 21 | // @ts-ignore 22 | import TestComponent from './TestComponent'; 23 | import TestComponentProps, { util } from './TestComponentProps'; 24 | import 'mutationobserver-shim'; 25 | import { createRoot } from 'react-dom/client'; 26 | global.MutationObserver = window.MutationObserver; 27 | 28 | describe('helper functions', () => { 29 | beforeEach(() => { 30 | document.getElementsByTagName('html')[0].innerHTML = ''; 31 | unRegisterAllComponents(); 32 | }); 33 | 34 | it('getCleanPropName', () => { 35 | expect(getCleanPropName('data-prop-some-random-prop')).toEqual( 36 | 'someRandomProp' 37 | ); 38 | }); 39 | 40 | it('getAbodeElements', () => { 41 | const abodeElement = document.createElement('div'); 42 | abodeElement.setAttribute('data-component', 'TestComponent'); 43 | document.body.appendChild(abodeElement); 44 | 45 | expect(getAbodeElements()).toHaveLength(0); 46 | 47 | register('TestComponent', () => TestComponent); 48 | 49 | expect(getAbodeElements()).toHaveLength(1); 50 | }); 51 | 52 | it('setUnpopulatedElements', () => { 53 | const abodeElement = document.createElement('div'); 54 | abodeElement.setAttribute('data-component', 'TestComponent'); 55 | document.body.appendChild(abodeElement); 56 | 57 | setUnpopulatedElements(); 58 | 59 | expect(unPopulatedElements).toHaveLength(0); 60 | 61 | register('TestComponent', () => TestComponent); 62 | setUnpopulatedElements(); 63 | 64 | expect(unPopulatedElements).toHaveLength(1); 65 | }); 66 | 67 | it('getElementProps', () => { 68 | const abodeElement = document.createElement('div'); 69 | abodeElement.setAttribute('data-component', 'TestComponent'); 70 | abodeElement.setAttribute('data-prop-test-prop', 'testPropValue'); 71 | abodeElement.setAttribute('data-prop-number-prop', '12345'); 72 | abodeElement.setAttribute('data-prop-null-prop', 'null'); 73 | abodeElement.setAttribute('data-prop-true-prop', 'true'); 74 | abodeElement.setAttribute('data-prop-leading-zeros', '0012'); 75 | abodeElement.setAttribute('data-prop-leading-zero', '012'); 76 | abodeElement.setAttribute('data-prop-sku-one', 'B123456'); 77 | abodeElement.setAttribute('data-prop-sku-two', 'AW-ARZA18-C0LM-78'); 78 | abodeElement.setAttribute('data-prop-sku-three', 'TO-8370-228-770-6.0'); 79 | abodeElement.setAttribute('data-prop-float', '10.46'); 80 | abodeElement.setAttribute('data-prop-empty-prop', ''); 81 | abodeElement.setAttribute( 82 | 'data-prop-json-prop', 83 | '{"id": 12345, "product": "keyboard", "variant": {"color": "blue"}}' 84 | ); 85 | 86 | const props = getElementProps(abodeElement); 87 | 88 | expect(props).toEqual({ 89 | testProp: 'testPropValue', 90 | numberProp: 12345, 91 | nullProp: null, 92 | trueProp: true, 93 | leadingZeros: '0012', 94 | leadingZero: '012', 95 | skuOne: 'B123456', 96 | skuTwo: 'AW-ARZA18-C0LM-78', 97 | skuThree: 'TO-8370-228-770-6.0', 98 | float: 10.46, 99 | emptyProp: '', 100 | jsonProp: { id: 12345, product: 'keyboard', variant: { color: 'blue' } }, 101 | }); 102 | }); 103 | 104 | it('getElementProps parses JSON', () => { 105 | fc.assert( 106 | fc.property(fc.json({ maxDepth: 10 }), data => { 107 | const abodeElement = document.createElement('div'); 108 | abodeElement.setAttribute('data-prop-test-prop', JSON.stringify(data)); 109 | const props = getElementProps(abodeElement); 110 | expect(props.testProp).toEqual(data); 111 | }) 112 | ); 113 | }); 114 | 115 | it('getElementProps does not parse strings with leading zeros followed by other digits', () => { 116 | const strWithLeadingZeros = fc 117 | .tuple(fc.integer({ min: 1, max: 10 }), fc.integer()) 118 | .map(t => { 119 | const [numberOfZeros, integer] = t; 120 | return '0'.repeat(numberOfZeros) + integer.toString(); 121 | }); 122 | fc.assert( 123 | fc.property(strWithLeadingZeros, data => { 124 | const abodeElement = document.createElement('div'); 125 | abodeElement.setAttribute('data-prop-test-prop', data); 126 | const props = getElementProps(abodeElement); 127 | expect(props.testProp).toEqual(data); 128 | }) 129 | ); 130 | }); 131 | 132 | it('setAttributes', () => { 133 | const abodeElement = document.createElement('div'); 134 | 135 | setAttributes(abodeElement, { classname: 'test-class-name' }); 136 | 137 | expect(abodeElement.getAttribute('classname')).toBe('test-class-name'); 138 | }); 139 | 140 | it('renderAbode without component name set', async () => { 141 | const abodeElement = document.createElement('div'); 142 | abodeElement.setAttribute('data-component', ''); 143 | const root = createRoot(abodeElement); 144 | 145 | let err = new Error(); 146 | try { 147 | await renderAbode(abodeElement, root); 148 | } catch (error) { 149 | err = error as Error; 150 | } 151 | 152 | expect(err.message).toEqual( 153 | 'not all react-abode elements have a value for data-component' 154 | ); 155 | }); 156 | 157 | it('renderAbode without component registered', async () => { 158 | const abodeElement = document.createElement('div'); 159 | abodeElement.setAttribute('data-component', 'TestComponent'); 160 | const root = createRoot(abodeElement); 161 | 162 | let err = new Error(); 163 | try { 164 | await renderAbode(abodeElement, root); 165 | } catch (error) { 166 | err = error as Error; 167 | } 168 | 169 | expect(err.message).toEqual('no component registered for TestComponent'); 170 | }); 171 | }); 172 | 173 | describe('exported functions', () => { 174 | beforeEach(() => { 175 | document.getElementsByTagName('html')[0].innerHTML = ''; 176 | unRegisterAllComponents(); 177 | }); 178 | 179 | it('register', async () => { 180 | register('TestComponent', () => import('./TestComponent')); 181 | expect(Object.keys(components)).toEqual(['TestComponent']); 182 | expect(Object.values(components).length).toEqual(1); 183 | let promise = Object.values(components)[0].module; 184 | expect(typeof promise.then).toEqual('function'); 185 | let module = await promise; 186 | expect(typeof module).toEqual('object'); 187 | expect(Object.keys(module)).toEqual(['default']); 188 | 189 | register('TestComponent2', () => TestComponent); 190 | expect(Object.keys(components)).toEqual([ 191 | 'TestComponent', 192 | 'TestComponent2', 193 | ]); 194 | expect(Object.values(components).length).toEqual(2); 195 | promise = Object.values(components)[1].module; 196 | expect(typeof promise.then).toEqual('function'); 197 | module = await promise; 198 | expect(typeof module).toEqual('function'); 199 | }); 200 | 201 | it('populate', async () => { 202 | const abodeElement = document.createElement('div'); 203 | abodeElement.setAttribute('data-component', 'TestComponent'); 204 | const abodeSecondElement = document.createElement('div'); 205 | abodeSecondElement.setAttribute('data-component', 'TestComponent2'); 206 | document.body.appendChild(abodeElement); 207 | document.body.appendChild(abodeSecondElement); 208 | expect(document.body.innerHTML).toEqual( 209 | `
` 210 | ); 211 | 212 | register('TestComponent', () => import('./TestComponent')); 213 | register('TestComponent2', () => TestComponent); 214 | await populate(); 215 | 216 | await delay(20); 217 | 218 | expect(document.body.innerHTML).toEqual( 219 | `
testing 1 2 3
` + 220 | `
testing 1 2 3
` 221 | ); 222 | }); 223 | 224 | it('getRegisteredComponents', () => { 225 | register('TestComponent', () => import('./TestComponent')); 226 | register('TestComponent2', () => TestComponent); 227 | 228 | const registeredComponents = getRegisteredComponents(); 229 | 230 | expect(Object.keys(registeredComponents).length).toEqual(2); 231 | }); 232 | 233 | it('uses custom prop parsers', async () => { 234 | const spy = jest.spyOn(util, 'getProps'); 235 | const abodeElement = document.createElement('div'); 236 | abodeElement.setAttribute('data-component', 'TestComponentProps'); 237 | abodeElement.setAttribute('data-prop-number', '1'); 238 | abodeElement.setAttribute('data-prop-boolean', 'true'); 239 | abodeElement.setAttribute('data-prop-number-as-string', '123'); 240 | abodeElement.setAttribute('data-prop-float', '1.01'); 241 | document.body.appendChild(abodeElement); 242 | 243 | register('TestComponentProps', () => TestComponentProps, { 244 | propParsers: { 245 | number: (prop: string) => Number(prop), 246 | boolean: (prop: string) => Boolean(prop), 247 | numberAsString: (prop: string) => prop, 248 | float: (prop: string) => parseFloat(prop), 249 | }, 250 | }); 251 | await populate(); 252 | await delay(20); 253 | 254 | expect(document.body.innerHTML).toEqual( 255 | `
1 2 3
` 256 | ); 257 | expect(spy).toHaveBeenCalled(); 258 | expect(spy).toHaveBeenCalledWith({ 259 | number: 1, 260 | boolean: true, 261 | numberAsString: '123', 262 | float: 1.01, 263 | }); 264 | }); 265 | 266 | it('unmounts the React component when the wrapper is removed from the DOM', async () => { 267 | const abodeElement = document.createElement('div'); 268 | abodeElement.setAttribute('data-component', 'TestComponentWithUnmount'); 269 | document.body.appendChild(abodeElement); 270 | register('TestComponentWithUnmount', () => 271 | import('./TestComponentWithUnmount') 272 | ); 273 | await populate(); 274 | await delay(20); 275 | expect(document.body.innerHTML).toEqual( 276 | `

test component

` 277 | ); 278 | 279 | document.body.removeChild(abodeElement); 280 | await delay(20); 281 | expect(document.body.innerHTML).toEqual(``); 282 | }); 283 | 284 | it('uses JSON.parse as a custom prop parser', async () => { 285 | const spy = jest.spyOn(util, 'getProps'); 286 | const abodeElement = document.createElement('div'); 287 | abodeElement.setAttribute('data-component', 'TestComponentProps'); 288 | document.body.appendChild(abodeElement); 289 | fc.assert( 290 | fc.property(fc.json(), data => { 291 | abodeElement.setAttribute('data-prop-anything', JSON.stringify(data)); 292 | register('TestComponentProps', () => TestComponentProps, { 293 | propParsers: { 294 | anything: (prop: string) => JSON.parse(prop), 295 | }, 296 | }); 297 | populate() 298 | .then(() => delay(20)) 299 | .then(() => { 300 | expect(spy).toHaveBeenCalledWith({ anything: data }); 301 | }); 302 | }) 303 | ); 304 | }); 305 | 306 | it.skip('getScriptProps', () => {}); 307 | it.skip('getActiveComponents', () => {}); 308 | it.skip('setComponentSelector', () => {}); 309 | it.skip('register', () => {}); 310 | }); 311 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "target": "ES5", 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "moduleResolution": "node", 17 | "baseUrl": "./", 18 | "paths": { 19 | "*": ["src/*", "node_modules/*"] 20 | }, 21 | "jsx": "react", 22 | "esModuleInterop": true 23 | } 24 | } 25 | --------------------------------------------------------------------------------