= T extends string | number ? P : Omit;
25 |
--------------------------------------------------------------------------------
/src/package/utils/cookies.ts:
--------------------------------------------------------------------------------
1 | function setCookie(cname: string, cvalue: string, exdays: number = 30) {
2 | const d = new Date();
3 | d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000);
4 | let expires = 'expires=' + d.toUTCString();
5 | document.cookie = cname + '=' + cvalue + ';' + expires + ';path=/';
6 | }
7 |
8 | function getCookie(cname: string) {
9 | let name = cname + '=',
10 | decodedCookie = decodeURIComponent(document.cookie),
11 | ca = decodedCookie.split(';');
12 |
13 | for (let i = 0; i < ca.length; i++) {
14 | let c = ca[i];
15 | while (c.charAt(0) == ' ') {
16 | c = c.substring(1);
17 | }
18 | if (c.indexOf(name) == 0) {
19 | return c.substring(name.length, c.length);
20 | }
21 | }
22 | return null;
23 | }
24 |
25 | export { getCookie, setCookie };
26 |
--------------------------------------------------------------------------------
/src/package/utils/objectCompare.ts:
--------------------------------------------------------------------------------
1 | export const deepCompare = (a: any, b: any): boolean => {
2 | if (a === b) return true;
3 | if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return a !== a && b !== b; // NaN check
4 |
5 | if (a.constructor !== b.constructor) return false;
6 |
7 | // Array comparison
8 | if (Array.isArray(a)) {
9 | if (a.length !== b.length) return false;
10 | for (let i = a.length; i--; ) if (!deepCompare(a[i], b[i])) return false;
11 | return true;
12 | }
13 |
14 | // Special object types
15 | if (a.constructor === RegExp) return a.source === b.source && a.flags === b.flags;
16 | if (a.valueOf !== Object.prototype.valueOf) return a.valueOf() === b.valueOf();
17 | if (a.toString !== Object.prototype.toString) return a.toString() === b.toString();
18 |
19 | // Object comparison
20 | const keys = Object.keys(a);
21 | if (keys.length !== Object.keys(b).length) return false;
22 |
23 | return keys.every(key => Object.prototype.hasOwnProperty.call(b, key) && deepCompare(a[key], b[key]));
24 | };
25 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Viet Cong Pham Quoc
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.
22 |
--------------------------------------------------------------------------------
/src/app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "my-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@types/node": "^16.18.102",
7 | "@types/react": "^18.3.3",
8 | "@types/react-dom": "^18.3.0",
9 | "react": "^18.3.1",
10 | "react-dom": "^18.3.1",
11 | "react-scripts": "5.0.1",
12 | "react-signify": "^1.2.1",
13 | "typescript": "^4.9.5",
14 | "web-vitals": "^2.1.4"
15 | },
16 | "scripts": {
17 | "start": "react-scripts start",
18 | "build": "react-scripts build",
19 | "test": "react-scripts test",
20 | "eject": "react-scripts eject"
21 | },
22 | "eslintConfig": {
23 | "extends": [
24 | "react-app",
25 | "react-app/jest"
26 | ]
27 | },
28 | "browserslist": {
29 | "production": [
30 | ">0.2%",
31 | "not dead",
32 | "not op_mini all"
33 | ],
34 | "development": [
35 | "last 1 chrome version",
36 | "last 1 firefox version",
37 | "last 1 safari version"
38 | ]
39 | },
40 | "devDependencies": {
41 | "@testing-library/jest-dom": "^6.4.6",
42 | "@testing-library/react": "^16.0.0",
43 | "@testing-library/user-event": "^14.5.2",
44 | "@types/jest": "^29.5.12"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/package/signify-sync/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Creates a synchronization system using BroadcastChannel.
3 | * This function allows you to establish communication between different browser contexts by sending messages
4 | * and synchronizing state changes.
5 | *
6 | * @template T - The type of data to be synchronized.
7 | * @param {Object} params - The parameters for the syncSystem function.
8 | * @param {string} params.key - A unique identifier for the broadcast channel.
9 | * @param {function} params.cb - A callback function that will be invoked
10 | * whenever a new message is received.
11 | * @returns
12 | * - An object containing the following methods:
13 | * - post(data: T): Sends the provided data to the BroadcastChannel.
14 | * - sync(getData: () => T): Synchronizes the current data with other contexts by posting the data when
15 | * a message is received on a global BroadcastChannel.
16 | */
17 | export const syncSystem = ({ key, cb }: { key: string; cb(val: T): void }) => {
18 | const mainKey = `bc_${key}`,
19 | bc = new BroadcastChannel(mainKey);
20 |
21 | bc.onmessage = e => cb(e.data);
22 |
23 | return {
24 | post: (data: T) => {
25 | bc.postMessage(data);
26 | },
27 | sync: (getData: () => T) => {
28 | const bcs = new BroadcastChannel(`bcs`);
29 | bcs.onmessage = e => mainKey === e.data && bc.postMessage(getData());
30 | bcs.postMessage(mainKey);
31 | }
32 | };
33 | };
34 |
--------------------------------------------------------------------------------
/src/app/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | React App
28 |
29 |
30 | You need to enable JavaScript to run this app.
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/src/package/signify-devTool/index.css:
--------------------------------------------------------------------------------
1 | .signify_popup {
2 | font-size: 12px;
3 | width: 300px;
4 | height: 300px;
5 | background-color: white;
6 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
7 | position: fixed;
8 | display: block;
9 | top: 50%;
10 | left: 50%;
11 | transform: translate(-50%, -50%);
12 | box-sizing: border-box;
13 | border-radius: 10px;
14 | overflow: hidden;
15 | }
16 | .signify_popup_header {
17 | cursor: move;
18 | display: flex;
19 | align-items: center;
20 | padding: 5px 20px;
21 | gap: 10px;
22 | font-size: 16px;
23 | color: white;
24 | user-select: none;
25 | }
26 | .signify_popup_header_button {
27 | cursor: pointer;
28 | }
29 | .signify_popup_header_label {
30 | margin-right: auto;
31 | overflow: hidden;
32 | text-overflow: ellipsis;
33 | text-wrap: nowrap;
34 | }
35 | .signify_popup_resizer {
36 | width: 20px;
37 | height: 20px;
38 | background-color: rgba(0, 0, 0, 0.1);
39 | position: absolute;
40 | bottom: 0;
41 | right: 0;
42 | cursor: se-resize;
43 | border-radius: 10px 0px 10px;
44 | }
45 | .signify_popup_json_viewer {
46 | margin: 0;
47 | height: calc(100% - 32px);
48 | overflow: auto;
49 | padding: 10px 20px;
50 | white-space: pre;
51 | box-sizing: border-box;
52 | }
53 | .signify_popup_json_viewer::-webkit-scrollbar {
54 | width: 8px;
55 | height: 8px;
56 | }
57 | .signify_popup_json_viewer::-webkit-scrollbar-thumb {
58 | background: gray;
59 | }
60 | .signify_popup_json_viewer::-webkit-scrollbar-track {
61 | background: #fff;
62 | }
63 | .signify_popup_json_key {
64 | color: brown;
65 | }
66 | .signify_popup_json_string {
67 | color: green;
68 | }
69 | .signify_popup_json_number {
70 | color: blue;
71 | }
72 | .signify_popup_json_boolean {
73 | color: purple;
74 | }
75 | .signify_popup_json_null {
76 | color: gray;
77 | }
78 |
--------------------------------------------------------------------------------
/src/app/README.md:
--------------------------------------------------------------------------------
1 | # Getting Started with Create React App
2 |
3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
4 |
5 | ## Available Scripts
6 |
7 | In the project directory, you can run:
8 |
9 | ### `npm start`
10 |
11 | Runs the app in the development mode.\
12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
13 |
14 | The page will reload if you make edits.\
15 | You will also see any lint errors in the console.
16 |
17 | ### `npm test`
18 |
19 | Launches the test runner in the interactive watch mode.\
20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
21 |
22 | ### `npm run build`
23 |
24 | Builds the app for production to the `build` folder.\
25 | It correctly bundles React in production mode and optimizes the build for the best performance.
26 |
27 | The build is minified and the filenames include the hashes.\
28 | Your app is ready to be deployed!
29 |
30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
31 |
32 | ### `npm run eject`
33 |
34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
35 |
36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
37 |
38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
39 |
40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
41 |
42 | ## Learn More
43 |
44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
45 |
46 | To learn React, check out the [React documentation](https://reactjs.org/).
47 |
--------------------------------------------------------------------------------
/src/package/signify-devTool/index.scss:
--------------------------------------------------------------------------------
1 | .signify {
2 | &_popup {
3 | font-size: 12px;
4 | width: 300px;
5 | height: 300px;
6 | background-color: white;
7 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
8 | position: fixed;
9 | display: block;
10 | top: 50%;
11 | left: 50%;
12 | transform: translate(-50%, -50%);
13 | box-sizing: border-box;
14 | border-radius: 10px;
15 | overflow: hidden;
16 |
17 | &_header {
18 | cursor: move;
19 | display: flex;
20 | align-items: center;
21 | padding: 5px 20px;
22 | gap: 10px;
23 | font-size: 16px;
24 | color: white;
25 | user-select: none;
26 |
27 | &_button {
28 | cursor: pointer;
29 | }
30 |
31 | &_label {
32 | margin-right: auto;
33 | overflow: hidden;
34 | text-overflow: ellipsis;
35 | text-wrap: nowrap;
36 | }
37 | }
38 |
39 | &_resizer {
40 | width: 20px;
41 | height: 20px;
42 | background-color: rgba(0, 0, 0, 0.1);
43 | position: absolute;
44 | bottom: 0;
45 | right: 0;
46 | cursor: se-resize;
47 | border-radius: 10px 0px 10px;
48 | }
49 |
50 | &_json {
51 | &_viewer {
52 | margin: 0;
53 | height: calc(100% - 32px);
54 | overflow: auto;
55 | padding: 10px 20px;
56 | white-space: pre;
57 | box-sizing: border-box;
58 |
59 | &::-webkit-scrollbar {
60 | width: 8px;
61 | height: 8px;
62 | }
63 |
64 | &::-webkit-scrollbar-thumb {
65 | background: gray;
66 | }
67 |
68 | &::-webkit-scrollbar-track {
69 | background: #fff;
70 | }
71 | }
72 |
73 | &_key {
74 | color: brown;
75 | }
76 | &_string {
77 | color: green;
78 | }
79 | &_number {
80 | color: blue;
81 | }
82 | &_boolean {
83 | color: purple;
84 | }
85 | &_null {
86 | color: gray;
87 | }
88 | }
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/package/signify-cache/index.ts:
--------------------------------------------------------------------------------
1 | import { TCacheConfig, TCacheSolution } from './cache.model';
2 |
3 | // Define a cache solution object that maps cache types to their respective storage mechanisms
4 | const cacheSolution: TCacheSolution = {
5 | LocalStorage: () => localStorage, // Using the localStorage API for persistent storage
6 | SessionStorage: () => sessionStorage // Using the sessionStorage API for temporary storage
7 | };
8 |
9 | /**
10 | * Retrieves the initial value from either LocalStorage or SessionStorage based on the provided cache configuration.
11 | * If the value is not found in the specified storage, it sets the initial value in the storage.
12 | *
13 | * @param initialValue - The initial value to be stored if no cached value exists.
14 | * @param cacheInfo - An optional configuration object that includes:
15 | * - key: The key under which the value is stored in the cache.
16 | * - type: The type of storage to use ('LocalStorage' or 'SesionStorage').
17 | * @returns The retrieved value from cache or the initial value if no cached value exists.
18 | */
19 | export const getInitialValue = (initialValue: T, cacheInfo?: TCacheConfig): T => {
20 | if (cacheInfo?.key) {
21 | if (typeof window === 'undefined') {
22 | throw new Error('The cache feature is not recommended for Server-Side Rendering. Please remove the cache properties from the Signify variable.');
23 | }
24 |
25 | const mainType = cacheInfo?.type ?? 'LocalStorage', // Default to LocalStorage if no type is provided
26 | tempValue = cacheSolution[mainType]().getItem(cacheInfo.key); // Retrieve item from storage
27 |
28 | if (tempValue) {
29 | return JSON.parse(tempValue); // Return parsed value if found in storage
30 | }
31 |
32 | // Set initial value in storage if not found
33 | cacheSolution[mainType]().setItem(cacheInfo.key, JSON.stringify(initialValue));
34 | }
35 |
36 | return initialValue; // Return a deep copy of the initial value
37 | };
38 |
39 | /**
40 | * Updates the stored value in the specified cache configuration.
41 | * If the key is provided in cacheInfo, it updates the corresponding storage with the new value.
42 | *
43 | * @param newValue - The new value to be stored in the cache.
44 | * @param cacheInfo - An optional configuration object that includes:
45 | * - key: The key under which the new value will be stored.
46 | * - type: The type of storage to use ('LocalStorage' or 'SesionStorage').
47 | */
48 | export const cacheUpdateValue = (newValue: T, cacheInfo?: TCacheConfig) => {
49 | if (cacheInfo?.key) {
50 | // Update item in specified storage
51 | cacheSolution[cacheInfo?.type ?? 'LocalStorage']().setItem(cacheInfo.key, JSON.stringify(newValue));
52 | }
53 | };
54 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-signify",
3 | "description": "A JS library for predictable and maintainable global state management",
4 | "version": "1.7.2",
5 | "type": "module",
6 | "homepage": "https://reactsignify.dev",
7 | "repository": {
8 | "type": "git",
9 | "url": "git+https://github.com/VietCPQ94/react-signify.git"
10 | },
11 | "bugs": {
12 | "url": "github:VietCPQ94/react-signify/issues"
13 | },
14 | "keywords": [
15 | "react",
16 | "react-signify",
17 | "signal",
18 | "state",
19 | "global state",
20 | "manage state",
21 | "react-signal",
22 | "signals-react",
23 | "re-render"
24 | ],
25 | "authors": "Viet Cong Pham Quoc (https://github.com/VietCPQ94)",
26 | "main": "dist/cjs/index.js",
27 | "module": "dist/index.js",
28 | "types": "dist/index.d.ts",
29 | "scripts": {
30 | "publish": "npm publish --access=public",
31 | "app-start": "yarn --cwd src/app start",
32 | "app-start-ssr": "yarn --cwd src/app-ssr dev",
33 | "app-test": "yarn --cwd src/app test --watchAll",
34 | "build": "rollup -c --bundleConfigAsCjs",
35 | "test": "yarn build && cp -r dist src/app/node_modules/react-signify && cp package.json src/app/node_modules/react-signify/ && rm -rf src/app/node_modules/.cache && yarn app-test",
36 | "start": "yarn build && cp -r dist src/app/node_modules/react-signify && cp package.json src/app/node_modules/react-signify/ && rm -rf src/app/node_modules/.cache && yarn app-start",
37 | "start-ssr": "yarn build && cp -r dist src/app-ssr/node_modules/react-signify && cp package.json src/app-ssr/node_modules/react-signify/ && rm -rf src/app-ssr/.next && yarn app-start-ssr",
38 | "prepack": "yarn build"
39 | },
40 | "author": "Viet Cong Pham Quoc",
41 | "license": "MIT",
42 | "exports": {
43 | "./package.json": "./package.json",
44 | ".": {
45 | "types": "./dist/index.d.ts",
46 | "import": "./dist/index.js",
47 | "default": "./dist/cjs/index.js"
48 | },
49 | "./devtool": {
50 | "types": "./dist/devtool.d.ts",
51 | "import": "./dist/devtool.js",
52 | "require": "./dist/cjs/devtool.js"
53 | }
54 | },
55 | "files": [
56 | "dist/*"
57 | ],
58 | "devDependencies": {
59 | "@rollup/plugin-alias": "^5.1.0",
60 | "@rollup/plugin-commonjs": "^25.0.8",
61 | "@rollup/plugin-node-resolve": "^15.2.3",
62 | "@rollup/plugin-terser": "^0.4.4",
63 | "@rollup/plugin-typescript": "^11.1.6",
64 | "@types/react": "^18.3.2",
65 | "rollup": "^4.17.2",
66 | "rollup-plugin-copy": "^3.5.0",
67 | "rollup-plugin-dts": "^6.1.1",
68 | "rollup-plugin-peer-deps-external": "^2.2.4",
69 | "rollup-plugin-postcss": "^4.0.2",
70 | "tslib": "^2.6.2",
71 | "typescript": "^5.4.5"
72 | },
73 | "peerDependencies": {
74 | "react": ">=17.0.2"
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/package/utils/objectClone.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Deep clone any JavaScript object with support for circular references and custom constructors
3 | * @param source The object to be cloned
4 | * @param options Optional configuration
5 | * @returns A deep clone of the source object
6 | */
7 | export const deepClone = (
8 | source: T,
9 | options?: {
10 | constructorHandlers?: [Function, (obj: any, fn?: (o: any) => any) => any][];
11 | }
12 | ): T => {
13 | // Handle primitives, null, and undefined directly
14 | if (typeof source !== 'object' || source === null) {
15 | return source;
16 | }
17 |
18 | // Create a WeakMap to track already cloned objects (for circular references)
19 | const cloneMap = new WeakMap();
20 |
21 | // Built-in object handlers
22 | const handlers = new Map any) => any>([
23 | [Date, (obj: Date) => new Date(obj.getTime())],
24 | [RegExp, (obj: RegExp) => new RegExp(obj.source, obj.flags)],
25 | [Map, (obj: Map, fn?: (o: any) => any) => new Map([...obj].map(([k, v]) => [fn!(k), fn!(v)]))],
26 | [Set, (obj: Set, fn?: (o: any) => any) => new Set([...obj].map(fn!))],
27 | [Error, (obj: Error, fn?: (o: any) => any) => Object.assign(new (obj.constructor as any)(obj.message), { stack: obj.stack, cause: obj.cause && fn!(obj.cause) })],
28 | [URL, (obj: URL) => new URL(obj.href)],
29 | [Blob, (obj: Blob) => obj.slice()],
30 | [File, (obj: File) => new File([obj], obj.name, { type: obj.type, lastModified: obj.lastModified })],
31 | ...(options?.constructorHandlers || [])
32 | ]);
33 |
34 | // Main clone function
35 | const clone = (obj: any): any => {
36 | // Handle primitives
37 | if (typeof obj !== 'object' || obj === null) return obj;
38 |
39 | // Handle circular references
40 | if (cloneMap.has(obj)) return cloneMap.get(obj);
41 |
42 | let result: any;
43 |
44 | // Handle different object types
45 | if (Array.isArray(obj)) {
46 | result = obj.map(clone);
47 | } else if (ArrayBuffer.isView(obj)) {
48 | result = obj instanceof Buffer ? Buffer.from(obj) : new (obj.constructor as any)(obj.buffer.slice(), obj.byteOffset, (obj as any).length);
49 | } else if (handlers.has(obj.constructor)) {
50 | result = handlers.get(obj.constructor)!(obj, clone);
51 | } else {
52 | // Handle plain objects
53 | result = Object.create(Object.getPrototypeOf(obj));
54 | cloneMap.set(obj, result);
55 |
56 | // Copy all properties in one pass
57 | [...Object.getOwnPropertyNames(obj), ...Object.getOwnPropertySymbols(obj)].forEach(key => {
58 | const descriptor = Object.getOwnPropertyDescriptor(obj, key)!;
59 | if (descriptor.value !== undefined) descriptor.value = clone(descriptor.value);
60 | Object.defineProperty(result, key, descriptor);
61 | });
62 | return result;
63 | }
64 |
65 | cloneMap.set(obj, result);
66 | return result;
67 | };
68 |
69 | return clone(source);
70 | };
71 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to make participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | - Using welcoming and inclusive language
18 | - Being respectful of differing viewpoints and experiences
19 | - Gracefully accepting constructive criticism
20 | - Focusing on what is best for the community
21 | - Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | - The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | - Trolling, insulting/derogatory comments, and personal or political attacks
28 | - Public or private harassment
29 | - Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | - Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies within all project spaces, and it also applies when
49 | an individual is representing the project or its community in public spaces.
50 | Examples of representing a project or community include using an official
51 | project e-mail address, posting via an official social media account, or acting
52 | as an appointed representative at an online or offline event. Representation of
53 | a project may be further defined and clarified by project maintainers.
54 |
55 | This Code of Conduct also applies outside the project spaces when there is a
56 | reasonable belief that an individual's behavior may have a negative impact on
57 | the project or its community.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported by contacting admin at . All
63 | complaints will be reviewed and investigated and will result in a response that
64 | is deemed necessary and appropriate to the circumstances. The project team is
65 | obligated to maintain confidentiality with regard to the reporter of an incident.
66 | Further details of specific enforcement policies may be posted separately.
67 |
68 | Project maintainers who do not follow or enforce the Code of Conduct in good
69 | faith may face temporary or permanent repercussions as determined by other
70 | members of the project's leadership.
71 |
72 | ## Attribution
73 |
74 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
75 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
76 |
77 | [homepage]: https://www.contributor-covenant.org
78 |
79 | For answers to common questions about this code of conduct, see
80 | https://www.contributor-covenant.org/faq
81 |
--------------------------------------------------------------------------------
/src/package/signify-core/signify.core.ts:
--------------------------------------------------------------------------------
1 | import React, { DependencyList, memo, useLayoutEffect, useRef, useState } from 'react';
2 | import { deepCompare } from '../utils/objectCompare';
3 | import { TConvertValueCb, TGetValueCb, TListeners, TUseValueCb, TWrapProps } from './signify.model';
4 |
5 | export const subscribeCore =
6 | (listeners: TListeners) =>
7 | (callback: TUseValueCb) => {
8 | listeners.add(callback);
9 | return { unsubscribe: () => listeners.delete(callback) };
10 | };
11 |
12 | /**
13 | * watchCore is a custom hook that subscribes to a set of listeners.
14 | * It allows the provided callback to be invoked whenever the state changes.
15 | * The listeners will be cleaned up when the component unmounts or dependencies change.
16 | *
17 | * @param listeners - A collection of listeners for state changes.
18 | * @returns A function that takes a callback and an optional dependency array.
19 | */
20 | export const watchCore =
21 | (listeners: TListeners) =>
22 | (callback: TUseValueCb, deps?: DependencyList) => {
23 | useLayoutEffect(() => {
24 | listeners.add(callback);
25 |
26 | return () => {
27 | listeners.delete(callback);
28 | };
29 | }, deps ?? []);
30 | };
31 |
32 | /**
33 | * useCore is a custom hook that retrieves a value from the state and triggers
34 | * a re-render when the value changes. It allows you to transform the retrieved
35 | * value using a provided conversion function.
36 | *
37 | * @param listeners - A collection of listeners for state changes.
38 | * @param getValue - A function to retrieve the current value from the state.
39 | * @returns A function that takes an optional value conversion function. v => v as Readonly
40 | */
41 | export const useCore =
42 | (listeners: TListeners, getValue: () => T) =>
43 | (pickValue?: TConvertValueCb) => {
44 | const trigger = useState({})[1];
45 | const listener = useRef(
46 | (() => {
47 | let temp = pickValue?.(getValue());
48 | const listenerFunc = () => {
49 | if (pickValue) {
50 | let newTemp = pickValue(getValue());
51 |
52 | if (deepCompare(temp, newTemp)) {
53 | return;
54 | }
55 | temp = newTemp;
56 | }
57 |
58 | trigger({});
59 | };
60 | return listenerFunc;
61 | })()
62 | );
63 |
64 | useLayoutEffect(() => {
65 | listeners.add(listener.current);
66 | return () => {
67 | listeners.delete(listener.current);
68 | };
69 | }, []);
70 |
71 | return (pickValue ? pickValue(getValue()) : getValue()) as P extends undefined ? T : P;
72 | };
73 |
74 | /**
75 | * htmlCore is a utility function that creates a React element by invoking
76 | * the provided value retrieval function. This is useful for rendering
77 | * values directly in a functional component.
78 | *
79 | * @param u - A function that retrieves the current value.
80 | * @returns A React element containing the rendered value.
81 | */
82 | //@ts-ignore
83 | export const htmlCore = (u: TGetValueCb) => React.createElement(() => u());
84 |
85 | /**
86 | * WrapCore is a higher-order component that wraps its children with the
87 | * current value retrieved from the provided function.
88 | *
89 | * @param u - A function that retrieves the current value.
90 | * @returns A component that renders its children with the current value.
91 | *
92 | * @example
93 | * const getValue = () => 'Wrapped Value';
94 | * const WrappedComponent = WrapCore(getValue);
95 | */
96 | export const WrapCore =
97 | (u: TGetValueCb) =>
98 | ({ children }: TWrapProps) =>
99 | children(u());
100 |
101 | /**
102 | * HardWrapCore is a memoized version of WrapCore that optimizes rendering
103 | * by preventing unnecessary updates. It uses shallow comparison to determine
104 | * if the component should re-render.
105 | *
106 | * @param u - A function that retrieves the current value.
107 | * @returns A memoized component that wraps its children with the current value.
108 | */
109 | export const HardWrapCore = (u: TGetValueCb) => memo(WrapCore(u), () => true) as ReturnType>;
110 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import resolve from '@rollup/plugin-node-resolve';
2 | import commonjs from '@rollup/plugin-commonjs';
3 | import typescript from '@rollup/plugin-typescript';
4 | import dts from 'rollup-plugin-dts';
5 | import terser from '@rollup/plugin-terser';
6 | import peerDepsExternal from 'rollup-plugin-peer-deps-external';
7 | import postcss from 'rollup-plugin-postcss';
8 | import alias from '@rollup/plugin-alias';
9 | import path from 'path';
10 |
11 | const packageJson = require('./package.json');
12 |
13 | export default [
14 | // Build main index file
15 | {
16 | input: 'src/package/index.ts',
17 | output: [
18 | {
19 | dir: 'dist/cjs',
20 | format: 'cjs',
21 | sourcemap: true,
22 | entryFileNames: 'index.js'
23 | },
24 | {
25 | dir: 'dist',
26 | format: 'esm',
27 | sourcemap: true,
28 | entryFileNames: 'index.js'
29 | }
30 | ],
31 | plugins: [
32 | alias({
33 | entries: [
34 | { find: 'react', replacement: path.resolve(__dirname, 'node_modules/react') },
35 | { find: 'react-dom', replacement: path.resolve(__dirname, 'node_modules/react-dom') }
36 | ]
37 | }),
38 | resolve({
39 | extensions: ['.js', '.jsx', '.ts', '.tsx']
40 | }),
41 | commonjs({
42 | include: /node_modules/
43 | }),
44 | peerDepsExternal(),
45 | typescript({ tsconfig: './tsconfig.json' }),
46 | terser({
47 | output: {
48 | comments: false
49 | },
50 | compress: {
51 | drop_console: true,
52 | drop_debugger: true,
53 | pure_funcs: ['console.info', 'console.debug', 'console.warn'],
54 | passes: 3,
55 | dead_code: true,
56 | keep_fargs: false,
57 | keep_fnames: false
58 | }
59 | })
60 | ],
61 | external: ['react', 'react-dom']
62 | },
63 | // Build DevTool file separately
64 | {
65 | input: 'src/package/signify-devTool/index.tsx',
66 | output: [
67 | {
68 | dir: 'dist/cjs',
69 | format: 'cjs',
70 | sourcemap: true,
71 | entryFileNames: 'devtool.js'
72 | },
73 | {
74 | dir: 'dist',
75 | format: 'esm',
76 | sourcemap: true,
77 | entryFileNames: 'devtool.js'
78 | }
79 | ],
80 | plugins: [
81 | alias({
82 | entries: [
83 | { find: 'react', replacement: path.resolve(__dirname, 'node_modules/react') },
84 | { find: 'react-dom', replacement: path.resolve(__dirname, 'node_modules/react-dom') }
85 | ]
86 | }),
87 | resolve({
88 | extensions: ['.js', '.jsx', '.ts', '.tsx']
89 | }),
90 | commonjs({
91 | include: /node_modules/
92 | }),
93 | peerDepsExternal(),
94 | typescript({ tsconfig: './tsconfig.json' }),
95 | terser({
96 | output: {
97 | comments: false
98 | },
99 | compress: {
100 | drop_console: true,
101 | drop_debugger: true,
102 | pure_funcs: ['console.info', 'console.debug', 'console.warn'],
103 | passes: 3,
104 | dead_code: true,
105 | keep_fargs: false,
106 | keep_fnames: false
107 | }
108 | }),
109 | postcss()
110 | ],
111 | external: ['react', 'react-dom']
112 | },
113 | // Generate type definitions for main index
114 | {
115 | input: 'src/package/index.ts',
116 | output: [
117 | {
118 | dir: 'dist',
119 | format: 'cjs',
120 | entryFileNames: 'index.d.ts'
121 | }
122 | ],
123 | plugins: [dts.default()],
124 | external: [/\.css$/]
125 | },
126 | // Generate type definitions for DevTool
127 | {
128 | input: 'src/package/signify-devTool/index.tsx',
129 | output: [
130 | {
131 | dir: 'dist',
132 | format: 'cjs',
133 | entryFileNames: 'devtool.d.ts'
134 | }
135 | ],
136 | plugins: [dts.default()],
137 | external: [/\.css$/, 'react', 'react-dom']
138 | }
139 | ];
140 |
--------------------------------------------------------------------------------
/src/package/signify-devTool/index.tsx:
--------------------------------------------------------------------------------
1 | import { ElementRef, memo, MouseEvent, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
2 | import { getCookie, setCookie } from '../utils/cookies';
3 | import './index.css';
4 |
5 | let index = 100;
6 |
7 | const getRandomPastelColor = () => {
8 | const r = Math.floor(Math.random() * 128),
9 | g = Math.floor(Math.random() * 128),
10 | b = Math.floor(Math.random() * 128);
11 | return `rgb(${r}, ${g}, ${b})`;
12 | };
13 |
14 | type TDevtool = { name: string; color?: string; item: any };
15 |
16 | export const DevTool = memo(
17 | ({ name, color, item }: TDevtool) => {
18 | const popup = useRef(null);
19 | let nameCookies = `rs-${name}`,
20 | isDragging = false,
21 | isResizing = false,
22 | offsetX = 0,
23 | offsetY = 0,
24 | renderCount = 0;
25 |
26 | useLayoutEffect(() => {
27 | if (popup.current) {
28 | const { x, y, h, w }: { [key: string]: string } = JSON.parse(getCookie(nameCookies) ?? '{}');
29 | x && (popup.current.style.left = x);
30 | y && (popup.current.style.top = y);
31 | w && (popup.current.style.width = w);
32 | h && (popup.current.style.height = h);
33 | }
34 | }, []);
35 |
36 | useEffect(() => {
37 | const mouseMove = (e: globalThis.MouseEvent) => {
38 | if (isDragging && popup.current) {
39 | popup.current.style.left = `${e.clientX - offsetX}px`;
40 | popup.current.style.top = `${e.clientY - offsetY}px`;
41 | }
42 |
43 | if (isResizing && popup.current) {
44 | const rect = popup.current.getBoundingClientRect();
45 |
46 | const newWidth = e.clientX - rect.left,
47 | newHeight = e.clientY - rect.top;
48 |
49 | if (newWidth > 100) {
50 | popup.current.style.width = `${newWidth + 10}px`;
51 | }
52 |
53 | if (newHeight > 100) {
54 | popup.current.style.height = `${newHeight + 10}px`;
55 | }
56 | }
57 | };
58 |
59 | const mouseUp = () => {
60 | isDragging = false;
61 | isResizing = false;
62 | document.body.style.cursor = 'default';
63 | if (popup.current) {
64 | setCookie(
65 | nameCookies,
66 | JSON.stringify({
67 | x: popup.current.style.left,
68 | y: popup.current.style.top,
69 | w: popup.current.style.width,
70 | h: popup.current.style.height
71 | })
72 | );
73 | }
74 | };
75 |
76 | document.addEventListener('mousemove', mouseMove);
77 | document.addEventListener('mouseup', mouseUp);
78 |
79 | return () => {
80 | document.removeEventListener('mousemove', mouseMove);
81 | document.removeEventListener('mouseup', mouseUp);
82 | };
83 | }, []);
84 |
85 | const headerMouseDown = useCallback(({ clientX, clientY }: { clientX: number; clientY: number }) => {
86 | if (popup.current) {
87 | isDragging = true;
88 | popup.current.style.zIndex = `${index++}`;
89 | offsetX = clientX - popup.current?.offsetLeft;
90 | offsetY = clientY - popup.current?.offsetTop;
91 | }
92 | }, []);
93 |
94 | const resizeMouseDown = useCallback((e: MouseEvent>) => {
95 | isResizing = true;
96 | document.body.style.cursor = 'se-resize';
97 | e.preventDefault();
98 | }, []);
99 |
100 | const handleFontSize = useCallback(
101 | (isUp: boolean) => () => {
102 | if (popup.current) {
103 | if (!popup.current.style.fontSize) {
104 | popup.current.style.fontSize = '12px';
105 | }
106 | popup.current.style.fontSize = Number(popup.current.style.fontSize.replace('px', '')) + (isUp ? 2 : -2) + 'px';
107 | }
108 | },
109 | []
110 | );
111 |
112 | const syntaxHighlight = useCallback((json: string) => {
113 | if (typeof json != 'string') {
114 | json = JSON.stringify(json, undefined, 2);
115 | }
116 | json = json.replace(/&/g, '&').replace(//g, '>');
117 | // eslint-disable-next-line
118 | return json.replace(/("(\\u[a-zA-Z0-9]{4}|\ $^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
119 | let cls = 'number';
120 | if (/^"/.test(match)) {
121 | if (/:$/.test(match)) {
122 | cls = 'key';
123 | } else {
124 | cls = 'string';
125 | }
126 | } else if (/true|false/.test(match)) {
127 | cls = 'boolean';
128 | } else if (/null/.test(match)) {
129 | cls = 'null';
130 | }
131 | return '';
132 | });
133 | }, []);
134 |
135 | return (
136 |
137 |
138 |
139 |
140 | {() => (
141 | <>
142 | {name} - {++renderCount}
143 | >
144 | )}
145 |
146 |
147 |
148 |
149 |
150 |
{(n: any) => }
151 |
152 |
153 |
154 | );
155 | },
156 | () => true
157 | );
158 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Signify
2 |
3 | 
4 |
5 | # Introduction
6 |
7 | React Signify is a simple library that provides features for managing and updating global state efficiently. It is particularly useful in React applications for managing state and auto-syncing when their values change.
8 | Advantages of the library:
9 |
10 | - Lightweight library
11 | - Simple syntax
12 | - Supports effective re-render control
13 |
14 | # Project information
15 |
16 | - Git: https://github.com/VietCPQ94/react-signify
17 | - NPM: [https://www.npmjs.com/package/react-signify](https://www.npmjs.com/package/react-signify)
18 |
19 | # Installation
20 |
21 | React Signify is available as a package on NPM for use with React applications:
22 |
23 | ```
24 | # NPM
25 | npm install react-signify
26 |
27 | # Yarn
28 | yarn add react-signify
29 | ```
30 |
31 | # Overview
32 |
33 | ## Initialize
34 |
35 | You can initialize Signify in any file, please refer to the following example
36 |
37 | ```tsx
38 | import { signify } from 'react-signify';
39 |
40 | const sCount = signify(0);
41 | ```
42 |
43 | Here we create a variable `sCount` with an initial value of `0`.
44 |
45 | ## Used in many places
46 |
47 | The usage is simple with the export/import tool of the module.
48 | File Store.js (export Signify)
49 |
50 | ```tsx
51 | import { signify } from 'react-signify';
52 |
53 | export const sCount = signify(0);
54 | ```
55 |
56 | Component A (import Signify)
57 |
58 | ```tsx
59 | import { sCount } from './store';
60 |
61 | export default function ComponentA() {
62 | const countValue = sCount.use();
63 | const handleUp = () => {
64 | sCount.set(pre => {
65 | pre.value += 1;
66 | });
67 | };
68 |
69 | return (
70 |
71 |
{countValue}
72 | UP
73 |
74 | );
75 | }
76 | ```
77 |
78 | From here we can see the flexibility of Signify, simple declaration, usable everywhere.
79 |
80 | ## Basic features
81 |
82 | ### Display on the interface
83 |
84 | We will use the `html` attribute to display the value as a `string` or `number` on the interface.
85 |
86 | ```tsx
87 | import { signify } from 'react-signify';
88 |
89 | const sCount = signify(0);
90 |
91 | export default function App() {
92 | return (
93 |
94 |
{sCount.html}
95 |
96 | );
97 | }
98 | ```
99 |
100 | ### Update value
101 |
102 | ```tsx
103 | import { signify } from 'react-signify';
104 |
105 | const sCount = signify(0);
106 |
107 | export default function App() {
108 | const handleSet = () => {
109 | sCount.set(1);
110 | };
111 |
112 | const handleUp = () => {
113 | sCount.set(pre => {
114 | pre.value += 1;
115 | });
116 | };
117 |
118 | return (
119 |
120 |
{sCount.html}
121 | Set 1
122 | UP 1
123 |
124 | );
125 | }
126 | ```
127 |
128 | Pressing the button will change the value of Signify and will be automatically updated on the interface.
129 |
130 | ## Advanced features
131 |
132 | ### Use
133 |
134 | The feature allows retrieving the value of Signify and using it as a state of the component.
135 |
136 | ```tsx
137 | import { signify } from 'react-signify';
138 |
139 | const sCount = signify(0);
140 |
141 | export default function App() {
142 | const countValue = sCount.use();
143 | const handleUp = () => {
144 | sCount.set(pre => {
145 | pre.value += 1;
146 | });
147 | };
148 |
149 | return (
150 |
151 |
{countValue}
152 | UP
153 |
154 | );
155 | }
156 | ```
157 |
158 | ### watch
159 |
160 | The feature allows for safe tracking of the value changes of Signify.
161 |
162 | ```tsx
163 | import { signify } from 'react-signify';
164 |
165 | const sCount = signify(0);
166 |
167 | export default function App() {
168 | const handleUp = () => {
169 | sCount.set(pre => {
170 | pre.value += 1;
171 | });
172 | };
173 |
174 | sCount.watch(value => {
175 | console.log(value);
176 | });
177 |
178 | return (
179 |
180 | UP
181 |
182 | );
183 | }
184 | ```
185 |
186 | ### Wrap
187 |
188 | The feature applies the value of Signify in a specific interface area.
189 |
190 | ```tsx
191 | import { signify } from 'react-signify';
192 |
193 | const sCount = signify(0);
194 |
195 | export default function App() {
196 | const handleUp = () => {
197 | sCount.set(pre => {
198 | pre.value += 1;
199 | });
200 | };
201 | return (
202 |
203 |
204 | {value => (
205 |
206 |
{value}
207 |
208 | )}
209 |
210 |
UP
211 |
212 | );
213 | }
214 | ```
215 |
216 | ### Hardwrap
217 |
218 | The feature applies the value of Signify in a specific interface area and limits unnecessary re-renders when the parent component re-renders.
219 |
220 | ```tsx
221 | import { signify } from 'react-signify';
222 |
223 | const sCount = signify(0);
224 |
225 | export default function App() {
226 | const handleUp = () => {
227 | sCount.set(pre => {
228 | pre.value += 1;
229 | });
230 | };
231 | return (
232 |
233 |
234 | {value => (
235 |
236 |
{value}
237 |
238 | )}
239 |
240 |
UP
241 |
242 | );
243 | }
244 | ```
245 |
246 | ### reset
247 |
248 | A tool that allows restoring the default value. Helps free up resources when no longer in use.
249 |
250 | ```tsx
251 | import { signify } from 'react-signify';
252 |
253 | const sCount = signify(0);
254 |
255 | sCount.reset();
256 | ```
257 |
258 | # See more
259 |
260 | - [Reference API](https://reactsignify.dev?page=178ffe42-6184-4973-8c66-4990023792cb)
261 | - [Render & Update](https://reactsignify.dev?page=6fea6251-87d1-4066-97a1-ff3393ded797)
262 | - [Usage with TypeScript](https://reactsignify.dev?page=ecc96837-657b-4a13-9001-d81262ae78d8)
263 | - [Devtool](https://reactsignify.dev?page=e5e11cc8-10a6-4979-90a4-a310e9f5c8b8)
264 | - [Style Guide](https://reactsignify.dev?page=074944b4-eb6c-476f-b293-e8768f45e5dc)
265 | - [Structure](https://reactsignify.dev?page=159467bd-4bed-4d5f-af11-3b9bb20fc9d6)
266 | - [Understand Signify](https://reactsignify.dev?page=a022737b-5f0e-47a5-990f-fa9a3b62662d)
267 |
--------------------------------------------------------------------------------
/src/app/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import { cleanup, findByTestId, fireEvent, getByTestId, render, screen } from '@testing-library/react';
2 | import App from './App';
3 |
4 | const keys = [
5 | 'btn-set',
6 | 'btn-stop',
7 | 'btn-resume',
8 | 'btn-reset',
9 | 'btn-countEnableConditionRender',
10 | 'btn-countDisableConditionRender',
11 | 'btn-countEnableConditionUpdate',
12 | 'btn-countDisableConditionUpdate',
13 | 'p-html',
14 | 'p-value',
15 | 'p-use',
16 | 'p-wrap',
17 | 'p-hardwrap',
18 | 'btn-setAge',
19 | 'btn-resetUser',
20 | 'btn-stopAge',
21 | 'btn-resumeAge',
22 | 'btn-ageEnableConditionRender',
23 | 'btn-ageDisableConditionRender',
24 | 'ps-html',
25 | 'ps-value',
26 | 'ps-use',
27 | 'ps-wrap',
28 | 'ps-hardwrap',
29 | 'pu-wrap',
30 | 'pu-hardwrap',
31 | 'btnu-set',
32 | 'btnu-stop',
33 | 'btnu-resume',
34 | 'btnu-reset',
35 | 'btnu-countEnableConditionRender',
36 | 'btnu-countDisableConditionRender',
37 | 'btnu-countEnableConditionUpdate',
38 | 'btnu-countDisableConditionUpdate',
39 | 'pu-value',
40 | 'pu-use',
41 | 'pu-wrap',
42 | 'pu-hardwrap',
43 | 'puw-watch',
44 | 'btnArr-set',
45 | 'btnArr-stop',
46 | 'btnArr-resume',
47 | 'btnArr-reset',
48 | 'btnArr-countEnableConditionRender',
49 | 'btnArr-countDisableConditionRender',
50 | 'btnArr-countEnableConditionUpdate',
51 | 'btnArr-countDisableConditionUpdate',
52 | 'parr-valuePick',
53 | 'parr-value',
54 | 'parr-use',
55 | 'parr-wrap',
56 | 'parr-hardwrap'
57 | ] as const;
58 |
59 | const checkCount = (value = '0') => {
60 | Object.values(keys)
61 | .filter(n => n.includes('p-'))
62 | .forEach(n => {
63 | expect(screen.getByTestId(n).innerHTML).toEqual(value);
64 | });
65 | };
66 |
67 | const checkAge = (value = '27') => {
68 | Object.values(keys)
69 | .filter(n => n.includes('ps-'))
70 | .forEach(n => {
71 | expect(screen.getByTestId(n).innerHTML).toEqual(value);
72 | });
73 | };
74 |
75 | const checkUser = (value = '27') => {
76 | Object.values(keys)
77 | .filter(n => n.includes('pu-'))
78 | .forEach(n => {
79 | expect(screen.getByTestId(n).innerHTML).toEqual(value);
80 | });
81 | };
82 |
83 | const checkArr = (value = '0') => {
84 | Object.values(keys)
85 | .filter(n => n.includes('parr-'))
86 | .forEach(n => {
87 | expect(screen.getByTestId(n).innerHTML).toEqual(value);
88 | });
89 | };
90 |
91 | const checkWatch = (id: 'psw-watch' | 'pw-watch' | 'parrw-watch') => {
92 | expect(screen.getByTestId(id).innerHTML).toEqual('OK');
93 | };
94 |
95 | const click = (id: (typeof keys)[number]) => {
96 | fireEvent.click(screen.getByTestId(id));
97 | };
98 |
99 | beforeEach(() => {
100 | cleanup();
101 | render( );
102 | click('btn-reset');
103 | click('btn-resetUser');
104 | click('btnu-reset');
105 | click('btnArr-reset');
106 | });
107 |
108 | describe('Normal Value Testing', () => {
109 | test('[sCount] Test Signify and all element init successfull', () => {
110 | checkCount();
111 | });
112 |
113 | test('[sCount] Test FireEvent set count', () => {
114 | checkCount();
115 | click('btn-set');
116 | checkCount('1');
117 | });
118 |
119 | test('[sCount] Test FireEvent stop/resume count', () => {
120 | checkCount();
121 | click('btn-stop');
122 | click('btn-set');
123 | checkCount();
124 | click('btn-resume');
125 | checkCount('1');
126 | });
127 |
128 | test('[sCount] Test reset', () => {
129 | checkCount();
130 | click('btn-set');
131 | checkCount('1');
132 | click('btn-reset');
133 | checkCount();
134 | });
135 |
136 | test('[sCount] Test watch', () => {
137 | click('btn-set');
138 | checkWatch('pw-watch');
139 | });
140 |
141 | test('[sCount] Test condition render', () => {
142 | click('btn-countEnableConditionRender');
143 | click('btn-set');
144 | checkCount();
145 | click('btn-set');
146 | checkCount();
147 | click('btn-countDisableConditionRender');
148 | click('btn-set');
149 | checkCount('3');
150 | });
151 |
152 | test('[sCount] Test condition update', () => {
153 | click('btn-countEnableConditionUpdate');
154 | click('btn-set');
155 | checkCount('1');
156 | click('btn-set');
157 | checkCount('1');
158 | click('btn-countDisableConditionUpdate');
159 | click('btn-set');
160 | checkCount('2');
161 | });
162 | });
163 |
164 | describe('Object Value Testing', () => {
165 | test('[sUser] Test Signify and all element init successfull', () => {
166 | checkUser();
167 | });
168 |
169 | test('[sUser] Test FireEvent set count', () => {
170 | checkUser();
171 | click('btnu-set');
172 | checkUser('28');
173 | });
174 |
175 | test('[sUser] Test FireEvent stop/resume count', () => {
176 | checkUser();
177 | click('btnu-stop');
178 | click('btnu-set');
179 | checkUser();
180 | click('btnu-resume');
181 | checkUser('28');
182 | });
183 |
184 | test('[sUser] Test reset', () => {
185 | checkUser();
186 | click('btnu-set');
187 | checkUser('28');
188 | click('btnu-reset');
189 | checkUser();
190 | });
191 |
192 | test('[sUser] Test watch', () => {
193 | click('btnu-set');
194 | checkWatch('pw-watch');
195 | });
196 |
197 | test('[sUser] Test condition render', () => {
198 | checkUser();
199 | click('btnu-countEnableConditionRender');
200 | click('btnu-set');
201 | checkUser('28');
202 | click('btnu-set');
203 | checkUser('28');
204 | click('btnu-countDisableConditionRender');
205 | click('btnu-set');
206 | checkUser('30');
207 | });
208 |
209 | test('[sUser] Test condition update', () => {
210 | click('btnu-countEnableConditionUpdate');
211 | click('btnu-set');
212 | checkUser('27');
213 | click('btnu-set');
214 | checkUser('27');
215 | click('btnu-countDisableConditionUpdate');
216 | click('btnu-set');
217 | checkUser('28');
218 | });
219 | });
220 |
221 | describe('Slice Testing', () => {
222 | test('[ssAge] Test Signify and all element init successfull', () => {
223 | checkAge();
224 | });
225 |
226 | test('[ssAge] Test FireEvent stop/resume count', () => {
227 | checkAge();
228 | click('btn-stopAge');
229 | click('btn-setAge');
230 | checkAge();
231 | click('btn-resumeAge');
232 | checkAge('28');
233 | });
234 |
235 | test('[ssAge] Test watch', () => {
236 | click('btn-setAge');
237 | checkWatch('psw-watch');
238 | });
239 |
240 | test('[ssAge] Test condition render', () => {
241 | click('btn-ageEnableConditionRender');
242 | checkAge('27');
243 | click('btn-setAge');
244 | checkAge('28');
245 | click('btn-setAge');
246 | checkAge('28');
247 | click('btn-ageDisableConditionRender');
248 | click('btn-setAge');
249 | checkAge('30');
250 | });
251 | });
252 |
253 | describe('Array Value Testing', () => {
254 | test('[sLs] Test Signify and all element init successfull', () => {
255 | checkArr();
256 | });
257 |
258 | test('[sLs] Test FireEvent set count', () => {
259 | checkArr();
260 | click('btnArr-set');
261 | checkArr('1');
262 | });
263 |
264 | test('[sLs] Test FireEvent stop/resume count', () => {
265 | checkArr();
266 | click('btnArr-stop');
267 | click('btnArr-set');
268 | checkArr();
269 | click('btnArr-resume');
270 | checkArr('1');
271 | });
272 |
273 | test('[sLs] Test reset', () => {
274 | checkArr();
275 | click('btnArr-set');
276 | checkArr('1');
277 | click('btnArr-reset');
278 | checkArr();
279 | });
280 |
281 | test('[sLs] Test watch', () => {
282 | click('btnArr-set');
283 | checkWatch('parrw-watch');
284 | });
285 |
286 | test('[sLs] Test condition render', () => {
287 | click('btnArr-countEnableConditionRender');
288 | click('btnArr-set');
289 | checkArr();
290 | click('btnArr-set');
291 | checkArr();
292 | click('btnArr-countDisableConditionRender');
293 | click('btnArr-set');
294 | checkArr('3');
295 | });
296 |
297 | test('[sLs] Test condition update', () => {
298 | click('btnArr-countEnableConditionUpdate');
299 | click('btnArr-set');
300 | checkArr('1');
301 | click('btnArr-set');
302 | checkArr('1');
303 | click('btnArr-countDisableConditionUpdate');
304 | click('btnArr-set');
305 | checkArr('2');
306 | });
307 | });
308 |
--------------------------------------------------------------------------------
/src/app/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { signify } from 'react-signify';
3 |
4 | export const sCount = signify(0);
5 |
6 | export const sUser = signify({
7 | name: 'Viet',
8 | info: {
9 | age: 27,
10 | address: 'USA'
11 | }
12 | });
13 |
14 | export const sLs = signify([{ count: 0 }]);
15 |
16 | const ssAge = sUser.slice(n => n.info.age);
17 | const ssInfo = sUser.slice(n => n.info);
18 |
19 | export default function App() {
20 | const count = sCount.use();
21 | const age = ssAge.use();
22 | const ageSlicePick = ssInfo.use(n => n.age);
23 | const user = sUser.use();
24 | const agePick = sUser.use(n => n.info.age);
25 | const ls = sLs.use();
26 | const arrayPick = sLs.use(n => n[0].count);
27 | const [isWatch, setIsWatch] = useState(false);
28 | const [isWatchSlice, setIsWatchSlice] = useState(false);
29 | const [isWatchUser, setIsWatchUser] = useState(false);
30 | const [isWatchLs, setIsWatchLs] = useState(false);
31 |
32 | sCount.watch(v => {
33 | setIsWatch(true);
34 | });
35 |
36 | ssAge.watch(v => {
37 | setIsWatchSlice(true);
38 | });
39 |
40 | sUser.watch(v => {
41 | setIsWatchUser(true);
42 | });
43 |
44 | sLs.watch(v => {
45 | setIsWatchLs(true);
46 | });
47 |
48 | return (
49 | <>
50 | Normal Case
51 |
52 | {
55 | sCount.set(pre => {
56 | pre.value += 1;
57 | });
58 | }}
59 | >
60 | set
61 |
62 |
63 | stop
64 |
65 |
66 | resume
67 |
68 | {
71 | sCount.reset();
72 | }}
73 | >
74 | reset
75 |
76 | sCount.conditionRendering(v => v < 1)}>
77 | Enable condition render
78 |
79 | sCount.conditionRendering(() => true)}>
80 | Disable condition render
81 |
82 | sCount.conditionUpdating(pre => pre < 1)}>
83 | Enable condition update
84 |
85 | sCount.conditionUpdating(() => true)}>
86 | Disable condition update
87 |
88 |
89 | {sCount.html}
90 | {sCount.value}
91 | {count}
92 | {n => {n}
}
93 | {n => {n}
}
94 | {isWatch && 'OK'}
95 |
96 | Slice
97 |
98 | sUser.set(pre => (pre.value.info.age += 1))}>
99 | Set Age
100 |
101 |
102 | reset
103 |
104 |
105 | stop
106 |
107 |
108 | resume
109 |
110 | ssAge.conditionRendering(v => v < 29)}>
111 | Enable condition render
112 |
113 | ssAge.conditionRendering(() => true)}>
114 | Disable condition render
115 |
116 |
117 | {ssAge.html}
118 | {ssAge.value}
119 | {age}
120 | {ageSlicePick}
121 | {n => {n}
}
122 | {n => {n}
}
123 | {isWatchSlice && 'OK'}
124 |
125 | Object Case
126 |
127 | sUser.set(pre => (pre.value.info.age += 1))}>
128 | set
129 |
130 |
131 | stop
132 |
133 |
134 | resume
135 |
136 |
137 | reset
138 |
139 | sUser.conditionRendering(v => v.info.age < 29)}>
140 | Enable condition render
141 |
142 | sUser.conditionRendering(() => true)}>
143 | Disable condition render
144 |
145 | sUser.conditionUpdating(pre => pre.info.age < 1)}>
146 | Enable condition update
147 |
148 | sUser.conditionUpdating(() => true)}>
149 | Disable condition update
150 |
151 |
152 |
153 | {agePick}
154 | {sUser.value.info.age}
155 | {user.info.age}
156 | {n => {n.info.age}
}
157 | {n => {n.info.age}
}
158 | {isWatchUser && 'OK'}
159 |
160 |
161 | Array Case
162 |
163 | sLs.set(pre => (pre.value[0].count += 1))}>
164 | set
165 |
166 |
167 | stop
168 |
169 |
170 | resume
171 |
172 |
173 | reset
174 |
175 | sLs.conditionRendering(v => v[0].count < 1)}>
176 | Enable condition render
177 |
178 | sLs.conditionRendering(() => true)}>
179 | Disable condition render
180 |
181 | sLs.conditionUpdating(pre => pre[0].count < 1)}>
182 | Enable condition update
183 |
184 | sLs.conditionUpdating(() => true)}>
185 | Disable condition update
186 |
187 |
188 |
189 | {arrayPick}
190 | {sLs.value[0].count}
191 | {ls[0].count}
192 | {n => {n[0].count}
}
193 | {n => {n[0].count}
}
194 | {isWatchLs && 'OK'}
195 | >
196 | );
197 | }
198 |
--------------------------------------------------------------------------------
/src/package/signify-core/index.ts:
--------------------------------------------------------------------------------
1 | import { syncSystem } from '../signify-sync';
2 | import { cacheUpdateValue, getInitialValue } from '../signify-cache';
3 | import { deepClone } from '../utils/objectClone';
4 | import { deepCompare } from '../utils/objectCompare';
5 | import { HardWrapCore, WrapCore, htmlCore, subscribeCore, useCore, watchCore } from './signify.core';
6 | import { TConditionRendering, TConditionUpdate as TConditionUpdating, TListeners, TOmitHtml, TSetterCallback, TSignifyConfig, TUseValueCb } from './signify.model';
7 |
8 | /**
9 | * Signify class for managing a reactive state in a React environment.
10 | *
11 | * This class encapsulates the state management logic, providing features such
12 | * as synchronization with external systems, conditional updates and rendering,
13 | * and various utilities to interact with the state.
14 | *
15 | * @template T - Type of the state value.
16 | */
17 | class Signify {
18 | #isRender = true; // Indicates whether the component should re-render.
19 | #initialValue: T; // Stores the initial value of the state.
20 | #value: T; // Current value of the state.
21 | #config?: TSignifyConfig; // Configuration options for Signify.
22 | #listeners: TListeners = new Set(); // Listeners for state changes.
23 | #coreListeners: TListeners = new Set(); // Listeners for state changes which check every change of state1.
24 | #syncSetter?: TUseValueCb; // Function to synchronize state externally.
25 | #conditionUpdating?: TConditionUpdating; // Condition for updating the state.
26 | #conditionRendering?: TConditionRendering; // Condition for rendering.
27 |
28 | /**
29 | * Constructor to initialize the Signify instance with an initial value and optional configuration.
30 | *
31 | * @param initialValue - The initial value of the state.
32 | * @param config - Optional configuration settings for state management.
33 | */
34 | constructor(initialValue: T, config?: TSignifyConfig) {
35 | this.#initialValue = initialValue; // set initial value.
36 | this.#value = getInitialValue(deepClone(initialValue), config?.cache); // Get initial value considering caching.
37 | this.#config = config;
38 |
39 | // If synchronization is enabled, setup the sync system.
40 | if (config?.syncKey) {
41 | const { post, sync } = syncSystem({
42 | key: config.syncKey,
43 | cb: data => {
44 | this.#value = data; // Update local state with synchronized value.
45 | cacheUpdateValue(this.value, this.#config?.cache); // Update cache with new value.
46 | this.#inform(); // Notify listeners about the state change.
47 | }
48 | });
49 |
50 | this.#syncSetter = post; // Assign the sync setter function.
51 |
52 | sync(() => this.value); // Sync on value changes.
53 | }
54 | }
55 |
56 | /**
57 | * Inform all listeners about the current value if rendering is allowed.
58 | */
59 | #inform = (isEnableCore = true) => {
60 | if (this.#isRender && (!this.#conditionRendering || this.#conditionRendering(this.value))) {
61 | this.#listeners.forEach(listener => listener(this.value)); // Notify each listener with the current value.
62 | }
63 |
64 | isEnableCore && this.#coreListeners.forEach(listener => listener(this.value));
65 | };
66 |
67 | /**
68 | * Force update the current state and inform listeners of the change.
69 | *
70 | * @param value - New value to set.
71 | */
72 | #forceUpdate = (value?: T) => {
73 | if (value !== undefined) {
74 | this.#value = value; // Update current value.
75 | }
76 | cacheUpdateValue(this.value, this.#config?.cache); // Update cache if applicable.
77 | this.#syncSetter?.(this.value); // Synchronize with external system if applicable.
78 | this.#inform(); // Notify listeners about the new value.
79 | };
80 |
81 | /**
82 | * Getter for obtaining the current value of the state.
83 | */
84 | get value(): T {
85 | return this.#value;
86 | }
87 |
88 | /**
89 | * Setter function to update the state. Can take a new value or a callback function which use to update value directly.
90 | *
91 | * @param v - New value or a callback to compute the new value based on current state.
92 | */
93 | readonly set = (v: T | TSetterCallback, isForceUpdate = false) => {
94 | let tempVal: T;
95 |
96 | if (typeof v === 'function') {
97 | let params = { value: isForceUpdate ? this.#value : deepClone(this.#value) };
98 | (v as TSetterCallback)(params); // Determine new value.
99 | tempVal = params.value;
100 | } else {
101 | tempVal = v; // Determine new value.
102 | }
103 |
104 | // Check if the new value is different and meets update conditions before updating.
105 | if (isForceUpdate || (!deepCompare(this.value, tempVal) && (!this.#conditionUpdating || this.#conditionUpdating(this.value, tempVal)))) {
106 | this.#forceUpdate(tempVal); // Perform forced update if conditions are satisfied.
107 | }
108 | };
109 |
110 | /**
111 | * Stops rendering updates for this instance.
112 | */
113 | readonly stop = () => {
114 | this.#isRender = false; // Disable rendering updates.
115 | };
116 |
117 | /**
118 | * Resumes rendering updates for this instance.
119 | */
120 | readonly resume = () => {
121 | this.#isRender = true; // Enable rendering updates.
122 | this.#inform(false); // Notify listeners of any current value changes.
123 | };
124 |
125 | /**
126 | * Resets the state back to its initial value.
127 | */
128 | readonly reset = () => {
129 | this.#forceUpdate(deepClone(this.#initialValue)); // Reset to initial value.
130 | };
131 |
132 | /**
133 | * Sets a condition for updating the state. The callback receives previous and new values and returns a boolean indicating whether to update.
134 | *
135 | * @param cb - Callback function for determining update conditions.
136 | */
137 | readonly conditionUpdating = (cb: TConditionUpdating) => (this.#conditionUpdating = cb);
138 |
139 | /**
140 | * Sets a condition for rendering. The callback receives the current value and returns a boolean indicating whether to render.
141 | *
142 | * @param cb - Callback function for determining render conditions.
143 | */
144 | readonly conditionRendering = (cb: TConditionRendering) => (this.#conditionRendering = cb);
145 |
146 | /**
147 | * Function to use the current value in components. This provides reactivity to component updates based on state changes.
148 | */
149 | readonly use = useCore(this.#listeners, () => this.value);
150 |
151 | /**
152 | * Function to watch changes on state and notify listeners accordingly.
153 | */
154 | readonly watch = watchCore(this.#coreListeners);
155 |
156 | /**
157 | * Function to subscribe to state changes and notify listeners accordingly.
158 | */
159 | readonly subscribe = subscribeCore(this.#coreListeners);
160 |
161 | /**
162 | * Generates HTML output from the use function to render dynamic content based on current state.
163 | */
164 | readonly html = htmlCore(this.use);
165 |
166 | /**
167 | * A wrapper component that allows for rendering based on current state while managing reactivity efficiently.
168 | */
169 | readonly Wrap = WrapCore(this.use);
170 |
171 | /**
172 | * A hard wrapper component that provides additional control over rendering and avoids unnecessary re-renders in parent components.
173 | */
174 | readonly HardWrap = HardWrapCore(this.use);
175 |
176 | /**
177 | * Creates a sliced version of the state by applying a function to derive a part of the current value.
178 | *
179 | * @param pick - Function that extracts a portion of the current value.
180 | */
181 | readonly slice = (pick: (v: T) => P) => {
182 | let _value: P = pick(this.value), // Extracted portion of the current state.
183 | _isRender = true, // Flag to manage rendering for sliced values.
184 | _conditionRendering: TConditionRendering
| undefined; // Condition for rendering sliced values.
185 | const _listeners: TListeners
= new Set(), // Listeners for sliced values.
186 | _coreListeners: TListeners
= new Set(),
187 | _inform = (isEnableCore = true) => {
188 | const temp = pick(this.value); // Get new extracted portion of the state.
189 |
190 | if (_isRender && (!_conditionRendering || _conditionRendering(temp))) {
191 | _value = temp; // Update sliced value if conditions are met.
192 | _listeners.forEach(listener => listener(temp)); // Notify listeners of sliced value change.
193 | }
194 |
195 | isEnableCore && _coreListeners.forEach(listener => listener(temp));
196 | },
197 | use = useCore(_listeners, () => _value), // Core function for reactivity with sliced values.
198 | control = {
199 | value: _value,
200 | use,
201 | watch: watchCore(_coreListeners), // Watch changes for sliced values.
202 | html: htmlCore(use), // Generate HTML output for sliced values.
203 | Wrap: WrapCore(use), // Wrapper component for sliced values with reactivity.
204 | HardWrap: HardWrapCore(use), // Hard wrapper component for more control over rendering of sliced values.
205 | stop: () => (_isRender = false), // Stop rendering updates for sliced values.
206 | resume: () => {
207 | _isRender = true; // Resume rendering updates for sliced values.
208 | _inform(false); // Inform listeners about any changes after resuming.
209 | },
210 | conditionRendering: (cb: TConditionRendering
) => (_conditionRendering = cb), // Set condition for rendering sliced values.
211 | subscribe: subscribeCore(_coreListeners) // Subscribe to state changes for sliced values.
212 | };
213 |
214 | // Add a listener to inform when the original state changes affecting the sliced output.
215 | this.#listeners.add(() => {
216 | if (!deepCompare(pick(this.value), _value)) {
217 | _inform(); // Trigger inform if sliced output has changed due to original state change.
218 | }
219 | });
220 |
221 | Object.defineProperty(control, 'value', {
222 | get: () => _value, // Getter for accessing sliced value directly.
223 | enumerable: false,
224 | configurable: false
225 | });
226 |
227 | return control as TOmitHtml
; // Return control object without HTML methods exposed directly.
228 | };
229 | }
230 |
231 | /**
232 | * ReactSignify
233 | * -
234 | * @link https://reactsignify.dev
235 | * @description
236 | * Factory function to create a new Signify instance with an initial value and optional configuration settings.
237 | *
238 | * @template T - Type of the initial state value.
239 | * @param initialValue - The initial value to start with in Signify instance.
240 | * @param config - Optional configuration settings for Signify instance behavior.
241 | *
242 | * @returns A new instance of Signify configured with provided initial settings.
243 | */
244 | export const signify = (initialValue: T, config?: TSignifyConfig): TOmitHtml> => new Signify(initialValue, config);
245 |
--------------------------------------------------------------------------------