diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index b090546..d405b66 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -30,7 +30,9 @@ jobs: id: cache-node-modules with: path: '**/node_modules' - key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('**/package.json') }} + key: + ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}-${{ + hashFiles('**/package.json') }} - uses: actions/cache@v2 name: Cache Jest cache @@ -49,18 +51,18 @@ jobs: - run: yarn install id: install - + - run: yarn run build id: turbo - + - uses: codecov/codecov-action@v2.1.0 with: - token: ${{ secrets.CODECOV_TOKEN }} + token: ${{ secrets.CODECOV_TOKEN }} - uses: ArtiomTr/jest-coverage-report-action@v2.0-rc.6 with: - github-token: ${{ secrets.GITHUB_TOKEN }} - package-manager: yarn + github-token: ${{ secrets.GITHUB_TOKEN }} + package-manager: yarn - name: Tests run: yarn run test:ci diff --git a/.github/workflows/pipeline-release.yml b/.github/workflows/pipeline-release.yml index a5a16dd..e48d358 100644 --- a/.github/workflows/pipeline-release.yml +++ b/.github/workflows/pipeline-release.yml @@ -3,10 +3,10 @@ name: pipeline-release on: pull_request: branches: - - "release-*" - - "master" + - 'release-*' + - 'master' tags-ignore: - - "*" + - '*' concurrency: group: ci-tests-${{ github.ref }}-1 @@ -51,12 +51,12 @@ jobs: - run: echo "::set-output name=date::$(date +'%Y-%m-%d')" - - uses: "marvinpinto/action-automatic-releases@latest" + - uses: 'marvinpinto/action-automatic-releases@latest' with: - repo_token: "${{ secrets.GITHUB_TOKEN }}" - automatic_release_tag: "latest" + repo_token: '${{ secrets.GITHUB_TOKEN }}' + automatic_release_tag: 'latest' prerelease: true files: | LICENSE.md packages/**/*.tgz - id: "automatic_releases" + id: 'automatic_releases' diff --git a/.prettierignore b/.prettierignore index 8d128ae..9edd73c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ +.yarn/* .cache public/ build/ @@ -15,4 +16,3 @@ dist/ *.uml *.json *.js -*.d.ts diff --git a/.prettierrc.js b/.prettierrc.js index c2b40d6..fb850cc 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,15 +1,15 @@ /** * @file Prettier configuration for Conformance - * @version 1.0.6 + * @version 1.0.8 * @summary base config adapted from AirBNB to maximize performance * @schema http://json.schemastore.org/prettierrc */ 'use strict'; - module.exports = { arrowParens: 'always', bracketSpacing: true, + cursorOffset: 1, endOfLine: 'lf', jsxBracketSameLine: false, jsxSingleQuote: false, @@ -21,6 +21,5 @@ module.exports = { tabWidth: 2, trailingComma: 'all', useTabs: false, -embeddedLanguageFormatting: "auto" - + embeddedLanguageFormatting: 'auto' }; diff --git a/gen-readme.js b/gen-readme.js new file mode 100644 index 0000000..4c5bb39 --- /dev/null +++ b/gen-readme.js @@ -0,0 +1,74 @@ + +const fs = require("fs"); +const path = require("path"); + +const src = __dirname + "/../src"; + +const exclude = [ + ".DS_Store", + "util.ts", + "index.tsx", + "index.ts", + "iconIndex.tsx", +]; + +const include = [".tsx", ".ts"]; + +const excludeExt = [".json", ".md"]; + +const basicDocjs = (name) => `<${name}> `; +const basicReadme = (name) => ` + # ${name} + ## Abstract + + ## Usage + + ## Development + + ### Related + + | Library | Description | NPM | + | ------------ | ----------------------------------------- | ------------------------------------------------ | + | @disco3/types | types | https://www.npmjs.com/package/@disco3/types | + + ### License + See [LICENSE](LICENSE.md) +`; + +const sourceFiles = fs + .readdirSync(src) + .map((sourcePath) => path.parse(sourcePath)) + // .filter((parsedPath) => include.includes(parsedPath.ext)) + .filter((parsedPath) => !exclude.includes(parsedPath.base)) + .filter((parsedPath) => !excludeExt.includes(parsedPath.ext)); + +// sourceFiles.map((parsedPath) => fs.mkdirSync(src + "/" + parsedPath.name)); + +console.log(sourceFiles); + +sourceFiles.map((parsedPath) => { + // const oldPath = src + "/" + parsedPath.base; + // const newPath = src + "/" + parsedPath.name + "/" + parsedPath.base; + // console.log(oldPath, newPath); + // fs.renameSync(oldPath, newPath, function (err) { + // if (err) throw err; + // // console.log('Successfully renamed - AKA moved!') + // }); + // const readmePath = src + "/" + parsedPath.name + "/" + "README.md"; + const docsJsonPath = src + "/" + parsedPath.name + "/" + "docs.json"; + + const docsJsPath = src + "/" + parsedPath.name + "/" + "docs.js"; + + if (fs.existsSync(docsJsonPath)) { + fs.unlinkSync(docsJsonPath); + } + // fs.writeFileSync(readmePath, basicReadme(parsedPath.name), "utf8"); + + if (fs.existsSync(docsJsPath)) { + // do something + } else { + fs.writeFileSync(docsJsPath, basicDocjs(parsedPath.name), "utf8"); + } +}); + +// console.log(sourceFiles) diff --git a/package.json b/package.json index 8f3d93c..d08a096 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@disco3/monorepo", - "version": "0.0.1", + "version": "0.0.2", "private": true, "workspaces": [ "packages/*" diff --git a/packages/core/src/index.spec.ts b/packages/core/src/index.spec.ts index 605a349..409d3e6 100644 --- a/packages/core/src/index.spec.ts +++ b/packages/core/src/index.spec.ts @@ -1,9 +1,11 @@ -import { Web3ReactHooks, initializeConnector } from '.'; import { act, renderHook } from '@testing-library/react-hooks'; import type { Actions } from '@disco3/types'; import { Connector } from '@disco3/types'; +import type { Web3ReactHooks, Web3ReactPriorityHooks } from '.'; +import { getPriorityConnector, initializeConnector } from '.'; + class MockConnector extends Connector { constructor(actions: Actions) { super(actions); @@ -19,6 +21,8 @@ class MockConnector extends Connector { } } +class MockConnector2 extends MockConnector {} + describe('#initializeConnector', () => { let connector: MockConnector; let hooks: Web3ReactHooks; @@ -123,3 +127,67 @@ describe('#initializeConnector', () => { expect(error).toBeInstanceOf(Error); }); }); + +describe('#useHighestPriorityConnector', () => { + let connector: MockConnector; + let hooks: Web3ReactHooks; + let store: Web3ReactStore; + + let connector2: MockConnector; + let hooks2: Web3ReactHooks; + let store2: Web3ReactStore; + + let priorityConnectorHooks: Web3ReactPriorityHooks; + + beforeEach(() => { + [connector, hooks, store] = initializeConnector( + (actions) => new MockConnector(actions), + ); + [connector2, hooks2, store2] = initializeConnector( + (actions) => new MockConnector2(actions), + ); + + priorityConnectorHooks = getPriorityConnector( + [connector, hooks], + [connector2, hooks2], + ); + }); + + test('returns first connector if both are uninitialized', () => { + const { + result: { current: priorityConnector }, + } = renderHook(() => priorityConnectorHooks.usePriorityConnector()); + + expect(priorityConnector).toBeInstanceOf(MockConnector); + expect(priorityConnector).not.toBeInstanceOf(MockConnector2); + }); + + test('returns first connector if it is initialized', () => { + act(() => connector.update({ chainId: 1, accounts: [] })); + const { + result: { current: priorityConnector }, + } = renderHook(() => priorityConnectorHooks.usePriorityConnector()); + + const { + result: { current: isActive }, + } = renderHook(() => priorityConnectorHooks.usePriorityIsActive()); + expect(isActive).toBe(true); + + expect(priorityConnector).toBeInstanceOf(MockConnector); + expect(priorityConnector).not.toBeInstanceOf(MockConnector2); + }); + + test('returns second connector if it is initialized', () => { + act(() => connector2.update({ chainId: 1, accounts: [] })); + const { + result: { current: priorityConnector }, + } = renderHook(() => priorityConnectorHooks.usePriorityConnector()); + + const { + result: { current: isActive }, + } = renderHook(() => priorityConnectorHooks.usePriorityIsActive()); + expect(isActive).toBe(true); + + expect(priorityConnector).toBeInstanceOf(MockConnector2); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 448c263..ba9a054 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,7 @@ import type { Actions, Connector, Web3ReactState } from '@disco3/types'; -import create, { UseBoundStore } from 'zustand'; +import type { EqualityChecker, UseBoundStore } from 'zustand'; +import create from 'zustand'; + import { useEffect, useMemo, useState } from 'react'; import type { Networkish } from '@ethersproject/networks'; @@ -10,10 +12,23 @@ export type Web3ReactHooks = ReturnType & ReturnType & ReturnType; +export type Web3ReactPriorityHooks = ReturnType; + +/** + * Wraps the initialization of a `connector`. Creates a zustand `store` with `actions` bound to it, and then passes + * these to the connector as specified in `f`. Also creates a variety of `hooks` bound to this `store`. + * + * @typeParam T - The type of the `connector` returned from `f`. + * @param f - A function which is called with `actions` bound to the returned `store`. + * @param allowedChainIds - An optional array of chainIds which the `connector` may connect to. If the `connector` is + * connected to a chainId which is not allowed, a ChainIdNotAllowedError error will be reported. + * If this argument is unspecified, the `connector` may connect to any chainId. + * @returns [connector, hooks, store] - The initialized connector, a variety of hooks, and a zustand store. + */ export function initializeConnector( f: (actions: Actions) => T, allowedChainIds?: number[], -): [T, Web3ReactHooks] { +): [T, Web3ReactHooks, Web3ReactStore] { const [store, actions] = createWeb3ReactStoreAndActions(allowedChainIds); const connector = f(actions); @@ -21,14 +36,157 @@ export function initializeConnector( const stateHooks = getStateHooks(useConnector); const derivedHooks = getDerivedHooks(stateHooks); - const augmentedHooks = getAugmentedHooks(connector, stateHooks, derivedHooks); - return [connector, { ...stateHooks, ...derivedHooks, ...augmentedHooks }]; + return [ + connector, + { ...stateHooks, ...derivedHooks, ...augmentedHooks }, + store, + ]; +} + +function computeIsActive({ + chainId, + accounts, + activating, + error, +}: Web3ReactState) { + return Boolean(chainId && accounts && !activating && !error); +} + +/** + * Creates a variety of convenience `hooks` that return data associated with the first of the `initializedConnectors` + * that is active. + * + * @param initializedConnectors - Two or more [connector, hooks] arrays, as returned from initializeConnector. + * @returns hooks - A variety of convenience hooks that wrap the hooks returned from initializeConnector. + */ +export function getPriorityConnector( + ...initializedConnectors: [Connector, Web3ReactHooks][] +) { + // the following code calls hooks in a map a lot, which violates the eslint rule. + // this is ok, though, because initializedConnectors never changes, so the same hooks are called each time + + function useActiveIndex() { + // eslint-disable-next-line react-hooks/rules-of-hooks + const values = initializedConnectors.map(([, { useIsActive }]) => + useIsActive(), + ); + const index = values.findIndex((isActive) => isActive); + return index === -1 ? undefined : index; + } + + function usePriorityConnector() { + return initializedConnectors[useActiveIndex() ?? 0][0]; + } + + function usePriorityChainId() { + // eslint-disable-next-line react-hooks/rules-of-hooks + const values = initializedConnectors.map(([, { useChainId }]) => + useChainId(), + ); + return values[useActiveIndex() ?? 0]; + } + + function usePriorityAccounts() { + // eslint-disable-next-line react-hooks/rules-of-hooks + const values = initializedConnectors.map(([, { useAccounts }]) => + useAccounts(), + ); + return values[useActiveIndex() ?? 0]; + } + + function usePriorityIsActivating() { + // eslint-disable-next-line react-hooks/rules-of-hooks + const values = initializedConnectors.map(([, { useIsActivating }]) => + useIsActivating(), + ); + return values[useActiveIndex() ?? 0]; + } + + function usePriorityError() { + // eslint-disable-next-line react-hooks/rules-of-hooks + const values = initializedConnectors.map(([, { useError }]) => useError()); + return values[useActiveIndex() ?? 0]; + } + + function usePriorityAccount() { + // eslint-disable-next-line react-hooks/rules-of-hooks + const values = initializedConnectors.map(([, { useAccount }]) => + useAccount(), + ); + return values[useActiveIndex() ?? 0]; + } + + function usePriorityIsActive() { + // eslint-disable-next-line react-hooks/rules-of-hooks + const values = initializedConnectors.map(([, { useIsActive }]) => + useIsActive(), + ); + return values[useActiveIndex() ?? 0]; + } + + function usePriorityProvider(network?: Networkish) { + const index = useActiveIndex(); + // eslint-disable-next-line react-hooks/rules-of-hooks + const values = initializedConnectors.map(([, { useProvider }], i) => + useProvider(network, i === index), + ); + return values[index ?? 0]; + } + + function usePriorityENSNames(provider: Web3Provider | undefined) { + const index = useActiveIndex(); + const values = initializedConnectors.map(([, { useENSNames }], i) => + // eslint-disable-next-line react-hooks/rules-of-hooks + useENSNames(i === index ? provider : undefined), + ); + return values[index ?? 0]; + } + + function usePriorityENSName(provider: Web3Provider | undefined) { + const index = useActiveIndex(); + // eslint-disable-next-line react-hooks/rules-of-hooks + const values = initializedConnectors.map(([, { useENSName }], i) => + useENSName(i === index ? provider : undefined), + ); + return values[index ?? 0]; + } + + function usePriorityWeb3React(provider: Web3Provider | undefined) { + const index = useActiveIndex(); + const values = initializedConnectors.map(([, { useWeb3React }], i) => + // eslint-disable-next-line react-hooks/rules-of-hooks + useWeb3React(i === index ? provider : undefined), + ); + return values[index ?? 0]; + } + + return { + usePriorityConnector, + usePriorityChainId, + usePriorityAccounts, + usePriorityIsActivating, + usePriorityError, + usePriorityAccount, + usePriorityIsActive, + usePriorityProvider, + usePriorityENSNames, + usePriorityENSName, + usePriorityWeb3React, + }; } const CHAIN_ID = (state: Web3ReactState) => state.chainId; const ACCOUNTS = (state: Web3ReactState) => state.accounts; +const ACCOUNTS_EQUALITY_CHECKER: EqualityChecker = ( + oldAccounts, + newAccounts, +) => + (oldAccounts === undefined && newAccounts === undefined) || + (oldAccounts !== undefined && + oldAccounts.length === newAccounts?.length && + oldAccounts.every((oldAccount, i) => oldAccount === newAccounts[i])); const ACTIVATING = (state: Web3ReactState) => state.activating; const ERROR = (state: Web3ReactState) => state.error; @@ -38,7 +196,7 @@ function getStateHooks(useConnector: UseBoundStore) { } function useAccounts(): Web3ReactState['accounts'] { - return useConnector(ACCOUNTS); + return useConnector(ACCOUNTS, ACCOUNTS_EQUALITY_CHECKER); } function useIsActivating(): Web3ReactState['activating'] { @@ -68,7 +226,12 @@ function getDerivedHooks({ const activating = useIsActivating(); const error = useError(); - return Boolean(chainId && accounts && !activating && !error); + return computeIsActive({ + chainId, + accounts, + activating, + error, + }); } return { useAccount, useIsActive }; @@ -109,25 +272,27 @@ function getAugmentedHooks( { useChainId, useAccounts, useError }: ReturnType, { useAccount, useIsActive }: ReturnType, ) { - function useProvider(network?: Networkish): Web3Provider | undefined { + function useProvider( + network?: Networkish, + enabled = true, + ): Web3Provider | undefined { const isActive = useIsActive(); const chainId = useChainId(); - const accounts = useChainId(); + const accounts = useAccounts(); return useMemo(() => { // we use chainId and accounts to re-render in case connector.provider changes in place - if (isActive && connector.provider && chainId && accounts) { + if (enabled && isActive && connector.provider && chainId && accounts) { return new Web3Provider(connector.provider, network); } - }, [isActive, network, chainId, accounts]); + }, [enabled, isActive, network, chainId, accounts]); } function useENSNames( provider: Web3Provider | undefined, ): (string | null)[] | undefined { const accounts = useAccounts(); - return useENS(provider, accounts); } @@ -135,26 +300,33 @@ function getAugmentedHooks( provider: Web3Provider | undefined, ): (string | null) | undefined { const account = useAccount(); + const accounts = useMemo( + () => (account === undefined ? undefined : [account]), + [account], + ); - return useENS(provider, account === undefined ? undefined : [account])?.[0]; + return useENS(provider, accounts)?.[0]; } // for backwards compatibility only function useWeb3React(provider: Web3Provider | undefined) { const chainId = useChainId(); + const account = useAccount(); const error = useError(); - const account = useAccount(); const isActive = useIsActive(); - return { - connector, - library: provider, - chainId, - account, - active: isActive, - error, - }; + return useMemo( + () => ({ + connector, + library: provider, + chainId, + account, + active: isActive, + error, + }), + [provider, chainId, account, isActive, error], + ); } return { useProvider, useENSNames, useENSName, useWeb3React }; diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 1b9b915..37064e3 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -61,6 +61,12 @@ export function createWeb3ReactStoreAndActions( // flag for tracking updates so we don't clobber data when cancelling activation let nullifier = 0; + /** + * Sets activating to true, indicating that an update is in progress. + * + * @returns cancelActivation - A function that cancels the activation by setting activating to false, + * as long as there haven't been any intervening updates. + */ function startActivation(): () => void { const nullifierCached = ++nullifier; @@ -74,6 +80,13 @@ export function createWeb3ReactStoreAndActions( }; } + /** + * Used to report a `stateUpdate` which is merged with existing state. The first `stateUpdate` that results in chainId + * and accounts being set will also set activating to false, indicating a successful connection. Similarly, if an + * error is set, the first `stateUpdate` that results in chainId and accounts being set will clear this error. + * + * @param stateUpdate - The state update to report. + */ function update(stateUpdate: Web3ReactStateUpdate): void { // validate chainId statically, independent of existing state if (stateUpdate.chainId !== undefined) { @@ -135,7 +148,12 @@ export function createWeb3ReactStoreAndActions( }); } - function reportError(error: Error | undefined) { + /** + * Used to report an `error`, which clears all existing state. + * + * @param error - The error to report. If undefined, the state will be reset to its default value. + */ + function reportError(error: Error | undefined): void { nullifier++; store.setState(() => ({ ...DEFAULT_STATE, error })); diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index d9d4c27..987d9ef 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -1,6 +1,5 @@ -import type { State, StoreApi } from 'zustand/vanilla'; - import type { EventEmitter } from 'node:events'; +import type { State, StoreApi } from 'zustand/vanilla'; export interface Web3ReactState extends State { chainId: number | undefined; @@ -11,15 +10,24 @@ export interface Web3ReactState extends State { export type Web3ReactStore = StoreApi; -export interface Web3ReactStateUpdate { - chainId?: number; - accounts?: string[]; -} +export type Web3ReactStateUpdate = + | { + chainId: number; + accounts: string[]; + } + | { + chainId: number; + accounts?: never; + } + | { + chainId?: never; + accounts: string[]; + }; export interface Actions { startActivation: () => () => void; update: (stateUpdate: Web3ReactStateUpdate) => void; - reportError: (error: Error) => void; + reportError: (error: Error | undefined) => void; } // per EIP-1193 @@ -45,21 +53,40 @@ export interface ProviderRpcError extends Error { data?: unknown; } -// per EIP-1193 export interface ProviderMessage { readonly type: string; readonly data: unknown; } export abstract class Connector { + /** + * An + * EIP-1193 ({@link https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1193.md}) and + * EIP-1102 ({@link https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1102.md}) compliant provider. + * This property must be defined while the connector is active. + */ public provider: Provider | undefined; protected readonly actions: Actions; + /** + * @param actions - Methods bound to a zustand store that tracks the state of the connector. + * Actions are used by the connector to report changes in connection status. + */ constructor(actions: Actions) { this.actions = actions; } + /** + * Initiate a connection. + */ public abstract activate(...args: unknown[]): Promise | void; - public deactivate?(...args: unknown[]): Promise | void; + + /** + * Initiate a disconnect. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public deactivate(..._args: unknown[]): Promise | void { + this.actions.reportError(undefined); + } } diff --git a/packages/types/tsconfig.cjs.json b/packages/types/tsconfig.cjs.json index 08d87e8..ecd311e 100644 --- a/packages/types/tsconfig.cjs.json +++ b/packages/types/tsconfig.cjs.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.cjs.json", "include": ["./src"], "compilerOptions": { + "noUnusedLocals": false, "outDir": "./dist/cjs" } } diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json index 67531bb..5ec2b72 100644 --- a/packages/types/tsconfig.json +++ b/packages/types/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.json", "include": ["./src"], "compilerOptions": { + "noUnusedLocals": false, "outDir": "./dist" } } diff --git a/tsconfig.json b/tsconfig.json index 73a74cc..6b98038 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,7 @@ "strictPropertyInitialization": true, "noImplicitThis": true, "alwaysStrict": true, - "noUnusedLocals": true, + "noUnusedLocals": false, "noUnusedParameters": true, "noImplicitReturns": false, "noFallthroughCasesInSwitch": true,