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.
38 changes: 20 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,52 +71,50 @@ type DialogProps = {
};

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

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,
};
});

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,
};
});

export function DialogButton() {
const dialog = useMolecule(DialogControllerMolecule);
const currentOpen = useSignal(dialog.open);
const isOpen = useSignal(dialog.isOpen);

return (
<button onClick={() => dialog.requestOpenChange(!currentOpen)}>
{currentOpen ? "Close" : "Open"}
<button onClick={() => dialog.requestOpenChange(!isOpen)}>
{isOpen ? "Close" : "Open"}
</button>
);
}
Expand Down Expand Up @@ -221,6 +219,10 @@ as `() => ({ open })` with `[open]`, and syncs top-level props only after those
dependencies change. This matches React's dependency model and avoids resyncing
referential props on every commit.

The dependency list is part of the React adapter contract. Include every React
value read by the getter. If the getter reads a value that is not in the list,
the molecule props will not update when that value changes.

Inside a molecule, read props as `props.name`; destructuring copies the current
value and loses reactivity.

Expand Down Expand Up @@ -291,7 +293,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 @@ -301,7 +303,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.
- `pnpm dev` — launch the playground counter demo.

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 @@ -35,14 +35,14 @@
"dev": "vite --config playground/vite.config.ts",
"build": "unbuild",
"prepack": "unbuild",
"smoke": "node --input-type=module -e \"const mod = await import('@sigrea/react'); for (const key of ['useMolecule', 'useSignal']) { if (typeof mod[key] !== 'function') throw new Error(key + ' export is missing'); }\" && node -e \"const mod = require('@sigrea/react'); for (const key of ['useMolecule', 'useSignal']) { if (typeof mod[key] !== 'function') throw new Error(key + ' 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
53 changes: 44 additions & 9 deletions packages/__tests__/useMolecule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,20 +115,55 @@ describe("useMolecule", () => {

it("rerenders signal consumers after committed props getter sync", async () => {
const dialogMolecule = molecule((props: { open: boolean }) => {
return { open: computed(() => props.open) };
return { status: computed(() => (props.open ? "open" : "closed")) };
});

function TestComponent({ open }: { open: boolean }) {
const instance = useMolecule(dialogMolecule, () => ({ open }), [open]);
const currentOpen = useSignal(instance.open);
return createElement("span", null, String(currentOpen));
function TestComponent({ isOpen }: { isOpen: boolean }) {
const instance = useMolecule(dialogMolecule, () => ({ open: isOpen }), [
isOpen,
]);
const status = useSignal(instance.status);
return createElement("span", null, status);
}

await root.render(createElement(TestComponent, { open: false }));
expect(root.container.textContent).toBe("false");
await root.render(createElement(TestComponent, { isOpen: false }));
expect(root.container.textContent).toBe("closed");

await root.render(createElement(TestComponent, { open: true }));
expect(root.container.textContent).toBe("true");
await root.render(createElement(TestComponent, { isOpen: true }));
expect(root.container.textContent).toBe("open");
});

it("syncs top-level key removal from props getters", async () => {
const dialogMolecule = molecule(
(props: { disabled?: boolean; open: boolean }) => {
return {
disabled: computed(() => props.disabled),
hasDisabled: computed(() => "disabled" in props),
};
},
);

function TestComponent({ disabled }: { disabled?: boolean }) {
const instance = useMolecule(
dialogMolecule,
() =>
disabled === undefined ? { open: true } : { disabled, open: true },
[disabled],
);
const hasDisabled = useSignal(instance.hasDisabled);
const currentDisabled = useSignal(instance.disabled);
return createElement(
"span",
null,
`${hasDisabled}:${String(currentDisabled)}`,
);
}

await root.render(createElement(TestComponent, { disabled: true }));
expect(root.container.textContent).toBe("true:true");

await root.render(createElement(TestComponent, {}));
expect(root.container.textContent).toBe("false:undefined");
});

it("does not resync referential props while dependencies are stable", async () => {
Expand Down