diff --git a/.changeset/fast-pens-leave.md b/.changeset/fast-pens-leave.md new file mode 100644 index 000000000..4ff0edf8b --- /dev/null +++ b/.changeset/fast-pens-leave.md @@ -0,0 +1,5 @@ +--- +"@preact/signals": minor +--- + +Provide `@preact/signals/utils` package with some helpers to make working with signals easier in Preact diff --git a/karma.conf.js b/karma.conf.js index fd71eaa30..35329637d 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -227,6 +227,7 @@ const pkgList = { "react/utils": "@preact/signals-react/utils", "react/runtime": "@preact/signals-react/runtime", "react-transform": "@preact/signals-react-transform", + "preact/utils": "@preact/signals/utils", }; module.exports = function (config) { diff --git a/package.json b/package.json index 46d3e4143..05d0e5959 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,11 @@ "private": true, "scripts": { "prebuild": "shx rm -rf packages/*/dist/", - "build": "pnpm build:core && pnpm build:preact && pnpm build:react-runtime && pnpm build:react && pnpm build:react-transform && pnpm build:react-utils", + "build": "pnpm build:core && pnpm build:preact && pnpm build:preact-utils && pnpm build:react-runtime && pnpm build:react && pnpm build:react-transform && pnpm build:react-utils", "_build": "microbundle --raw --globals @preact/signals-core=preactSignalsCore,preact/hooks=preactHooks,@preact/signals-react/runtime=reactSignalsRuntime", "build:core": "pnpm _build --cwd packages/core && pnpm postbuild:core", "build:preact": "pnpm _build --cwd packages/preact && pnpm postbuild:preact", + "build:preact-utils": "pnpm _build --cwd packages/preact/utils && pnpm postbuild:preact-utils", "build:react": "pnpm _build --cwd packages/react --external \"react,@preact/signals-react/runtime,@preact/signals-core\" && pnpm postbuild:react", "build:react-utils": "pnpm _build --cwd packages/react/utils && pnpm postbuild:react-utils", "build:react-runtime": "pnpm _build --cwd packages/react/runtime && pnpm postbuild:react-runtime", @@ -14,6 +15,7 @@ "postbuild:core": "cd packages/core/dist && shx mv -f index.d.ts signals-core.d.ts", "postbuild:preact": "cd packages/preact/dist && shx mv -f preact/src/index.d.ts signals.d.ts && shx rm -rf preact", "postbuild:react": "cd packages/react/dist && shx mv -f react/src/index.d.ts signals.d.ts && shx rm -rf react", + "postbuild:preact-utils": "cd packages/preact/utils/dist && shx mv -f preact/utils/src/index.d.ts . && shx rm -rf preact", "postbuild:react-utils": "cd packages/react/utils/dist && shx mv -f react/utils/src/index.d.ts . && shx rm -rf react", "postbuild:react-runtime": "cd packages/react/runtime/dist && shx mv -f react/runtime/src/*.d.ts . && shx rm -rf react", "lint": "pnpm lint:eslint && pnpm lint:tsc", diff --git a/packages/preact/README.md b/packages/preact/README.md index 6b62d16f7..9c303a587 100644 --- a/packages/preact/README.md +++ b/packages/preact/README.md @@ -111,6 +111,84 @@ function Person() { This way we'll bypass checking the virtual-dom and update the DOM property directly. +## Utility Components and Hooks + +The `@preact/signals/utils` package provides additional utility components and hooks to make working with signals even easier. + +### Show Component + +The `Show` component provides a declarative way to conditionally render content based on a signal's value. + +```js +import { Show } from "@preact/signals/utils"; +import { signal } from "@preact/signals"; + +const isVisible = signal(false); + +function App() { + return ( + Nothing to see here

}> +

Now you see me!

+
+ ); +} + +// You can also use a function to access the value +function App() { + return {value =>

The value is {value}

}
; +} +``` + +### For Component + +The `For` component helps you render lists from signal arrays with automatic caching of rendered items. + +```js +import { For } from "@preact/signals/utils"; +import { signal } from "@preact/signals"; + +const items = signal(["A", "B", "C"]); + +function App() { + return ( + No items

}> + {(item, index) =>
Item: {item}
} +
+ ); +} +``` + +### Additional Hooks + +#### useLiveSignal + +The `useLiveSignal` hook allows you to create a local signal that stays synchronized with an external signal. + +```js +import { useLiveSignal } from "@preact/signals/utils"; +import { signal } from "@preact/signals"; + +const external = signal(0); + +function Component() { + const local = useLiveSignal(external); + // local will automatically update when external changes +} +``` + +#### useSignalRef + +The `useSignalRef` hook creates a signal that behaves like a React ref with a `.current` property. + +```js +import { useSignalRef } from "@preact/signals/utils"; + +function Component() { + const ref = useSignalRef(null); + return
The ref's value is {ref.current}
; +} +``` + ## License `MIT`, see the [LICENSE](../../LICENSE) file. diff --git a/packages/preact/package.json b/packages/preact/package.json index 4775088bf..63990ce5f 100644 --- a/packages/preact/package.json +++ b/packages/preact/package.json @@ -30,6 +30,12 @@ "browser": "./dist/signals.module.js", "import": "./dist/signals.mjs", "require": "./dist/signals.js" + }, + "./utils": { + "types": "./utils/dist/index.d.ts", + "browser": "./utils/dist/utils.module.js", + "import": "./utils/dist/utils.mjs", + "require": "./utils/dist/utils.js" } }, "mangle": "../../mangle.json", @@ -38,7 +44,10 @@ "dist", "CHANGELOG.md", "LICENSE", - "README.md" + "README.md", + "utils/dist", + "utils/package.json", + "utils/src" ], "scripts": { "prepublishOnly": "cd ../.. && pnpm build:preact" diff --git a/packages/preact/utils/package.json b/packages/preact/utils/package.json new file mode 100644 index 000000000..d1f77edb5 --- /dev/null +++ b/packages/preact/utils/package.json @@ -0,0 +1,27 @@ +{ + "name": "@preact/signals-utils", + "description": "Sub package for @preact/signals that contains some useful utilities", + "private": true, + "amdName": "preactSignalsutils", + "main": "dist/utils.js", + "module": "dist/utils.module.js", + "unpkg": "dist/utils.min.js", + "types": "dist/index.d.ts", + "source": "src/index.ts", + "mangle": "../../../mangle.json", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "browser": "./dist/utils.module.js", + "import": "./dist/utils.mjs", + "require": "./dist/utils.js" + } + }, + "dependencies": { + "@preact/signals-core": "workspace:^1.3.0" + }, + "peerDependencies": { + "@preact/signals": "workspace:*", + "preact": ">= 10.25.0" + } +} diff --git a/packages/preact/utils/src/index.ts b/packages/preact/utils/src/index.ts new file mode 100644 index 000000000..98478882d --- /dev/null +++ b/packages/preact/utils/src/index.ts @@ -0,0 +1,71 @@ +import { ReadonlySignal, Signal } from "@preact/signals-core"; +import { useSignal } from "@preact/signals"; +import { Fragment, createElement, JSX } from "preact"; +import { useMemo } from "preact/hooks"; + +interface ShowProps { + when: Signal | ReadonlySignal; + fallback?: JSX.Element; + children: JSX.Element | ((value: T) => JSX.Element); +} + +export function Show(props: ShowProps): JSX.Element | null { + const value = props.when.value; + if (!value) return props.fallback || null; + return typeof props.children === "function" + ? props.children(value) + : props.children; +} + +interface ForProps { + each: + | Signal> + | ReadonlySignal> + | (() => Signal> | ReadonlySignal>); + fallback?: JSX.Element; + children: (value: T, index: number) => JSX.Element; +} + +export function For(props: ForProps): JSX.Element | null { + const cache = useMemo(() => new Map(), []); + let list = ( + (typeof props.each === "function" ? props.each() : props.each) as Signal< + Array + > + ).value; + + if (!list.length) return props.fallback || null; + + const items = list.map((value, key) => { + if (!cache.has(value)) { + cache.set(value, props.children(value, key)); + } + return cache.get(value); + }); + + return createElement(Fragment, null, items); +} + +export function useLiveSignal( + value: Signal | ReadonlySignal +): Signal | ReadonlySignal> { + const s = useSignal(value); + if (s.peek() !== value) s.value = value; + return s; +} + +export function useSignalRef(value: T): Signal & { current: T } { + const ref = useSignal(value) as Signal & { current: T }; + if (!("current" in ref)) + Object.defineProperty(ref, "current", refSignalProto); + return ref; +} +const refSignalProto = { + configurable: true, + get(this: Signal) { + return this.value; + }, + set(this: Signal, v: any) { + this.value = v; + }, +}; diff --git a/packages/preact/utils/test/browser/index.test.tsx b/packages/preact/utils/test/browser/index.test.tsx new file mode 100644 index 000000000..dc1653634 --- /dev/null +++ b/packages/preact/utils/test/browser/index.test.tsx @@ -0,0 +1,84 @@ +import { signal } from "@preact/signals"; +import { For, Show, useSignalRef } from "@preact/signals/utils"; +import { render, createElement } from "preact"; +import { act } from "preact/test-utils"; + +describe("@preact/signals-utils", () => { + let scratch: HTMLDivElement; + + beforeEach(async () => { + scratch = document.createElement("div"); + document.body.appendChild(scratch); + }); + + afterEach(async () => { + render(null, scratch); + }); + + describe("", () => { + it("Should reactively show an element", () => { + const toggle = signal(false)!; + const Paragraph = (props: any) =>

{props.children}

; + act(() => { + render( + Hiding}> + Showing + , + scratch + ); + }); + expect(scratch.innerHTML).to.eq("

Hiding

"); + + act(() => { + toggle.value = true; + }); + expect(scratch.innerHTML).to.eq("

Showing

"); + }); + }); + + describe("", () => { + it("Should iterate over a list of signals", () => { + const list = signal>([])!; + const Paragraph = (p: any) =>

{p.children}

; + act(() => { + render( + No items}> + {item => {item}} + , + scratch + ); + }); + expect(scratch.innerHTML).to.eq("

No items

"); + + act(() => { + list.value = ["foo", "bar"]; + }); + expect(scratch.innerHTML).to.eq("

foo

bar

"); + }); + }); + + describe("useSignalRef", () => { + it("should work", () => { + let ref; + const Paragraph = (p: any) => { + ref = useSignalRef(null); + return p.type === "span" ? ( + {p.children} + ) : ( +

{p.children}

+ ); + }; + act(() => { + render(1, scratch); + }); + expect(scratch.innerHTML).to.eq("

1

"); + expect((ref as any).value instanceof HTMLParagraphElement).to.eq(true); + + act(() => { + render(1, scratch); + }); + expect(scratch.innerHTML).to.eq("1"); + expect((ref as any).value instanceof HTMLSpanElement).to.eq(true); + }); + }); +}); diff --git a/packages/react/package.json b/packages/react/package.json index 2b8d0cd79..fcc140f2f 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -53,6 +53,9 @@ "runtime/dist", "runtime/src", "runtime/package.json", + "utils/dist", + "utils/src", + "utils/package.json", "CHANGELOG.md", "LICENSE", "README.md" diff --git a/packages/react/utils/package.json b/packages/react/utils/package.json index 94b68c663..0a832e1dd 100644 --- a/packages/react/utils/package.json +++ b/packages/react/utils/package.json @@ -1,5 +1,5 @@ { - "name": "@preact/signals-react-runtime", + "name": "@preact/signals-react-utils", "description": "Sub package for @preact/signals-react that contains some useful utilities", "private": true, "amdName": "reactSignalsutils", @@ -21,6 +21,7 @@ "@preact/signals-core": "workspace:^1.3.0" }, "peerDependencies": { + "@preact/signals-react": "workspace:*", "react": "^16.14.0 || 17.x || 18.x || 19.x" } } diff --git a/packages/react/utils/test/browser/index.test.tsx b/packages/react/utils/test/browser/index.test.tsx index 4d50d10b1..4aa007aa4 100644 --- a/packages/react/utils/test/browser/index.test.tsx +++ b/packages/react/utils/test/browser/index.test.tsx @@ -1,4 +1,4 @@ -import { For, Show, useSignalRef } from "../../src"; +import { For, Show, useSignalRef } from "@preact/signals-react/utils"; import { act, checkHangingAct, diff --git a/tsconfig.json b/tsconfig.json index 2988026e8..4d3f9d15a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ "@preact/signals": ["./packages/preact/src/index.ts"], "@preact/signals-react": ["./packages/react/src/index.ts"], "@preact/signals-react/utils": ["./packages/react/utils/src/index.ts"], + "@preact/signals/utils": ["./packages/preact/utils/src/index.ts"], "@preact/signals-react/runtime": [ "./packages/react/runtime/src/index.ts" ],