├── .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 | `` +
220 | ``
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 | ``
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 |
--------------------------------------------------------------------------------