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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
The library exports from `index.ts`, and `pnpm build` emits dual ESM and CJS bundles under `dist/`. Core signal primitives live in `packages/core`, molecule lifecycle hooks live in `packages/molecule/lifecycle`, reusable molecule helpers live in `packages/molecule`, and shared fixtures live in `packages/__tests__`. Images that illustrate behavior stay in `images/`, while generated artifacts such as `dist/` and `coverage/` must be kept out of commits by relying on the existing `.gitignore`.

## Build, Test, and Local Development
Install dependencies with `pnpm install` to match the locked Node 20 and pnpm 10 toolchain. `pnpm build` runs unbuild to compile TypeScript, `pnpm test` executes Vitest once in run mode, and `pnpm test:coverage` collects V8 instrumentation for release gating. Use `pnpm typecheck` for isolated `tsc` diagnostics, and call `pnpm cicheck` before large pushes to mimic CI by chaining tests, type checking, and formatting fixes. Run `pnpm format` (or `pnpm format:fix`) to apply Biome rules whenever code touches shared modules.
Install dependencies with `pnpm install` to match the locked Node 24 and pnpm 10 toolchain. `pnpm build` runs unbuild to compile TypeScript, `pnpm test` executes Vitest once in run mode, and `pnpm test:coverage` collects V8 instrumentation for release gating. Use `pnpm typecheck` for isolated `tsc` diagnostics, and run `pnpm -s cicheck` before large pushes to mirror CI by chaining tests, type checking, build, package smoke checks, and formatting checks. Run `pnpm format` to check Biome rules or `pnpm format:fix` to apply fixes.

## Coding Style and Naming Conventions
All source files are authored as ES modules with two-space indentation. Runtime exports favor `camelCase`, types and classes use `PascalCase`, and molecule factories use the `molecule()` helper. Lifecycle utilities exposed publicly should mirror `onMount` and `onUnmount` naming so downstream adapters can grep for familiar hooks. Formatting deviations are considered lint failures, so run Biome scripts before committing to catch drift quickly.
Expand Down
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ pnpm format:fix # Apply Biome auto-fixes

**CI simulation:**
```bash
pnpm cicheck # Run tests + typecheck + format (identical to CI)
pnpm -s cicheck # Run test, typecheck, build, smoke, and format checks
```

**Run a single test:**
Expand Down Expand Up @@ -121,9 +121,9 @@ packages/

## Release Flow

- Maintainers run `pnpm release` locally, which executes tests, builds, bumps the version via Changelogen, and commits/tag the release (e.g., `chore(release): vX.Y.Z` plus `vX.Y.Z` tag).
- Push the commit and tag (`git push --follow-tags`). This triggers `.github/workflows/publish.yml` automatically or it can be re-run via `workflow_dispatch`.
- `publish` installs deps, runs tests/build, publishes to npm (with `--provenance`) and GitHub Packages, ensures the tag exists, and syncs GitHub Release notes via `pnpm dlx changelogen gh release vX.Y.Z --token $GITHUB_TOKEN`.
- Maintainers run `SIGREA_RELEASE_VERSION=x.y.z mise run release_version` from a clean `main` branch. The task runs `pnpm -s cicheck`, updates the changelog, and creates the `chore(release): vX.Y.Z` commit plus annotated `vX.Y.Z` tag.
- Push the commit and tag with `mise run push_release`. This runs `git push origin main --follow-tags` and triggers `.github/workflows/publish.yml`.
- `publish` installs deps, runs `pnpm -s cicheck`, publishes to npm with OIDC trusted publishing, and syncs GitHub Release notes via `pnpm exec changelogen gh release`.

## Important Notes

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.
60 changes: 32 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ needed to build hooks.
Inspired by:
- [Vue 3](https://vuejs.org/) — deep reactivity and scope control
- [nanostores](https://github.com/nanostores/nanostores) — store-centric architecture
- [bunshi](https://github.com/saasquatch/bunshi) — molecule and composition API design
- [bunshi](https://github.com/saasquatch/bunshi) — molecule concepts and `get()`-based parent-child graph design

## Table of Contents

Expand Down Expand Up @@ -138,9 +138,9 @@ level. Official adapters keep them in sync with component props. If you use core
directly, call `updateMoleculeProps(instance, nextProps)` to replace them.

Read props through `props.propName`. Destructuring a prop value copies the
current value and loses reactivity. Use `toSignal(props, "propName")` when you
need to pass a prop around as a
`ReadonlySignal`.
current value and loses reactivity. Use `toSignal(props, "propName")` only when
an internal helper needs a prop-shaped `ReadonlySignal`; do not return prop
mirrors from a molecule just because a prop is reactive.

The props object must be a plain object. Sigrea syncs enumerable top-level
properties and passes nested values as-is.
Expand Down Expand Up @@ -176,92 +176,96 @@ interface 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,
};
});
```

This pattern keeps the controlled value in a parent or controller molecule. The
child molecule reads `props.open` and sends `update:open` when it wants its
child molecule reads props internally and sends `update:open` when it wants its
owner to replace the value. Framework adapters mount the controller molecule.
Components read the signals and computed values it returns; raw molecule events
stay inside the molecule graph.
Components read the controller-owned signals and computed values it returns; raw
molecule events stay inside the molecule graph.

### Composing molecules with `get()`

```ts
import { get, molecule, toSignal } from "@sigrea/core";
import { computed, get, molecule } from "@sigrea/core";

interface TabIndicatorProps {
selectedValue: string;
value: string;
}

const TabIndicatorMolecule = molecule<TabIndicatorProps>((props) => {
const isSelected = computed(() => props.selectedValue === props.value);

return {
selectedValue: toSignal(props, "selectedValue"),
isSelected,
};
});

interface TabsProps {
selectedValue: string;
indicatorValue: string;
}

const TabsMolecule = molecule<TabsProps>((props) => {
const indicator = get(TabIndicatorMolecule, () => ({
value: props.indicatorValue,
selectedValue: props.selectedValue,
}));

return {
indicator,
selectedValue: toSignal(props, "selectedValue"),
};
});
```

Notes:

- Use `computed()` for derived state, and `toSignal(props, "key")` when you need
to pass a reactive prop as a signal to another API.
- Use `computed()` for derived state. Use `toSignal(props, "key")` only when an
internal helper needs a signal view of a prop, not as a default return shape.
- Use `get()` to create and own child molecule instances.
- `get()` must be called synchronously during molecule setup.
- `get(Child, props)` passes a static props snapshot. Use
`get(Child, () => ({ ... }))` to derive child props reactively from parent
props.
props. The child instance is not recreated when live props change.
- Props sync is top-level only. Nested objects are passed through as values;
replace the top-level prop when a nested value must notify dependents.
- `onUnmount()` callbacks and `watch()` effects are tied to the mount lifecycle.
- `watch()` and `watchEffect()` return callable stop handles; calling a handle
directly is equivalent to calling `handle.stop()`, and each handle also
Expand Down Expand Up @@ -373,7 +377,7 @@ export default defineConfig(({ command }) => ({
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 @@ -383,7 +387,7 @@ You can also run pnpm scripts directly:
- `pnpm typecheck` — run TypeScript type checking.
- `pnpm test:coverage` — collect coverage.
- `pnpm build` — build the package.
- `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 @@ -42,14 +42,14 @@
"scripts": {
"build": "unbuild",
"prepack": "unbuild",
"smoke": "node --input-type=module -e \"const mod = await import('@sigrea/core'); for (const key of ['signal', 'computed', 'molecule', 'get', 'toSignal']) { if (typeof mod[key] !== 'function') throw new Error(key + ' export is missing'); }\" && node -e \"const mod = require('@sigrea/core'); for (const key of ['signal', 'computed', 'molecule', 'get', 'toSignal']) { 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"
},
"dependencies": {
"alien-signals": "^3.1.1"
Expand Down
5 changes: 5 additions & 0 deletions packages/__tests__/publicTypes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ describe("public types", () => {
expectTypeOf(nestedSignal).toEqualTypeOf<
ReadonlySignal<{ flag: boolean }>
>();
const assertToSignalReadonly = () => {
// @ts-expect-error toSignal returns a readonly view.
nestedSignal.value = { flag: true };
};
void assertToSignalReadonly;

const numberSource: WatchSource<number> = count;
const objectSource: WatchSource<{ count: number }> = readonlyDeepState;
Expand Down
Loading