From 378567e9cb5dc2776337660ecfe8d75071d7dcc6 Mon Sep 17 00:00:00 2001 From: Dev Agrawal Date: Mon, 14 Oct 2024 04:32:39 -0500 Subject: [PATCH] added events package --- packages/events/LICENSE | 21 +++ packages/events/README.md | 281 +++++++++++++++++++++++++++++ packages/events/dev/index.tsx | 105 +++++++++++ packages/events/package.json | 66 +++++++ packages/events/src/index.ts | 108 +++++++++++ packages/events/test/index.test.ts | 151 ++++++++++++++++ packages/events/tsconfig.json | 3 + pnpm-lock.yaml | 6 + 8 files changed, 741 insertions(+) create mode 100644 packages/events/LICENSE create mode 100644 packages/events/README.md create mode 100644 packages/events/dev/index.tsx create mode 100644 packages/events/package.json create mode 100644 packages/events/src/index.ts create mode 100644 packages/events/test/index.test.ts create mode 100644 packages/events/tsconfig.json diff --git a/packages/events/LICENSE b/packages/events/LICENSE new file mode 100644 index 000000000..38b41d975 --- /dev/null +++ b/packages/events/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Solid Primitives Working Group + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/events/README.md b/packages/events/README.md new file mode 100644 index 000000000..ffc806646 --- /dev/null +++ b/packages/events/README.md @@ -0,0 +1,281 @@ +

+ Solid Primitives events +

+ +# @solid-primitives/events + +[![turborepo](https://img.shields.io/badge/built%20with-turborepo-cc00ff.svg?style=for-the-badge&logo=turborepo)](https://turborepo.org/) +[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/events?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/events) +[![version](https://img.shields.io/npm/v/@solid-primitives/events?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/events) +[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) + +A set of primitives for declarative event composition and state derivation for solidjs. You can think of it as a much simpler version of Rxjs that integrates well with Solidjs. + +[Here is an implementation of the Strello demo that uses `solid-events`](https://github.com/devagrawal09/strello/pull/1/files). + +## Contents +- [@solid-primitives/events](#solid-primitivesevents) + - [Contents](#contents) + - [Installatiom](#installatiom) + - [`createEvent`](#createevent) + - [Tranformation](#tranformation) + - [Disposal](#disposal) + - [Halting](#halting) + - [Async Events](#async-events) + - [`createSubject`](#createsubject) + - [`createAsyncSubject`](#createasyncsubject) + - [`createSubjectStore`](#createsubjectstore) + - [`createTopic`](#createtopic) + - [`createPartition`](#createpartition) + - [Use Cases](#use-cases) + +## Installatiom + +```bash +npm install solid-events +``` +or +```bash +pnpm install solid-events +``` +or +```bash +bun install solid-events +``` + + +## `createEvent` + +Returns an event handler and an event emitter. The handler can execute a callback when the event is emitted. + +```ts +const [onEvent, emitEvent] = createEvent() + +onEvent(payload => console.log(`Event emitted:`, payload)) + +... + +emitEvent(`Hello World!`) +// logs "Event emitted: Hello World!" +``` + +### Tranformation + +The handler can return a new handler with the value returned from the callback. This allows chaining transformations. + +```ts +const [onIncrement, emitIncrement] = createEvent() + +const onMessage = onIncrement((delta) => `Increment by ${delta}`) + +onMessage(message => console.log(`Message emitted:`, message)) + +... + +emitIncrement(2) +// logs "Message emitted: Increment by 2" +``` + +### Disposal +Handlers that are called inside a component are automatically cleaned up with the component, so no manual bookeeping is necesarry. + +```tsx +function Counter() { + const [onIncrement, emitIncrement] = createEvent() + + const onMessage = onIncrement((delta) => `Increment by ${delta}`) + + onMessage(message => console.log(`Message emitted:`, message)) + + return
....
+} +``` +Calling `onIncrement` and `onMessage` registers a stateful subscription. The lifecycle of these subscriptions are tied to their owner components. This ensures there's no memory leaks. + +### Halting + +Event propogation can be stopped at any point using `halt()` + +```ts +const [onIncrement, emitIncrement] = createEvent() + +const onValidIncrement = onIncrement(delta => delta < 1 ? halt() : delta) +const onMessage = onValidIncrement((delta) => `Increment by ${delta}`) + +onMessage(message => console.log(`Message emitted:`, message)) + +... + +emitIncrement(2) +// logs "Message emitted: Increment by 2" + +... + +emitIncrement(0) +// Doesn't log anything +``` + +`halt()` returns a `never`, so typescript correctly infers the return type of the handler. + +### Async Events + +If you return a promise from an event callback, the resulting event will wait to emit until the promise resolves. In other words, promises are automatically flattened by events. + +```ts +async function createBoard(boardData) { + "use server" + const boardId = await db.boards.create(boardData) + return boardId +} + +const [onCreateBoard, emitCreateBoard] = createEvent() + +const onBoardCreated = onCreateBoard(boardData => createBoard(boardData)) + +onBoardCreated(boardId => navigate(`/board/${boardId}`)) +``` + +## `createSubject` + +Events can be used to derive state using Subjects. A Subject is a signal that can be derived from event handlers. + +```ts +const [onIncrement, emitIncrement] = createEvent() +const [onReset, emitReset] = createEvent() + +const onMessage = onIncrement((delta) => `Increment by ${delta}`) +onMessage(message => console.log(`Message emitted:`, message)) + +const count = createSubject( + 0, + onIncrement(delta => currentCount => currentCount + delta), + onReset(() => 0) +) + +createEffect(() => console.log(`count`, count())) + +... + +emitIncrement(2) +// logs "Message emitted: Increment by 2" +// logs "count 2" + +emitReset() +// logs "count 0" +``` + +To update the value of a subject, event handlers can return a value (like `onReset`), or a function that transforms the current value (like `onIncrement`). + +`createSubject` can also accept a signal as the first input instead of a static value. The subject's value resets whenever the source signal updates. + +```tsx +function Counter(props) { + const [onIncrement, emitIncrement] = createEvent() + const [onReset, emitReset] = createEvent() + + const count = createSubject( + () => props.count, + onIncrement(delta => currentCount => currentCount + delta), + onReset(() => 0) + ) + + return
...
+} +``` + +`createSubject` has some compound variations to complete use cases. + +### `createAsyncSubject` + +This subject accepts a reactive async function as the first argument similar to `createAsync`, and resets whenever the function reruns. + +```ts +const getBoards = cache(async () => { + "use server"; + // fetch from database +}, "get-boards"); + +export default function HomePage() { + const [onDeleteBoard, emitDeleteBoard] = createEvent(); + + const boards = createAsyncSubject( + () => getBoards(), + onDeleteBoard( + (boardId) => (boards) => boards.filter((board) => board.id !== boardId) + ) + ); + + ... +} +``` + +### `createSubjectStore` + +This subject is a store instead of a regular signal. Event handlers can mutate the current state of the board directly. Uses `produce` under the hood. + +```ts +const boardStore = createSubjectStore( + () => boardData(), + onCreateNote((createdNote) => (board) => { + const index = board.notes.findIndex((n) => n.id === note.id); + if (index === -1) board.notes.push(note); + }), + onDeleteNote(([id]) => (board) => { + const index = board.notes.findIndex((n) => n.id === id); + if (index !== -1) board.notes.splice(index, 1); + }) + ... +) +``` +Similar to `createSubject`, the first argument can be a signal that resets the value of the store. When this signal updates, the store is updated using `reconcile`. + +## `createTopic` + +A topic combines multiple events into one. This is simply a more convenient way to merge events than manually iterating through them. + +```ts +const [onIncrement, emitIncrement] = createEvent() +const [onDecrement, emitDecrement] = createEvent() + +const onMessage = createTopic( + onIncrement(() => `Increment by ${delta}`), + onDecrement(() => `Decrement by ${delta}`) +); +onMessage(message => console.log(`Message emitted:`, message)) + +... + +emitIncrement(2) +// logs "Message emitted: Increment by 2" + +emitDecrement(1) +// logs "Message emitted: Decrement by 1" +``` + +## `createPartition` + +A partition splits an event based on a conditional. This is simply a more convenient way to conditionally split events than using `halt()`. + +```ts +const [onIncrement, emitIncrement] = createEvent() + +const [onValidIncrement, onInvalidIncrement] = createPartition( + onIncrement, + delta => delta > 0 +) + +onValidIncrement(delta => console.log(`Valid increment by ${delta}`)) + +onInvalidIncrement(delta => console.log(`Please use a number greater than 0`)) + +... + +emitIncrement(2) +// logs "Valid increment by 2" + +emitIncrement(0) +// logs "Please use a number greater than 0" + +``` + +## Use Cases diff --git a/packages/events/dev/index.tsx b/packages/events/dev/index.tsx new file mode 100644 index 000000000..22689c0a5 --- /dev/null +++ b/packages/events/dev/index.tsx @@ -0,0 +1,105 @@ +import { createEffect, onCleanup } from "solid-js"; +import { createEvent, createSubject, halt } from "../src/index.js"; + +function Counter() { + const [onStart, emitStart] = createEvent(); + const [onPause, emitPause] = createEvent(); + const [onSet, emitSet] = createEvent(); + const [onReset, emitReset] = createEvent(); + const [onIncrement, emitIncrement] = createEvent(); + const [onDecrement, emitDecrement] = createEvent(); + const [onTimerChange, emitTimerChange] = createEvent(); + + const onInvalidTimerChange = onTimerChange(n => (n > 0 ? null : true)); + + const timerActive = createSubject( + true, + onStart(() => true), + onPause(() => false), + onInvalidTimerChange(() => false), + ); + + const count = createSubject( + 0, + onSet, + onReset(() => 0), + onIncrement(() => c => c + 1), + onDecrement(() => c => c - 1), + ); + + const delay = createSubject( + 500, + onTimerChange(n => (n > 0 ? n : halt())), + ); + + createEffect(() => { + if (delay() && timerActive()) { + const i = setInterval(emitIncrement, delay()); + onCleanup(() => clearInterval(i)); + } + }); + + return ( +
+ {count()} +
+
+ + + +
+
+ + +
+
+ + +
+
+
+ ); +} + +export default Counter; diff --git a/packages/events/package.json b/packages/events/package.json new file mode 100644 index 000000000..14e8d793b --- /dev/null +++ b/packages/events/package.json @@ -0,0 +1,66 @@ +{ + "name": "@solid-primitives/events", + "version": "0.0.100", + "description": "Declarative event composition and state derivation primitives for Solidjs.", + "author": "Dev Agrawal ", + "contributors": [], + "license": "MIT", + "homepage": "https://primitives.solidjs.community/package/events", + "repository": { + "type": "git", + "url": "git+https://github.com/solidjs-community/solid-primitives.git" + }, + "bugs": { + "url": "https://github.com/solidjs-community/solid-primitives/issues" + }, + "primitive": { + "name": "events", + "stage": 0, + "list": [ + "createEvent", + "createSubject", + "createSubjectStore", + "createTopic", + "createPartition" + ], + "category": "Reactivity" + }, + "keywords": [ + "solid", + "primitives" + ], + "private": false, + "sideEffects": false, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "browser": {}, + "exports": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "typesVersions": {}, + "scripts": { + "dev": "tsx ../../scripts/dev.ts", + "build": "tsx ../../scripts/build.ts", + "vitest": "vitest -c ../../configs/vitest.config.ts", + "test": "pnpm run vitest", + "test:ssr": "pnpm run vitest --mode ssr" + }, + "peerDependencies": { + "solid-js": "^1.6.12" + }, + "dependencies": { + "rxjs": "^7.8.1" + } +} diff --git a/packages/events/src/index.ts b/packages/events/src/index.ts new file mode 100644 index 000000000..aabd208a3 --- /dev/null +++ b/packages/events/src/index.ts @@ -0,0 +1,108 @@ +import { Observable, Subject } from "rxjs"; +import { Accessor, createComputed, createMemo, createSignal, onCleanup, untrack } from "solid-js"; +import { createStore, produce, reconcile } from "solid-js/store"; + +export type Handler = ((transform: (e: E) => Promise | O) => Handler) & { + $: Observable; +}; +export type Emitter = (e: E) => void; + +function makeHandler($: Observable): Handler { + function handler(transform: (e: E) => Promise | O): Handler { + const next$ = new Subject(); + const sub = $.subscribe(e => { + try { + const res = transform(e); + if (res instanceof Promise) res.then(o => next$.next(o)); + else next$.next(res); + } catch (e) { + if (!(e instanceof HaltError)) throw e; + console.info(e.message); + } + }); + onCleanup(() => sub.unsubscribe()); + return makeHandler(next$); + } + + handler.$ = $; + return handler; +} + +export function createEvent(): [Handler, Emitter] { + const $ = new Subject(); + return [makeHandler($), e => $.next(e)] as const; +} + +export function createTopic(...args: Handler[]): Handler { + const [onEvent, emitEvent] = createEvent(); + args.forEach(h => h(emitEvent)); + return onEvent; +} + +export function createSubject( + init: T, + ...events: Array T)>> +): Accessor; +export function createSubject( + init: () => T, + ...events: Array T)>> +): Accessor; +export function createSubject( + init: undefined, + ...events: Array T)>> +): Accessor; +export function createSubject( + init: T | undefined, + ...events: Array T)>> +): Accessor; +export function createSubject( + init: (() => T) | T | undefined, + ...events: Array T)>> +) { + if (typeof init === "function") { + const memoSubject = createMemo(() => createSubject((init as () => T)(), ...events)); + return () => memoSubject()(); + } else { + const [signal, setSignal] = createSignal(init); + events.forEach(h => h(setSignal)); + return signal; + } +} + +export function createSubjectStore( + init: () => T, + ...events: Array void>> +): T; +export function createSubjectStore( + init: (() => T) | T | undefined, + ...events: Array void>> +) { + if (typeof init === "function") { + const [store, setStore] = createStore(untrack(init)); + createComputed(() => setStore(reconcile(init()))); + const event = createTopic(...events); + event(mutation => setStore(produce(mutation))); + return store; + } else { + const [store, setStore] = init ? createStore(init) : createStore(); + const event = createTopic(...events); + event(mutation => setStore(produce(mutation))); + return store; + } +} + +export class HaltError extends Error { + constructor(public reason?: string) { + super(reason ? "Event propogation halted: " + reason : "Event propogation halted"); + } +} + +export function halt(reason?: string): never { + throw new HaltError(reason); +} + +export function createPartition(handler: Handler, predicate: (arg: T) => boolean) { + const trueHandler = handler(p => (predicate(p) ? p : halt())); + const falseHandler = handler(p => (predicate(p) ? halt() : p)); + return [trueHandler, falseHandler] as const; +} diff --git a/packages/events/test/index.test.ts b/packages/events/test/index.test.ts new file mode 100644 index 000000000..39ef56000 --- /dev/null +++ b/packages/events/test/index.test.ts @@ -0,0 +1,151 @@ +import { createRoot } from "solid-js"; +import { describe, expect, test } from "vitest"; +import { createEvent, createPartition, createTopic, halt } from "../src/index.js"; +import { setTimeout } from "timers/promises"; + +describe(`createEvent`, () => { + test(`emits to callback`, () => { + const messages = [] as string[]; + + const d = createRoot(d => { + const [on, emit] = createEvent(); + on(p => messages.push(p)); + emit(`hello`); + return d; + }); + + expect(messages).toEqual([`hello`]); + d(); + }); + + test(`emits to callback asynchronously`, async () => { + const messages = [] as string[]; + + const [d, emit] = createRoot(d => { + const [on, emit] = createEvent(); + on(p => messages.push(p)); + emit(`hello`); + return [d, emit]; + }); + + expect(messages).toEqual([`hello`]); + + await setTimeout(10); + emit(`world`); + expect(messages).toEqual([`hello`, `world`]); + d(); + }); + + test(`cleans up with the root`, () => { + const messages = [] as string[]; + + const [d, emit] = createRoot(d => { + const [on, emit] = createEvent(); + on(p => messages.push(p)); + emit(`hello`); + return [d, emit]; + }); + + expect(messages).toEqual([`hello`]); + d(); + emit(`world`); + expect(messages).toEqual([`hello`]); + }); + + test(`transforms into new handler`, () => { + const messages = [] as string[]; + + const d = createRoot(d => { + const [on, emit] = createEvent(); + const on2 = on(p => `Decorated: ${p}`); + on2(p => messages.push(p)); + emit(`hello`); + return d; + }); + + expect(messages).toEqual([`Decorated: hello`]); + d(); + }); + + test(`halts`, () => { + const messages = [] as string[]; + + const d = createRoot(d => { + const [on, emit] = createEvent(); + const onValid = on(p => (p.length < 3 ? halt() : p)); + onValid(p => messages.push(p)); + emit(`hello`); + emit(`hi`); + return d; + }); + + expect(messages).toEqual([`hello`]); + d(); + }); + + test(`flattens a promise`, async () => { + const messages = [] as string[]; + + const d = createRoot(d => { + const [on, emit] = createEvent(); + const onAsync = on(async p => { + await setTimeout(10); + return p; + }); + onAsync(p => messages.push(p)); + emit(`hello`); + return d; + }); + + await setTimeout(10); + + expect(messages).toEqual([`hello`]); + d(); + }); +}); + +describe(`createPartition`, () => { + test(`partitions an event`, () => { + const messages = [] as string[]; + + const d = createRoot(d => { + const [on, emit] = createEvent(); + const [onValid, onInvalid] = createPartition(on, p => p.length >= 3); + onValid(p => messages.push(`valid: ${p}`)); + onInvalid(p => messages.push(`invalid: ${p}`)); + + emit(`hello`); + emit(`hi`); + + return d; + }); + + expect(messages).toEqual([`valid: hello`, `invalid: hi`]); + d(); + }); +}); + +describe(`createTopic`, () => { + test(`merges events`, () => { + const messages = [] as string[]; + + const d = createRoot(d => { + const [on1, emit1] = createEvent(); + const [on2, emit2] = createEvent(); + const on = createTopic(on1, on2); + on(p => messages.push(p)); + + emit1(`hello`); + emit2(`world`); + + return d; + }); + + expect(messages).toEqual([`hello`, `world`]); + d(); + }); +}); + +describe(`createSubject`, () => { + test.todo(`need effects to run on server to test signals`); +}); diff --git a/packages/events/tsconfig.json b/packages/events/tsconfig.json new file mode 100644 index 000000000..4082f16a5 --- /dev/null +++ b/packages/events/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1994beda5..01919a046 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -304,6 +304,12 @@ importers: specifier: ^1.8.7 version: 1.8.20 + packages/events: + dependencies: + rxjs: + specifier: ^7.8.1 + version: 7.8.1 + packages/fetch: dependencies: node-fetch: