mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 19:04:46 +00:00
Compare commits
2 Commits
09fe8079c0
...
feat/stdli
| Author | SHA1 | Date | |
|---|---|---|---|
| a61fb85088 | |||
| 01b13d6a65 |
12
.github/workflows/ci.yaml
vendored
12
.github/workflows/ci.yaml
vendored
@@ -16,14 +16,14 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
@@ -31,11 +31,5 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
run: pnpm build && pnpm test
|
||||
4
.github/workflows/publish.yaml
vendored
4
.github/workflows/publish.yaml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
name: Check version changes and publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# @robonen/oxlint
|
||||
|
||||
Composable [oxlint](https://oxc.rs/docs/guide/usage/linter.html) configuration presets.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install -D @robonen/oxlint oxlint
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Create `oxlint.config.ts` in your project root:
|
||||
|
||||
```ts
|
||||
import { defineConfig } from 'oxlint';
|
||||
import { compose, base, typescript, vue, vitest, imports } from '@robonen/oxlint';
|
||||
|
||||
export default defineConfig(
|
||||
compose(base, typescript, vue, vitest, imports),
|
||||
);
|
||||
```
|
||||
|
||||
Append custom rules after presets to override them:
|
||||
|
||||
```ts
|
||||
compose(base, typescript, {
|
||||
rules: { 'eslint/no-console': 'off' },
|
||||
ignorePatterns: ['dist'],
|
||||
});
|
||||
```
|
||||
|
||||
## Presets
|
||||
|
||||
| Preset | Description |
|
||||
| ------------ | -------------------------------------------------- |
|
||||
| `base` | Core eslint, oxc, unicorn rules |
|
||||
| `typescript` | TypeScript-specific rules (via overrides) |
|
||||
| `vue` | Vue 3 Composition API / `<script setup>` rules |
|
||||
| `vitest` | Test file rules (via overrides) |
|
||||
| `imports` | Import rules (cycles, duplicates, ordering) |
|
||||
| `node` | Node.js-specific rules |
|
||||
|
||||
## API
|
||||
|
||||
### `compose(...configs: OxlintConfig[]): OxlintConfig`
|
||||
|
||||
Merges multiple configs into one:
|
||||
|
||||
- **plugins** — union (deduplicated)
|
||||
- **rules / categories** — last wins
|
||||
- **overrides / ignorePatterns** — concatenated
|
||||
- **env / globals** — shallow merge
|
||||
- **settings** — deep merge
|
||||
@@ -1,4 +0,0 @@
|
||||
import { defineConfig } from 'oxlint';
|
||||
import { compose, base, typescript, imports } from '@robonen/oxlint';
|
||||
|
||||
export default defineConfig(compose(base, typescript, imports));
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"name": "@robonen/oxlint",
|
||||
"version": "0.0.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Composable oxlint configuration presets",
|
||||
"keywords": [
|
||||
"oxlint",
|
||||
"oxc",
|
||||
"linter",
|
||||
"config",
|
||||
"presets"
|
||||
],
|
||||
"author": "Robonen Andrew <robonenandrew@gmail.com>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "configs/oxlint"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"engines": {
|
||||
"node": ">=22.18.0"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "oxlint -c oxlint.config.ts",
|
||||
"test": "vitest run",
|
||||
"dev": "vitest dev",
|
||||
"build": "tsdown"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/oxlint": "workspace:*",
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"oxlint": "catalog:",
|
||||
"tsdown": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"oxlint": ">=1.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import type { OxlintConfig } from './types';
|
||||
|
||||
/**
|
||||
* Deep merge two objects. Arrays are concatenated, objects are recursively merged.
|
||||
*/
|
||||
function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
|
||||
const result = { ...target };
|
||||
|
||||
for (const key of Object.keys(source)) {
|
||||
const targetValue = target[key];
|
||||
const sourceValue = source[key];
|
||||
|
||||
if (
|
||||
typeof targetValue === 'object' && targetValue !== null && !Array.isArray(targetValue)
|
||||
&& typeof sourceValue === 'object' && sourceValue !== null && !Array.isArray(sourceValue)
|
||||
) {
|
||||
result[key] = deepMerge(
|
||||
targetValue as Record<string, unknown>,
|
||||
sourceValue as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
else {
|
||||
result[key] = sourceValue;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose multiple oxlint configurations into a single config.
|
||||
*
|
||||
* - `plugins` — union (deduplicated)
|
||||
* - `categories` — later configs override earlier
|
||||
* - `rules` — later configs override earlier
|
||||
* - `overrides` — concatenated
|
||||
* - `env` — merged (later overrides earlier)
|
||||
* - `globals` — merged (later overrides earlier)
|
||||
* - `settings` — deep-merged
|
||||
* - `ignorePatterns` — concatenated
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { compose, base, typescript, vue } from '@robonen/oxlint';
|
||||
* import { defineConfig } from 'oxlint';
|
||||
*
|
||||
* export default defineConfig(
|
||||
* compose(base, typescript, vue, {
|
||||
* rules: { 'eslint/no-console': 'off' },
|
||||
* }),
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function compose(...configs: OxlintConfig[]): OxlintConfig {
|
||||
const result: OxlintConfig = {};
|
||||
|
||||
for (const config of configs) {
|
||||
// Plugins — union with dedup
|
||||
if (config.plugins?.length) {
|
||||
result.plugins = Array.from(new Set([...(result.plugins ?? []), ...config.plugins]));
|
||||
}
|
||||
|
||||
// Categories — shallow merge
|
||||
if (config.categories) {
|
||||
result.categories = { ...result.categories, ...config.categories };
|
||||
}
|
||||
|
||||
// Rules — shallow merge (later overrides earlier)
|
||||
if (config.rules) {
|
||||
result.rules = { ...result.rules, ...config.rules };
|
||||
}
|
||||
|
||||
// Overrides — concatenate
|
||||
if (config.overrides?.length) {
|
||||
result.overrides = [...(result.overrides ?? []), ...config.overrides];
|
||||
}
|
||||
|
||||
// Env — shallow merge
|
||||
if (config.env) {
|
||||
result.env = { ...result.env, ...config.env };
|
||||
}
|
||||
|
||||
// Globals — shallow merge
|
||||
if (config.globals) {
|
||||
result.globals = { ...result.globals, ...config.globals };
|
||||
}
|
||||
|
||||
// Settings — deep merge
|
||||
if (config.settings) {
|
||||
result.settings = deepMerge(
|
||||
(result.settings ?? {}) as Record<string, unknown>,
|
||||
config.settings as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
// Ignore patterns — concatenate
|
||||
if (config.ignorePatterns?.length) {
|
||||
result.ignorePatterns = [...(result.ignorePatterns ?? []), ...config.ignorePatterns];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/* Compose */
|
||||
export { compose } from './compose';
|
||||
|
||||
/* Presets */
|
||||
export { base, typescript, vue, vitest, imports, node } from './presets';
|
||||
|
||||
/* Types */
|
||||
export type {
|
||||
OxlintConfig,
|
||||
OxlintOverride,
|
||||
OxlintEnv,
|
||||
OxlintGlobals,
|
||||
AllowWarnDeny,
|
||||
DummyRule,
|
||||
DummyRuleMap,
|
||||
RuleCategories,
|
||||
} from './types';
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { OxlintConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Base configuration for any JavaScript/TypeScript project.
|
||||
*
|
||||
* Enables `correctness` category and opinionated rules from
|
||||
* `eslint`, `oxc`, and `unicorn` plugins.
|
||||
*/
|
||||
export const base: OxlintConfig = {
|
||||
plugins: ['eslint', 'oxc', 'unicorn'],
|
||||
|
||||
categories: {
|
||||
correctness: 'error',
|
||||
},
|
||||
|
||||
rules: {
|
||||
/* ── eslint core ──────────────────────────────────────── */
|
||||
'eslint/eqeqeq': 'error',
|
||||
'eslint/no-console': 'warn',
|
||||
'eslint/no-debugger': 'error',
|
||||
'eslint/no-eval': 'error',
|
||||
'eslint/no-var': 'error',
|
||||
'eslint/prefer-const': 'error',
|
||||
'eslint/prefer-template': 'warn',
|
||||
'eslint/no-useless-constructor': 'warn',
|
||||
'eslint/no-useless-rename': 'warn',
|
||||
'eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'eslint/no-self-compare': 'error',
|
||||
'eslint/no-template-curly-in-string': 'warn',
|
||||
'eslint/no-throw-literal': 'error',
|
||||
'eslint/no-return-assign': 'warn',
|
||||
'eslint/no-else-return': 'warn',
|
||||
'eslint/no-lonely-if': 'warn',
|
||||
'eslint/no-unneeded-ternary': 'warn',
|
||||
'eslint/prefer-object-spread': 'warn',
|
||||
'eslint/prefer-exponentiation-operator': 'warn',
|
||||
'eslint/no-useless-computed-key': 'warn',
|
||||
'eslint/no-useless-concat': 'warn',
|
||||
'eslint/curly': 'off',
|
||||
|
||||
/* ── unicorn ──────────────────────────────────────────── */
|
||||
'unicorn/prefer-node-protocol': 'error',
|
||||
'unicorn/no-instanceof-array': 'error',
|
||||
'unicorn/no-new-array': 'error',
|
||||
'unicorn/prefer-array-flat-map': 'warn',
|
||||
'unicorn/prefer-array-flat': 'warn',
|
||||
'unicorn/prefer-includes': 'warn',
|
||||
'unicorn/prefer-string-slice': 'warn',
|
||||
'unicorn/prefer-string-starts-ends-with': 'warn',
|
||||
'unicorn/throw-new-error': 'error',
|
||||
'unicorn/error-message': 'warn',
|
||||
'unicorn/no-useless-spread': 'warn',
|
||||
'unicorn/no-useless-undefined': 'off',
|
||||
'unicorn/prefer-optional-catch-binding': 'warn',
|
||||
'unicorn/prefer-type-error': 'warn',
|
||||
'unicorn/no-thenable': 'error',
|
||||
'unicorn/prefer-number-properties': 'warn',
|
||||
'unicorn/prefer-global-this': 'warn',
|
||||
|
||||
/* ── oxc ──────────────────────────────────────────────── */
|
||||
'oxc/no-accumulating-spread': 'warn',
|
||||
'oxc/bad-comparison-sequence': 'error',
|
||||
'oxc/bad-min-max-func': 'error',
|
||||
'oxc/bad-object-literal-comparison': 'error',
|
||||
'oxc/const-comparisons': 'error',
|
||||
'oxc/double-comparisons': 'error',
|
||||
'oxc/erasing-op': 'error',
|
||||
'oxc/missing-throw': 'error',
|
||||
'oxc/bad-bitwise-operator': 'error',
|
||||
'oxc/bad-char-at-comparison': 'error',
|
||||
'oxc/bad-replace-all-arg': 'error',
|
||||
},
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { OxlintConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Import plugin rules for clean module boundaries.
|
||||
*/
|
||||
export const imports: OxlintConfig = {
|
||||
plugins: ['import'],
|
||||
|
||||
rules: {
|
||||
'import/no-duplicates': 'error',
|
||||
'import/no-self-import': 'error',
|
||||
'import/no-cycle': 'warn',
|
||||
'import/first': 'warn',
|
||||
'import/no-mutable-exports': 'error',
|
||||
'import/no-amd': 'error',
|
||||
'import/no-commonjs': 'warn',
|
||||
'import/no-empty-named-blocks': 'warn',
|
||||
'import/consistent-type-specifier-style': ['warn', 'prefer-top-level'],
|
||||
},
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
export { base } from './base';
|
||||
export { typescript } from './typescript';
|
||||
export { vue } from './vue';
|
||||
export { vitest } from './vitest';
|
||||
export { imports } from './imports';
|
||||
export { node } from './node';
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { OxlintConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Node.js-specific rules.
|
||||
*/
|
||||
export const node: OxlintConfig = {
|
||||
plugins: ['node'],
|
||||
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
|
||||
rules: {
|
||||
'node/no-exports-assign': 'error',
|
||||
'node/no-new-require': 'error',
|
||||
},
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { OxlintConfig } from '../types';
|
||||
|
||||
/**
|
||||
* TypeScript-specific rules.
|
||||
*
|
||||
* Applied via `overrides` for `*.ts`, `*.tsx`, `*.mts`, `*.cts` files.
|
||||
*/
|
||||
export const typescript: OxlintConfig = {
|
||||
plugins: ['typescript'],
|
||||
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'],
|
||||
rules: {
|
||||
'typescript/consistent-type-imports': 'error',
|
||||
'typescript/no-explicit-any': 'off',
|
||||
'typescript/no-non-null-assertion': 'off',
|
||||
'typescript/prefer-as-const': 'error',
|
||||
'typescript/no-empty-object-type': 'warn',
|
||||
'typescript/no-wrapper-object-types': 'error',
|
||||
'typescript/no-duplicate-enum-values': 'error',
|
||||
'typescript/no-unsafe-declaration-merging': 'error',
|
||||
'typescript/no-import-type-side-effects': 'error',
|
||||
'typescript/no-useless-empty-export': 'warn',
|
||||
'typescript/no-inferrable-types': 'warn',
|
||||
'typescript/prefer-function-type': 'warn',
|
||||
'typescript/ban-tslint-comment': 'error',
|
||||
'typescript/consistent-type-definitions': ['warn', 'interface'],
|
||||
'typescript/prefer-for-of': 'warn',
|
||||
'typescript/no-unnecessary-type-constraint': 'warn',
|
||||
'typescript/adjacent-overload-signatures': 'warn',
|
||||
'typescript/array-type': ['warn', { default: 'array-simple' }],
|
||||
'typescript/no-this-alias': 'error',
|
||||
'typescript/triple-slash-reference': 'error',
|
||||
'typescript/no-namespace': 'error',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { OxlintConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Vitest rules for test files.
|
||||
*
|
||||
* Applied via `overrides` for common test file patterns.
|
||||
*/
|
||||
export const vitest: OxlintConfig = {
|
||||
plugins: ['vitest'],
|
||||
|
||||
overrides: [
|
||||
{
|
||||
files: [
|
||||
'**/*.test.{ts,tsx,js,jsx}',
|
||||
'**/*.spec.{ts,tsx,js,jsx}',
|
||||
'**/test/**/*.{ts,tsx,js,jsx}',
|
||||
'**/__tests__/**/*.{ts,tsx,js,jsx}',
|
||||
],
|
||||
rules: {
|
||||
'vitest/no-conditional-tests': 'warn',
|
||||
'vitest/no-import-node-test': 'error',
|
||||
'vitest/prefer-to-be-truthy': 'warn',
|
||||
'vitest/prefer-to-be-falsy': 'warn',
|
||||
'vitest/prefer-to-be-object': 'warn',
|
||||
'vitest/prefer-to-have-length': 'warn',
|
||||
'vitest/consistent-test-filename': 'warn',
|
||||
'vitest/prefer-describe-function-title': 'warn',
|
||||
|
||||
/* relax strict rules in tests */
|
||||
'eslint/no-unused-vars': 'off',
|
||||
'typescript/no-explicit-any': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { OxlintConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Vue.js-specific rules.
|
||||
*
|
||||
* Enforces Composition API with `<script setup>` and type-based declarations.
|
||||
*/
|
||||
export const vue: OxlintConfig = {
|
||||
plugins: ['vue'],
|
||||
|
||||
rules: {
|
||||
'vue/no-arrow-functions-in-watch': 'error',
|
||||
'vue/no-deprecated-destroyed-lifecycle': 'error',
|
||||
'vue/no-export-in-script-setup': 'error',
|
||||
'vue/no-lifecycle-after-await': 'error',
|
||||
'vue/no-multiple-slot-args': 'error',
|
||||
'vue/no-import-compiler-macros': 'error',
|
||||
'vue/define-emits-declaration': ['error', 'type-based'],
|
||||
'vue/define-props-declaration': ['error', 'type-based'],
|
||||
'vue/prefer-import-from-vue': 'error',
|
||||
'vue/no-required-prop-with-default': 'warn',
|
||||
'vue/valid-define-emits': 'error',
|
||||
'vue/valid-define-props': 'error',
|
||||
'vue/require-typed-ref': 'warn',
|
||||
},
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Re-exported configuration types from `oxlint`.
|
||||
*
|
||||
* Keeps the preset API in sync with the oxlint CLI without
|
||||
* maintaining a separate copy of the types.
|
||||
*
|
||||
* @see https://oxc.rs/docs/guide/usage/linter/config-file-reference.html
|
||||
*/
|
||||
export type {
|
||||
OxlintConfig,
|
||||
OxlintOverride,
|
||||
OxlintEnv,
|
||||
OxlintGlobals,
|
||||
AllowWarnDeny,
|
||||
DummyRule,
|
||||
DummyRuleMap,
|
||||
RuleCategories,
|
||||
} from 'oxlint';
|
||||
@@ -1,146 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { compose } from '../src/compose';
|
||||
import type { OxlintConfig } from '../src/types';
|
||||
|
||||
describe('compose', () => {
|
||||
it('should return empty config when no configs provided', () => {
|
||||
expect(compose()).toEqual({});
|
||||
});
|
||||
|
||||
it('should return the same config when one config provided', () => {
|
||||
const config: OxlintConfig = {
|
||||
plugins: ['eslint'],
|
||||
rules: { 'eslint/no-console': 'warn' },
|
||||
};
|
||||
const result = compose(config);
|
||||
expect(result.plugins).toEqual(['eslint']);
|
||||
expect(result.rules).toEqual({ 'eslint/no-console': 'warn' });
|
||||
});
|
||||
|
||||
it('should merge plugins with dedup', () => {
|
||||
const a: OxlintConfig = { plugins: ['eslint', 'oxc'] };
|
||||
const b: OxlintConfig = { plugins: ['oxc', 'typescript'] };
|
||||
|
||||
const result = compose(a, b);
|
||||
expect(result.plugins).toEqual(['eslint', 'oxc', 'typescript']);
|
||||
});
|
||||
|
||||
it('should override rules from later configs', () => {
|
||||
const a: OxlintConfig = { rules: { 'eslint/no-console': 'error', 'eslint/eqeqeq': 'warn' } };
|
||||
const b: OxlintConfig = { rules: { 'eslint/no-console': 'off' } };
|
||||
|
||||
const result = compose(a, b);
|
||||
expect(result.rules).toEqual({
|
||||
'eslint/no-console': 'off',
|
||||
'eslint/eqeqeq': 'warn',
|
||||
});
|
||||
});
|
||||
|
||||
it('should override categories from later configs', () => {
|
||||
const a: OxlintConfig = { categories: { correctness: 'error', suspicious: 'warn' } };
|
||||
const b: OxlintConfig = { categories: { suspicious: 'off' } };
|
||||
|
||||
const result = compose(a, b);
|
||||
expect(result.categories).toEqual({
|
||||
correctness: 'error',
|
||||
suspicious: 'off',
|
||||
});
|
||||
});
|
||||
|
||||
it('should concatenate overrides', () => {
|
||||
const a: OxlintConfig = {
|
||||
overrides: [{ files: ['**/*.ts'], rules: { 'typescript/no-explicit-any': 'warn' } }],
|
||||
};
|
||||
const b: OxlintConfig = {
|
||||
overrides: [{ files: ['**/*.test.ts'], rules: { 'eslint/no-unused-vars': 'off' } }],
|
||||
};
|
||||
|
||||
const result = compose(a, b);
|
||||
expect(result.overrides).toHaveLength(2);
|
||||
expect(result.overrides?.[0]?.files).toEqual(['**/*.ts']);
|
||||
expect(result.overrides?.[1]?.files).toEqual(['**/*.test.ts']);
|
||||
});
|
||||
|
||||
it('should merge env', () => {
|
||||
const a: OxlintConfig = { env: { browser: true } };
|
||||
const b: OxlintConfig = { env: { node: true } };
|
||||
|
||||
const result = compose(a, b);
|
||||
expect(result.env).toEqual({ browser: true, node: true });
|
||||
});
|
||||
|
||||
it('should merge globals', () => {
|
||||
const a: OxlintConfig = { globals: { MY_VAR: 'readonly' } };
|
||||
const b: OxlintConfig = { globals: { ANOTHER: 'writable' } };
|
||||
|
||||
const result = compose(a, b);
|
||||
expect(result.globals).toEqual({ MY_VAR: 'readonly', ANOTHER: 'writable' });
|
||||
});
|
||||
|
||||
it('should deep merge settings', () => {
|
||||
const a: OxlintConfig = {
|
||||
settings: {
|
||||
react: { version: '18.2.0' },
|
||||
next: { rootDir: 'apps/' },
|
||||
},
|
||||
};
|
||||
const b: OxlintConfig = {
|
||||
settings: {
|
||||
react: { linkComponents: [{ name: 'Link', linkAttribute: 'to', attributes: ['to'] }] },
|
||||
},
|
||||
};
|
||||
|
||||
const result = compose(a, b);
|
||||
expect(result.settings).toEqual({
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
linkComponents: [{ name: 'Link', linkAttribute: 'to', attributes: ['to'] }],
|
||||
},
|
||||
next: { rootDir: 'apps/' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should concatenate ignorePatterns', () => {
|
||||
const a: OxlintConfig = { ignorePatterns: ['dist'] };
|
||||
const b: OxlintConfig = { ignorePatterns: ['node_modules', 'coverage'] };
|
||||
|
||||
const result = compose(a, b);
|
||||
expect(result.ignorePatterns).toEqual(['dist', 'node_modules', 'coverage']);
|
||||
});
|
||||
|
||||
it('should handle composing all presets together', () => {
|
||||
const base: OxlintConfig = {
|
||||
plugins: ['eslint', 'oxc'],
|
||||
categories: { correctness: 'error' },
|
||||
rules: { 'eslint/no-console': 'warn' },
|
||||
};
|
||||
const ts: OxlintConfig = {
|
||||
plugins: ['typescript'],
|
||||
overrides: [{ files: ['**/*.ts'], rules: { 'typescript/no-explicit-any': 'warn' } }],
|
||||
};
|
||||
const custom: OxlintConfig = {
|
||||
rules: { 'eslint/no-console': 'off' },
|
||||
ignorePatterns: ['dist'],
|
||||
};
|
||||
|
||||
const result = compose(base, ts, custom);
|
||||
|
||||
expect(result.plugins).toEqual(['eslint', 'oxc', 'typescript']);
|
||||
expect(result.categories).toEqual({ correctness: 'error' });
|
||||
expect(result.rules).toEqual({ 'eslint/no-console': 'off' });
|
||||
expect(result.overrides).toHaveLength(1);
|
||||
expect(result.ignorePatterns).toEqual(['dist']);
|
||||
});
|
||||
|
||||
it('should skip undefined/empty fields', () => {
|
||||
const a: OxlintConfig = { plugins: ['eslint'] };
|
||||
const b: OxlintConfig = { rules: { 'eslint/no-console': 'warn' } };
|
||||
|
||||
const result = compose(a, b);
|
||||
expect(result.plugins).toEqual(['eslint']);
|
||||
expect(result.rules).toEqual({ 'eslint/no-console': 'warn' });
|
||||
expect(result.overrides).toBeUndefined();
|
||||
expect(result.env).toBeUndefined();
|
||||
expect(result.settings).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm', 'cjs'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
hash: false,
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -1,27 +1,45 @@
|
||||
# @robonen/tsconfig
|
||||
|
||||
Shared base TypeScript configuration.
|
||||
Базовый конфигурационный файл для TypeScript
|
||||
|
||||
## Install
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
pnpm install -D @robonen/tsconfig
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Extend from it in your `tsconfig.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.json"
|
||||
}
|
||||
```
|
||||
|
||||
## What's Included
|
||||
## Описание основных параметров
|
||||
|
||||
- **Target / Module**: ESNext with Bundler resolution
|
||||
- **Strict mode**: `strict`, `noUncheckedIndexedAccess`
|
||||
- **Module safety**: `verbatimModuleSyntax`, `isolatedModules`
|
||||
- **Declarations**: `declaration` enabled
|
||||
- **Interop**: `esModuleInterop`, `allowJs`, `resolveJsonModule`
|
||||
```json
|
||||
{
|
||||
"module": "Preserve", // использовать ту же версию модуля, что и сборщик
|
||||
"noEmit": true, // не генерировать файлы
|
||||
"moduleResolution": "Bundler", // разрешение модулей на основе сборщика
|
||||
"target": "ESNext", // целевая версия JavaScript
|
||||
|
||||
|
||||
"skipLibCheck": true, // не проверять типы, заданные во всех файлах описания типов (*.d.ts)
|
||||
"esModuleInterop": true, // создать хелперы __importStar и __importDefault для обеспечения совместимости с экосистемой Babel и включить allowSyntheticDefaultImports для совместимости с системой типов
|
||||
"allowSyntheticDefaultImports": true, // разрешить импортировать модули не имеющие внутри себя "import default"
|
||||
"allowJs": true, // разрешить импортировать файлы JavaScript
|
||||
"resolveJsonModule": true, // разрешить импортировать файлы JSON
|
||||
"moduleDetection": "force", // заставляет TypeScript рассматривать все файлы как модули. Это помогает избежать ошибок cannot redeclare block-scoped variable»
|
||||
"isolatedModules": true, // орабатывать каждый файл, как отдельный изолированный модуль
|
||||
"removeComments": false, // удалять комментарии из исходного кода
|
||||
"verbatimModuleSyntax": true, // сохранять синтаксис модулей в исходном коде (важно при импорте типов)
|
||||
"useDefineForClassFields": true, // использование классов стандарта TC39, а не TypeScript
|
||||
"strict": true, // включить все строгие проверки (noImplicitAny, noImplicitThis, alwaysStrict, strictNullChecks, strictFunctionTypes, strictPropertyInitialization)
|
||||
"noUncheckedIndexedAccess": true, // запрещает доступ к массиву или объекту без предварительной проверки того, определен ли он
|
||||
"declaration": true, // генерировать файлы описания типов (*.d.ts)
|
||||
|
||||
"composite": true, // указывает TypeScript создавать файлы .tsbuildinfo. Это сообщает TypeScript, что ваш проект является частью монорепозитория, а также помогает кэшировать сборки для более быстрой работы
|
||||
"sourceMap": true, // генерировать карту исходного кода
|
||||
"declarationMap": true // генерировать карту исходного кода для файлов описания типов (*.d.ts)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "packages/tsconfig"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
"node": ">=22.17.1"
|
||||
},
|
||||
"files": [
|
||||
"**tsconfig.json"
|
||||
|
||||
@@ -1,23 +1 @@
|
||||
# @robonen/platform
|
||||
|
||||
Platform-dependent utilities for browser & multi-runtime environments.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install @robonen/platform
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Entry | Utilities | Description |
|
||||
| ------------------ | ------------- | -------------------------------- |
|
||||
| `@robonen/platform/browsers` | `focusGuard` | Browser-specific helpers |
|
||||
| `@robonen/platform/multi` | `global` | Cross-runtime (Node/Bun/Deno) utilities |
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { focusGuard } from '@robonen/platform/browsers';
|
||||
import { global } from '@robonen/platform/multi';
|
||||
```
|
||||
16
core/platform/build.config.ts
Normal file
16
core/platform/build.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
entries: [
|
||||
'src/browsers',
|
||||
'src/multi',
|
||||
],
|
||||
clean: true,
|
||||
declaration: true,
|
||||
rollup: {
|
||||
emitCJS: true,
|
||||
esbuild: {
|
||||
// minify: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { defineConfig } from 'oxlint';
|
||||
import { compose, base, typescript, imports } from '@robonen/oxlint';
|
||||
|
||||
export default defineConfig(
|
||||
compose(base, typescript, imports, {
|
||||
overrides: [
|
||||
{
|
||||
files: ['src/multi/global/index.ts'],
|
||||
rules: {
|
||||
'unicorn/prefer-global-this': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -18,9 +18,9 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "packages/platform"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
"node": ">=22.17.1"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -29,25 +29,22 @@
|
||||
"exports": {
|
||||
"./browsers": {
|
||||
"types": "./dist/browsers.d.ts",
|
||||
"import": "./dist/browsers.js",
|
||||
"import": "./dist/browsers.mjs",
|
||||
"require": "./dist/browsers.cjs"
|
||||
},
|
||||
"./multi": {
|
||||
"types": "./dist/multi.d.ts",
|
||||
"import": "./dist/multi.js",
|
||||
"import": "./dist/multi.mjs",
|
||||
"require": "./dist/multi.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "oxlint -c oxlint.config.ts",
|
||||
"test": "vitest run",
|
||||
"dev": "vitest dev",
|
||||
"build": "tsdown"
|
||||
"build": "unbuild"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/oxlint": "workspace:*",
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"oxlint": "catalog:",
|
||||
"tsdown": "catalog:"
|
||||
"unbuild": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*
|
||||
* @since 0.0.3
|
||||
*/
|
||||
export function focusGuard(namespace = 'focus-guard') {
|
||||
export function focusGuard(namespace: string = 'focus-guard') {
|
||||
const guardAttr = `data-${namespace}`;
|
||||
|
||||
const createGuard = () => {
|
||||
@@ -39,7 +39,7 @@ export function focusGuard(namespace = 'focus-guard') {
|
||||
};
|
||||
}
|
||||
|
||||
export function createGuardAttrs(namespace = 'focus-guard') {
|
||||
export function createGuardAttrs(namespace: string) {
|
||||
const element = document.createElement('span');
|
||||
|
||||
element.setAttribute(namespace, '');
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// eslint-disable
|
||||
|
||||
export interface DebounceOptions {
|
||||
/**
|
||||
* Call the function on the leading edge of the timeout, instead of waiting for the trailing edge
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
browsers: 'src/browsers/index.ts',
|
||||
multi: 'src/multi/index.ts',
|
||||
},
|
||||
format: ['esm', 'cjs'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
hash: false,
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,32 +1 @@
|
||||
# @robonen/stdlib
|
||||
|
||||
Standard library of platform-independent utilities for TypeScript.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install @robonen/stdlib
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Utilities |
|
||||
| --------------- | --------------------------------------------------------------- |
|
||||
| **arrays** | `cluster`, `first`, `last`, `sum`, `unique` |
|
||||
| **async** | `sleep`, `tryIt` |
|
||||
| **bits** | `flags` |
|
||||
| **collections** | `get` |
|
||||
| **math** | `clamp`, `lerp`, `remap` + BigInt variants |
|
||||
| **objects** | `omit`, `pick` |
|
||||
| **patterns** | `pubsub` |
|
||||
| **structs** | `stack` |
|
||||
| **sync** | `mutex` |
|
||||
| **text** | `levenshteinDistance`, `trigramDistance` |
|
||||
| **types** | JS & TS type utilities |
|
||||
| **utils** | `timestamp`, `noop` |
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { first, sleep, clamp } from '@robonen/stdlib';
|
||||
```
|
||||
9
core/stdlib/build.config.ts
Normal file
9
core/stdlib/build.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
rollup: {
|
||||
esbuild: {
|
||||
// minify: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
import { defineConfig } from 'oxlint';
|
||||
import { compose, base, typescript, imports } from '@robonen/oxlint';
|
||||
|
||||
export default defineConfig(compose(base, typescript, imports));
|
||||
@@ -18,9 +18,9 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "packages/stdlib"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
"node": ">=22.17.1"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -29,20 +29,18 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "oxlint -c oxlint.config.ts",
|
||||
"test": "vitest run",
|
||||
"dev": "vitest dev",
|
||||
"build": "tsdown"
|
||||
"build": "unbuild"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/oxlint": "workspace:*",
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"oxlint": "catalog:",
|
||||
"tsdown": "catalog:"
|
||||
"pathe": "catalog:",
|
||||
"unbuild": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './retry';
|
||||
export * from './sleep';
|
||||
export * from './tryIt';
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export interface AsyncPoolOptions {
|
||||
export type AsyncPoolOptions = {
|
||||
concurrency?: number;
|
||||
}
|
||||
250
core/stdlib/src/async/retry/index.test.ts
Normal file
250
core/stdlib/src/async/retry/index.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { retry } from '.';
|
||||
|
||||
describe('retry', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('return the result on first successful attempt', async () => {
|
||||
const successFn = vi.fn().mockResolvedValue('success');
|
||||
|
||||
const result = await retry(successFn);
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(successFn).toHaveBeenCalledTimes(1);
|
||||
expect(successFn).toHaveBeenCalledWith({ count: 1, stop: expect.any(Function) });
|
||||
});
|
||||
|
||||
it('use default times value of 2', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||
|
||||
await expect(retry(failingFn)).rejects.toThrow('Test error');
|
||||
|
||||
expect(failingFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retry the specified number of times on failure', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||
|
||||
await expect(retry(failingFn, { times: 3 })).rejects.toThrow('Test error');
|
||||
|
||||
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||
expect(failingFn).toHaveBeenNthCalledWith(1, { count: 1, stop: expect.any(Function) });
|
||||
expect(failingFn).toHaveBeenNthCalledWith(2, { count: 2, stop: expect.any(Function) });
|
||||
expect(failingFn).toHaveBeenNthCalledWith(3, { count: 3, stop: expect.any(Function) });
|
||||
});
|
||||
|
||||
it('succeed on the last attempt', async () => {
|
||||
const partiallyFailingFn = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('First failure'))
|
||||
.mockRejectedValueOnce(new Error('Second failure'))
|
||||
.mockResolvedValue('success');
|
||||
|
||||
const result = await retry(partiallyFailingFn, { times: 3 });
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(partiallyFailingFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('use custom shouldRetry function', async () => {
|
||||
const networkError = new Error('Network failed');
|
||||
networkError.name = 'NetworkError';
|
||||
const failingFn = vi.fn().mockRejectedValue(networkError);
|
||||
|
||||
await expect(retry(failingFn, {
|
||||
times: 3,
|
||||
shouldRetry: (error) => error.name !== 'NetworkError'
|
||||
})).rejects.toThrow('Network failed');
|
||||
|
||||
expect(failingFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retry with custom shouldRetry based on count', async () => {
|
||||
const testError = new Error('Test error');
|
||||
const failingFn = vi.fn().mockRejectedValue(testError);
|
||||
|
||||
await expect(retry(failingFn, {
|
||||
times: 5,
|
||||
shouldRetry: (error, count) => count < 3 // Only retry first 2 attempts
|
||||
})).rejects.toThrow('Test error');
|
||||
|
||||
expect(failingFn).toHaveBeenCalledTimes(3); // Initial + 2 retries
|
||||
});
|
||||
|
||||
it('retry specific error types with custom shouldRetry', async () => {
|
||||
const temporaryError = new Error('Temporary failure');
|
||||
temporaryError.name = 'TemporaryError';
|
||||
const permanentError = new Error('Permanent failure');
|
||||
permanentError.name = 'PermanentError';
|
||||
|
||||
const failingFn = vi.fn()
|
||||
.mockRejectedValueOnce(temporaryError)
|
||||
.mockRejectedValueOnce(temporaryError)
|
||||
.mockRejectedValueOnce(permanentError);
|
||||
|
||||
await expect(retry(failingFn, {
|
||||
times: 5,
|
||||
shouldRetry: (error) => error.name === 'TemporaryError'
|
||||
})).rejects.toThrow('Permanent failure');
|
||||
|
||||
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('wait for the specified delay between retries', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||
|
||||
const retryPromise = retry(failingFn, { times: 3, delay: 1000 });
|
||||
|
||||
// First call should happen immediately
|
||||
expect(failingFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance time to trigger first retry
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(failingFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Advance time to trigger second retry
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||
|
||||
await expect(retryPromise).rejects.toThrow('Test error');
|
||||
});
|
||||
|
||||
it('use dynamic delay function', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||
const delayFn = vi.fn((count: number) => count * 500);
|
||||
|
||||
const retryPromise = retry(failingFn, { times: 3, delay: delayFn });
|
||||
|
||||
// First call should happen immediately
|
||||
expect(failingFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// First retry should wait for delay(2) = 1000ms
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(failingFn).toHaveBeenCalledTimes(2);
|
||||
expect(delayFn).toHaveBeenCalledWith(2);
|
||||
|
||||
// Second retry should wait for delay(3) = 1500ms
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||
expect(delayFn).toHaveBeenCalledWith(3);
|
||||
|
||||
await expect(retryPromise).rejects.toThrow('Test error');
|
||||
});
|
||||
|
||||
it('not delay after the last attempt', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||
|
||||
const retryPromise = retry(failingFn, { times: 2, delay: 1000 });
|
||||
|
||||
// Wait for the first retry delay
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// Should complete without further delays
|
||||
await expect(retryPromise).rejects.toThrow('Test error');
|
||||
expect(failingFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handle zero delay', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||
|
||||
await expect(retry(failingFn, { times: 3, delay: 0 })).rejects.toThrow('Test error');
|
||||
|
||||
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('pass the count parameter to the function', async () => {
|
||||
const countingFn = vi.fn(async ({ count }: { count: number }) => {
|
||||
if (count < 3) {
|
||||
throw new Error(`Attempt ${count} failed`);
|
||||
}
|
||||
return `Success on attempt ${count}`;
|
||||
});
|
||||
|
||||
const result = await retry(countingFn, { times: 3 });
|
||||
|
||||
expect(result).toBe('Success on attempt 3');
|
||||
expect(countingFn).toHaveBeenCalledWith({ count: 1, stop: expect.any(Function) });
|
||||
expect(countingFn).toHaveBeenCalledWith({ count: 2, stop: expect.any(Function) });
|
||||
expect(countingFn).toHaveBeenCalledWith({ count: 3, stop: expect.any(Function) });
|
||||
});
|
||||
|
||||
it('throw the last error when all attempts fail', async () => {
|
||||
const firstError = new Error('First error');
|
||||
const lastError = new Error('Last error');
|
||||
const failingFn = vi.fn()
|
||||
.mockRejectedValueOnce(firstError)
|
||||
.mockRejectedValueOnce(lastError);
|
||||
|
||||
await expect(retry(failingFn, { times: 2 })).rejects.toThrow('Last error');
|
||||
});
|
||||
|
||||
it('handle times value of 1', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||
|
||||
await expect(retry(failingFn, { times: 1 })).rejects.toThrow('Test error');
|
||||
|
||||
expect(failingFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handle function that returns non-promise values', async () => {
|
||||
const syncFn = vi.fn(async ({ count }: { count: number }) => {
|
||||
if (count === 1) {
|
||||
throw new Error('First attempt failed');
|
||||
}
|
||||
return 'success';
|
||||
});
|
||||
|
||||
const result = await retry(syncFn, { times: 2 });
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(syncFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handle complex return types', async () => {
|
||||
const complexFn = vi.fn().mockResolvedValue({
|
||||
data: [1, 2, 3],
|
||||
status: 'ok',
|
||||
metadata: { timestamp: 123456 }
|
||||
});
|
||||
|
||||
const result = await retry(complexFn);
|
||||
|
||||
expect(result).toEqual({
|
||||
data: [1, 2, 3],
|
||||
status: 'ok',
|
||||
metadata: { timestamp: 123456 }
|
||||
});
|
||||
});
|
||||
|
||||
it('stop retrying when stop function is called', async () => {
|
||||
const customError = new Error('Custom stop error');
|
||||
const stopFn = vi.fn(async ({ count, stop }: { count: number, stop: (error: any) => void }) => {
|
||||
if (count === 2) {
|
||||
stop(customError);
|
||||
}
|
||||
throw new Error(`Attempt ${count} failed`);
|
||||
});
|
||||
|
||||
await expect(retry(stopFn, { times: 5 })).rejects.toThrow('Custom stop error');
|
||||
|
||||
expect(stopFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stop retrying with undefined error when stop is called without argument', async () => {
|
||||
const stopFn = vi.fn(async ({ count, stop }: { count: number, stop: (error?: any) => void }) => {
|
||||
if (count === 2) {
|
||||
stop();
|
||||
}
|
||||
throw new Error(`Attempt ${count} failed`);
|
||||
});
|
||||
|
||||
await expect(retry(stopFn, { times: 5 })).rejects.toBeUndefined();
|
||||
|
||||
expect(stopFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,22 @@
|
||||
// eslint-disable
|
||||
import { tryIt } from '../tryIt';
|
||||
import { sleep } from '../sleep';
|
||||
import { isFunction } from '../../types';
|
||||
|
||||
export interface RetryOptions {
|
||||
times?: number;
|
||||
delay?: number;
|
||||
backoff: (options: RetryOptions & { count: number }) => number;
|
||||
delay?: number | ((count: number) => number);
|
||||
shouldRetry?: (error: Error, count: number) => boolean;
|
||||
}
|
||||
|
||||
export type RetryFunction<Return> = (
|
||||
args: {
|
||||
count: number;
|
||||
stop: (error: any) => void;
|
||||
},
|
||||
) => Promise<Return>;
|
||||
|
||||
const RetryEarlyExit = Symbol('RetryEarlyExit');
|
||||
|
||||
/**
|
||||
* @name retry
|
||||
* @category Async
|
||||
@@ -26,14 +38,51 @@ export interface RetryOptions {
|
||||
* .then(response => response.json())
|
||||
* }, { times: 3, delay: 1000 });
|
||||
*
|
||||
* @since 0.0.8
|
||||
*/
|
||||
export async function retry<Return>(
|
||||
fn: () => Promise<Return>,
|
||||
options: RetryOptions
|
||||
) {
|
||||
fn: RetryFunction<Return>,
|
||||
options: RetryOptions = {},
|
||||
): Promise<Return> {
|
||||
const {
|
||||
times = 3,
|
||||
times = 2,
|
||||
delay = 0,
|
||||
shouldRetry,
|
||||
} = options;
|
||||
|
||||
let count = 0;
|
||||
let count = 1;
|
||||
let lastError: Error = new Error('Retry failed');
|
||||
|
||||
while (count <= times) {
|
||||
const metadata = {
|
||||
count,
|
||||
stop: (error?: any) => {
|
||||
throw { [RetryEarlyExit]: error };
|
||||
},
|
||||
};
|
||||
|
||||
const { error, data } = await tryIt(fn)(metadata);
|
||||
|
||||
if (!error)
|
||||
return data;
|
||||
|
||||
if (RetryEarlyExit in error)
|
||||
throw error[RetryEarlyExit];
|
||||
|
||||
if (shouldRetry && !shouldRetry(error, count))
|
||||
throw error;
|
||||
|
||||
lastError = error;
|
||||
count++;
|
||||
|
||||
// Don't delay after the last attempt
|
||||
if (count <= times) {
|
||||
const delayMs = isFunction(delay) ? delay(count) : delay;
|
||||
|
||||
if (delayMs > 0)
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
@@ -6,62 +6,62 @@ describe('tryIt', () => {
|
||||
const syncFn = (x: number) => x * 2;
|
||||
const wrappedSyncFn = tryIt(syncFn);
|
||||
|
||||
const [error, result] = wrappedSyncFn(2);
|
||||
const { error, data } = wrappedSyncFn(2);
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
expect(result).toBe(4);
|
||||
expect(data).toBe(4);
|
||||
});
|
||||
|
||||
it('handle synchronous functions with errors', () => {
|
||||
const syncFn = (): void => { throw new Error('Test error') };
|
||||
const wrappedSyncFn = tryIt(syncFn);
|
||||
|
||||
const [error, result] = wrappedSyncFn();
|
||||
const { error, data } = wrappedSyncFn();
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error?.message).toBe('Test error');
|
||||
expect(result).toBeUndefined();
|
||||
expect(data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handle asynchronous functions without errors', async () => {
|
||||
const asyncFn = async (x: number) => x * 2;
|
||||
const wrappedAsyncFn = tryIt(asyncFn);
|
||||
|
||||
const [error, result] = await wrappedAsyncFn(2);
|
||||
const { error, data } = await wrappedAsyncFn(2);
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
expect(result).toBe(4);
|
||||
expect(data).toBe(4);
|
||||
});
|
||||
|
||||
it('handle asynchronous functions with errors', async () => {
|
||||
const asyncFn = async () => { throw new Error('Test error') };
|
||||
const wrappedAsyncFn = tryIt(asyncFn);
|
||||
|
||||
const [error, result] = await wrappedAsyncFn();
|
||||
const { error, data } = await wrappedAsyncFn();
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error?.message).toBe('Test error');
|
||||
expect(result).toBeUndefined();
|
||||
expect(data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handle promise-based functions without errors', async () => {
|
||||
const promiseFn = (x: number) => Promise.resolve(x * 2);
|
||||
const wrappedPromiseFn = tryIt(promiseFn);
|
||||
|
||||
const [error, result] = await wrappedPromiseFn(2);
|
||||
const { error, data } = await wrappedPromiseFn(2);
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
expect(result).toBe(4);
|
||||
expect(data).toBe(4);
|
||||
});
|
||||
|
||||
it('handle promise-based functions with errors', async () => {
|
||||
const promiseFn = () => Promise.reject(new Error('Test error'));
|
||||
const wrappedPromiseFn = tryIt(promiseFn);
|
||||
|
||||
const [error, result] = await wrappedPromiseFn();
|
||||
const { error, data } = await wrappedPromiseFn();
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error?.message).toBe('Test error');
|
||||
expect(result).toBeUndefined();
|
||||
expect(data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isPromise } from '../../types';
|
||||
|
||||
export type TryItReturn<Return> = Return extends Promise<any>
|
||||
? Promise<[Error, undefined] | [undefined, Awaited<Return>]>
|
||||
: [Error, undefined] | [undefined, Return];
|
||||
? Promise<{ error: Error; data: undefined } | { error: undefined; data: Awaited<Return> }>
|
||||
: { error: Error; data: undefined } | { error: undefined; data: Return };
|
||||
|
||||
/**
|
||||
* @name tryIt
|
||||
@@ -14,10 +14,10 @@ export type TryItReturn<Return> = Return extends Promise<any>
|
||||
*
|
||||
* @example
|
||||
* const wrappedFetch = tryIt(fetch);
|
||||
* const [error, result] = await wrappedFetch('https://jsonplaceholder.typicode.com/todos/1');
|
||||
* const { error, data } = await wrappedFetch('https://jsonplaceholder.typicode.com/todos/1');
|
||||
*
|
||||
* @example
|
||||
* const [error, result] = await tryIt(fetch)('https://jsonplaceholder.typicode.com/todos/1');
|
||||
* const { error, data } = await tryIt(fetch)('https://jsonplaceholder.typicode.com/todos/1');
|
||||
*
|
||||
* @since 0.0.3
|
||||
*/
|
||||
@@ -30,12 +30,12 @@ export function tryIt<Args extends any[], Return>(
|
||||
|
||||
if (isPromise(result))
|
||||
return result
|
||||
.then((value) => [undefined, value])
|
||||
.catch((error) => [error, undefined]) as TryItReturn<Return>;
|
||||
.then((value) => ({ error: undefined, data: value }))
|
||||
.catch((error) => ({ error, data: undefined })) as TryItReturn<Return>;
|
||||
|
||||
return [undefined, result] as TryItReturn<Return>;
|
||||
return { error: undefined, data: result } as TryItReturn<Return>;
|
||||
} catch (error) {
|
||||
return [error, undefined] as TryItReturn<Return>;
|
||||
return { error, data: undefined } as TryItReturn<Return>;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface BitVectorLike {
|
||||
export interface BitVector {
|
||||
getBit(index: number): boolean;
|
||||
setBit(index: number): void;
|
||||
clearBit(index: number): void;
|
||||
@@ -12,7 +12,7 @@ export interface BitVectorLike {
|
||||
*
|
||||
* @since 0.0.3
|
||||
*/
|
||||
export class BitVector extends Uint8Array implements BitVectorLike {
|
||||
export class BitVector extends Uint8Array implements BitVector {
|
||||
constructor(size: number) {
|
||||
super(Math.ceil(size / 8));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Collection, Path } from '../../types';
|
||||
import { type Collection, type Path } from '../../types';
|
||||
|
||||
export type ExtractFromObject<O extends Record<PropertyKey, unknown>, K> =
|
||||
K extends keyof O
|
||||
@@ -9,7 +9,7 @@ export type ExtractFromObject<O extends Record<PropertyKey, unknown>, K> =
|
||||
|
||||
export type ExtractFromArray<A extends readonly any[], K> =
|
||||
any[] extends A
|
||||
? A extends ReadonlyArray<infer T>
|
||||
? A extends readonly (infer T)[]
|
||||
? T | undefined
|
||||
: undefined
|
||||
: K extends keyof A
|
||||
|
||||
@@ -46,13 +46,13 @@ describe('clamp', () => {
|
||||
|
||||
it('handle NaN and Infinity', () => {
|
||||
// value is NaN
|
||||
expect(clamp(Number.NaN, 0, 100)).toBe(Number.NaN);
|
||||
expect(clamp(NaN, 0, 100)).toBe(NaN);
|
||||
|
||||
// min is NaN
|
||||
expect(clamp(50, Number.NaN, 100)).toBe(Number.NaN);
|
||||
expect(clamp(50, NaN, 100)).toBe(NaN);
|
||||
|
||||
// max is NaN
|
||||
expect(clamp(50, 0, Number.NaN)).toBe(Number.NaN);
|
||||
expect(clamp(50, 0, NaN)).toBe(NaN);
|
||||
|
||||
// value is Infinity
|
||||
expect(clamp(Infinity, 0, 100)).toBe(100);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { isArray } from '../../types';
|
||||
import type { Arrayable } from '../../types';
|
||||
import { isArray, type Arrayable } from '../../types';
|
||||
|
||||
/**
|
||||
* @name omit
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { isArray } from '../../types';
|
||||
import type { Arrayable } from '../../types';
|
||||
import { isArray, type Arrayable } from '../../types';
|
||||
|
||||
/**
|
||||
* @name pick
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { last } from '../../arrays';
|
||||
import { isArray } from '../../types';
|
||||
|
||||
export interface StackOptions {
|
||||
export type StackOptions = {
|
||||
maxSize?: number;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @name Stack
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { MaybePromise } from "../../types";
|
||||
|
||||
/**
|
||||
* @name SyncMutex
|
||||
* @category Utils
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { get } from '../../collections';
|
||||
import { isFunction } from '../../types';
|
||||
import type { Collection, Path, PathToType, Stringable, Trim, UnionToIntersection } from '../../types';
|
||||
import { isFunction, type Path, type PathToType, type Stringable, type Trim, type UnionToIntersection } from '../../types';
|
||||
|
||||
/**
|
||||
* Type of a value that will be used to replace a placeholder in a template.
|
||||
@@ -56,7 +55,7 @@ export type GenerateTypes<T extends string, Target = string> = UnionToIntersecti
|
||||
|
||||
export function templateObject<
|
||||
T extends string,
|
||||
A extends GenerateTypes<ExtractPlaceholders<T>, TemplateValue> & Collection
|
||||
A extends GenerateTypes<ExtractPlaceholders<T>, TemplateValue>
|
||||
>(template: T, args: A, fallback?: TemplateFallback) {
|
||||
return template.replace(TEMPLATE_PLACEHOLDER, (_, key) => {
|
||||
const value = get(args, key)?.toString();
|
||||
|
||||
@@ -11,7 +11,7 @@ export type Trigrams = Map<string, number>;
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function trigramProfile(text: string): Trigrams {
|
||||
text = `\n\n${text}\n\n`;
|
||||
text = '\n\n' + text + '\n\n';
|
||||
|
||||
const trigrams = new Map<string, number>();
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('casts', () => {
|
||||
expect(toString(null)).toBe('[object Null]');
|
||||
expect(toString(/abc/)).toBe('[object RegExp]');
|
||||
expect(toString(new Date())).toBe('[object Date]');
|
||||
expect(toString(new Error('test'))).toBe('[object Error]');
|
||||
expect(toString(new Error())).toBe('[object Error]');
|
||||
expect(toString(new Promise(() => {}))).toBe('[object Promise]');
|
||||
expect(toString(new Map())).toBe('[object Map]');
|
||||
expect(toString(new Set())).toBe('[object Set]');
|
||||
|
||||
@@ -77,7 +77,7 @@ describe('complex', () => {
|
||||
|
||||
describe('isError', () => {
|
||||
it('true if the value is an error', () => {
|
||||
expect(isError(new Error('test'))).toBe(true);
|
||||
expect(isError(new Error())).toBe(true);
|
||||
});
|
||||
|
||||
it('false if the value is not an error', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { toString } from './casts';
|
||||
import { toString } from '.';
|
||||
|
||||
/**
|
||||
* @name isFunction
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { toString } from './casts';
|
||||
import { toString } from '.';
|
||||
|
||||
/**
|
||||
* @name isObject
|
||||
|
||||
@@ -35,35 +35,35 @@ describe('collections', () => {
|
||||
describe('PathToType', () => {
|
||||
it('convert simple object path', () => {
|
||||
type actual = PathToType<['user', 'name']>;
|
||||
interface expected { user: { name: unknown } }
|
||||
type expected = { user: { name: unknown } };
|
||||
|
||||
expectTypeOf<actual>().toEqualTypeOf<expected>();
|
||||
});
|
||||
|
||||
it('convert simple array path', () => {
|
||||
type actual = PathToType<['user', '0']>;
|
||||
interface expected { user: unknown[] }
|
||||
type expected = { user: unknown[] };
|
||||
|
||||
expectTypeOf<actual>().toEqualTypeOf<expected>();
|
||||
});
|
||||
|
||||
it('convert complex object path', () => {
|
||||
type actual = PathToType<['user', 'addresses', '0', 'street']>;
|
||||
interface expected { user: { addresses: Array<{ street: unknown }> } }
|
||||
type expected = { user: { addresses: { street: unknown }[] } };
|
||||
|
||||
expectTypeOf<actual>().toEqualTypeOf<expected>();
|
||||
});
|
||||
|
||||
it('convert double dot path', () => {
|
||||
type actual = PathToType<['user', '', 'name']>;
|
||||
interface expected { user: { '': { name: unknown } } }
|
||||
type expected = { user: { '': { name: unknown } } };
|
||||
|
||||
expectTypeOf<actual>().toEqualTypeOf<expected>();
|
||||
});
|
||||
|
||||
it('convert to custom target', () => {
|
||||
type actual = PathToType<['user', 'name'], string>;
|
||||
interface expected { user: { name: string } }
|
||||
type expected = { user: { name: string } };
|
||||
|
||||
expectTypeOf<actual>().toEqualTypeOf<expected>();
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ export type PathToType<T extends string[], Target = unknown> =
|
||||
T extends [infer Head, ...infer Rest]
|
||||
? Head extends `${number}`
|
||||
? Rest extends string[]
|
||||
? Array<PathToType<Rest, Target>>
|
||||
? PathToType<Rest, Target>[]
|
||||
: never
|
||||
: Rest extends string[]
|
||||
? { [K in Head & string]: PathToType<Rest, Target> }
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('string', () => {
|
||||
expectTypeOf(Number(1)).toExtend<Stringable>();
|
||||
expectTypeOf(String(1)).toExtend<Stringable>();
|
||||
expectTypeOf(Symbol()).toExtend<Stringable>();
|
||||
expectTypeOf([1]).toExtend<Stringable>();
|
||||
expectTypeOf(new Array(1)).toExtend<Stringable>();
|
||||
expectTypeOf(new Object()).toExtend<Stringable>();
|
||||
expectTypeOf(new Date()).toExtend<Stringable>();
|
||||
});
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm', 'cjs'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
hash: false,
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -1,21 +1 @@
|
||||
# @robonen/renovate
|
||||
|
||||
Shared [Renovate](https://docs.renovatebot.com/) configuration preset.
|
||||
|
||||
## Usage
|
||||
|
||||
Reference it in your `renovate.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["github>robonen/tools//infra/renovate/default.json"]
|
||||
}
|
||||
```
|
||||
|
||||
## What's Included
|
||||
|
||||
- Extends `config:base` and `group:allNonMajor`
|
||||
- Semantic commit type: `chore`
|
||||
- Range strategy: `bump`
|
||||
- Auto-approves & auto-merges minor, patch, pin, and digest updates (scheduled 1–3 AM)
|
||||
@@ -16,9 +16,9 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "packages/renovate"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"engines": {
|
||||
"node": ">=22.18.0"
|
||||
"node": ">=22.17.1"
|
||||
},
|
||||
"files": [
|
||||
"default.json"
|
||||
@@ -27,6 +27,6 @@
|
||||
"test": "renovate-config-validator ./default.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"renovate": "^43.12.0"
|
||||
"renovate": "^41.43.5"
|
||||
}
|
||||
}
|
||||
|
||||
19
package.json
19
package.json
@@ -15,24 +15,23 @@
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/robonen/tools.git"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"engines": {
|
||||
"node": ">=22.18.0"
|
||||
"node": ">=22.17.1"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.11",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"@vitest/ui": "catalog:",
|
||||
"citty": "^0.2.1",
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "catalog:",
|
||||
"@types/node": "^22.16.5",
|
||||
"citty": "^0.1.6",
|
||||
"jiti": "^2.5.1",
|
||||
"scule": "^1.3.0",
|
||||
"vitest": "catalog:"
|
||||
"jsdom": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"@vitest/ui": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"lint": "pnpm -r lint",
|
||||
"test": "vitest run",
|
||||
"test:ui": "vitest --ui",
|
||||
"create": "jiti ./bin/cli.ts"
|
||||
|
||||
7294
pnpm-lock.yaml
generated
7294
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -3,15 +3,13 @@ packages:
|
||||
- core/*
|
||||
- infra/*
|
||||
- web/*
|
||||
- docs
|
||||
|
||||
catalog:
|
||||
'@vitest/coverage-v8': ^4.0.18
|
||||
'@vitest/coverage-v8': ^3.2.4
|
||||
'@vue/test-utils': ^2.4.6
|
||||
jsdom: ^28.0.0
|
||||
oxlint: ^1.2.0
|
||||
tsdown: ^0.12.5
|
||||
vitest: ^4.0.18
|
||||
'@vitest/ui': ^4.0.18
|
||||
vue: ^3.5.28
|
||||
nuxt: ^4.3.1
|
||||
jsdom: ^26.1.0
|
||||
pathe: ^2.0.3
|
||||
unbuild: 3.6.0
|
||||
vitest: ^3.2.4
|
||||
'@vitest/ui': ^3.2.4
|
||||
vue: ^3.5.18
|
||||
|
||||
@@ -3,11 +3,16 @@ import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
'configs/oxlint/vitest.config.ts',
|
||||
'core/stdlib/vitest.config.ts',
|
||||
'core/platform/vitest.config.ts',
|
||||
'web/vue/vitest.config.ts',
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
typecheck: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
environment: 'jsdom',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['core/*', 'web/*'],
|
||||
|
||||
@@ -1,28 +1 @@
|
||||
# @robonen/vue
|
||||
|
||||
Collection of composables and utilities for Vue 3.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install @robonen/vue
|
||||
```
|
||||
|
||||
## Composables
|
||||
|
||||
| Category | Composables |
|
||||
| -------------- | ------------------------------------------------------------------ |
|
||||
| **browser** | `useEventListener`, `useFocusGuard`, `useSupported` |
|
||||
| **component** | `unrefElement`, `useRenderCount`, `useRenderInfo` |
|
||||
| **lifecycle** | `tryOnBeforeMount`, `tryOnMounted`, `tryOnScopeDispose`, `useMounted` |
|
||||
| **math** | `useClamp` |
|
||||
| **reactivity** | `broadcastedRef`, `useCached`, `useLastChanged`, `useSyncRefs` |
|
||||
| **state** | `useAppSharedState`, `useAsyncState`, `useContextFactory`, `useCounter`, `useInjectionStore`, `useToggle` |
|
||||
| **storage** | `useLocalStorage`, `useSessionStorage`, `useStorage`, `useStorageAsync` |
|
||||
| **utilities** | `useOffsetPagination` |
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { useToggle, useEventListener } from '@robonen/vue';
|
||||
```
|
||||
11
web/vue/build.config.ts
Normal file
11
web/vue/build.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
externals: ['vue'],
|
||||
rollup: {
|
||||
inlineDependencies: true,
|
||||
esbuild: {
|
||||
// minify: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
import { defineConfig } from 'oxlint';
|
||||
import { compose, base, typescript, vue, vitest, imports } from '@robonen/oxlint';
|
||||
|
||||
export default defineConfig(compose(base, typescript, vue, vitest, imports));
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@robonen/vue",
|
||||
"version": "0.0.11",
|
||||
"version": "0.0.9",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Collection of powerful tools for Vue",
|
||||
"keywords": [
|
||||
@@ -16,9 +16,9 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "./packages/vue"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
"node": ">=22.17.1"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -27,26 +27,23 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "oxlint -c oxlint.config.ts",
|
||||
"test": "vitest run",
|
||||
"dev": "vitest dev",
|
||||
"build": "tsdown"
|
||||
"build": "unbuild"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/oxlint": "workspace:*",
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"@vue/test-utils": "catalog:",
|
||||
"oxlint": "catalog:",
|
||||
"tsdown": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@robonen/platform": "workspace:*",
|
||||
"@robonen/stdlib": "workspace:*",
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"@vue/test-utils": "catalog:",
|
||||
"unbuild": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './useEventListener';
|
||||
export * from './useFocusGuard';
|
||||
export * from './useSupported';
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './unrefElement';
|
||||
export * from './useRenderCount';
|
||||
export * from './useRenderInfo';
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { computed, defineComponent, nextTick, ref, shallowRef } from 'vue';
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { unrefElement } from '.';
|
||||
|
||||
describe(unrefElement, () => {
|
||||
it('returns a plain element when passed a raw element', () => {
|
||||
const htmlEl = document.createElement('div');
|
||||
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
|
||||
expect(unrefElement(htmlEl)).toBe(htmlEl);
|
||||
expect(unrefElement(svgEl)).toBe(svgEl);
|
||||
});
|
||||
|
||||
it('returns element when passed a ref or shallowRef to an element', () => {
|
||||
const el = document.createElement('div');
|
||||
const elRef = ref<HTMLElement | null>(el);
|
||||
const shallowElRef = shallowRef<HTMLElement | null>(el);
|
||||
|
||||
expect(unrefElement(elRef)).toBe(el);
|
||||
expect(unrefElement(shallowElRef)).toBe(el);
|
||||
});
|
||||
|
||||
it('returns element when passed a computed ref or getter function', () => {
|
||||
const el = document.createElement('div');
|
||||
const computedElRef = computed(() => el);
|
||||
const elGetter = () => el;
|
||||
|
||||
expect(unrefElement(computedElRef)).toBe(el);
|
||||
expect(unrefElement(elGetter)).toBe(el);
|
||||
});
|
||||
|
||||
it('returns component $el when passed a component instance', async () => {
|
||||
const Child = defineComponent({
|
||||
template: `<span class="child-el">child</span>`,
|
||||
});
|
||||
|
||||
const Parent = defineComponent({
|
||||
components: { Child },
|
||||
template: `<Child ref="childRef" />`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Parent);
|
||||
await nextTick();
|
||||
|
||||
const childInstance = (wrapper.vm as any).$refs.childRef;
|
||||
const result = unrefElement(childInstance);
|
||||
|
||||
expect(result).toBe(childInstance.$el);
|
||||
expect((result as HTMLElement).classList.contains('child-el')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handles null and undefined values', () => {
|
||||
expect(unrefElement(undefined)).toBe(undefined);
|
||||
expect(unrefElement(null)).toBe(null);
|
||||
expect(unrefElement(ref<null>(null))).toBe(null);
|
||||
expect(unrefElement(ref<undefined>(undefined))).toBe(undefined);
|
||||
expect(unrefElement(() => null)).toBe(null);
|
||||
expect(unrefElement(() => undefined)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { ComponentPublicInstance, MaybeRef, MaybeRefOrGetter } from 'vue';
|
||||
import { toValue } from 'vue';
|
||||
|
||||
export type VueInstance = ComponentPublicInstance;
|
||||
export type MaybeElement = HTMLElement | SVGElement | VueInstance | undefined | null;
|
||||
|
||||
export type MaybeElementRef<El extends MaybeElement = MaybeElement> = MaybeRef<El>;
|
||||
export type MaybeComputedElementRef<El extends MaybeElement = MaybeElement> = MaybeRefOrGetter<El>;
|
||||
|
||||
export type UnRefElementReturn<T extends MaybeElement = MaybeElement> = T extends VueInstance ? Exclude<MaybeElement, VueInstance> : T | undefined;
|
||||
|
||||
/**
|
||||
* @name unrefElement
|
||||
* @category Component
|
||||
* @description Unwraps a Vue element reference to get the underlying instance or DOM element.
|
||||
*
|
||||
* @param {MaybeComputedElementRef<El>} elRef - The element reference to unwrap.
|
||||
* @returns {UnRefElementReturn<El>} - The unwrapped element or undefined.
|
||||
*
|
||||
* @example
|
||||
* const element = useTemplateRef<HTMLElement>('element');
|
||||
* const result = unrefElement(element); // result is the element instance
|
||||
*
|
||||
* @example
|
||||
* const component = useTemplateRef<Component>('component');
|
||||
* const result = unrefElement(component); // result is the component instance
|
||||
*
|
||||
* @since 0.0.11
|
||||
*/
|
||||
export function unrefElement<El extends MaybeElement>(elRef: MaybeComputedElementRef<El>): UnRefElementReturn<El> {
|
||||
const plain = toValue(elRef);
|
||||
return (plain as VueInstance)?.$el ?? plain;
|
||||
}
|
||||
@@ -1,8 +1,18 @@
|
||||
export * from './browser';
|
||||
export * from './component';
|
||||
export * from './lifecycle';
|
||||
export * from './math';
|
||||
export * from './reactivity';
|
||||
export * from './state';
|
||||
export * from './storage';
|
||||
export * from './utilities';
|
||||
export * from './tryOnBeforeMount';
|
||||
export * from './tryOnMounted';
|
||||
export * from './tryOnScopeDispose';
|
||||
export * from './useAppSharedState';
|
||||
export * from './useAsyncState';
|
||||
export * from './useCached';
|
||||
export * from './useClamp';
|
||||
export * from './useContextFactory';
|
||||
export * from './useCounter';
|
||||
export * from './useFocusGuard';
|
||||
export * from './useInjectionStore';
|
||||
export * from './useLastChanged';
|
||||
export * from './useMounted';
|
||||
export * from './useOffsetPagination';
|
||||
export * from './useRenderCount';
|
||||
export * from './useRenderInfo';
|
||||
export * from './useSupported';
|
||||
export * from './useSyncRefs';
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './tryOnBeforeMount';
|
||||
export * from './tryOnMounted';
|
||||
export * from './tryOnScopeDispose';
|
||||
export * from './useMounted';
|
||||
@@ -1 +0,0 @@
|
||||
export * from './useClamp';
|
||||
@@ -1,41 +0,0 @@
|
||||
import { customRef, onScopeDispose } from 'vue';
|
||||
|
||||
/**
|
||||
* @name broadcastedRef
|
||||
* @category Reactivity
|
||||
* @description Creates a custom ref that syncs its value across browser tabs via the BroadcastChannel API
|
||||
*
|
||||
* @param {string} key The channel key to use for broadcasting
|
||||
* @param {T} initialValue The initial value of the ref
|
||||
* @returns {Ref<T>} A custom ref that broadcasts value changes across tabs
|
||||
*
|
||||
* @example
|
||||
* const count = broadcastedRef('counter', 0);
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function broadcastedRef<T>(key: string, initialValue: T) {
|
||||
const channel = new BroadcastChannel(key);
|
||||
|
||||
onScopeDispose(channel.close);
|
||||
|
||||
return customRef<T>((track, trigger) => {
|
||||
channel.onmessage = (event) => {
|
||||
track();
|
||||
return event.data;
|
||||
};
|
||||
|
||||
channel.postMessage(initialValue);
|
||||
|
||||
return {
|
||||
get() {
|
||||
return initialValue;
|
||||
},
|
||||
set(newValue: T) {
|
||||
initialValue = newValue;
|
||||
channel.postMessage(newValue);
|
||||
trigger();
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './broadcastedRef';
|
||||
export * from './useCached';
|
||||
export * from './useLastChanged';
|
||||
export * from './useSyncRefs';
|
||||
@@ -1,6 +0,0 @@
|
||||
export * from './useAppSharedState';
|
||||
export * from './useAsyncState';
|
||||
export * from './useContextFactory';
|
||||
export * from './useCounter';
|
||||
export * from './useInjectionStore';
|
||||
export * from './useToggle';
|
||||
@@ -1,110 +0,0 @@
|
||||
import { it, expect, describe } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useToggle } from '.';
|
||||
|
||||
describe(useToggle, () => {
|
||||
it('initialize with false by default', () => {
|
||||
const { value } = useToggle();
|
||||
expect(value.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('initialize with the provided initial value', () => {
|
||||
const { value } = useToggle(true);
|
||||
expect(value.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('initialize with the provided initial value from a ref', () => {
|
||||
const { value } = useToggle(ref(true));
|
||||
expect(value.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('toggle from false to true', () => {
|
||||
const { value, toggle } = useToggle(false);
|
||||
toggle();
|
||||
expect(value.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('toggle from true to false', () => {
|
||||
const { value, toggle } = useToggle(true);
|
||||
toggle();
|
||||
expect(value.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('toggle multiple times', () => {
|
||||
const { value, toggle } = useToggle(false);
|
||||
toggle();
|
||||
expect(value.value).toBeTruthy();
|
||||
toggle();
|
||||
expect(value.value).toBeFalsy();
|
||||
toggle();
|
||||
expect(value.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('toggle returns the new value', () => {
|
||||
const { toggle } = useToggle(false);
|
||||
expect(toggle()).toBeTruthy();
|
||||
expect(toggle()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('set a specific value via toggle', () => {
|
||||
const { value, toggle } = useToggle(false);
|
||||
toggle(true);
|
||||
expect(value.value).toBeTruthy();
|
||||
toggle(true);
|
||||
expect(value.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('use custom truthy and falsy values', () => {
|
||||
const { value, toggle } = useToggle('off', {
|
||||
truthyValue: 'on',
|
||||
falsyValue: 'off',
|
||||
});
|
||||
|
||||
expect(value.value).toBe('off');
|
||||
toggle();
|
||||
expect(value.value).toBe('on');
|
||||
toggle();
|
||||
expect(value.value).toBe('off');
|
||||
});
|
||||
|
||||
it('set a specific custom value via toggle', () => {
|
||||
const { value, toggle } = useToggle('off', {
|
||||
truthyValue: 'on',
|
||||
falsyValue: 'off',
|
||||
});
|
||||
|
||||
toggle('on');
|
||||
expect(value.value).toBe('on');
|
||||
toggle('on');
|
||||
expect(value.value).toBe('on');
|
||||
});
|
||||
|
||||
it('use ref-based truthy and falsy values', () => {
|
||||
const truthy = ref('yes');
|
||||
const falsy = ref('no');
|
||||
|
||||
const { value, toggle } = useToggle('no', {
|
||||
truthyValue: truthy,
|
||||
falsyValue: falsy,
|
||||
});
|
||||
|
||||
expect(value.value).toBe('no');
|
||||
toggle();
|
||||
expect(value.value).toBe('yes');
|
||||
toggle();
|
||||
expect(value.value).toBe('no');
|
||||
});
|
||||
|
||||
it('use getter-based truthy and falsy values', () => {
|
||||
const { value, toggle } = useToggle(0, {
|
||||
truthyValue: () => 1,
|
||||
falsyValue: () => 0,
|
||||
});
|
||||
|
||||
expect(value.value).toBe(0);
|
||||
toggle();
|
||||
expect(value.value).toBe(1);
|
||||
toggle();
|
||||
expect(value.value).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import { ref, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter, MaybeRef, Ref } from 'vue';
|
||||
|
||||
export interface UseToggleOptions<Truthy, Falsy> {
|
||||
truthyValue?: MaybeRefOrGetter<Truthy>,
|
||||
falsyValue?: MaybeRefOrGetter<Falsy>,
|
||||
}
|
||||
|
||||
export interface UseToggleReturn<Truthy, Falsy> {
|
||||
value: Ref<Truthy | Falsy>;
|
||||
toggle: (value?: Truthy | Falsy) => Truthy | Falsy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useToggle
|
||||
* @category State
|
||||
* @description A composable that provides a boolean toggle with customizable truthy/falsy values
|
||||
*
|
||||
* @param {MaybeRef<Truthy | Falsy>} [initialValue=false] The initial value
|
||||
* @param {UseToggleOptions<Truthy, Falsy>} [options={}] Options for custom truthy/falsy values
|
||||
* @returns {UseToggleReturn<Truthy, Falsy>} The toggle state and function
|
||||
*
|
||||
* @example
|
||||
* const { value, toggle } = useToggle();
|
||||
*
|
||||
* @example
|
||||
* const { value, toggle } = useToggle(false, { truthyValue: 'on', falsyValue: 'off' });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useToggle<Truthy = true, Falsy = false>(
|
||||
initialValue: MaybeRef<Truthy | Falsy> = false as Truthy | Falsy,
|
||||
options: UseToggleOptions<Truthy, Falsy> = {},
|
||||
): UseToggleReturn<Truthy, Falsy> {
|
||||
const {
|
||||
truthyValue = true as Truthy,
|
||||
falsyValue = false as Falsy,
|
||||
} = options;
|
||||
|
||||
const value = ref(initialValue) as Ref<Truthy | Falsy>;
|
||||
|
||||
const toggle = (newValue?: Truthy | Falsy) => {
|
||||
if (newValue !== undefined) {
|
||||
value.value = newValue;
|
||||
return value.value;
|
||||
}
|
||||
|
||||
const truthy = toValue(truthyValue);
|
||||
const falsy = toValue(falsyValue);
|
||||
|
||||
value.value = value.value === truthy ? falsy : truthy;
|
||||
|
||||
return value.value;
|
||||
};
|
||||
|
||||
return { value, toggle };
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './useLocalStorage';
|
||||
export * from './useSessionStorage';
|
||||
export * from './useStorage';
|
||||
export * from './useStorageAsync';
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import { useLocalStorage } from '.';
|
||||
|
||||
describe(useLocalStorage, () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('stores and reads a string via localStorage', async () => {
|
||||
const state = useLocalStorage<string>('ls-string', 'hello');
|
||||
|
||||
expect(state.value).toBe('hello');
|
||||
expect(localStorage.getItem('ls-string')).toBe('hello');
|
||||
|
||||
state.value = 'world';
|
||||
await nextTick();
|
||||
|
||||
expect(localStorage.getItem('ls-string')).toBe('world');
|
||||
});
|
||||
|
||||
it('stores and reads a number', async () => {
|
||||
const state = useLocalStorage<number>('ls-number', 42);
|
||||
|
||||
expect(state.value).toBe(42);
|
||||
|
||||
state.value = 100;
|
||||
await nextTick();
|
||||
|
||||
expect(localStorage.getItem('ls-number')).toBe('100');
|
||||
});
|
||||
|
||||
it('stores and reads an object', async () => {
|
||||
const state = useLocalStorage('ls-obj', { a: 1 });
|
||||
|
||||
expect(state.value).toEqual({ a: 1 });
|
||||
|
||||
state.value = { a: 2 };
|
||||
await nextTick();
|
||||
|
||||
expect(JSON.parse(localStorage.getItem('ls-obj')!)).toEqual({ a: 2 });
|
||||
});
|
||||
|
||||
it('reads existing value from localStorage on init', () => {
|
||||
localStorage.setItem('ls-existing', '"stored"');
|
||||
|
||||
const state = useLocalStorage('ls-existing', 'default', {
|
||||
serializer: { read: (v) => JSON.parse(v), write: (v) => JSON.stringify(v) },
|
||||
});
|
||||
|
||||
expect(state.value).toBe('stored');
|
||||
});
|
||||
|
||||
it('removes from localStorage when set to null', async () => {
|
||||
const state = useLocalStorage<string | null>('ls-null', 'value');
|
||||
expect(localStorage.getItem('ls-null')).toBe('value');
|
||||
|
||||
state.value = null;
|
||||
await nextTick();
|
||||
|
||||
expect(localStorage.getItem('ls-null')).toBeNull();
|
||||
});
|
||||
|
||||
it('passes options through to useStorage', () => {
|
||||
const state = useLocalStorage<string>('ls-no-write', 'default', {
|
||||
writeDefaults: false,
|
||||
});
|
||||
|
||||
expect(state.value).toBe('default');
|
||||
expect(localStorage.getItem('ls-no-write')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import { VueToolsError } from '@/utils/error';
|
||||
import { useStorage } from '../useStorage';
|
||||
import type { UseStorageOptions } from '../useStorage';
|
||||
|
||||
/**
|
||||
* @name useLocalStorage
|
||||
* @category Storage
|
||||
* @description Reactive localStorage binding — creates a ref synced with `window.localStorage`
|
||||
*
|
||||
* @param {string} key The storage key
|
||||
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
|
||||
* @param {UseStorageOptions<T>} [options={}] Options
|
||||
* @returns {Ref<T>} A reactive ref synced with localStorage
|
||||
*
|
||||
* @example
|
||||
* const count = useLocalStorage('my-count', 0);
|
||||
*
|
||||
* @example
|
||||
* const state = useLocalStorage('my-state', { hello: 'world' });
|
||||
*
|
||||
* @since 0.0.12
|
||||
*/
|
||||
export function useLocalStorage<T extends string>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
|
||||
export function useLocalStorage<T extends number>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
|
||||
export function useLocalStorage<T extends boolean>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
|
||||
export function useLocalStorage<T>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
|
||||
export function useLocalStorage<T = unknown>(key: string, initialValue: MaybeRefOrGetter<null>, options?: UseStorageOptions<T>): Ref<T>;
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: MaybeRefOrGetter<T>,
|
||||
options: UseStorageOptions<T> = {},
|
||||
): Ref<T> {
|
||||
const storage = defaultWindow?.localStorage;
|
||||
|
||||
if (!storage)
|
||||
throw new VueToolsError('useLocalStorage: localStorage is not available');
|
||||
|
||||
return useStorage(key, initialValue, storage, options);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import { useSessionStorage } from '.';
|
||||
|
||||
describe(useSessionStorage, () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('stores and reads a string via sessionStorage', async () => {
|
||||
const state = useSessionStorage<string>('ss-string', 'hello');
|
||||
|
||||
expect(state.value).toBe('hello');
|
||||
expect(sessionStorage.getItem('ss-string')).toBe('hello');
|
||||
|
||||
state.value = 'world';
|
||||
await nextTick();
|
||||
|
||||
expect(sessionStorage.getItem('ss-string')).toBe('world');
|
||||
});
|
||||
|
||||
it('stores and reads a number', async () => {
|
||||
const state = useSessionStorage<number>('ss-number', 42);
|
||||
|
||||
expect(state.value).toBe(42);
|
||||
|
||||
state.value = 100;
|
||||
await nextTick();
|
||||
|
||||
expect(sessionStorage.getItem('ss-number')).toBe('100');
|
||||
});
|
||||
|
||||
it('stores and reads an object', async () => {
|
||||
const state = useSessionStorage('ss-obj', { a: 1 });
|
||||
|
||||
expect(state.value).toEqual({ a: 1 });
|
||||
|
||||
state.value = { a: 2 };
|
||||
await nextTick();
|
||||
|
||||
expect(JSON.parse(sessionStorage.getItem('ss-obj')!)).toEqual({ a: 2 });
|
||||
});
|
||||
|
||||
it('reads existing value from sessionStorage on init', () => {
|
||||
sessionStorage.setItem('ss-existing', '"stored"');
|
||||
|
||||
const state = useSessionStorage('ss-existing', 'default', {
|
||||
serializer: { read: (v) => JSON.parse(v), write: (v) => JSON.stringify(v) },
|
||||
});
|
||||
|
||||
expect(state.value).toBe('stored');
|
||||
});
|
||||
|
||||
it('removes from sessionStorage when set to null', async () => {
|
||||
const state = useSessionStorage<string | null>('ss-null', 'value');
|
||||
expect(sessionStorage.getItem('ss-null')).toBe('value');
|
||||
|
||||
state.value = null;
|
||||
await nextTick();
|
||||
|
||||
expect(sessionStorage.getItem('ss-null')).toBeNull();
|
||||
});
|
||||
|
||||
it('passes options through to useStorage', () => {
|
||||
const state = useSessionStorage<string>('ss-no-write', 'default', {
|
||||
writeDefaults: false,
|
||||
});
|
||||
|
||||
expect(state.value).toBe('default');
|
||||
expect(sessionStorage.getItem('ss-no-write')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import { VueToolsError } from '@/utils/error';
|
||||
import { useStorage } from '../useStorage';
|
||||
import type { UseStorageOptions } from '../useStorage';
|
||||
|
||||
/**
|
||||
* @name useSessionStorage
|
||||
* @category Storage
|
||||
* @description Reactive sessionStorage binding — creates a ref synced with `window.sessionStorage`
|
||||
*
|
||||
* @param {string} key The storage key
|
||||
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
|
||||
* @param {UseStorageOptions<T>} [options={}] Options
|
||||
* @returns {Ref<T>} A reactive ref synced with sessionStorage
|
||||
*
|
||||
* @example
|
||||
* const count = useSessionStorage('my-count', 0);
|
||||
*
|
||||
* @example
|
||||
* const state = useSessionStorage('my-state', { hello: 'world' });
|
||||
*
|
||||
* @since 0.0.12
|
||||
*/
|
||||
export function useSessionStorage<T extends string>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
|
||||
export function useSessionStorage<T extends number>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
|
||||
export function useSessionStorage<T extends boolean>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
|
||||
export function useSessionStorage<T>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
|
||||
export function useSessionStorage<T = unknown>(key: string, initialValue: MaybeRefOrGetter<null>, options?: UseStorageOptions<T>): Ref<T>;
|
||||
export function useSessionStorage<T>(
|
||||
key: string,
|
||||
initialValue: MaybeRefOrGetter<T>,
|
||||
options: UseStorageOptions<T> = {},
|
||||
): Ref<T> {
|
||||
const storage = defaultWindow?.sessionStorage;
|
||||
|
||||
if (!storage)
|
||||
throw new VueToolsError('useSessionStorage: sessionStorage is not available');
|
||||
|
||||
return useStorage(key, initialValue, storage, options);
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import { useStorage, StorageSerializers } from '.';
|
||||
import type { StorageLike } from '.';
|
||||
|
||||
function createMockStorage(): StorageLike & { store: Map<string, string> } {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
return {
|
||||
store,
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => store.set(key, value),
|
||||
removeItem: (key: string) => store.delete(key),
|
||||
};
|
||||
}
|
||||
|
||||
describe(useStorage, () => {
|
||||
// --- Basic types ---
|
||||
|
||||
it('stores and reads a string', async () => {
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage<string>('test-string', 'hello', storage);
|
||||
|
||||
expect(state.value).toBe('hello');
|
||||
expect(storage.getItem('test-string')).toBe('hello');
|
||||
|
||||
state.value = 'world';
|
||||
await nextTick();
|
||||
|
||||
expect(storage.getItem('test-string')).toBe('world');
|
||||
});
|
||||
|
||||
it('stores and reads a number', async () => {
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage<number>('test-number', 42, storage);
|
||||
|
||||
expect(state.value).toBe(42);
|
||||
|
||||
state.value = 100;
|
||||
await nextTick();
|
||||
|
||||
expect(storage.getItem('test-number')).toBe('100');
|
||||
});
|
||||
|
||||
it('stores and reads a boolean', async () => {
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage<boolean>('test-bool', true, storage);
|
||||
|
||||
expect(state.value).toBeTruthy();
|
||||
|
||||
state.value = false;
|
||||
await nextTick();
|
||||
|
||||
expect(storage.getItem('test-bool')).toBe('false');
|
||||
});
|
||||
|
||||
it('stores and reads an object', async () => {
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage('test-obj', { a: 1, b: 'two' }, storage);
|
||||
|
||||
expect(state.value).toEqual({ a: 1, b: 'two' });
|
||||
|
||||
state.value = { a: 2, b: 'three' };
|
||||
await nextTick();
|
||||
|
||||
expect(JSON.parse(storage.getItem('test-obj')!)).toEqual({ a: 2, b: 'three' });
|
||||
});
|
||||
|
||||
// --- Reads existing value from storage ---
|
||||
|
||||
it('reads existing value from storage on init', () => {
|
||||
const storage = createMockStorage();
|
||||
storage.store.set('existing', '"stored-value"');
|
||||
|
||||
const state = useStorage('existing', 'default', storage, {
|
||||
serializer: StorageSerializers.object,
|
||||
});
|
||||
|
||||
expect(state.value).toBe('stored-value');
|
||||
});
|
||||
|
||||
// --- Removes item when set to null ---
|
||||
|
||||
it('removes from storage when value is set to null', async () => {
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage<string | null>('test-null', 'value', storage);
|
||||
|
||||
await nextTick();
|
||||
expect(storage.getItem('test-null')).toBe('value');
|
||||
|
||||
state.value = null;
|
||||
await nextTick();
|
||||
|
||||
expect(storage.getItem('test-null')).toBeNull();
|
||||
});
|
||||
|
||||
// --- Custom serializer ---
|
||||
|
||||
it('uses custom serializer', async () => {
|
||||
const storage = createMockStorage();
|
||||
|
||||
const serializer = {
|
||||
read: (v: string) => v.split(',').map(Number),
|
||||
write: (v: number[]) => v.join(','),
|
||||
};
|
||||
|
||||
const state = useStorage('custom-ser', [1, 2, 3], storage, { serializer });
|
||||
|
||||
expect(state.value).toEqual([1, 2, 3]);
|
||||
|
||||
state.value = [4, 5, 6];
|
||||
await nextTick();
|
||||
|
||||
expect(storage.getItem('custom-ser')).toBe('4,5,6');
|
||||
});
|
||||
|
||||
// --- Merge defaults ---
|
||||
|
||||
it('merges defaults with stored value', () => {
|
||||
const storage = createMockStorage();
|
||||
storage.store.set('merge-test', JSON.stringify({ hello: 'stored' }));
|
||||
|
||||
const state = useStorage(
|
||||
'merge-test',
|
||||
{ hello: 'default', greeting: 'hi' },
|
||||
storage,
|
||||
{ mergeDefaults: true },
|
||||
);
|
||||
|
||||
expect(state.value.hello).toBe('stored');
|
||||
expect(state.value.greeting).toBe('hi');
|
||||
});
|
||||
|
||||
it('uses custom merge function', () => {
|
||||
const storage = createMockStorage();
|
||||
storage.store.set('merge-fn', JSON.stringify({ a: 1 }));
|
||||
|
||||
const state = useStorage(
|
||||
'merge-fn',
|
||||
{ a: 0, b: 2 },
|
||||
storage,
|
||||
{
|
||||
mergeDefaults: (stored, defaults) => ({ ...defaults, ...stored, b: stored.b ?? defaults.b }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(state.value).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
// --- Map and Set ---
|
||||
|
||||
it('stores and reads a Map', async () => {
|
||||
const storage = createMockStorage();
|
||||
const initial = new Map([['key1', 'val1']]);
|
||||
const state = useStorage('test-map', initial, storage);
|
||||
|
||||
expect(state.value).toEqual(new Map([['key1', 'val1']]));
|
||||
|
||||
state.value = new Map([['key2', 'val2']]);
|
||||
await nextTick();
|
||||
|
||||
const raw = storage.getItem('test-map');
|
||||
expect(JSON.parse(raw!)).toEqual([['key2', 'val2']]);
|
||||
});
|
||||
|
||||
it('stores and reads a Set', async () => {
|
||||
const storage = createMockStorage();
|
||||
const initial = new Set([1, 2, 3]);
|
||||
const state = useStorage('test-set', initial, storage);
|
||||
|
||||
expect(state.value).toEqual(new Set([1, 2, 3]));
|
||||
|
||||
state.value = new Set([4, 5]);
|
||||
await nextTick();
|
||||
|
||||
const raw = storage.getItem('test-set');
|
||||
expect(JSON.parse(raw!)).toEqual([4, 5]);
|
||||
});
|
||||
|
||||
// --- Date ---
|
||||
|
||||
it('stores and reads a Date', async () => {
|
||||
const storage = createMockStorage();
|
||||
const date = new Date('2026-02-14T00:00:00.000Z');
|
||||
const state = useStorage('test-date', date, storage);
|
||||
|
||||
expect(state.value).toEqual(date);
|
||||
|
||||
const newDate = new Date('2026-12-25T00:00:00.000Z');
|
||||
state.value = newDate;
|
||||
await nextTick();
|
||||
|
||||
expect(storage.getItem('test-date')).toBe(newDate.toISOString());
|
||||
});
|
||||
|
||||
// --- Error handling ---
|
||||
|
||||
it('calls onError when read fails', () => {
|
||||
const storage = createMockStorage();
|
||||
storage.store.set('bad-json', '{invalid');
|
||||
const onError = vi.fn();
|
||||
|
||||
const state = useStorage('bad-json', { fallback: true }, storage, { onError });
|
||||
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
expect(state.value).toEqual({ fallback: true });
|
||||
});
|
||||
|
||||
it('calls onError when write fails', async () => {
|
||||
const onError = vi.fn();
|
||||
const storage: StorageLike = {
|
||||
getItem: () => null,
|
||||
setItem: () => { throw new Error('quota exceeded'); },
|
||||
removeItem: () => {},
|
||||
};
|
||||
|
||||
const state = useStorage<string>('fail-write', 'init', storage, { onError });
|
||||
|
||||
// One error from initial persist of defaults
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
|
||||
state.value = 'new';
|
||||
await nextTick();
|
||||
|
||||
// Another error from the write triggered by value change
|
||||
expect(onError).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// --- Persists defaults on init ---
|
||||
|
||||
it('persists default value to storage on init when key does not exist', () => {
|
||||
const storage = createMockStorage();
|
||||
useStorage('new-key', 'default-val', storage);
|
||||
|
||||
expect(storage.getItem('new-key')).toBe('default-val');
|
||||
});
|
||||
|
||||
it('does not overwrite existing storage value with defaults', () => {
|
||||
const storage = createMockStorage();
|
||||
storage.store.set('existing-key', 'existing-val');
|
||||
|
||||
useStorage('existing-key', 'default-val', storage);
|
||||
|
||||
expect(storage.getItem('existing-key')).toBe('existing-val');
|
||||
});
|
||||
|
||||
// --- writeDefaults: false ---
|
||||
|
||||
it('does not persist defaults when writeDefaults is false', () => {
|
||||
const storage = createMockStorage();
|
||||
useStorage('no-write', 'default-val', storage, { writeDefaults: false });
|
||||
|
||||
expect(storage.getItem('no-write')).toBeNull();
|
||||
});
|
||||
|
||||
// --- No infinite loop on init ---
|
||||
|
||||
it('calls setItem exactly once on init for writeDefaults', () => {
|
||||
const setItem = vi.fn();
|
||||
const storage: StorageLike = {
|
||||
getItem: () => null,
|
||||
setItem,
|
||||
removeItem: vi.fn(),
|
||||
};
|
||||
|
||||
useStorage<string>('init-key', 'value', storage);
|
||||
|
||||
expect(setItem).toHaveBeenCalledOnce();
|
||||
expect(setItem).toHaveBeenCalledWith('init-key', 'value');
|
||||
});
|
||||
|
||||
// --- No-op write when value unchanged ---
|
||||
|
||||
it('does not call setItem when value is unchanged', async () => {
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage<string>('noop-key', 'same', storage);
|
||||
|
||||
const setItem = vi.spyOn(storage, 'setItem');
|
||||
setItem.mockClear();
|
||||
|
||||
// Re-assign the same value
|
||||
state.value = 'same';
|
||||
await nextTick();
|
||||
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- shallow: false with deep mutation ---
|
||||
|
||||
it('writes to storage on deep object mutation with shallow: false', async () => {
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage('deep-obj', { nested: { count: 0 } }, storage, { shallow: false });
|
||||
|
||||
expect(state.value.nested.count).toBe(0);
|
||||
|
||||
state.value.nested.count = 42;
|
||||
await nextTick();
|
||||
|
||||
expect(JSON.parse(storage.getItem('deep-obj')!)).toEqual({ nested: { count: 42 } });
|
||||
});
|
||||
|
||||
// --- Multiple rapid assignments ---
|
||||
|
||||
it('only writes last value when multiple assignments happen before flush', async () => {
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage<string>('rapid-key', 'initial', storage);
|
||||
|
||||
const setItem = vi.spyOn(storage, 'setItem');
|
||||
setItem.mockClear();
|
||||
|
||||
state.value = 'first';
|
||||
state.value = 'second';
|
||||
state.value = 'third';
|
||||
await nextTick();
|
||||
|
||||
// Watcher fires once with the last value (pre flush batches)
|
||||
expect(storage.getItem('rapid-key')).toBe('third');
|
||||
});
|
||||
});
|
||||
@@ -1,224 +0,0 @@
|
||||
import { ref, shallowRef, watch, toValue } from 'vue';
|
||||
import type { Ref, MaybeRefOrGetter } from 'vue';
|
||||
import { isBoolean, isNumber, isString, isObject, isMap, isSet, isDate } from '@robonen/stdlib';
|
||||
import type { ConfigurableFlush } from '@/types';
|
||||
|
||||
export interface StorageSerializer<T> {
|
||||
read: (raw: string) => T;
|
||||
write: (value: T) => string;
|
||||
}
|
||||
|
||||
export const StorageSerializers: { [K: string]: StorageSerializer<any> } & {
|
||||
boolean: StorageSerializer<boolean>;
|
||||
number: StorageSerializer<number>;
|
||||
string: StorageSerializer<string>;
|
||||
object: StorageSerializer<any>;
|
||||
map: StorageSerializer<Map<any, any>>;
|
||||
set: StorageSerializer<Set<any>>;
|
||||
date: StorageSerializer<Date>;
|
||||
} = {
|
||||
boolean: {
|
||||
read: (v: string) => v === 'true',
|
||||
write: (v: boolean) => String(v),
|
||||
},
|
||||
number: {
|
||||
read: (v: string) => Number.parseFloat(v),
|
||||
write: (v: number) => String(v),
|
||||
},
|
||||
string: {
|
||||
read: (v: string) => v,
|
||||
write: (v: string) => v,
|
||||
},
|
||||
object: {
|
||||
read: (v: string) => JSON.parse(v),
|
||||
write: (v: any) => JSON.stringify(v),
|
||||
},
|
||||
map: {
|
||||
read: (v: string) => new Map(JSON.parse(v)),
|
||||
write: (v: Map<any, any>) => JSON.stringify([...v.entries()]),
|
||||
},
|
||||
set: {
|
||||
read: (v: string) => new Set(JSON.parse(v)),
|
||||
write: (v: Set<any>) => JSON.stringify([...v]),
|
||||
},
|
||||
date: {
|
||||
read: (v: string) => new Date(v),
|
||||
write: (v: Date) => v.toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
export interface StorageLike {
|
||||
getItem: (key: string) => string | null;
|
||||
setItem: (key: string, value: string) => void;
|
||||
removeItem: (key: string) => void;
|
||||
}
|
||||
|
||||
export interface UseStorageOptions<T> extends ConfigurableFlush {
|
||||
/**
|
||||
* Use shallowRef instead of ref for the internal state
|
||||
* @default true
|
||||
*/
|
||||
shallow?: boolean;
|
||||
/**
|
||||
* Watch for deep changes
|
||||
* @default true
|
||||
*/
|
||||
deep?: boolean;
|
||||
/**
|
||||
* Write the default value to the storage when it does not exist
|
||||
* @default true
|
||||
*/
|
||||
writeDefaults?: boolean;
|
||||
/**
|
||||
* Custom serializer for reading/writing storage values
|
||||
*/
|
||||
serializer?: StorageSerializer<T>;
|
||||
/**
|
||||
* Merge the default value with the stored value
|
||||
* @default false
|
||||
*/
|
||||
mergeDefaults?: boolean | ((stored: T, defaults: T) => T);
|
||||
/**
|
||||
* Error handler for read/write failures
|
||||
*/
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export type UseStorageReturn<T> = Ref<T>;
|
||||
|
||||
export function guessSerializer<T>(value: T): StorageSerializer<T> {
|
||||
if (isBoolean(value)) return StorageSerializers.boolean as any;
|
||||
if (isNumber(value)) return StorageSerializers.number as any;
|
||||
if (isString(value)) return StorageSerializers.string as any;
|
||||
if (isMap(value)) return StorageSerializers.map as any;
|
||||
if (isSet(value)) return StorageSerializers.set as any;
|
||||
if (isDate(value)) return StorageSerializers.date as any;
|
||||
if (isObject(value)) return StorageSerializers.object as any;
|
||||
|
||||
return StorageSerializers.object as any;
|
||||
}
|
||||
|
||||
export function shallowMerge<T>(stored: T, defaults: T): T {
|
||||
if (isObject(stored) && isObject(defaults))
|
||||
return { ...defaults, ...stored };
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useStorage
|
||||
* @category Storage
|
||||
* @description Reactive Storage binding — creates a ref synced with a storage backend
|
||||
*
|
||||
* @param {string} key The storage key
|
||||
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
|
||||
* @param {StorageLike} storage The storage backend
|
||||
* @param {UseStorageOptions<T>} [options={}] Options
|
||||
* @returns {Ref<T>} A reactive ref synced with storage
|
||||
*
|
||||
* @example
|
||||
* const count = useStorage('my-count', 0, storage);
|
||||
*
|
||||
* @example
|
||||
* const state = useStorage('my-state', { hello: 'world' }, storage);
|
||||
*
|
||||
* @example
|
||||
* const id = useStorage('my-id', 'default', storage, {
|
||||
* serializer: { read: (v) => v, write: (v) => v },
|
||||
* });
|
||||
*
|
||||
* @since 0.0.12
|
||||
*/
|
||||
export function useStorage<T extends string>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): Ref<T>;
|
||||
export function useStorage<T extends number>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): Ref<T>;
|
||||
export function useStorage<T extends boolean>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): Ref<T>;
|
||||
export function useStorage<T>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): Ref<T>;
|
||||
export function useStorage<T = unknown>(key: string, initialValue: MaybeRefOrGetter<null>, storage: StorageLike, options?: UseStorageOptions<T>): Ref<T>;
|
||||
export function useStorage<T>(
|
||||
key: string,
|
||||
initialValue: MaybeRefOrGetter<T>,
|
||||
storage: StorageLike,
|
||||
options: UseStorageOptions<T> = {},
|
||||
): Ref<T> {
|
||||
const {
|
||||
shallow = true,
|
||||
deep = true,
|
||||
flush = 'pre',
|
||||
writeDefaults = true,
|
||||
mergeDefaults = false,
|
||||
onError = console.error, // eslint-disable-line no-console
|
||||
} = options;
|
||||
|
||||
const defaults = toValue(initialValue);
|
||||
const serializer = options.serializer ?? guessSerializer(defaults);
|
||||
|
||||
const data = (shallow ? shallowRef : ref)(defaults) as Ref<T>;
|
||||
|
||||
function read(): T {
|
||||
const raw = storage.getItem(key);
|
||||
|
||||
if (raw === undefined || raw === null) {
|
||||
if (writeDefaults && defaults !== undefined && defaults !== null) {
|
||||
try {
|
||||
storage.setItem(key, serializer.write(defaults));
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
try {
|
||||
let value = serializer.read(raw);
|
||||
|
||||
if (mergeDefaults) {
|
||||
value = typeof mergeDefaults === 'function'
|
||||
? mergeDefaults(value, defaults)
|
||||
: shallowMerge(value, defaults);
|
||||
}
|
||||
|
||||
return value;
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
function write(value: T) {
|
||||
try {
|
||||
const oldValue = storage.getItem(key);
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
storage.removeItem(key);
|
||||
} else {
|
||||
const serialized = serializer.write(value);
|
||||
|
||||
if (oldValue !== serialized)
|
||||
storage.setItem(key, serialized);
|
||||
}
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
}
|
||||
|
||||
function update() {
|
||||
pauseWatch();
|
||||
|
||||
try {
|
||||
data.value = read();
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
} finally {
|
||||
resumeWatch();
|
||||
}
|
||||
}
|
||||
|
||||
const { pause: pauseWatch, resume: resumeWatch } = watch(data, (newValue) => {
|
||||
write(newValue);
|
||||
}, { flush, deep });
|
||||
|
||||
update();
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import { useStorageAsync } from '.';
|
||||
import type { StorageLikeAsync } from '.';
|
||||
|
||||
function createMockAsyncStorage(): StorageLikeAsync & { store: Map<string, string> } {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
return {
|
||||
store,
|
||||
getItem: async (key: string) => store.get(key) ?? null,
|
||||
setItem: async (key: string, value: string) => { store.set(key, value); },
|
||||
removeItem: async (key: string) => { store.delete(key); },
|
||||
};
|
||||
}
|
||||
|
||||
function createDelayedAsyncStorage(delay: number): StorageLikeAsync & { store: Map<string, string> } {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
return {
|
||||
store,
|
||||
getItem: (key: string) => new Promise((resolve) => setTimeout(() => resolve(store.get(key) ?? null), delay)),
|
||||
setItem: (key: string, value: string) => new Promise((resolve) => setTimeout(() => { store.set(key, value); resolve(); }, delay)),
|
||||
removeItem: (key: string) => new Promise((resolve) => setTimeout(() => { store.delete(key); resolve(); }, delay)),
|
||||
};
|
||||
}
|
||||
|
||||
describe(useStorageAsync, () => {
|
||||
// --- Basic read/write ---
|
||||
|
||||
it('returns default value before storage is ready', () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
const { state, isReady } = useStorageAsync('key', 'default', storage);
|
||||
|
||||
expect(state.value).toBe('default');
|
||||
expect(isReady.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('reads existing value from async storage', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('key', 'stored');
|
||||
|
||||
const { state, isReady } = await useStorageAsync('key', 'default', storage);
|
||||
|
||||
expect(state.value).toBe('stored');
|
||||
expect(isReady.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('writes value to async storage on change', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
const { state } = await useStorageAsync<string>('key', 'initial', storage);
|
||||
|
||||
state.value = 'updated';
|
||||
await nextTick();
|
||||
|
||||
// Allow async write to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(storage.store.get('key')).toBe('updated');
|
||||
});
|
||||
|
||||
// --- Types ---
|
||||
|
||||
it('reads and writes a number', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('num', '42');
|
||||
|
||||
const { state } = await useStorageAsync<number>('num', 0, storage);
|
||||
|
||||
expect(state.value).toBe(42);
|
||||
|
||||
state.value = 100;
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(storage.store.get('num')).toBe('100');
|
||||
});
|
||||
|
||||
it('reads and writes a boolean', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('flag', 'true');
|
||||
|
||||
const { state } = await useStorageAsync('flag', false, storage);
|
||||
|
||||
expect(state.value).toBeTruthy();
|
||||
|
||||
state.value = false;
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(storage.store.get('flag')).toBe('false');
|
||||
});
|
||||
|
||||
it('reads and writes an object', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('obj', JSON.stringify({ a: 1 }));
|
||||
|
||||
const { state } = await useStorageAsync('obj', { a: 0, b: 2 }, storage);
|
||||
|
||||
expect(state.value).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
// --- Awaitable ---
|
||||
|
||||
it('is awaitable and resolves after initial read', async () => {
|
||||
const storage = createDelayedAsyncStorage(50);
|
||||
storage.store.set('delayed', 'loaded');
|
||||
|
||||
const { state, isReady } = await useStorageAsync('delayed', 'default', storage);
|
||||
|
||||
expect(state.value).toBe('loaded');
|
||||
expect(isReady.value).toBeTruthy();
|
||||
});
|
||||
|
||||
// --- onReady callback ---
|
||||
|
||||
it('calls onReady callback after initial load', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('ready', 'ready-value');
|
||||
|
||||
const onReady = vi.fn();
|
||||
|
||||
await useStorageAsync('ready', 'default', storage, { onReady });
|
||||
|
||||
expect(onReady).toHaveBeenCalledOnce();
|
||||
expect(onReady).toHaveBeenCalledWith('ready-value');
|
||||
});
|
||||
|
||||
it('calls onReady with default when key not in storage', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
const onReady = vi.fn();
|
||||
|
||||
await useStorageAsync('missing', 'fallback', storage, { onReady });
|
||||
|
||||
expect(onReady).toHaveBeenCalledWith('fallback');
|
||||
});
|
||||
|
||||
// --- Merge defaults ---
|
||||
|
||||
it('merges defaults with stored value', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('merge', JSON.stringify({ hello: 'stored' }));
|
||||
|
||||
const { state } = await useStorageAsync(
|
||||
'merge',
|
||||
{ hello: 'default', greeting: 'hi' },
|
||||
storage,
|
||||
{ mergeDefaults: true },
|
||||
);
|
||||
|
||||
expect(state.value.hello).toBe('stored');
|
||||
expect(state.value.greeting).toBe('hi');
|
||||
});
|
||||
|
||||
it('uses custom merge function', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('merge-fn', JSON.stringify({ a: 1 }));
|
||||
|
||||
const { state } = await useStorageAsync(
|
||||
'merge-fn',
|
||||
{ a: 0, b: 2 },
|
||||
storage,
|
||||
{
|
||||
mergeDefaults: (stored, defaults) => ({ ...defaults, ...stored, b: stored.b ?? defaults.b }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(state.value).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
// --- Custom serializer ---
|
||||
|
||||
it('uses custom async serializer', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('custom', '1,2,3');
|
||||
|
||||
const serializer = {
|
||||
read: async (v: string) => v.split(',').map(Number),
|
||||
write: async (v: number[]) => v.join(','),
|
||||
};
|
||||
|
||||
const { state } = await useStorageAsync('custom', [0], storage, { serializer });
|
||||
|
||||
expect(state.value).toEqual([1, 2, 3]);
|
||||
|
||||
state.value = [4, 5, 6];
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(storage.store.get('custom')).toBe('4,5,6');
|
||||
});
|
||||
|
||||
// --- Null / remove ---
|
||||
|
||||
it('removes from storage when value is set to null', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('nullable', 'exists');
|
||||
|
||||
const { state } = await useStorageAsync<string | null>('nullable', 'default', storage);
|
||||
|
||||
state.value = null;
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(storage.store.has('nullable')).toBeFalsy();
|
||||
});
|
||||
|
||||
// --- Error handling ---
|
||||
|
||||
it('calls onError when read fails', async () => {
|
||||
const onError = vi.fn();
|
||||
const storage: StorageLikeAsync = {
|
||||
getItem: async () => { throw new Error('read failure'); },
|
||||
setItem: async () => {},
|
||||
removeItem: async () => {},
|
||||
};
|
||||
|
||||
const { state } = await useStorageAsync('fail-read', 'fallback', storage, { onError });
|
||||
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
expect(state.value).toBe('fallback');
|
||||
});
|
||||
|
||||
it('calls onError when write fails', async () => {
|
||||
const onError = vi.fn();
|
||||
const storage: StorageLikeAsync = {
|
||||
getItem: async () => null,
|
||||
setItem: async () => { throw new Error('write failure'); },
|
||||
removeItem: async () => {},
|
||||
};
|
||||
|
||||
const { state } = await useStorageAsync<string>('fail-write', 'initial', storage, { onError });
|
||||
|
||||
// One error from writeDefaults persisting initial value
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
|
||||
state.value = 'new';
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Another error from the write triggered by value change
|
||||
expect(onError).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// --- No unnecessary write-back on initial read ---
|
||||
|
||||
it('does not write back to storage after initial read', async () => {
|
||||
const setItem = vi.fn(async () => {});
|
||||
const storage: StorageLikeAsync = {
|
||||
getItem: async () => 'existing',
|
||||
setItem,
|
||||
removeItem: async () => {},
|
||||
};
|
||||
|
||||
await useStorageAsync('key', 'default', storage);
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not write back to storage when key is missing and writeDefaults is false', async () => {
|
||||
const setItem = vi.fn(async () => {});
|
||||
const storage: StorageLikeAsync = {
|
||||
getItem: async () => null,
|
||||
setItem,
|
||||
removeItem: async () => {},
|
||||
};
|
||||
|
||||
await useStorageAsync('key', 'default', storage, { writeDefaults: false });
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- shallow: false with deep mutation ---
|
||||
|
||||
it('writes to storage on deep object mutation with shallow: false', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
const { state } = await useStorageAsync(
|
||||
'deep-obj',
|
||||
{ nested: { count: 0 } },
|
||||
storage,
|
||||
{ shallow: false },
|
||||
);
|
||||
|
||||
state.value.nested.count = 42;
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(JSON.parse(storage.store.get('deep-obj')!)).toEqual({ nested: { count: 42 } });
|
||||
});
|
||||
|
||||
// --- Multiple rapid assignments ---
|
||||
|
||||
it('handles multiple rapid assignments', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
const { state } = await useStorageAsync<string>('rapid', 'initial', storage);
|
||||
|
||||
state.value = 'first';
|
||||
state.value = 'second';
|
||||
state.value = 'third';
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(storage.store.get('rapid')).toBe('third');
|
||||
});
|
||||
|
||||
// --- writeDefaults ---
|
||||
|
||||
it('persists defaults to storage when key does not exist', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
|
||||
await useStorageAsync('new-key', 'default-val', storage);
|
||||
|
||||
expect(storage.store.get('new-key')).toBe('default-val');
|
||||
});
|
||||
|
||||
it('does not persist defaults when writeDefaults is false', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
|
||||
await useStorageAsync('new-key', 'default-val', storage, { writeDefaults: false });
|
||||
|
||||
expect(storage.store.has('new-key')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does not overwrite existing value with defaults', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('existing', 'stored');
|
||||
|
||||
await useStorageAsync('existing', 'default', storage);
|
||||
|
||||
expect(storage.store.get('existing')).toBe('stored');
|
||||
});
|
||||
});
|
||||
@@ -1,188 +0,0 @@
|
||||
import { ref, shallowRef, watch, toValue } from 'vue';
|
||||
import type { Ref, ShallowRef, MaybeRefOrGetter, UnwrapRef } from 'vue';
|
||||
import type { ConfigurableFlush } from '@/types';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
import { guessSerializer, shallowMerge } from '../useStorage';
|
||||
|
||||
export interface StorageSerializerAsync<T> {
|
||||
read: (raw: string) => T | Promise<T>;
|
||||
write: (value: T) => string | Promise<string>;
|
||||
}
|
||||
|
||||
export interface StorageLikeAsync {
|
||||
getItem: (key: string) => string | null | Promise<string | null>;
|
||||
setItem: (key: string, value: string) => void | Promise<void>;
|
||||
removeItem: (key: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface UseStorageAsyncOptions<T, Shallow extends boolean = true> extends ConfigurableFlush {
|
||||
/**
|
||||
* Use shallowRef instead of ref for the internal state
|
||||
* @default true
|
||||
*/
|
||||
shallow?: Shallow;
|
||||
/**
|
||||
* Watch for deep changes
|
||||
* @default true
|
||||
*/
|
||||
deep?: boolean;
|
||||
/**
|
||||
* Write the default value to the storage when it does not exist
|
||||
* @default true
|
||||
*/
|
||||
writeDefaults?: boolean;
|
||||
/**
|
||||
* Custom serializer for reading/writing storage values
|
||||
*/
|
||||
serializer?: StorageSerializerAsync<T>;
|
||||
/**
|
||||
* Merge the default value with the stored value
|
||||
* @default false
|
||||
*/
|
||||
mergeDefaults?: boolean | ((stored: T, defaults: T) => T);
|
||||
/**
|
||||
* Called once when the initial value has been loaded from storage
|
||||
*/
|
||||
onReady?: (value: T) => void;
|
||||
/**
|
||||
* Error handler for read/write failures
|
||||
*/
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface UseStorageAsyncReturnBase<T, Shallow extends boolean> {
|
||||
state: Shallow extends true ? ShallowRef<T> : Ref<UnwrapRef<T>>;
|
||||
isReady: Ref<boolean>;
|
||||
}
|
||||
|
||||
export type UseStorageAsyncReturn<T, Shallow extends boolean> =
|
||||
& UseStorageAsyncReturnBase<T, Shallow>
|
||||
& PromiseLike<UseStorageAsyncReturnBase<T, Shallow>>;
|
||||
|
||||
/**
|
||||
* @name useStorageAsync
|
||||
* @category Storage
|
||||
* @description Reactive Storage binding with async support — creates a ref synced with an async storage backend
|
||||
*
|
||||
* @param {string} key The storage key
|
||||
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
|
||||
* @param {StorageLikeAsync} storage The async storage backend
|
||||
* @param {UseStorageAsyncOptions<T>} [options={}] Options
|
||||
* @returns {UseStorageAsyncReturn<T, Shallow>} An object with state ref and isReady flag, also awaitable
|
||||
*
|
||||
* @example
|
||||
* const { state } = useStorageAsync('access-token', '', asyncStorage);
|
||||
*
|
||||
* @example
|
||||
* const { state, isReady } = await useStorageAsync('settings', { theme: 'dark' }, asyncStorage);
|
||||
*
|
||||
* @example
|
||||
* const { state } = useStorageAsync('key', 'default', asyncStorage, {
|
||||
* onReady: (value) => console.log('Loaded:', value),
|
||||
* });
|
||||
*
|
||||
* @since 0.0.12
|
||||
*/
|
||||
export function useStorageAsync<T extends string, Shallow extends boolean = true>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
|
||||
export function useStorageAsync<T extends number, Shallow extends boolean = true>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
|
||||
export function useStorageAsync<T extends boolean, Shallow extends boolean = true>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
|
||||
export function useStorageAsync<T, Shallow extends boolean = true>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
|
||||
export function useStorageAsync<T = unknown, Shallow extends boolean = true>(key: string, initialValue: MaybeRefOrGetter<null>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
|
||||
export function useStorageAsync<T, Shallow extends boolean = true>(
|
||||
key: string,
|
||||
initialValue: MaybeRefOrGetter<T>,
|
||||
storage: StorageLikeAsync,
|
||||
options: UseStorageAsyncOptions<T, Shallow> = {},
|
||||
): UseStorageAsyncReturn<T, Shallow> {
|
||||
const {
|
||||
shallow = true,
|
||||
deep = true,
|
||||
flush = 'pre',
|
||||
writeDefaults = true,
|
||||
mergeDefaults = false,
|
||||
onReady,
|
||||
onError = console.error, // eslint-disable-line no-console
|
||||
} = options;
|
||||
|
||||
const defaults = toValue(initialValue);
|
||||
const serializer = options.serializer ?? guessSerializer(defaults);
|
||||
|
||||
const state = (shallow ? shallowRef : ref)(defaults) as Shallow extends true ? ShallowRef<T> : Ref<UnwrapRef<T>>;
|
||||
const isReady = ref(false);
|
||||
|
||||
async function read(): Promise<T> {
|
||||
try {
|
||||
const raw = await storage.getItem(key);
|
||||
|
||||
if (raw === undefined || raw === null) {
|
||||
if (writeDefaults && defaults !== undefined && defaults !== null) {
|
||||
try {
|
||||
await storage.setItem(key, await serializer.write(defaults));
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
let value: T = await serializer.read(raw) as T;
|
||||
|
||||
if (mergeDefaults) {
|
||||
value = typeof mergeDefaults === 'function'
|
||||
? mergeDefaults(value, defaults)
|
||||
: shallowMerge(value, defaults);
|
||||
}
|
||||
|
||||
return value;
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
async function write(value: T) {
|
||||
try {
|
||||
if (value === undefined || value === null) {
|
||||
await storage.removeItem(key);
|
||||
} else {
|
||||
const raw = await serializer.write(value);
|
||||
await storage.setItem(key, raw);
|
||||
}
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
}
|
||||
|
||||
let stopWatch: (() => void) | null = null;
|
||||
|
||||
tryOnScopeDispose(() => stopWatch?.());
|
||||
|
||||
const shell: UseStorageAsyncReturnBase<T, Shallow> = {
|
||||
state,
|
||||
isReady,
|
||||
};
|
||||
|
||||
const readyPromise: Promise<UseStorageAsyncReturnBase<T, Shallow>> = read().then((value) => {
|
||||
(state as Ref).value = value;
|
||||
isReady.value = true;
|
||||
onReady?.(value);
|
||||
|
||||
// Set up watcher AFTER initial state is set — avoids write-back on init
|
||||
const stop = watch(state, (newValue) => {
|
||||
write(newValue as T);
|
||||
}, { flush, deep });
|
||||
|
||||
stopWatch = stop;
|
||||
|
||||
return shell;
|
||||
});
|
||||
|
||||
return {
|
||||
...shell,
|
||||
// eslint-disable-next-line unicorn/no-thenable
|
||||
then(onFulfilled, onRejected) {
|
||||
return readyPromise.then(onFulfilled, onRejected);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { onBeforeMount, nextTick } from 'vue';
|
||||
import type { ComponentInternalInstance } from 'vue';
|
||||
import { getLifeCycleTarger } from '@/utils';
|
||||
import { onBeforeMount, nextTick, type ComponentInternalInstance } from 'vue';
|
||||
import { getLifeCycleTarger } from '../..';
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
|
||||
// TODO: test
|
||||
@@ -12,7 +11,7 @@ export interface TryOnBeforeMountOptions {
|
||||
|
||||
/**
|
||||
* @name tryOnBeforeMount
|
||||
* @category Lifecycle
|
||||
* @category Components
|
||||
* @description Call onBeforeMount if it's inside a component lifecycle hook, otherwise just calls it
|
||||
*
|
||||
* @param {VoidFunction} fn - The function to run on before mount.
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, it, vi, expect } from 'vitest';
|
||||
import { defineComponent, nextTick } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import { defineComponent, nextTick, type PropType } from 'vue';
|
||||
import { tryOnMounted } from '.';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
@@ -12,12 +11,12 @@ const ComponentStub = defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
if (props.callback) { tryOnMounted(props.callback); }
|
||||
props.callback && tryOnMounted(props.callback);
|
||||
},
|
||||
template: `<div></div>`,
|
||||
});
|
||||
|
||||
describe(tryOnMounted, () => {
|
||||
describe('tryOnMounted', () => {
|
||||
it('run the callback when mounted', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { onMounted, nextTick } from 'vue';
|
||||
import type { ComponentInternalInstance } from 'vue';
|
||||
import { getLifeCycleTarger } from '@/utils';
|
||||
import { onMounted, nextTick, type ComponentInternalInstance } from 'vue';
|
||||
import { getLifeCycleTarger } from '../..';
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
|
||||
// TODO: tests
|
||||
@@ -12,7 +11,7 @@ export interface TryOnMountedOptions {
|
||||
|
||||
/**
|
||||
* @name tryOnMounted
|
||||
* @category Lifecycle
|
||||
* @category Components
|
||||
* @description Call onMounted if it's inside a component lifecycle hook, otherwise just calls it
|
||||
*
|
||||
* @param {VoidFunction} fn The function to call
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, effectScope } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import { defineComponent, effectScope, type PropType } from 'vue';
|
||||
import { tryOnScopeDispose } from '.';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
@@ -18,12 +17,12 @@ const ComponentStub = defineComponent({
|
||||
template: '<div></div>',
|
||||
});
|
||||
|
||||
describe(tryOnScopeDispose, () => {
|
||||
describe('tryOnScopeDispose', () => {
|
||||
it('returns false when the scope is not active', () => {
|
||||
const callback = vi.fn();
|
||||
const detectedScope = tryOnScopeDispose(callback);
|
||||
|
||||
expect(detectedScope).toBeFalsy();
|
||||
expect(detectedScope).toBe(false);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -36,7 +35,7 @@ describe(tryOnScopeDispose, () => {
|
||||
detectedScope = tryOnScopeDispose(callback);
|
||||
});
|
||||
|
||||
expect(detectedScope).toBeTruthy();
|
||||
expect(detectedScope).toBe(true);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
scope.stop();
|
||||
@@ -3,7 +3,7 @@ import { getCurrentScope, onScopeDispose } from 'vue';
|
||||
|
||||
/**
|
||||
* @name tryOnScopeDispose
|
||||
* @category Lifecycle
|
||||
* @category Components
|
||||
* @description A composable that will run a callback when the scope is disposed or do nothing if the scope isn't available.
|
||||
*
|
||||
* @param {VoidFunction} callback - The callback to run when the scope is disposed.
|
||||
@@ -2,7 +2,7 @@ import { describe, it, vi, expect } from 'vitest';
|
||||
import { ref, reactive } from 'vue';
|
||||
import { useAppSharedState } from '.';
|
||||
|
||||
describe(useAppSharedState, () => {
|
||||
describe('useAppSharedState', () => {
|
||||
it('initialize state only once', () => {
|
||||
const stateFactory = (initValue?: number) => {
|
||||
const count = ref(initValue ?? 0);
|
||||
@@ -2,7 +2,7 @@ import { isShallow, nextTick, ref } from 'vue';
|
||||
import { it, expect, describe, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { useAsyncState } from '.';
|
||||
|
||||
describe(useAsyncState, () => {
|
||||
describe('useAsyncState', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
@@ -18,15 +18,15 @@ describe(useAsyncState, () => {
|
||||
);
|
||||
|
||||
expect(state.value).toBe('initial');
|
||||
expect(isReady.value).toBeFalsy();
|
||||
expect(isLoading.value).toBeTruthy();
|
||||
expect(isReady.value).toBe(false);
|
||||
expect(isLoading.value).toBe(true);
|
||||
expect(error.value).toBe(null);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('data');
|
||||
expect(isReady.value).toBeTruthy();
|
||||
expect(isLoading.value).toBeFalsy();
|
||||
expect(isReady.value).toBe(true);
|
||||
expect(isLoading.value).toBe(false);
|
||||
expect(error.value).toBe(null);
|
||||
});
|
||||
|
||||
@@ -37,15 +37,15 @@ describe(useAsyncState, () => {
|
||||
);
|
||||
|
||||
expect(state.value).toBe('initial');
|
||||
expect(isReady.value).toBeFalsy();
|
||||
expect(isLoading.value).toBeTruthy();
|
||||
expect(isReady.value).toBe(false);
|
||||
expect(isLoading.value).toBe(true);
|
||||
expect(error.value).toBe(null);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('data');
|
||||
expect(isReady.value).toBeTruthy();
|
||||
expect(isLoading.value).toBeFalsy();
|
||||
expect(isReady.value).toBe(true);
|
||||
expect(isLoading.value).toBe(false);
|
||||
expect(error.value).toBe(null);
|
||||
});
|
||||
|
||||
@@ -56,15 +56,15 @@ describe(useAsyncState, () => {
|
||||
);
|
||||
|
||||
expect(state.value).toBe('initial');
|
||||
expect(isReady.value).toBeFalsy();
|
||||
expect(isLoading.value).toBeTruthy();
|
||||
expect(isReady.value).toBe(false);
|
||||
expect(isLoading.value).toBe(true);
|
||||
expect(error.value).toBe(null);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('initial');
|
||||
expect(isReady.value).toBeFalsy();
|
||||
expect(isLoading.value).toBeFalsy();
|
||||
expect(isReady.value).toBe(false);
|
||||
expect(isLoading.value).toBe(false);
|
||||
expect(error.value).toEqual(new Error('test-error'));
|
||||
});
|
||||
|
||||
@@ -131,14 +131,14 @@ describe(useAsyncState, () => {
|
||||
);
|
||||
|
||||
const promise = execute();
|
||||
expect(isLoading.value).toBeTruthy();
|
||||
expect(isLoading.value).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(isLoading.value).toBeTruthy();
|
||||
expect(isLoading.value).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
await promise;
|
||||
expect(isLoading.value).toBeFalsy();
|
||||
expect(isLoading.value).toBe(false);
|
||||
});
|
||||
|
||||
it('is awaitable', async () => {
|
||||
@@ -160,15 +160,15 @@ describe(useAsyncState, () => {
|
||||
executeImmediately();
|
||||
|
||||
expect(state.value).toBe('initial');
|
||||
expect(isLoading.value).toBeTruthy();
|
||||
expect(isReady.value).toBeFalsy();
|
||||
expect(isLoading.value).toBe(true);
|
||||
expect(isReady.value).toBe(false);
|
||||
expect(error.value).toBe(null);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('data');
|
||||
expect(isReady.value).toBeTruthy();
|
||||
expect(isLoading.value).toBeFalsy();
|
||||
expect(isReady.value).toBe(true);
|
||||
expect(isLoading.value).toBe(false);
|
||||
expect(error.value).toBe(null);
|
||||
});
|
||||
|
||||
@@ -193,7 +193,7 @@ describe(useAsyncState, () => {
|
||||
);
|
||||
|
||||
expect(state.value.a).toBe(1);
|
||||
expect(isShallow(state)).toBeTruthy();
|
||||
expect(isShallow(state)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses ref when shallow is false', async () => {
|
||||
@@ -204,6 +204,6 @@ describe(useAsyncState, () => {
|
||||
);
|
||||
|
||||
expect(state.value.a).toBe(1);
|
||||
expect(isShallow(state)).toBeFalsy();
|
||||
expect(isShallow(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ref, shallowRef, watch } from 'vue';
|
||||
import type { Ref, ShallowRef, UnwrapRef } from 'vue';
|
||||
import { ref, shallowRef, watch, type Ref, type ShallowRef, type UnwrapRef } from 'vue';
|
||||
import { isFunction, sleep } from '@robonen/stdlib';
|
||||
|
||||
export interface UseAsyncStateOptions<Shallow extends boolean, Data = any> {
|
||||
@@ -104,12 +103,8 @@ export function useAsyncState<Data, Params extends any[] = [], Shallow extends b
|
||||
watch(
|
||||
isLoading,
|
||||
(loading) => {
|
||||
if (loading === false) {
|
||||
if (error.value)
|
||||
reject(error.value);
|
||||
else
|
||||
resolve(shell);
|
||||
}
|
||||
if (loading === false)
|
||||
error.value ? reject(error.value) : resolve(shell);
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
@@ -122,7 +117,6 @@ export function useAsyncState<Data, Params extends any[] = [], Shallow extends b
|
||||
|
||||
return {
|
||||
...shell,
|
||||
// eslint-disable-next-line unicorn/no-thenable
|
||||
then(onFulfilled, onRejected) {
|
||||
return waitResolve().then(onFulfilled, onRejected);
|
||||
},
|
||||
@@ -4,7 +4,7 @@ import { useCached } from '.';
|
||||
|
||||
const arrayEquals = (a: number[], b: number[]) => a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
|
||||
describe(useCached, () => {
|
||||
describe('useCached', () => {
|
||||
it('default comparator', async () => {
|
||||
const externalValue = ref(0);
|
||||
const cachedValue = useCached(externalValue);
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ref, watch, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref, WatchOptions } from 'vue';
|
||||
import { ref, watch, toValue, type MaybeRefOrGetter, type Ref, type WatchOptions } from 'vue';
|
||||
|
||||
export type Comparator<Value> = (a: Value, b: Value) => boolean;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ref, readonly, computed } from 'vue';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useClamp } from '.';
|
||||
|
||||
describe(useClamp, () => {
|
||||
describe('useClamp', () => {
|
||||
it('non-reactive values should be clamped', () => {
|
||||
const clampedValue = useClamp(10, 0, 5);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user