diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e7428b8..eebb0f2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,8 +2,6 @@ name: Build and release on: push: - branches: - - main workflow_dispatch: inputs: release: @@ -45,7 +43,7 @@ jobs: pnpm test - name: Determine release tag - if: ${{ inputs.release }} + if: ${{ inputs.release && github.ref == 'refs/heads/main' }} run: | cd packages/reactivity-core @@ -54,14 +52,14 @@ jobs: echo RELEASE_TAG=$NAME@$VERSION >> $GITHUB_ENV - name: Tag release - if: ${{ inputs.release }} + if: ${{ inputs.release && github.ref == 'refs/heads/main' }} uses: rickstaa/action-create-tag@v1 with: tag: "${{ env.RELEASE_TAG }}" force_push_tag: true - name: Publish package - if: ${{ inputs.release }} + if: ${{ inputs.release && github.ref == 'refs/heads/main' }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | diff --git a/README.md b/README.md index f4eeef3..c8b8d94 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,10 @@ Framework agnostic library for building reactive applications. > [!WARNING] > The APIs implemented in this repository are highly experimental and subject to change. +## Reactivity-API + +See the [README of the `@conterra/reactivity-core` package](./packages/reactivity-core/README.md). + ## Setup Install [pnpm](https://pnpm.io/), for example by running `npm install -g pnpm`. @@ -38,6 +42,22 @@ $ pnpm watch-types You can try this library together with Vue3 in the `playground` directory. See [README](./playground/README.md) for more details. +## Render typedoc + +Build typedoc output (to `dist/docs`). + +```bash +$ pnpm build-docs +``` + +Then, use any local web server to serve the documentation. +The following example uses [serve](https://www.npmjs.com/package/serve): + +```bash +$ pnpm install -g serve # global installation; only necessary once +$ serve dist/docs +``` + ## Releasing The release process is semi-automatic at this time. diff --git a/package.json b/package.json index 9913faf..641e5cd 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "clean": "pnpm -r run --parallel clean", "build": "pnpm -r run --aggregate-output build", + "build-docs": "typedoc", "test": "pnpm -r run --aggregate-output test run", "check-types": "tsc", "watch-types": "pnpm check-types -w", @@ -25,6 +26,7 @@ "prettier": "^3.2.5", "rimraf": "^5.0.5", "tsx": "^4.7.2", + "typedoc": "^0.25.13", "typescript": "~5.4.4", "vite": "^5.2.8", "vitest": "^1.4.0" @@ -32,14 +34,5 @@ "engines": { "node": ">= 18", "pnpm": "^8" - }, - "pnpm": { - "packageExtensions": { - "@vue/test-utils@^2": { - "peerDependencies": { - "vue": "^3" - } - } - } } } diff --git a/packages/reactivity-core/README.md b/packages/reactivity-core/README.md index 32090e9..4af51fd 100644 --- a/packages/reactivity-core/README.md +++ b/packages/reactivity-core/README.md @@ -1,9 +1,323 @@ # @conterra/reactivity-core -This library implements a reactivity system based on [signals](https://github.com/preactjs/signals). -It is designed to serve as the foundation for reactive applications (such as user interfaces). +> UI framework independent reactivity library with support for all kinds of values -## Motivation +## Quick Example + +```ts + +``` + +## Usage + +Signals are reactive "boxes" that contain a value that may change at any time. +They can be easily watched for changes by, for example, registering a callback using `watch()`. + +Signals may also be composed via _computed_ signals, whose values are derived from other signals and are then automatically kept up to date. +They can also be used in classes (or plain objects) for organization based on concern or privacy. + +### Basics + +The following snippet creates a signal `r` through the function `reactive()` that initially holds the value `"foo"`. +`reactive()` is one of the most basic forms to construct a signal: + +```ts +import { reactive } from "@conterra/reactivity-core"; + +const r = reactive("foo"); +console.log(r.value); // prints "foo" + +r.value = "bar"; +console.log(r.value); // prints "bar" +``` + +The current value of a signal can be accessed by reading the property `.value`. +If you have a writable signal (which is the case for signals returned by `reactive()`), you can update the inner value of assigning to the property `.value`. + +Whenever the value of a signal changes, any watcher (a party interested in the current value) will automatically be notified. +The `effect()` function is one way to track one (or many) signals: + +```ts +import { reactive, effect } from "@conterra/reactivity-core"; + +const r = reactive("foo"); + +// Effect callback is executed once; prints "foo" immediately +effect(() => { + console.log(r.value); +}); + +// Triggers another execution of the effect; prints "bar" now. +r.value = "bar"; +``` + +`effect()` executes a callback function and tracks which signals have been accessed (meaning: reading `signal.value`) by that function - even indirectly. +Whenever one of those signals changes in any way, the effect will be triggered again. + +Signals can be composed by deriving values from them via `computed()`. +`computed()` takes a callback function as its argument. +That function can access any number of signals and should then return some JavaScript value. +The following example creates a computed signal that always returns twice the original `age`. + +```ts +import { reactive, computed } from "@conterra/reactivity-core"; + +const age = reactive(21); +const doubleAge = computed(() => age.value * 2); + +console.log(doubleAge.value); // 42 +age.value = 22; +console.log(doubleAge.value); // 44 +``` + +Computed signals can be watched (e.g. via `effect()`) like any other signal: + +```ts +import { reactive, computed, effect } from "@conterra/reactivity-core"; + +const age = reactive(21); +const doubleAge = computed(() => age.value * 2); + +// prints 42 +effect(() => { + console.log(doubleAge.value); +}); + +// re-executes effect, which prints 44 +age.value = 22; +``` + +Computed signals only re-compute their value (by invoking the callback function) when any of their dependencies have changed. +For as long as nothing has changed, the current value will be cached. +This can make even complex computed signals very efficient. + +Note that the callback function for a computed signal should be stateless: +it is supposed to compute a value (possibly very often), and it should not change the state of the application while doing so. + +### Using signals for reactive object properties + +You can use signals in your classes (or single objects) to implement reactive objects. +For example: + +```ts +import { reactive } from "@conterra/reactivity-core"; + +class Person { + // Could be private or public + _name = reactive(""); + + get name() { + return this._name.value; + } + + set name(value) { + // Reactive write -> watches that used the `name` getter are notified. + // We could use this setter (which could also be a normal method) to enforce preconditions. + this._name.value = value; + } +} +``` + +Instances of person are now reactive, since their state is actually stored in signals: + +```ts +import { effect } from "@conterra/reactivity-core"; + +// Person from previous example +const p = new Person(); +p.name = "E. Example"; + +// Prints "E. Example" +effect(() => { + console.log(p.name); +}); + +// Triggers effect again; prints "T. Test" +p.name = "T. Test"; +``` + +You can also provide computed values or accessor methods in your class: + +```ts +import { reactive, computed } from "@conterra/reactivity-core"; + +// In this example, first name and last name can only be written to. +// Only the combined full name is available to users of the class. +class Person { + _firstName = reactive(""); + _lastName = reactive(""); + _fullName = computed(() => `${this._firstName.value} ${this._lastName.value}`); + + setFirstName(name) { + this._firstName.value = name; + } + + setLastName(name) { + this._lastName.value = name; + } + + getFullName() { + return this._fullName.value; + } +} +``` + +### Effect vs. Watch + +We provide two different APIs to run code when reactive values change. +The simpler one is effect `effect()`: + +```js +import { reactive, effect } from "@conterra/reactivity-core"; + +const r1 = reactive(0); +const r2 = reactive(1); +const r3 = reactive(2); + +// Will run whenever _any_ of the given signals changed, +// even if the sum turns out to be the same. +effect(() => { + const sum = r1.value + r2.value + r3.value; + console.log(sum); +}); +``` + +If your effect callbacks become more complex, it may be difficult to control which signals are ultimately used. +This can result in your effect running too often, because you're really only interested in _some_ changes and not all of them. + +In that case, you can use `watch()` to have more fine grain control: + +```js +import { reactive, watch } from "@conterra/reactivity-core"; + +const r1 = reactive(0); +const r2 = reactive(1); +const r3 = reactive(2); + +watch( + // (1) + () => { + const sum = r1.value + r2.value + r3.value; + return [sum]; + }, + // (2) + ([sum]) => { + console.log(sum); + }, + // (3) + { immediate: true } +); +``` + +`watch()` takes two functions and one (optional) options object: + +- **(1)**: The _selector_ function. + This function's body is tracked (like in `effect()`) and all its reactive dependencies are recorded. + The function must return an array of values. +- **(2)**: The _callback_ function. + This function is called whenever the selector function returned different values, and it receives those values as its first argument. + The callback itself is _not_ reactive. +- **(3)**: By default, the callback function will only be invoked after the watched values changed at least once. + By specifying `immediate: true`, the callback will also run for the initial values. + +In this example, the callback function will only re-run when the computed sum truly changed. + +### Complex values + +TODO: Object identity, arrays, immutability --> Reactive Collections + +### Cleanup + +Both `effect()` and `watch()` return a `CleanupHandle` to stop watching for changes: + +```js +const h1 = effect(/* ... */); +const h2 = watch(/* ... */); + +// When you are no longer interested in changes: +h1.destroy(); +h2.destroy(); +``` + +When a watcher is not cleaned up properly, it will continue to execute (possibly forever). +This leads to unintended side effects, unnecessary memory consumption and waste of computational power. + +### Reactive collections + +This package provides a set of collection classes to simplify working with complex values. + +#### Array + +The `ReactiveArray` behaves largely like a normal `Array`. +Most standard methods have been reimplemented with support for reactivity (new methods can be added on demand). + +The only major difference is that one cannot use the `[]` operator. +Users must use the `array.get(index)` and `array.set(index, value)` methods instead. + +Example: + +```ts +import { reactiveArray } from "@conterra/reactivity-core"; + +// Optionally accepts initial content +const array = reactiveArray(); + +// Prints undefined since the array is initially empty +effect(() => { + console.log(array.get(0)); +}); + +array.push(1); // effect prints 1 + +// later +array.set(0, 123); // effect prints 123 +``` + +#### Set + +The `ReactiveSet` can be used as substitute for the standard `Set`. + +Example: + +```ts +import { reactiveSet } from "@conterra/reactivity-core"; + +// Optionally accepts initial content +const set = reactiveSet(); + +// Prints 0 since the set is initially empty +effect(() => { + console.log(set.size); +}); + +set.add(123); // effect prints 1 +``` + +#### Map + +The `ReactiveMap` can be used as a substitute for the standard `Map`. + +Example: + +```ts +import { reactiveSet } from "@conterra/reactivity-core"; + +// Optionally accepts initial content +const map = reactiveMap(); + +// Prints undefined since the map is initially empty +effect(() => { + console.log(map.get("foo")); +}); + +map.set("foo", "bar"); // effect prints "bar" +``` + +#### Struct + +TODO + +## Why? One of the most important responsibilities of an application is to accurately present the current state of the system. Such an application will have to implement the means to: @@ -22,8 +336,10 @@ These solutions often come with some trade-offs: - They may impose unusual programming paradigms (e.g. a centralized store instead of a graph of objects) that may be different to integrate with technologies like TypeScript. - They may only support reactivity for _some_ objects. For example, Vue's reactivity system is based on wrapping objects with proxies; this is incompatible with some legitimate objects - a fact that can be both surprising and difficult to debug. +- They may only support reactivity _locally_. + For example, a store library may support reactivity _within_ a single store, but referring to values from multiple stores may be difficult. -This library implements a different set of trade-offs, based on signals: +This library implements a different set of trade-offs, based on [signals](https://github.com/preactjs/signals): - The implementation is not tied to any UI technology. It can be used with any UI Framework, or none, or multiple UI Frameworks at the same time. @@ -33,17 +349,103 @@ This library implements a different set of trade-offs, based on signals: - State can be kept in objects and classes (this pairs nicely with TypeScript). The state rendered by the user interface can be gathered from an arbitrary set of objects. -## Overview +## API + +See the comments inside the type declarations or the built TypeDoc documentation. -Signals are reactive "boxes" that contain a value, that may change at any time. -They may be composed via derived (or "computed") signals, which are updated automatically whenever one of their dependencies is updated. -They can also be used in classes (or plain objects) for organization based on concern or privacy. -Finally, any kind of value(s) that have been made reactive using signals can be watched for changes by registering a callback. +## Installation -## API +With [npm](https://npmjs.org/) installed, run + +```sh +npm install @conterra/reactivity-core +``` + +## Gotchas and tips + +### Avoid side effects in computed signals + +Computed signals should use a side effect free function. +Oftentimes, you cannot control how often the function is re-executed, because that depends on how often +the dependencies of your functions change and when your signal is actually read (computation is lazy). + +Example: + +```ts +let nonreactiveValue = 1; +const reactiveValue = reactive(1); +const computedValue = computed(() => { + // This works, but it usually bad style for the reasons outlined above: + nonReactiveValue += 1; + + // XXX + // This is outright forbidden and will result in a runtime error. + // You cannot modify a signal from inside a computed signal. + reactiveValue.value += 1; + return "whatever"; +}); +``` + +### Don't trigger an effect from within itself + +Updating the value of some signal from within an effect is fine in general. +However, you should take care not to produce a cycle. + +Example: this is okay (but could be replaced by a computed signal). + +```ts +const v1 = reactive(0); +const v2 = reactive(1); +effect(() => { + // Updates v2 whenever v1 changes + v2.value = v1.value * 2; +}); +``` + +Example: this is _not_ okay. + +```ts +const v1 = reactive(0); +effect(() => { + // same as `v1.value = v1.value + 1` + v1.value += 1; // throws! +}); +``` + +This is the shortest possible example of a cycle within an effect. +When the effect executed, it _reads_ from `v1` (thus requiring that the effect re-executes whenever `v1` changes) +and then it _writes_ to v1, thus changing it. +This effect would re-trigger itself endlessly - luckily the underlying signals library throws an exception when this case is detected. + +#### Workaround + +Sometimes you _really_ have to read something and don't want to become a reactive dependency. +In that case, you can wrap the code block with `untracked()`. +Example: + +```ts +import { reactive, effect, untracked } from "@conterra/reactivity-core"; + +const v1 = reactive(0); +effect(() => { + // Code inside untracked() will not be come a dependency of the effect. + const value = untracked(() => v1.value); + v1.value = value + 1; +}); +``` + +The example above will not throw an error anymore because the _read_ to `v1` has been wrapped with `untracked()`. + +> NOTE: In very simple situations you can also use the `.peek()` method of a signal, which is essentially a tiny `untracked` block that only reads from that signal. The code above could be changed to `const value = v1.peek()`. + +### Batching multiple updates + +### Sync vs async effect / watch + +### Writing nonreactive code -### Primitives +### Effects triggering "too often" -### Subscribing to changes +## License -### Collections +Apache-2.0 (see `LICENSE` file) diff --git a/packages/reactivity-core/Reactive.ts b/packages/reactivity-core/Reactive.ts index 3c0d386..a3cad07 100644 --- a/packages/reactivity-core/Reactive.ts +++ b/packages/reactivity-core/Reactive.ts @@ -11,19 +11,24 @@ export type AddBrand = T & { [IS_REACTIVE]: true }; export type AddWritableBrand = AddBrand & { [IS_WRITABLE_REACTIVE]: true }; /** - * Holds a value. + * A signal that holds a reactive value. * - * When a reactive value changes, all users of that value (computed reactive values, effects, watchers) + * When the value changes, all users of that value (computed signals, effects, watchers) * are notified automatically. + * + * @group Primitives */ -export interface Reactive { - // Compile time symbol to identify reactive objects. +export interface ReadonlyReactive { + /** + * Compile time symbol to identify reactive signals. + * @internal + */ [IS_REACTIVE]: true; /** - * Accesses the current value stored in this reactive object. + * Accesses the current value stored in this signal. * - * This access is tracked: users (computed reactives, effects, etc.) will be registered as a user of this value + * This access is tracked: users (computed signals, effects, etc.) will be registered as a user of this value * and will be notified when it changes. */ readonly value: T; @@ -35,53 +40,60 @@ export interface Reactive { */ peek(): T; - // For compatibility with builtin JS constructs + /** + * Same as `.value`. + * + * For compatibility with builtin JS constructs. + **/ toJSON(): T; + + /** + * Formats `.value` as a string. + * + * For compatibility with builtin JS constructs. + **/ toString(): string; } /** - * Holds a mutable value. + * A signal that holds a mutable value. * * The value stored in this object can be changed through assignment, * and all its users will be notified automatically. + * + * @group Primitives */ -export interface WritableReactive extends Reactive { - // Compile time symbol to identify writable objects. +export interface Reactive extends ReadonlyReactive { + /** + * Compile time symbol to identify writable reactive objects. + * @internal + */ [IS_WRITABLE_REACTIVE]: true; /** * Reads or writes the current value in this reactive object. * - * @see {@link Reactive.value} + * @see {@link ReadonlyReactive.value} */ value: T; } /** - * Holds a value from an external source. + * A signal that holds a value from an external source. * * Instances of this type are used to integrate "foreign" state into the reactivity system. + * + * @group Primitives */ -export interface ExternalReactive extends Reactive { +export interface ExternalReactive extends ReadonlyReactive { /** * Notifies the reactivity system that the external value has changed. * * The users of this value will be notified automatically; if there are any users * then the value will be re-computed from its external source using the original callback. * - * NOTE: This function is bound to its instance. You can use it directly as an event handler callback. + * NOTE: This function is bound to its instance. You can use it directly as an event handler callback + * without safeguarding `this`. */ trigger(): void; } - -/** - * A handle returned by various functions to dispose of a resource, - * such as a watcher or an effect. - */ -export interface CleanupHandle { - /** - * Performs the cleanup action associated with the resource. - */ - destroy(): void; -} diff --git a/packages/reactivity-core/ReactiveImpl.ts b/packages/reactivity-core/ReactiveImpl.ts index 5a22d45..67711e9 100644 --- a/packages/reactivity-core/ReactiveImpl.ts +++ b/packages/reactivity-core/ReactiveImpl.ts @@ -9,18 +9,22 @@ import { AddBrand, AddWritableBrand, ExternalReactive, - Reactive, + ReadonlyReactive, RemoveBrand, - WritableReactive + Reactive } from "./Reactive"; /** * A function that should return `true` if `a` and `b` are considered equal, `false` otherwise. + * + * @group Primitives */ export type EqualsFunc = (a: T, b: T) => boolean; /** - * Options that can be passed when creating a new reactive value. + * Options that can be passed when creating a new signal. + * + * @group Primitives */ export interface ReactiveOptions { /** @@ -34,7 +38,7 @@ export interface ReactiveOptions { } /** - * Creates a new writable reactive value, initialized to `undefined`. + * Creates a new mutable signal, initialized to `undefined`. * * Example: * @@ -43,11 +47,13 @@ export interface ReactiveOptions { * console.log(foo.value); // undefined * foo.value = 123; // updates the current value * ``` + * + * @group Primitives */ -export function reactive(): WritableReactive; +export function reactive(): Reactive; /** - * Creates a new writable reactive value, initialized to the given value. + * Creates a new mutable signal, initialized to the given value. * * Example: * @@ -56,18 +62,20 @@ export function reactive(): WritableReactive; * console.log(foo.value); // 123 * foo.value = 456; // updates the current value * ``` + * + * @group Primitives */ -export function reactive(initialValue: T, options?: ReactiveOptions): WritableReactive; +export function reactive(initialValue: T, options?: ReactiveOptions): Reactive; export function reactive( initialValue?: T, options?: ReactiveOptions -): WritableReactive { +): Reactive { const impl = new WritableReactiveImpl(initialValue, options?.equal); return impl as AddWritableBrand; } /** - * Creates a new computed reactive value. + * Creates a new computed signal. * * The `compute` callback will be executed (and re-executed as necessary) to provide the current value. * The function body of `compute` is tracked automatically: any reactive values used by `compute` @@ -86,8 +94,10 @@ export function reactive( * foo.value = 2; * console.log(doubleFoo.value); // 4 * ``` + * + * @group Primitives */ -export function computed(compute: () => T, options?: ReactiveOptions): Reactive { +export function computed(compute: () => T, options?: ReactiveOptions): ReadonlyReactive { const impl = new ComputedReactiveImpl(compute, options?.equal); return impl as AddBrand; } @@ -121,6 +131,8 @@ export function computed(compute: () => T, options?: ReactiveOptions): Rea * * // later: unsubscribe from signal * ``` + * + * @group Primitives */ export function external(compute: () => T, options?: ReactiveOptions): ExternalReactive { /* @@ -139,12 +151,14 @@ export function external(compute: () => T, options?: ReactiveOptions): Ext But that function is a) internal and b) mangled (to `N` at the time of this writing) -- this is too risky. */ const invalidateSignal = rawSignal(false); + const invalidate = () => { + invalidateSignal.value = !invalidateSignal.peek(); + }; const externalReactive = computed(() => { invalidateSignal.value; return rawUntracked(() => compute()); }, options); - (externalReactive as RemoveBrand as ReactiveImpl).trigger = () => - (invalidateSignal.value = !invalidateSignal.peek()); + (externalReactive as RemoveBrand as ReactiveImpl).trigger = invalidate; return externalReactive as ExternalReactive; } @@ -164,18 +178,20 @@ export function external(compute: () => T, options?: ReactiveOptions): Ext * const r2 = reactive(2); * * // Log r1 and r2 every time they change. - * effect(() => { + * syncEffect(() => { * console.log(r1.value, r2.value); * }); * * // Trigger multiple updates at once. * batch(() => { - * // these two updates don't trigger the effect + * // these two updates don't trigger the effect yet * r1.value = 2; * r2.value = 3; * }); * // now the effect runs once * ``` + * + * @group Primitives */ export function batch(callback: () => T): T { return rawBatch(callback); @@ -188,18 +204,22 @@ export function batch(callback: () => T): T { * even if they occur inside a computed object or in an effect. * * `untracked` returns the value of `callback()`. + * + * @group Primitives */ export function untracked(callback: () => T): T { return rawUntracked(callback); } /** - * Returns the current `.value` of the given reactive object, or the input argument itself + * Returns the current `.value` of the given signal, or the input argument itself * if it is not reactive. * * The access to `.value` is tracked. + * + * @group Primitives */ -export function getValue(maybeReactive: Reactive | T) { +export function getValue(maybeReactive: ReadonlyReactive | T) { if (!isReactive(maybeReactive)) { return maybeReactive; } @@ -207,12 +227,14 @@ export function getValue(maybeReactive: Reactive | T) { } /** - * Returns the current `.value` of the given reactive object, or the input argument itself + * Returns the current `.value` of the given signal, or the input argument itself * if it is not reactive. * * The access to `.value` is _not_ tracked. + * + * @group Primitives */ -export function peekValue(maybeReactive: Reactive | T) { +export function peekValue(maybeReactive: ReadonlyReactive | T) { if (!isReactive(maybeReactive)) { return maybeReactive; } @@ -226,7 +248,7 @@ export function peekValue(maybeReactive: Reactive | T) { * * @group Primitives */ -export function isReactive(maybeReactive: Reactive | T): maybeReactive is Reactive { +export function isReactive(maybeReactive: ReadonlyReactive | T): maybeReactive is ReadonlyReactive { return maybeReactive instanceof ReactiveImpl; } @@ -239,7 +261,7 @@ export function isReactive(maybeReactive: Reactive | T): maybeReactive is */ export function isWritableReactive( maybeReactive: Reactive | T -): maybeReactive is WritableReactive { +): maybeReactive is Reactive { return maybeReactive instanceof WritableReactiveImpl; } @@ -247,7 +269,7 @@ const REACTIVE_SIGNAL = Symbol("signal"); const CUSTOM_EQUALS = Symbol("equals"); abstract class ReactiveImpl - implements RemoveBrand & WritableReactive & ExternalReactive> + implements RemoveBrand & Reactive & ExternalReactive> { private [REACTIVE_SIGNAL]: Signal; diff --git a/packages/reactivity-core/TaskQueue.ts b/packages/reactivity-core/TaskQueue.ts index 1aaaca7..e20714d 100644 --- a/packages/reactivity-core/TaskQueue.ts +++ b/packages/reactivity-core/TaskQueue.ts @@ -1,4 +1,4 @@ -import { CleanupHandle } from "./Reactive"; +import { CleanupHandle } from "./sync"; type TaskFn = () => void; diff --git a/packages/reactivity-core/async.test.ts b/packages/reactivity-core/async.test.ts index 22e431f..0e6ba5a 100644 --- a/packages/reactivity-core/async.test.ts +++ b/packages/reactivity-core/async.test.ts @@ -105,7 +105,7 @@ describe("watch", () => { }, { immediate: true } ); - expect(spy).toBeCalledTimes(0); // async + expect(spy).toBeCalledTimes(1); // sync await waitForMacroTask(); expect(spy).toBeCalledTimes(1); diff --git a/packages/reactivity-core/async.ts b/packages/reactivity-core/async.ts index 5c1b532..233e0a0 100644 --- a/packages/reactivity-core/async.ts +++ b/packages/reactivity-core/async.ts @@ -1,7 +1,7 @@ -import { CleanupHandle } from "./Reactive"; +import { CleanupHandle } from "./sync"; import { TaskQueue } from "./TaskQueue"; // eslint-disable-next-line @typescript-eslint/no-unused-vars -import { EffectFunc, syncEffectOnce, syncEffect, WatchOptions, syncWatch } from "./sync"; +import { EffectFunc, syncEffect, syncEffectOnce, syncWatch, WatchOptions } from "./sync"; /** * Runs the callback function and tracks its reactive dependencies. @@ -37,6 +37,8 @@ import { EffectFunc, syncEffectOnce, syncEffect, WatchOptions, syncWatch } from * > This is done to avoid redundant executions as a result of many fine-grained changes. * > * > If you need more control, take a look at {@link syncEffect}. + * + * @group Watching */ export function effect(callback: EffectFunc): CleanupHandle { let currentSyncEffect: CleanupHandle | undefined; @@ -133,6 +135,8 @@ export function effect(callback: EffectFunc): CleanupHandle { * > This is done to avoid redundant executions as a result of many fine-grained changes. * > * > If you need more control, take a look at {@link syncWatch}. + * + * @group Watching */ export function watch( selector: () => Values, @@ -141,10 +145,18 @@ export function watch( ): CleanupHandle { let currentValues: Values; let currentDispatch: CleanupHandle | undefined; + let initialSyncExecution = true; const watchHandle = syncWatch( selector, (values) => { currentValues = values; + + // If the user passed 'immediate: true', the initial execution is not deferred sync. + if (initialSyncExecution) { + callback(currentValues); + return; + } + if (currentDispatch) { return; } @@ -158,6 +170,7 @@ export function watch( }, options ); + initialSyncExecution = false; function destroy() { currentDispatch?.destroy(); diff --git a/packages/reactivity-core/collections/array.ts b/packages/reactivity-core/collections/array.ts index c32d378..f698bb7 100644 --- a/packages/reactivity-core/collections/array.ts +++ b/packages/reactivity-core/collections/array.ts @@ -1,10 +1,12 @@ -import { WritableReactive } from "../Reactive"; +import { Reactive } from "../Reactive"; import { reactive } from "../ReactiveImpl"; /** * Reactive array interface without modifying methods. * * See also {@link ReactiveArray}. + * + * @group Collections */ export interface ReadonlyReactiveArray extends Iterable { /** @@ -267,6 +269,8 @@ export interface ReadonlyReactiveArray extends Iterable { * Not all builtin array methods are implemented right now, but most of them are. * * Reads and writes to this array are reactive. + * + * @group Collections */ export interface ReactiveArray extends ReadonlyReactiveArray { /** @@ -331,13 +335,15 @@ export interface ReactiveArray extends ReadonlyReactiveArray { * // With initial content * const array2 = reactiveArray([1, 2, 3]); * ``` + * + * @group Collections */ export function reactiveArray(items?: Iterable): ReactiveArray { return new ReactiveArrayImpl(items); } class ReactiveArrayImpl implements ReactiveArray { - #items: WritableReactive[]; + #items: Reactive[]; #structureChanged = reactive(false); constructor(initial: Iterable | undefined) { @@ -382,7 +388,7 @@ class ReactiveArrayImpl implements ReactiveArray { /* eslint-disable @typescript-eslint/no-explicit-any */ splice(...args: any[]): T[] { const newItems: any[] | undefined = args[2]; - const removedCells: WritableReactive[] = (this.#items.splice as any)(...args); + const removedCells: Reactive[] = (this.#items.splice as any)(...args); if ((newItems != null && newItems.length !== 0) || removedCells.length !== 0) { this.#triggerStructuralChange(); } @@ -507,7 +513,7 @@ class ReactiveArrayImpl implements ReactiveArray { ): any { this.#subscribeToStructureChange(); return (this.#items.reduce as any)( - (previousValue: any, cell: WritableReactive, index: number) => { + (previousValue: any, cell: Reactive, index: number) => { return callback(previousValue, cell.value, index); }, ...args @@ -522,7 +528,7 @@ class ReactiveArrayImpl implements ReactiveArray { ): any { this.#subscribeToStructureChange(); return (this.#items.reduceRight as any)( - (previousValue: any, cell: WritableReactive, index: number) => { + (previousValue: any, cell: Reactive, index: number) => { return callback(previousValue, cell.value, index); }, ...args diff --git a/packages/reactivity-core/collections/map.ts b/packages/reactivity-core/collections/map.ts index e638be7..e0adbc3 100644 --- a/packages/reactivity-core/collections/map.ts +++ b/packages/reactivity-core/collections/map.ts @@ -1,4 +1,4 @@ -import { WritableReactive } from "../Reactive"; +import { Reactive } from "../Reactive"; import { reactive } from "../ReactiveImpl"; /** @@ -7,6 +7,8 @@ import { reactive } from "../ReactiveImpl"; * This map interface is designed ot be very similar to (but not exactly the same as) the standard JavaScript `Map`. * * Reads from and writes to this map are reactive. + * + * @group Collections */ export interface ReactiveMap extends Iterable<[key: K, value: V]> { /** @@ -65,6 +67,8 @@ export interface ReactiveMap extends Iterable<[key: K, value: V]> { * Reactive map interface without modifying methods. * * See also {@link ReactiveMap}. + * + * @group Collections */ export type ReadonlyReactiveMap = Omit, "set" | "delete" | "clear">; @@ -80,13 +84,15 @@ export type ReadonlyReactiveMap = Omit, "set" | "delete" * // With initial content * const map2 = reactiveMap([["foo", 1], ["bar", 2]]); * ``` + * + * @group Collections */ export function reactiveMap(initial?: Iterable<[K, V]> | undefined): ReactiveMap { return new ReactiveMapImpl(initial); } class ReactiveMapImpl implements ReactiveMap { - #map: Map> = new Map(); + #map: Map> = new Map(); #structureChanged = reactive(false); // toggled to notify about additions, removals constructor(initial: Iterable<[K, V]> | undefined) { diff --git a/packages/reactivity-core/collections/set.ts b/packages/reactivity-core/collections/set.ts index 1c4d32b..00b9ac2 100644 --- a/packages/reactivity-core/collections/set.ts +++ b/packages/reactivity-core/collections/set.ts @@ -6,6 +6,8 @@ import { ReactiveMap, reactiveMap } from "./map"; * This set interface is designed ot be very similar to (but not exactly the same as) the standard JavaScript `Set`. * * Reads from and writes to this set are reactive. + * + * @group Collections */ export interface ReactiveSet extends Iterable { /** @@ -56,8 +58,10 @@ export interface ReactiveSet extends Iterable { * Reactive set interface without modifying methods. * * See also {@link ReactiveSet}. + * + * @group Collections */ -export type ReadonlyReactiveSet = Omit, "set" | "delete" | "clear">; +export type ReadonlyReactiveSet = Omit, "add" | "delete" | "clear">; /** * Constructs a new {@link ReactiveMap} with the given initial content. @@ -71,6 +75,8 @@ export type ReadonlyReactiveSet = Omit, "set" | "delete" | "cl * // With initial content * const set2 = reactiveSet(["foo", "bar"]); * ``` + * + * @group Collections */ export function reactiveSet(initial?: Iterable | undefined): ReactiveSet { return new ReactiveSetImpl(initial); diff --git a/packages/reactivity-core/index.ts b/packages/reactivity-core/index.ts index 4c6d47b..f4d1851 100644 --- a/packages/reactivity-core/index.ts +++ b/packages/reactivity-core/index.ts @@ -1,8 +1,25 @@ +/** + * A framework agnostic library for building reactive applications. + * + * @module + * + * @groupDescription Primitives + * + * Primitive building blocks for reactive code. + * + * @groupDescription Watching + * + * Utilities to run code when reactive values change. + * + * @groupDescription Collections + * + * Reactive collections to simplify working with complex data structures. + */ + export { - type ExternalReactive, + type ReadonlyReactive, type Reactive, - type WritableReactive, - type CleanupHandle + type ExternalReactive, } from "./Reactive"; export { type EqualsFunc, @@ -21,6 +38,7 @@ export { type EffectCleanupFn, type EffectFunc, type WatchOptions, + type CleanupHandle, syncEffect, syncEffectOnce, syncWatch diff --git a/packages/reactivity-core/sync.ts b/packages/reactivity-core/sync.ts index 36a9b35..5f3fe7f 100644 --- a/packages/reactivity-core/sync.ts +++ b/packages/reactivity-core/sync.ts @@ -4,13 +4,27 @@ import { untracked } from "./ReactiveImpl"; // Import required for docs // eslint-disable-next-line @typescript-eslint/no-unused-vars import { effect, watch } from "./async"; -import { CleanupHandle } from "./Reactive"; + +/** + * A handle returned by various functions to dispose of a resource, + * such as a watcher or an effect. + * + * @group Watching + */ +export interface CleanupHandle { + /** + * Performs the cleanup action associated with the resource. + */ + destroy(): void; +} /** * A cleanup function returned from an effect. * * This function will be invoked before the effect is triggered again, * or when the effect is disposed. + * + * @group Watching */ export type EffectCleanupFn = () => void; @@ -19,6 +33,8 @@ export type EffectCleanupFn = () => void; * * Instructions in this function are tracked: when any of its reactive * dependencies change, the effect will be triggered again. + * + * @group Watching */ export type EffectFunc = (() => void) | (() => EffectCleanupFn); @@ -54,6 +70,8 @@ export type EffectFunc = (() => void) | (() => EffectCleanupFn); * // later: * handle.destroy(); * ``` + * + * @group Watching */ export function syncEffect(callback: EffectFunc): CleanupHandle { const destroy = rawEffect(callback); @@ -69,6 +87,8 @@ export function syncEffect(callback: EffectFunc): CleanupHandle { * Typically, `onInvalidate` will be very cheap (e.g. schedule a new render). * * Note that `onInvalidate` will never be invoked more than once. + * + * @group Watching */ export function syncEffectOnce(callback: EffectFunc, onInvalidate: () => void): CleanupHandle { let execution = 0; @@ -91,6 +111,8 @@ export function syncEffectOnce(callback: EffectFunc, onInvalidate: () => void): /** * Options that can be passed to {@link syncWatch}. + * + * @group Watching */ export interface WatchOptions { /** @@ -137,6 +159,8 @@ export interface WatchOptions { * ``` * * > NOTE: You must *not* modify the array that gets passed into `callback`. + * + * @group Watching */ export function syncWatch( selector: () => Values, diff --git a/packages/reactivity-core/typedoc.json b/packages/reactivity-core/typedoc.json new file mode 100644 index 0000000..0b66a58 --- /dev/null +++ b/packages/reactivity-core/typedoc.json @@ -0,0 +1,6 @@ +{ + // project-root is a dev dependency (link to the project root) + "extends": ["project-root/typedoc.base.json"], + "entryPoints": ["./index.ts"], + "groupOrder": ["Primitives", "Watching", "Collections", "*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 200584a..da9e907 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,8 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -packageExtensionsChecksum: b6cec3b3e5b13b2793181379b638e9c5 - importers: .: @@ -40,6 +38,9 @@ importers: tsx: specifier: ^4.7.2 version: 4.7.2 + typedoc: + specifier: ^0.25.13 + version: 0.25.13(typescript@5.4.4) typescript: specifier: ~5.4.4 version: 5.4.4 @@ -1074,6 +1075,10 @@ packages: engines: {node: '>=12'} dev: true + /ansi-sequence-parser@1.1.1: + resolution: {integrity: sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==} + dev: true + /ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -1806,12 +1811,22 @@ packages: yallist: 4.0.0 dev: true + /lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + dev: true + /magic-string@0.30.7: resolution: {integrity: sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==} engines: {node: '>=12'} dependencies: '@jridgewell/sourcemap-codec': 1.4.15 + /marked@4.3.0: + resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==} + engines: {node: '>= 12'} + hasBin: true + dev: true + /merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} dev: true @@ -2126,6 +2141,15 @@ packages: engines: {node: '>=8'} dev: true + /shiki@0.14.7: + resolution: {integrity: sha512-dNPAPrxSc87ua2sKJ3H5dQ/6ZaY8RNnaAqK+t0eG7p0Soi2ydiqbGOTaZCqaYvA/uZYfS1LJnemt3Q+mSfcPCg==} + dependencies: + ansi-sequence-parser: 1.1.1 + jsonc-parser: 3.2.1 + vscode-oniguruma: 1.7.0 + vscode-textmate: 8.0.0 + dev: true + /siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} dev: true @@ -2282,6 +2306,20 @@ packages: engines: {node: '>=10'} dev: true + /typedoc@0.25.13(typescript@5.4.4): + resolution: {integrity: sha512-pQqiwiJ+Z4pigfOnnysObszLiU3mVLWAExSPf+Mu06G/qsc3wzbuM56SZQvONhHLncLUhYzOVkjFFpFfL5AzhQ==} + engines: {node: '>= 16'} + hasBin: true + peerDependencies: + typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x + dependencies: + lunr: 2.3.9 + marked: 4.3.0 + minimatch: 9.0.3 + shiki: 0.14.7 + typescript: 5.4.4 + dev: true + /typescript@5.4.4: resolution: {integrity: sha512-dGE2Vv8cpVvw28v8HCPqyb08EzbBURxDpuhJvTrusShUfGnhHBafDsLdS1EhhxyL6BJQE+2cT3dDPAv+MQ6oLw==} engines: {node: '>=14.17'} @@ -2419,6 +2457,14 @@ packages: - terser dev: true + /vscode-oniguruma@1.7.0: + resolution: {integrity: sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==} + dev: true + + /vscode-textmate@8.0.0: + resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==} + dev: true + /vue-eslint-parser@9.4.2(eslint@8.57.0): resolution: {integrity: sha512-Ry9oiGmCAK91HrKMtCrKFWmSFWvYkpGglCeFAIqDdr9zdXmMMpJOmUJS7WWsW7fX81h6mwHmUZCQQ1E0PkSwYQ==} engines: {node: ^14.17.0 || >=16.0.0} diff --git a/typedoc.base.json b/typedoc.base.json new file mode 100644 index 0000000..17d88f9 --- /dev/null +++ b/typedoc.base.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "includeVersion": true, + "exclude": ["**/test-data/**", "**.test.*", "**/node_modules/**"], + "excludeExternals": true, + "excludePrivate": true, + "excludeInternal": true, + "skipErrorChecking": true +} diff --git a/typedoc.config.cjs b/typedoc.config.cjs new file mode 100644 index 0000000..3a741d1 --- /dev/null +++ b/typedoc.config.cjs @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) +// SPDX-License-Identifier: Apache-2.0 +// See https://typedoc.org/options/ +module.exports = { + name: "Reactive APIs", + readme: "none", + out: "dist/docs", + entryPointStrategy: "packages", + entryPoints: ["./packages/reactivity-core"], + skipErrorChecking: true, + validation: { + notExported: false, + invalidLink: true, + notDocumented: true + } +};