diff --git a/.eslintignore b/.eslintignore index 76add87..507cfd8 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,3 @@ node_modules -dist \ No newline at end of file +dist +jest.*config.js \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 9b9b723..7fdbd80 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -14,6 +14,13 @@ module.exports = { 'plugin:react/jsx-runtime', 'google', ], + 'root': true, + 'parser': '@typescript-eslint/parser', + 'extends': [ + 'eslint:recommended', + 'plugin:@typescript-eslint/eslint-recommended', + 'plugin:@typescript-eslint/recommended', + ], 'parserOptions': { 'ecmaFeatures': { 'jsx': true, @@ -26,6 +33,7 @@ module.exports = { 'react-hooks', 'testing-library', 'jest', + '@typescript-eslint', ], 'rules': { 'require-jsdoc': 0, diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 49c1bf3..384c1cb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,28 +1,24 @@ -name: CI -on: [pull_request] +name: Test + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + jobs: build: - name: Test + runs-on: ubuntu-latest - strategy: - matrix: - node-version: [14.x] + steps: - - uses: actions/checkout@v2 - - uses: preactjs/compressed-size-action@v2 + - uses: actions/checkout@v3 + - name: Use Node.js 17 + uses: actions/setup-node@v3 with: - compression: 'brotli' - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} + node-version: '17' - name: Install dependencies - run: | - npm install -g yarn - - name: yarn install, build, and test - run: | - yarn install - yarn build - yarn run bundlewatch - yarn lint - yarn test + run: npm install + - run: npm run build + - run: npm run bundlewatch + - run: npm test \ No newline at end of file diff --git a/babel.config.js b/babel.config.js index f49cdf8..ee95f71 100644 --- a/babel.config.js +++ b/babel.config.js @@ -10,6 +10,7 @@ module.exports = { }, ], ['@babel/preset-react', {runtime: 'automatic'}], + '@babel/preset-typescript', ], plugins: [], }; diff --git a/package.json b/package.json index f51d9a7..02227c3 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "teaful", - "version": "0.10.0", + "version": "0.11.0-canary.2", "description": "Tiny, easy and powerful React state management (less than 1kb)", "license": "MIT", "keywords": [ "react", + "typescript", "preact", "state", "state management", @@ -23,7 +24,7 @@ "type": "git", "url": "https://github.com/teafuljs/teaful.git" }, - "source": "package/index.js", + "source": "package/index.ts", "main": "dist/index.js", "umd:main": "dist/index.umd.js", "module": "dist/index.m.js", @@ -39,7 +40,7 @@ }, { "path": "./dist/index.modern.js", - "maxSize": "1 kB" + "maxSize": "1.1 kB" }, { "path": "./dist/index.m.js", @@ -51,18 +52,19 @@ } ] }, - "types": "package/index.d.ts", + "types": "dist/index.d.ts", "scripts": { "lint": "eslint ./package ./tests", - "format": "eslint --fix ./package ./tests ./examples", - "test": "jest ./package ./tests", + "format": "eslint --fix ./package ./tests", + "format:examples": "eslint --fix ./examples", + "test": "jest", "test:example:todo-list": "jest ./examples/todo-list", "test:examples": "jest ./examples", - "test:watch": "jest ./package ./tests --watch", - "copy-types": "node ./config/copy-type-definition.js", - "build": "microbundle --jsx React.createElement --no-generateTypes && yarn copy-types", + "test:watch": "jest ./tests --watch", + "build": "microbundle --jsx React.createElement", "dev": "microbundle watch", - "prepublish": "yarn build" + "prepublish": "yarn build", + "bundlewatch": "bundlewatch" }, "peerDependencies": { "react": ">= 16.8.0", @@ -74,33 +76,43 @@ } }, "jest": { - "testEnvironment": "jsdom", - "moduleNameMapper": { - "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js", - "\\.(css|less)$": "identity-obj-proxy" - } + "projects": [ + "./tests/config/jest.config.js", + "./tests/config/jest.lint.config.js", + "./tests/config/jest.tsc.config.js" + ] + }, + "jest-runner-tsc": { + "tsconfigPath": "./tsconfig.json" }, "devDependencies": { "@babel/polyfill": "7.12.1", - "@babel/preset-env": "7.16.4", - "@babel/preset-react": "7.16.0", - "@testing-library/dom": "8.11.1", - "@testing-library/react": "12.1.2", + "@babel/preset-env": "7.16.11", + "@babel/preset-react": "7.16.7", + "@babel/preset-typescript": "7.16.7", + "@testing-library/dom": "8.11.3", + "@testing-library/react": "12.1.4", "@testing-library/user-event": "13.5.0", - "babel-jest": "27.4.2", - "bundlewatch": "0.3.2", - "eslint": "8.4.0", + "@types/jest": "27.4.1", + "@types/react": "17.0.40", + "@types/react-dom": "17.0.13", + "@typescript-eslint/eslint-plugin": "5.15.0", + "@typescript-eslint/parser": "5.15.0", + "babel-jest": "27.5.1", + "bundlewatch": "0.3.3", + "eslint": "8.11.0", "eslint-config-google": "0.14.0", - "eslint-plugin-jest": "25.3.0", - "eslint-plugin-react": "7.27.1", + "eslint-plugin-jest": "26.1.1", + "eslint-plugin-react": "7.29.4", "eslint-plugin-react-hooks": "4.3.0", - "eslint-plugin-testing-library": "5.0.1", - "jest": "27.4.3", + "eslint-plugin-testing-library": "5.1.0", + "jest": "27.5.1", + "jest-runner-eslint": "1.0.0", + "jest-runner-tsc": "1.6.0", "microbundle": "0.14.2", "react": "17.0.2", "react-dom": "17.0.2", - "react-test-renderer": "17.0.2", - "shelljs": "0.8.4" + "react-test-renderer": "17.0.2" }, "bugs": "https://github.com/teafuljs/teaful/issues" } diff --git a/package/index.d.ts b/package/index.d.ts deleted file mode 100644 index 2d61e05..0000000 --- a/package/index.d.ts +++ /dev/null @@ -1,61 +0,0 @@ -declare module "teaful" { - - import React from "react"; - - type setter = (value?: T | ((value: T) => T | undefined | null) ) => void; - type HookReturn = [T, setter]; - type initialStoreType = Record; - - type Hook = ( - initial?: S, - onAfterUpdate?: afterCallbackType - ) => HookReturn; - - type HookDry = (initial?: S) => HookReturn; - - export type Hoc = { store: HookReturn }; - - type HocFunc = ( - component: R, - initial?: S, - onAfterUpdate?: afterCallbackType - ) => R; - - type afterCallbackType = (param: { - store: S; - prevStore: S; - }) => void; - - type getStoreType = { - [key in keyof S]: S[key] extends initialStoreType - ? useStoreType & HookDry : HookDry; - }; - - type useStoreType = { - [key in keyof S]: S[key] extends initialStoreType - ? useStoreType & Hook : Hook; - }; - - type setStoreType = { - [key in keyof S]: S[key] extends initialStoreType - ? setStoreType & setter : setter; - }; - - type withStoreType = { - [key in keyof S]: S[key] extends initialStoreType - ? withStoreType & HocFunc - : HocFunc; - }; - - function createStore( - initial?: S, - afterCallback?: afterCallbackType - ): { - getStore: HookDry & getStoreType; - useStore: Hook & useStoreType; - setStore: setter & setStoreType; - withStore: HocFunc & withStoreType; - }; - - export default createStore; -} diff --git a/package/index.js b/package/index.ts similarity index 64% rename from package/index.js rename to package/index.ts index 055aeb2..997b2a1 100644 --- a/package/index.js +++ b/package/index.ts @@ -1,17 +1,21 @@ -import {useEffect, useReducer, createElement} from 'react'; +import {useEffect, useReducer, createElement, ComponentClass, FunctionComponent} from 'react'; +import type { Args, ArgsHoc, ExtraFn, Hoc, Listener, ListenersObj, ReducerFn, Result, Store, Subscription, Validator } from './types'; let MODE_GET = 1; let MODE_USE = 2; let MODE_WITH = 3; let MODE_SET = 4; let DOT = '.'; -let extras = []; +let extras: ExtraFn[] = []; -export default function createStore(defaultStore = {}, callback) { - let subscription = createSubscription(); +export default function createStore( + initial: S = {} as S, + callback?: Listener +) { + let subscription = createSubscription(); // Initialize the store and callbacks - let allStore = defaultStore; + let allStore = initial; // Add callback subscription subscription._subscribe(DOT, callback); @@ -20,29 +24,38 @@ export default function createStore(defaultStore = {}, callback) { * Proxy validator that implements: * - useStore hook proxy * - getStore helper proxy + * - setStore helper proxy * - withStore HoC proxy */ - let validator = { - _path: [], - _getHoC(Comp, path, initValue, callback) { - let componentName = Comp.displayName || Comp.name || ''; - let WithStore = (props) => { + let validator: Validator = { + _path: [] as string[], + _getHoC( + Comp: ComponentClass>, + path: string[], + initValue: S, + callback?: Listener + ) { + let componentName = Comp.displayName || Comp.name; + let WithStore: FunctionComponent = (props) => { let last = path.length - 1; let store = path.length ? path.reduce( - (a, c, index) => index === last ? a[c](initValue, callback) : a[c], + (a: Store, c: string, index) => index === last + ? a[c](initValue, callback) + : a[c], useStore, ) : useStore(initValue, callback); - return createElement(Comp, {...props, store}); + return createElement>( + Comp, {...props, store} + ); }; WithStore.displayName = `withStore(${componentName})`; return WithStore; }, - get(target, path) { - if (path === 'prototype') return {}; + get(target: () => number, path: string) { this._path.push(path); - return new Proxy(target, validator); + return path === 'prototype' ? {} : new Proxy(target, validator); }, - apply(getMode, _, args) { + apply(getMode: () => number, _: unknown, args: Args & ArgsHoc) { let mode = getMode(); let param = args[0]; let callback = args[1]; @@ -96,7 +109,7 @@ export default function createStore(defaultStore = {}, callback) { return [value, update]; }, }; - let createProxy = (mode) => new Proxy(() => mode, validator); + let createProxy = (mode: number) => new Proxy(() => mode, validator); let useStore = createProxy(MODE_USE); let getStore = createProxy(MODE_GET); let withStore = createProxy(MODE_WITH); @@ -108,7 +121,8 @@ export default function createStore(defaultStore = {}, callback) { * @param {string} path * @param {function} callback */ - function useSubscription(path, callback) { + function useSubscription(path: string, callback?: Listener) { + // @ts-expect-error - useReducer as forceRender without rest of args let forceRender = useReducer(() => [])[1]; useEffect(() => { @@ -130,7 +144,7 @@ export default function createStore(defaultStore = {}, callback) { function updateField(path = '') { let fieldPath = Array.isArray(path) ? path : path.split(DOT); - return (newValue) => { + return (newValue: unknown) => { let prevStore = allStore; let value = newValue; @@ -152,51 +166,57 @@ export default function createStore(defaultStore = {}, callback) { }; } - function getField(path, fn = (a, c) => a?.[c]) { + function getField( + path?: string[] | string, + fn: ReducerFn = (a, c) => a?.[c] + ) { if (!path) return allStore; - return (Array.isArray(path) ? path : path.split(DOT)).reduce(fn, allStore); + return (Array.isArray(path) ? path : path.split(DOT)) + .reduce(fn, allStore); } - function setField(store, [prop, ...rest], value) { - let newObj = Array.isArray(store) ? [...store] : {...store}; + function setField(store: Store, [prop, ...rest]: string[], value: any) { + let newObj: any = Array.isArray(store) ? [...store] : {...store}; newObj[prop] = rest.length ? setField(store[prop], rest, value) : value; return newObj; } - function existProperty(path) { - return getField(path, (a, c, index, arr) => { - if (index === arr.length - 1) return c in (a || {}); - return a?.[c]; + function existProperty(path: string[] | string) { + return getField(path, (a = {}, c, index, arr) => { + if (index === (arr as string[]).length - 1) return c in a; + return a[c]; }); } let result = extras.reduce((res, fn) => { let newRes = fn(res, subscription); return typeof newRes === 'object' ? {...res, ...newRes} : res; - }, {useStore, getStore, withStore, setStore}); + }, {useStore, getStore, withStore, setStore} as Result); /** * createStore function returns: * - useStore hook * - getStore helper + * - setStore helper * - withStore HoC * - extras that 3rd party can add * @returns {object} */ - return result; + return result as Result; } -createStore.ext = (extra) => typeof extra === 'function' && extras.push(extra); +createStore.ext = (extra: ExtraFn) => extras.push(extra); -function createSubscription() { - let listeners = {}; +function createSubscription(): Subscription { + let listeners: ListenersObj = {}; return { // Renamed to "s" after build to minify code _subscribe(path, listener) { - if (typeof listener !== 'function') return; - if (!listeners[path]) listeners[path] = new Set(); - listeners[path].add(listener); + if (typeof listener === 'function') { + if (!listeners[path]) listeners[path] = new Set(); + listeners[path].add(listener); + } }, // Renamed to "n" after build to minify code _notify(path, params) { @@ -208,9 +228,10 @@ function createSubscription() { }, // Renamed to "u" after build to minify code _unsubscribe(path, listener) { - if (typeof listener !== 'function') return; - listeners[path].delete(listener); - if (listeners[path].size === 0) delete listeners[path]; + if (typeof listener === 'function') { + listeners[path].delete(listener); + if (listeners[path].size === 0) delete listeners[path]; + } }, }; } diff --git a/package/types.ts b/package/types.ts new file mode 100644 index 0000000..925e683 --- /dev/null +++ b/package/types.ts @@ -0,0 +1,103 @@ +import {ComponentClass} from 'react'; + +export type Setter = ( + value?: T | ((value: T) => T | undefined | null) +) => void; + +export type HookReturn = [T, Setter]; +export type Store = Record; + +export type ReducerFn = ( + a: Store, + c: string, + index?: number, + arr?: string[] +) => any + +export type Params = { + store: S, + prevStore: S +}; + +export type ListenersObj = { + [key: string]: Set> +} + +export type Subscription = { + _subscribe(path: string, listener?: Listener): void; + _unsubscribe(path: string, listener?: Listener): void; + _notify(path: string, params: Params): void; +} + +export type ExtraFn = ( + res: Result, + subscription: Subscription +) => Extra; + +export type Extra = { + [key: string]: any; +} +export type ValueOf = T[keyof T]; + +export type Validator = ProxyHandler>> & Extra + +export type Hook = ( + initial?: S, + onAfterUpdate?: Listener +) => HookReturn; + +export type HookDry = (initial?: S) => HookReturn; + +export type Hoc = { store: HookReturn }; + +export type Args = [ + param: S | ComponentClass>, + callback: Listener | undefined, +] + +export type ArgsHoc = [ + component: ComponentClass>, + param: S, + callback: Listener | undefined +] + +export type HocFunc = ComponentClass> = ( + component: R, + initial?: S, + onAfterUpdate?: Listener +) => R & { store: useStoreType }; + +export type Listener = (params: Params) => void; + +export type getStoreType = { + [key in keyof S]: S[key] extends Store + ? useStoreType & HookDry : HookDry; +}; + +export type setStoreType = { + [key in keyof S]: S[key] extends Store + ? setStoreType & Setter : Setter; +}; + +export type useStoreType = { + [key in keyof S]: S[key] extends Store + ? useStoreType & Hook : Hook; +}; + +export type withStoreType = { + [key in keyof S]: S[key] extends Store + ? withStoreType & HocFunc + : HocFunc; +}; + +type Complete = { + [P in keyof Required]: + Pick extends Required> ? T[P] : (T[P] | undefined); +} + +export type Result = { + getStore: HookDry> & getStoreType>; + useStore: Hook> & useStoreType>; + withStore: HocFunc> & withStoreType>; + setStore: Setter> & setStoreType>; +} & Extra diff --git a/tests/config/jest.config.js b/tests/config/jest.config.js new file mode 100644 index 0000000..b455488 --- /dev/null +++ b/tests/config/jest.config.js @@ -0,0 +1,9 @@ +const path = require('path') + +module.exports = { + rootDir: path.join(__dirname, '../../'), + testEnvironment: 'jsdom', + displayName: 'test', + moduleFileExtensions: ['js', 'jsx','ts', 'tsx'], + testMatch: ['/tests/*.tsx'], +} \ No newline at end of file diff --git a/tests/config/jest.lint.config.js b/tests/config/jest.lint.config.js new file mode 100644 index 0000000..06cf728 --- /dev/null +++ b/tests/config/jest.lint.config.js @@ -0,0 +1,8 @@ +const path = require('path') + +module.exports = { + rootDir: path.join(__dirname, '../../'), + runner: 'jest-runner-eslint', + displayName: 'lint', + testMatch: ['/tests/*.tsx'], +}; diff --git a/tests/config/jest.tsc.config.js b/tests/config/jest.tsc.config.js new file mode 100644 index 0000000..e2cd7f5 --- /dev/null +++ b/tests/config/jest.tsc.config.js @@ -0,0 +1,12 @@ +const path = require('path') + +// jest-runner-tsc doesn't run tests. It runs the tsc (typescript compiler) +// on the typescript files. Then, it make sense to test that there aren't +// TypeScript issues on the tests (tests are using the Teaful types). +module.exports = { + rootDir: path.join(__dirname, '../../'), + runner: 'jest-runner-tsc', + displayName: 'tsc', + moduleFileExtensions: ['js', 'jsx','ts', 'tsx'], + testMatch: ['/tests/*.tsx'], +}; diff --git a/tests/example.counter.test.js b/tests/example.counter.test.tsx similarity index 90% rename from tests/example.counter.test.js rename to tests/example.counter.test.tsx index d822922..bf8d8ef 100644 --- a/tests/example.counter.test.js +++ b/tests/example.counter.test.tsx @@ -8,8 +8,9 @@ import createStore from '../package/index'; describe('Example: Counter', () => { it('should work with a simple counter', () => { + type Count = { count: number }; const initialStore = {count: 0}; - const {useStore} = createStore(initialStore); + const {useStore} = createStore(initialStore); function Counter() { const [count, setCount] = useStore.count(); @@ -65,9 +66,18 @@ describe('Example: Counter', () => { it('should work with a counter in a class component', async () => { const initialStore = {count: 0}; - const {useStore, withStore} = createStore(initialStore); - class Counter extends Component { + type storeType = { + count: number; + } + + const {useStore, withStore} = createStore(initialStore); + + type Props = { + store: ReturnType; + } + + class Counter extends Component { render() { const [count, setCount] = this.props.store; return ( @@ -129,10 +139,15 @@ describe('Example: Counter', () => { }); it('should work with a counter in a class component: with all store', async () => { + type Count = { count: number }; const initialStore = {count: 0}; - const {useStore, withStore} = createStore(initialStore); + const {useStore, withStore} = createStore(initialStore); + + type Props = { + store: ReturnType; + } - class Counter extends Component { + class Counter extends Component { render() { const [store, setStore] = this.props.store; return ( @@ -203,10 +218,15 @@ describe('Example: Counter', () => { it('should work with a counter: as a new value (not defined on the store)', async () => { - const {useStore} = createStore({anotherValue: ''}); + type storeType = { + anotherValue: string, + count?: number + } + + const {useStore} = createStore({anotherValue: ''}); function Counter() { - const initialCountValue = useRef(); + const initialCountValue = useRef(); const [count, setCount] = useStore.count(); useEffect(() => { @@ -221,8 +241,8 @@ describe('Example: Counter', () => { return (

{count}

- - + +