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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,15 @@ jobs:
- name: Typecheck
run: pnpm run typecheck

- name: Lint
run: pnpm run lint

- name: Format check
run: pnpm run format:check

- name: Test
run: pnpm run test:coverage

- name: Build
# Node 22 builds as part of package:check below.
if: matrix.node == 24
run: pnpm run build

- name: Verify packed package
Expand Down
31 changes: 26 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,9 +385,18 @@ This specialized API intentionally accepts one dependency. Use `useEffectWhen` w

**Example:**

The example includes a minimal product type and the application's analytics client shape.

<!-- package-example: matching-hook-scalar -->

```tsx
import { useEffectWhenMatch } from "@okyrychenko-dev/react-effect-when";

type Product = { id: string };
declare const analytics: {
track(event: string, properties: Record<string, string>): void;
};

type ProductQueryPending = { status: "pending" };
type ProductQueryError = { status: "error"; error: Error };
type ProductQuerySuccess = { status: "success"; data: Product };
Expand All @@ -409,7 +418,9 @@ function ProductAnalytics({ query }: ProductAnalyticsProps) {
}
```

The array form is additive: existing single-value calls work unchanged. For example, using the same `ProductQuery` type:
The array form is additive: existing single-value calls work unchanged. The following example uses the imports, product/query types and analytics client declared in the preceding example:

<!-- package-example: matching-hook-multiple -->

```tsx
function ProductQueryObserver({ query }: ProductAnalyticsProps) {
Expand Down Expand Up @@ -450,6 +461,8 @@ For standalone factory calls, provide the key type, complete discriminated union

**Example:**

<!-- package-example: matching-explicit -->

```tsx
import { createEffectWhen, matchPredicate } from "@okyrychenko-dev/react-effect-when";

Expand Down Expand Up @@ -499,6 +512,8 @@ Binds the complete source union once and returns a matching factory. Its key and

The source union still needs an explicit type: a key and selection cannot describe the fields of unselected variants. Binding it in a separate step lets TypeScript infer the later key and selection; defaulting the selected type in the existing factory would instead widen it to all source variants.

<!-- package-example: matching-source-bound -->

```tsx
import { createEffectWhen, matchPredicateFor, useEffectWhen } from "@okyrychenko-dev/react-effect-when";

Expand Down Expand Up @@ -887,25 +902,31 @@ public documentation for package usage.

## Development

`format:check` includes linting. `package:check` builds the package, checks its packed files and runtime exports, compiles installed ESM/CommonJS consumers, and runs ATTW and publint.

The packed checker also compiles the README examples marked `matching-explicit`, `matching-source-bound`, `matching-hook-scalar`, and `matching-hook-multiple`. The multi-value hook example uses the preceding scalar example's documented context. Their bodies and public imports are checked unchanged against both declaration adapters; missing or duplicate selection markers fail the check. These compilation checks supplement the dedicated exact-type and rejection fixtures. Other README snippets and example runtime execution are outside this check's scope. Temporary consumers and tarballs are removed on success and failure.

```bash
pnpm install --frozen-lockfile
pnpm run typecheck
pnpm run lint
pnpm run format:check
pnpm run test:run
pnpm run test:coverage
pnpm run build
pnpm pack --pack-destination /tmp/react-effect-when-pack
pnpm run package:check
```

CI runs coverage tests on Node 22 and 24, uploads coverage on Node 22, and runs the packed contract on Node 22. Node 24 retains a separate build; Node 22 uses the build owned by `package:check`.

## Publish Checklist

Before publishing a new version, make sure the combined release check passes:
Before publishing a new version, make sure the combined release check passes. It cleans build output, runs the full tests, typechecks, checks lint/formatting and verifies the packed contract:

```bash
pnpm run release:check
```

`prepublishOnly` retains the same release gate for direct publication. The release workflow checks the tag against the package version before publishing with provenance; its detached tag checkout uses `--no-git-checks`. Both the explicit workflow gate and the publication lifecycle gate remain in place.

## License

MIT © Oleksii Kyrychenko
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"package:check": "pnpm run build && node scripts/check-packed-package.mjs && attw --pack . && publint",
"dev": "tsup --watch",
"clean": "rm -rf dist",
"release:check": "pnpm run clean && pnpm run test:run && pnpm run typecheck && pnpm run lint && pnpm run format:check && pnpm run package:check",
"release:check": "pnpm run clean && pnpm run test:run && pnpm run typecheck && pnpm run format:check && pnpm run package:check",
"prepublishOnly": "pnpm run release:check",
"typecheck": "tsc --noEmit",
"lint": "eslint src scripts --ext .ts,.tsx,.mjs",
Expand Down
75 changes: 58 additions & 17 deletions scripts/check-packed-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,62 @@ function assertExportTargets(manifest, packageRoot) {
}
}

function typecheck(files, context) {
try {
execFileSync(
join(repositoryPath, "node_modules/.bin/tsc"),
[
"--noEmit",
"--strict",
"--skipLibCheck",
"--module",
"NodeNext",
"--moduleResolution",
"NodeNext",
"--target",
"ES2020",
...files,
],
{ cwd: consumerRoot, stdio: "inherit" }
);
} catch (cause) {
throw new Error(`Packed declaration check failed: ${context}`, { cause });
}
}

function documentedExample(readme, identity) {
const sections = readme.split(`<!-- package-example: ${identity} -->`);
const context = `README example ${identity} (ESM/CommonJS)`;
invariant(sections.length === 2, `${context}: expected exactly one selection marker`);
const block = sections[1].match(/^\s*```tsx\r?\n([\s\S]*?)\r?\n```(?=\r?\n|$)/u);
invariant(block !== null, `${context}: expected a fenced tsx example after the marker`);
return block[1];
}

function checkDocumentedExamples(packageRoot) {
const readme = readFileSync(join(packageRoot, "README.md"), "utf8");
// These are the selected matching examples, not a general Markdown compiler.
const exampleGroups = [
["matching-explicit"],
["matching-source-bound"],
["matching-hook-scalar"],
// The array example explicitly uses the preceding scalar example's context.
["matching-hook-scalar", "matching-hook-multiple"],
];

for (const identities of exampleGroups) {
const contents = identities.map((identity) => documentedExample(readme, identity)).join("\n\n");
for (const [adapter, extension] of [
["ESM", "mts"],
["CommonJS", "cts"],
]) {
const filename = `${identities.join("+")}.${extension}`;
writeFileSync(join(consumerRoot, filename), contents);
typecheck([filename], `README examples ${identities.join(", ")} (${adapter})`);
}
}
}

try {
run("pnpm", ["pack", "--out", tarballPath]);
mkdirSync(extractRoot, { recursive: true });
Expand Down Expand Up @@ -192,23 +248,8 @@ try {

run("node", [join(consumerRoot, "esm.mjs")]);
run("node", [join(consumerRoot, "cjs.cjs")]);
execFileSync(
join(repositoryPath, "node_modules/.bin/tsc"),
[
"--noEmit",
"--strict",
"--skipLibCheck",
"--module",
"NodeNext",
"--moduleResolution",
"NodeNext",
"--target",
"ES2020",
"consumer.mts",
"consumer.cts",
],
{ cwd: consumerRoot, stdio: "inherit" }
);
typecheck(["consumer.mts", "consumer.cts"], "exact-type and rejection fixtures (ESM/CommonJS)");
checkDocumentedExamples(packageRoot);
} finally {
rmSync(temporaryRoot, { force: true, recursive: true });
}
Loading