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
2 changes: 1 addition & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ Quick checks run. If not run, say why.

CI also validates PR title via semantic-pr.yml (Conventional Commits).
-->
- [ ] `mise run ci`
- [ ] `pnpm -s cicheck`
11 changes: 4 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: 'pnpm'
Expand All @@ -35,7 +35,4 @@ jobs:
NODE

- run: pnpm install
- run: pnpm -s test
- run: pnpm -s typecheck
- run: pnpm -s build
- run: pnpm -s format
- run: pnpm -s cicheck
10 changes: 4 additions & 6 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,13 @@ jobs:
environment: release
concurrency: publish-${{ github.ref }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
- run: pnpm install
- run: pnpm test
- run: pnpm typecheck
- run: pnpm build
- run: pnpm -s cicheck
- name: Ensure npm CLI supports trusted publishing
run: |
npm i -g npm@11.5.1
Expand Down
14 changes: 7 additions & 7 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ TypeScript strict mode, Biome for formatting, Vitest for tests.
`pnpm build` — build the library via unbuild
`pnpm format` — check formatting (no writes)
`pnpm format:fix` — apply formatting
`pnpm -s cicheck` — run test, typecheck, build, smoke, and format checks

## Commit Convention

Expand All @@ -27,16 +28,15 @@ Changelogen reads Conventional Commits directly, so please keep commit messages
## Pull Requests

Ensure the following before requesting review:
CI passes (test/typecheck/build/format) and the PR title follows Conventional Commits.
`pnpm -s cicheck` passes and the PR title follows Conventional Commits.

## Release Workflow

This repository now uses [changelogen](https://github.com/unjs/changelogen) to infer the next semantic version from Conventional Commits, update `CHANGELOG.md`, and create the release commit plus tag. The workflow is intentionally linear so that a single maintainer can ship safely end to end.
This repository uses [changelogen](https://github.com/unjs/changelogen) to update `CHANGELOG.md` and create the release commit plus tag. Releases use an explicit version so maintainers do not depend on an inferred bump.

1. Ensure `main` is up to date and clean. Run `pnpm changelog --no-output` if you want to preview the generated notes without touching the tree.
2. Execute `pnpm release`. This script runs `pnpm test`, `pnpm build`, and `changelogen --release` in sequence. The command bumps the version in `package.json`, rewrites `CHANGELOG.md`, and creates a `chore(release): vX.Y.Z` commit alongside the annotated `vX.Y.Z` tag.
- If you want to force a bump level or a specific version, run changelogen directly (recommended): `pnpm exec changelogen --release --minor` or `pnpm exec changelogen --release -r 0.4.0`.
3. Push the commit and tag together: `git push origin main --follow-tags`. If you need to stage multiple release commits, push in chronological order so tags stay in sync.
4. Tag pushes trigger `.github/workflows/publish.yml` automatically. The job runs on the `release` environment, installs dependencies, executes tests/type checks/build, publishes to npm via OIDC trusted publishing, and then calls `pnpm exec changelogen gh release vX.Y.Z --token $GITHUB_TOKEN` to sync the GitHub Release body with the freshly updated `CHANGELOG.md`.
1. Ensure `main` is up to date and clean. Run `mise run notes` if you want to preview the generated notes without touching the tree.
2. Run `SIGREA_RELEASE_VERSION=x.y.z mise run release_version`. This runs `pnpm -s cicheck`, updates the changelog, amends the release commit if formatting changes are needed, and creates the annotated `vX.Y.Z` tag.
3. Push the commit and tag together with `mise run push_release`. The task uses `git push origin main --follow-tags`.
4. Tag pushes trigger `.github/workflows/publish.yml`. The job runs on the `release` environment, installs dependencies, runs `pnpm -s cicheck`, publishes to npm with OIDC trusted publishing, and then syncs the GitHub Release body with `pnpm exec changelogen gh release`.

If the publish workflow fails, fix the root cause and re-run the job from the GitHub Actions UI. Avoid creating a new tag unless you intend to cut a new release. If you must roll back, delete the tag locally and remotely, revert the release commit, and start over from step 1.
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,13 @@ See the exported utility groups below and the [function docs](https://github.com

Use `createEvents` when a molecule needs to send typed events to a parent or
controller molecule. For controlled values, use `update:*` event names such as
`update:open`, `update:value`, or `update:selectedValue`.
`update:open`, `update:value`, or `update:selectedValue`; the event segment
matches the controlled prop, while local boolean state can use names such as
`isOpen`.

`send()` resolves after registered listeners finish. Listeners registered in a
Sigrea scope are removed when that scope is disposed, including listeners
registered during molecule setup.

See [createEvents](./packages/use/createEvents/index.md) for the full pattern.

Expand Down Expand Up @@ -84,7 +90,7 @@ This repo targets Node.js 24 or later.
If you use mise:

- `mise trust -y` — trust `mise.toml` (first run only).
- `mise run ci` — run CI-equivalent checks locally.
- `pnpm -s cicheck` — run CI-equivalent checks locally.
- `mise run notes` — preview release notes (optional).

You can also run pnpm scripts directly:
Expand All @@ -94,7 +100,7 @@ You can also run pnpm scripts directly:
- `pnpm typecheck` — run TypeScript type checking.
- `pnpm test:coverage` — collect coverage.
- `pnpm build` — compile via unbuild to produce dual CJS/ESM bundles.
- `pnpm cicheck` — run CI checks locally.
- `pnpm -s cicheck` — run CI checks locally.

See [CONTRIBUTING.md](./CONTRIBUTING.md) for workflow details.

Expand Down
62 changes: 17 additions & 45 deletions mise.toml
Original file line number Diff line number Diff line change
@@ -1,56 +1,28 @@
[tools]
node = "24"
pnpm = "10.0.0"

[tasks.check_node]
description = "Ensure Node.js version is >= 24"
run = "node -e 'const major = Number(process.versions.node.split(\".\")[0]); if (major < 24) { console.error(`Node.js >= 24 is required (current: ${process.versions.node})`); process.exit(1); }'"

[tasks.ci]
description = "Run CI-equivalent checks (install/test/typecheck/build/format)"
depends = ["check_node"]
run = [
"pnpm install",
"pnpm -s test",
"pnpm -s typecheck",
"pnpm -s build",
"pnpm -s format",
]

[tasks.notes]
description = "Preview changelog notes (no file changes)"
run = "pnpm exec changelogen --no-output"

[tasks.release_patch]
description = "Cut release commit + tag (force patch)"
depends = ["ci"]
confirm = "Create release commit + tag (patch) on main?"
run = '''
test "$(git rev-parse --abbrev-ref HEAD)" = "main" && \
pnpm exec changelogen --clean --release --patch && \
pnpm format:fix && \
git diff --quiet || git commit --amend --no-edit
'''

[tasks.release_minor]
description = "Cut release commit + tag (force minor)"
depends = ["ci"]
confirm = "Create release commit + tag (minor) on main?"
run = '''
test "$(git rev-parse --abbrev-ref HEAD)" = "main" && \
pnpm exec changelogen --clean --release --minor && \
pnpm format:fix && \
git diff --quiet || git commit --amend --no-edit
'''

[tasks.release_major]
description = "Cut release commit + tag (force major)"
depends = ["ci"]
confirm = "Create release commit + tag (major) on main?"
[tasks.release_version]
description = "Cut release commit + annotated tag for SIGREA_RELEASE_VERSION"
run = '''
test "$(git rev-parse --abbrev-ref HEAD)" = "main" && \
pnpm exec changelogen --clean --release --major && \
pnpm format:fix && \
git diff --quiet || git commit --amend --no-edit
test "$(git rev-parse --abbrev-ref HEAD)" = "main"
test -n "${SIGREA_RELEASE_VERSION:-}" || { echo "SIGREA_RELEASE_VERSION is required, for example SIGREA_RELEASE_VERSION=0.7.1 mise run release_version." >&2; exit 1; }
test -z "$(git status --porcelain)" || { echo "Git status must be clean before release." >&2; git status --short; exit 1; }
pnpm -s cicheck
test -z "$(git status --porcelain)" || { echo "cicheck changed files." >&2; git status --short; exit 1; }
pnpm exec changelogen --clean --release -r "$SIGREA_RELEASE_VERSION"
pnpm format:fix
if ! git diff --quiet; then
git add -A
git commit --amend --no-edit
git tag -fa "v$SIGREA_RELEASE_VERSION" -m "v$SIGREA_RELEASE_VERSION"
fi
test "$(git cat-file -t "v$SIGREA_RELEASE_VERSION")" = "tag"
test -z "$(git status --porcelain)" || { echo "Release changed files unexpectedly." >&2; git status --short; exit 1; }
'''

[tasks.push_release]
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,14 @@
"scripts": {
"build": "unbuild",
"prepack": "unbuild",
"smoke": "node --input-type=module -e \"const mod = await import('@sigrea/use'); if (typeof mod.createEvents !== 'function') throw new Error('createEvents export is missing');\" && node -e \"const mod = require('@sigrea/use'); if (typeof mod.createEvents !== 'function') throw new Error('createEvents export is missing');\"",
"changelog": "changelogen",
"release": "pnpm test && pnpm build && changelogen --release",
"test": "vitest run",
"test:coverage": "vitest --coverage",
"typecheck": "tsc -p tsconfig.json --noEmit",
"format": "biome check .",
"format:fix": "biome check --write .",
"cicheck": "pnpm test && pnpm typecheck && pnpm format:fix"
"cicheck": "pnpm -s test && pnpm -s typecheck && pnpm -s build && pnpm -s smoke && pnpm -s format"
},
"peerDependencies": {
"@sigrea/core": "^0.7.0"
Expand Down
2 changes: 1 addition & 1 deletion packages/use/__tests__/publicTypes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3118,7 +3118,7 @@ describe("public types", () => {
cleared: [];
moved: [x: number, y: number];
selected: [file: { readonly name: string }];
"update:open": [open: boolean];
"update:open": [next: boolean];
};
type ReadonlyEvents = {
selected: readonly [file: { readonly name: string }];
Expand Down
30 changes: 16 additions & 14 deletions packages/use/createEvents/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,49 +75,51 @@ interface DialogProps {
}

type DialogEvents = {
"update:open": [open: boolean];
"update:open": [next: boolean];
};

export const DialogMolecule = molecule<DialogProps>((props) => {
const { send, on } = createEvents<DialogEvents>();
const open = toSignal(props, "open");
const disabled = computed(() => props.disabled ?? false);
const isOpen = toSignal(props, "open");
const isDisabled = computed(() => props.disabled ?? false);

const requestOpenChange = async (nextOpen: boolean) => {
if (disabled.value) {
const requestOpenChange = async (next: boolean) => {
if (isDisabled.value || isOpen.value === next) {
return;
}
await send("update:open", nextOpen);
await send("update:open", next);
};

return {
disabled,
on,
open,
requestOpenChange,
};
});

export const DialogControllerMolecule = molecule(() => {
const open = signal(false);
const isOpen = signal(false);
const dialog = get(DialogMolecule, () => ({
open: open.value,
open: isOpen.value,
}));

dialog.on("update:open", (nextOpen) => {
open.value = nextOpen;
dialog.on("update:open", (next) => {
isOpen.value = next;
});

return {
open: readonly(open),
isOpen: readonly(isOpen),
requestOpenChange: dialog.requestOpenChange,
};
});
```

Use `update:value`, `update:open`, `update:selectedValue`, and similar names for
controlled value requests. Use names like `close`, `select`, and `submit` for
user actions that do not directly replace a controlled value.
user actions that do not directly replace a controlled value. When a boolean
state value could be confused with an action such as `open()` or `close()`, name
internal state `isOpen` and name the update payload `next`. Avoid returning prop
mirrors such as `toSignal(props, "open")`; expose actions or derived state
instead.

Expose `on` only when a parent or controller molecule needs to listen. Keep
`send` private to the molecule; callers should use the action functions the
Expand Down
47 changes: 46 additions & 1 deletion packages/use/createEvents/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
// @vitest-environment node

import { createScope, disposeScope, runWithScope } from "@sigrea/core";
import {
createScope,
disposeMolecule,
disposeScope,
molecule,
runWithScope,
} from "@sigrea/core";
import { describe, expect, it } from "vitest";

import { createEvents } from "./index";
Expand Down Expand Up @@ -88,6 +94,25 @@ describe("createEvents", () => {
expect(calls).toEqual(["ready"]);
});

it("resolves send after async listeners finish", async () => {
const events = createEvents<{ submitted: [] }>();
const calls: string[] = [];

events.on("submitted", async () => {
await Promise.resolve();
calls.push("listener");
});

const sent = events.send("submitted").then(() => {
calls.push("send");
});
calls.push("after-send");

await sent;

expect(calls).toEqual(["after-send", "listener", "send"]);
});

it("registers the same listener once for the same event", async () => {
const events = createEvents<{
primary: [value: number];
Expand Down Expand Up @@ -144,6 +169,26 @@ describe("createEvents", () => {
expect(calls).toEqual(["a"]);
});

it("removes molecule-scoped listeners when the molecule is disposed", async () => {
const events = createEvents<{ "update:open": [next: boolean] }>();
const calls: boolean[] = [];

const ControllerMolecule = molecule(() => {
events.on("update:open", (next) => {
calls.push(next);
});
return {};
});

const controller = ControllerMolecule();

await events.send("update:open", true);
disposeMolecule(controller);
await events.send("update:open", false);

expect(calls).toEqual([true]);
});

it("keeps outer duplicated listeners after scoped duplicates are disposed", async () => {
const events = createEvents<{ selected: [id: string] }>();
const calls: string[] = [];
Expand Down