From 67e67357a85879d27c9058b111132c6fda2ce912 Mon Sep 17 00:00:00 2001 From: aose-yuu Date: Sun, 17 May 2026 02:42:35 +0900 Subject: [PATCH 1/5] test: cover live molecule props contracts --- packages/__tests__/publicTypes.test.ts | 5 +++ packages/molecule/__tests__/molecule.test.ts | 45 +++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/__tests__/publicTypes.test.ts b/packages/__tests__/publicTypes.test.ts index 0eb5508..a388c53 100644 --- a/packages/__tests__/publicTypes.test.ts +++ b/packages/__tests__/publicTypes.test.ts @@ -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 = count; const objectSource: WatchSource<{ count: number }> = readonlyDeepState; diff --git a/packages/molecule/__tests__/molecule.test.ts b/packages/molecule/__tests__/molecule.test.ts index 8afdd18..7e1f338 100644 --- a/packages/molecule/__tests__/molecule.test.ts +++ b/packages/molecule/__tests__/molecule.test.ts @@ -112,6 +112,10 @@ describe("molecule", () => { expect(instance.open.value).toBe(false); expect(instance.disabled.value).toBeUndefined(); + + expect(() => { + (instance.open as { value: boolean }).value = true; + }).toThrow("Cannot assign to a readonly computed value."); }); it("rejects non-plain object props containers", () => { @@ -672,7 +676,7 @@ describe("molecule", () => { it("passes props to child molecule instances via get", () => { const ChildMolecule = molecule((props: { id: number }) => { - const identifier = signal(props.id); + const identifier = computed(() => props.id); return { identifier }; }); @@ -684,10 +688,16 @@ describe("molecule", () => { const parent = ParentMolecule({ childId: 42 }); trackMolecule(parent); expect(parent.child.identifier.value).toBe(42); + + updateMoleculeProps(parent, { childId: 7 }); + + expect(parent.child.identifier.value).toBe(42); }); it("updates child molecule props when get receives a props getter", () => { + let childSetupRuns = 0; const ChildMolecule = molecule((props: { id: number }) => { + childSetupRuns += 1; const identifier = computed(() => props.id); return { identifier }; }); @@ -705,6 +715,39 @@ describe("molecule", () => { updateMoleculeProps(parent, { childId: 7 }); expect(parent.child.identifier.value).toBe(7); + expect(childSetupRuns).toBe(1); + }); + + it("tracks only top-level props through child props getters", () => { + const ChildMolecule = molecule( + (props: { options: { placement: string } }) => { + const placement = computed(() => props.options.placement); + return { placement }; + }, + ); + + const ParentMolecule = molecule( + (props: { options: { placement: string } }) => { + const child = get(ChildMolecule, () => ({ + options: props.options, + })); + return { child }; + }, + ); + + const options = { placement: "start" }; + const parent = ParentMolecule({ options }); + trackMolecule(parent); + + expect(parent.child.placement.value).toBe("start"); + + options.placement = "end"; + + expect(parent.child.placement.value).toBe("start"); + + updateMoleculeProps(parent, { options: { placement: "end" } }); + + expect(parent.child.placement.value).toBe("end"); }); it("rejects invalid child props returned from a props getter", () => { From 8da0bf8a29188807fdb6a7ac9cd47f41ec8a1fd3 Mon Sep 17 00:00:00 2001 From: aose-yuu Date: Sun, 17 May 2026 02:43:01 +0900 Subject: [PATCH 2/5] chore: route release checks through cicheck --- .github/workflows/ci.yml | 11 +++---- .github/workflows/publish.yml | 10 +++--- mise.toml | 62 ++++++++++------------------------- package.json | 4 +-- 4 files changed, 27 insertions(+), 60 deletions(-) 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/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 e11c0e3..db33e79 100644 --- a/package.json +++ b/package.json @@ -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" From 2e8f035cdfeed04ac7f226d1160ceaab3db4a148 Mon Sep 17 00:00:00 2001 From: aose-yuu Date: Sun, 17 May 2026 02:43:30 +0900 Subject: [PATCH 3/5] docs: align maintenance guidance with release guards --- .github/pull_request_template.md | 2 +- AGENTS.md | 2 +- CLAUDE.md | 8 ++++---- CONTRIBUTING.md | 14 +++++++------- README.md | 10 ++++++---- 5 files changed, 19 insertions(+), 17 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b5c2867..01d1b9a 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/AGENTS.md b/AGENTS.md index 55bda9e..fc2179e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index cec8745..da9265c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:** @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a40f7d..9bc0883 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 5f7cfdf..0a6c914 100644 --- a/README.md +++ b/README.md @@ -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 @@ -261,7 +261,9 @@ Notes: - `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 @@ -373,7 +375,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: @@ -383,7 +385,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. From c0d343f4f0f32ff7fa0287a215e6b2f89d477f95 Mon Sep 17 00:00:00 2001 From: aose-yuu Date: Sun, 17 May 2026 02:54:56 +0900 Subject: [PATCH 4/5] refactor: clarify controlled open state naming --- README.md | 22 ++++++++++---------- packages/molecule/__tests__/molecule.test.ts | 12 +++++------ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 0a6c914..abbd924 100644 --- a/README.md +++ b/README.md @@ -176,48 +176,48 @@ interface 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 isOpen = toSignal(props, "open"); const disabled = computed(() => props.disabled ?? false); - const requestOpenChange = async (nextOpen: boolean) => { + const requestOpenChange = async (next: boolean) => { if (disabled.value) { return; } - await send("update:open", nextOpen); + await send("update:open", next); }; return { disabled, + isOpen, 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.open` as `isOpen` 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. diff --git a/packages/molecule/__tests__/molecule.test.ts b/packages/molecule/__tests__/molecule.test.ts index 7e1f338..83ea69d 100644 --- a/packages/molecule/__tests__/molecule.test.ts +++ b/packages/molecule/__tests__/molecule.test.ts @@ -91,30 +91,30 @@ describe("molecule", () => { const DialogMolecule = molecule( (props: { disabled?: boolean; open: boolean }) => { const disabled = toSignal(props, "disabled"); - const open = toSignal(props, "open"); + const isOpen = toSignal(props, "open"); - return { disabled, open }; + return { disabled, isOpen }; }, ); const instance = DialogMolecule({ open: false }); trackMolecule(instance); - expect(instance.open.value).toBe(false); + expect(instance.isOpen.value).toBe(false); expect(instance.disabled.value).toBeUndefined(); updateMoleculeProps(instance, { disabled: true, open: true }); - expect(instance.open.value).toBe(true); + expect(instance.isOpen.value).toBe(true); expect(instance.disabled.value).toBe(true); updateMoleculeProps(instance, { open: false }); - expect(instance.open.value).toBe(false); + expect(instance.isOpen.value).toBe(false); expect(instance.disabled.value).toBeUndefined(); expect(() => { - (instance.open as { value: boolean }).value = true; + (instance.isOpen as { value: boolean }).value = true; }).toThrow("Cannot assign to a readonly computed value."); }); From 2d8f87ec9a453f065eae7c2a60903c410ad0bf98 Mon Sep 17 00:00:00 2001 From: aose-yuu Date: Sun, 17 May 2026 02:59:24 +0900 Subject: [PATCH 5/5] refactor: avoid returning prop mirrors from molecule examples --- README.md | 32 +++++++++++--------- packages/molecule/__tests__/molecule.test.ts | 28 +++++++++++------ 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index abbd924..494c281 100644 --- a/README.md +++ b/README.md @@ -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. @@ -182,18 +182,16 @@ type DialogEvents = { const DialogMolecule = molecule((props) => { const { send, on } = createEvents(); const isOpen = toSignal(props, "open"); - const disabled = computed(() => props.disabled ?? false); + const isDisabled = computed(() => props.disabled ?? false); const requestOpenChange = async (next: boolean) => { - if (disabled.value) { + if (isDisabled.value || isOpen.value === next) { return; } await send("update:open", next); }; return { - disabled, - isOpen, on, requestOpenChange, }; @@ -217,46 +215,50 @@ const DialogControllerMolecule = molecule(() => { ``` This pattern keeps the controlled value in a parent or controller molecule. The -child molecule reads `props.open` as `isOpen` 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((props) => { + const isSelected = computed(() => props.selectedValue === props.value); + return { - selectedValue: toSignal(props, "selectedValue"), + isSelected, }; }); interface TabsProps { selectedValue: string; + indicatorValue: string; } const TabsMolecule = molecule((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 diff --git a/packages/molecule/__tests__/molecule.test.ts b/packages/molecule/__tests__/molecule.test.ts index 83ea69d..87c8b6c 100644 --- a/packages/molecule/__tests__/molecule.test.ts +++ b/packages/molecule/__tests__/molecule.test.ts @@ -87,35 +87,45 @@ describe("molecule", () => { expect(capturedProps).toBe(initialProps); }); - it("exposes live props as keyed readonly signals", () => { + it("tracks live props as keyed readonly signals", () => { + let disabledSignal!: { readonly value: boolean | undefined }; + let isOpenSignal!: { readonly value: boolean }; + const DialogMolecule = molecule( (props: { disabled?: boolean; open: boolean }) => { const disabled = toSignal(props, "disabled"); const isOpen = toSignal(props, "open"); + disabledSignal = disabled; + isOpenSignal = isOpen; - return { disabled, isOpen }; + return { + read() { + return { + disabled: disabled.value, + isOpen: isOpen.value, + }; + }, + }; }, ); const instance = DialogMolecule({ open: false }); trackMolecule(instance); - expect(instance.isOpen.value).toBe(false); - expect(instance.disabled.value).toBeUndefined(); + expect(instance.read()).toEqual({ disabled: undefined, isOpen: false }); updateMoleculeProps(instance, { disabled: true, open: true }); - expect(instance.isOpen.value).toBe(true); - expect(instance.disabled.value).toBe(true); + expect(instance.read()).toEqual({ disabled: true, isOpen: true }); updateMoleculeProps(instance, { open: false }); - expect(instance.isOpen.value).toBe(false); - expect(instance.disabled.value).toBeUndefined(); + expect(instance.read()).toEqual({ disabled: undefined, isOpen: false }); expect(() => { - (instance.isOpen as { value: boolean }).value = true; + (isOpenSignal as { value: boolean }).value = true; }).toThrow("Cannot assign to a readonly computed value."); + expect(disabledSignal.value).toBeUndefined(); }); it("rejects non-plain object props containers", () => {