diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b7a476f..c25997b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -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` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae2b9fe..24676ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3169142..df3daec 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f9230ee..94cef68 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 @@ -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. diff --git a/README.md b/README.md index e2c2a2d..d0ccfc4 100644 --- a/README.md +++ b/README.md @@ -71,52 +71,50 @@ type DialogProps = { }; type DialogEvents = { - "update:open": [open: boolean]; + "update:open": [next: boolean]; }; const DialogMolecule = molecule((props) => { const { send, on } = createEvents(); - 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 ( - ); } @@ -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. @@ -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: @@ -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. diff --git a/mise.toml b/mise.toml index 9125bd0..00ff26d 100644 --- a/mise.toml +++ b/mise.toml @@ -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] diff --git a/package.json b/package.json index cc50e8f..b41c8ff 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/__tests__/useMolecule.test.ts b/packages/__tests__/useMolecule.test.ts index 6c22736..5fd1f4d 100644 --- a/packages/__tests__/useMolecule.test.ts +++ b/packages/__tests__/useMolecule.test.ts @@ -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 () => {