Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
root = true

[*]
indent_style = tab
tab_width = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 100
max_line_length = 120

[*.yml]
indent_style = space
Expand Down
46 changes: 23 additions & 23 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,27 @@ permissions:
contents: read

jobs:
# ToDo: Configure linter
# lint:
# name: "Lint"
# runs-on: ubuntu-latest
# steps:
# - name: "Checkout"
# uses: actions/checkout@v4
# with:
# fetch-depth: 0
# - name: 'Enable corepack'
# run: corepack enable
# - uses: actions/setup-node@v4
# with:
# node-version: 22
# cache: 'yarn'
#
# - name: "Install dependencies"
# run: yarn install --immutable
#
# - name: "Lint"
# run: yarn lint -f @react-hookz/gha

lint:
name: "Lint"
runs-on: ubuntu-latest
steps:
- name: "Checkout"
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 'Enable corepack'
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'yarn'

- name: "Install dependencies"
run: yarn install --immutable

- name: "Lint"
run: yarn lint -f @react-hookz/gha

build:
name: "Build"
Expand Down Expand Up @@ -98,7 +98,7 @@ jobs:
permissions:
pull-requests: write
contents: write
needs: [ "test", "build" ]
needs: [ "test", "build", "lint" ]
if: github.actor == 'dependabot[bot]' && github.event_name == 'pull_request'
steps:
- uses: fastify/github-action-merge-dependabot@v3
Expand All @@ -108,7 +108,7 @@ jobs:
semantic-release:
name: "Release"
runs-on: ubuntu-latest
needs: [ "test", "build" ]
needs: [ "test", "build", "lint" ]
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
outputs:
new-release-published: ${{ steps.release.outputs.new-release-published }}
Expand Down
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
yarn lint-staged
8 changes: 8 additions & 0 deletions .prettierrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import ver0Cfg from '@ver0/eslint-config/.prettierrc.js';

/**
* @type {import("prettier").Config}
*/
export default {
...ver0Cfg,
};
18 changes: 18 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {buildConfig} from '@ver0/eslint-config';

/** @typedef {import('eslint').Linter} Linter */

/** @type {Linter.Config[]} */
const cfg = [
...buildConfig({
globals: 'node',
react: true,
vitest: true,
}),
{
files: ['README.md'],
language: 'markdown/gfm',
},
];

export default cfg;
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
},
"license": "MIT",
"author": "Anton Zinovyev <xog3@yandex.ru>",
"engines": {
"node": ">=18"
},
"type": "module",
"files": [
"dist"
Expand All @@ -31,16 +34,21 @@
"postinstall": "husky",
"build": "yarn build:clean && yarn tsc -p tsconfig.build.json",
"build:clean": "rimraf dist",
"lint": "eslint",
"lint:fix": "eslint --fix",
"test": "vitest --run",
"test:coverage": "vitest --run --coverage"
},
"packageManager": "yarn@4.6.0",
"devDependencies": {
"@commitlint/cli": "^19.7.1",
"@commitlint/config-conventional": "^19.7.1",
"@react-hookz/eslint-formatter-gha": "^3.0.3",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@ver0/eslint-config": "^1.1.1",
"@vitest/coverage-v8": "^3.0.5",
"eslint": "^9.19.0",
"husky": "^9.1.7",
"jsdom": "^26.0.0",
"lint-staged": "^15.4.3",
Expand Down
11 changes: 6 additions & 5 deletions src/act.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import {act as reAct} from "react";
import {act as reAct} from 'react';

type reactGlobal = typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
type reactGlobal = typeof globalThis & {IS_REACT_ACT_ENVIRONMENT?: boolean};


/* v8 ignore next 7 */
/* v8 ignore next 9 */
function getGlobalThis(): reactGlobal {
if (typeof globalThis !== 'undefined') return globalThis;
// eslint-disable-next-line unicorn/prefer-global-this
if (typeof self !== 'undefined') return self;
// eslint-disable-next-line unicorn/prefer-global-this
if (typeof window !== 'undefined') return window;

throw new Error('Unable to locate global object');
Expand All @@ -20,7 +21,7 @@ function setIsReactEnvironment() {

return () => {
g.IS_REACT_ACT_ENVIRONMENT = initial;
}
};
}

/**
Expand Down
3 changes: 1 addition & 2 deletions src/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export async function hooksCleanup() {
for (const cb of cbs) {
cbs.delete(cb);

// eslint-disable-next-line no-await-in-loop
await cb();
}
}
Expand All @@ -18,8 +19,6 @@ export async function hooksCleanup() {
*/
export function cleanupAdd(cb: CleanupCallback) {
cbs.add(cb);

return () => cleanupRemove(cb);
}

/**
Expand Down
74 changes: 41 additions & 33 deletions src/create-hook-renderer.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,31 @@
import {JSXElementConstructor, ReactNode} from "react";
import type {RootOptions} from "react-dom/client";
import {cleanupAdd, cleanupRemove} from "./cleanup.js";
import type {JSXElementConstructor, ReactNode} from 'react';
import type {RootOptions} from 'react-dom/client';
import {cleanupAdd, cleanupRemove} from './cleanup.js';

/**
* Represents the result of rendering a hook.
*/
export type ResultValue<T> = {
readonly value: undefined;
readonly error: Error;
} | {
readonly value: T;
readonly error: undefined;
}
export type ResultValue<T> =
| {
readonly value: undefined;
readonly error: Error;
}
| {
readonly value: T;
readonly error: undefined;
};

/**
* Represents the last rendered hook result and all previous results.
*/
export type ResultValues<T> = ResultValue<T> & {
readonly all: ResultValue<T>[];
}
readonly all: Array<ResultValue<T>>;
};

function newResults<T>() {
const results: ResultValue<T>[] = []
const results: Array<ResultValue<T>> = [];

// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const result = {
get all() {
return [...results];
Expand All @@ -35,7 +38,7 @@ function newResults<T>() {
return undefined;
}

return results[results.length - 1].value;
return results.at(-1)!.value;
},
get error() {
// it is impossible to test it in unit-tests - results are populated
Expand All @@ -45,9 +48,9 @@ function newResults<T>() {
return undefined;
}

return results[results.length - 1].error;
return results.at(-1)!.error;
},
} as ResultValues<T>
} as ResultValues<T>;

return {
result,
Expand All @@ -56,7 +59,7 @@ function newResults<T>() {
},
setError(error: Error) {
results.push(Object.freeze({value: undefined, error}));
}
},
};
}

Expand All @@ -65,21 +68,24 @@ export type Renderer<Props> = {
render(props?: Props): Promise<void>;
rerender(props?: Props): Promise<void>;
unmount(): Promise<void>;
}
};

// Describes the props that renderer creator receives, which allows it to expose callback invocation results.
export type RendererProps<Props, Result> = {
callback(props: Props): Result;
setValue(value: Result): void;
setError(error: Error): void;
}
};

// Describes the renderer creator function that creates a renderer for a specific hook.
export type RendererCreator<Props, Result, TRenderer extends Renderer<Props>> = (props: RendererProps<Props, Result>, options?: RendererOptions<NoInfer<Props>>) => TRenderer;
export type RendererCreator<Props, Result, TRenderer extends Renderer<Props>> = (
props: RendererProps<Props, Result>,
options?: RendererOptions<NoInfer<Props>>
) => TRenderer;
export type RendererOptions<Props> = {
initialProps?: Props;
wrapper?: JSXElementConstructor<{ children: ReactNode }>
} & Pick<RootOptions, 'onCaughtError' | 'onRecoverableError'>
wrapper?: JSXElementConstructor<{children: ReactNode}>;
} & Pick<RootOptions, 'onCaughtError' | 'onRecoverableError'>;

export function createHookRenderer<Props, Result, TRenderer extends Renderer<Props>>(
createRenderer: RendererCreator<Props, Result, TRenderer>
Expand All @@ -90,24 +96,26 @@ export function createHookRenderer<Props, Result, TRenderer extends Renderer<Pro
// this variable holds previous props, so it is possible to rerender
// with previous props preserved
let hookProps = options?.initialProps;
const {render, rerender, unmount, ...rest} = createRenderer({
callback,
setValue,
setError
}, options)

const {render, rerender, unmount, ...rest} = createRenderer(
{
callback,
setValue,
setError,
},
options
);

await render(hookProps);

const rerenderHook = async (props?: Props) => {
hookProps = props ?? hookProps;
await rerender(hookProps);
}
};

const unmountHook = async () => {
cleanupRemove(unmountHook)
cleanupRemove(unmountHook);
await unmount();
}
};

cleanupAdd(unmountHook);

Expand All @@ -116,6 +124,6 @@ export function createHookRenderer<Props, Result, TRenderer extends Renderer<Pro
rerender: rerenderHook,
unmount: unmountHook,
...rest,
}
}
};
};
}
Loading