Skip to content

Commit

Permalink
New Util: ReactiveArray (#9)
Browse files Browse the repository at this point in the history
* Copy code from tracked-built-ins

* Update README

* Add footnote

* Noice

* yay

* More reactivity tests
  • Loading branch information
NullVoxPopuli authored Apr 1, 2024
1 parent debb902 commit c07b8c0
Show file tree
Hide file tree
Showing 5 changed files with 679 additions and 18 deletions.
45 changes: 36 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@ Utils for the [Signal's Proposal](https://github.com/proposal-signals/proposal-s

## APIs

> [!NOTE]
> All examples either use JavaScript or a mixed-language psuedocode[^syntax-based-off] to convey the reactive intention of using Signals.
> These utilities can be used in any framework that wires up Signals to their rendering implementation.
[^syntax-based-off]: The syntax is based of a mix of [Glimmer-flavored Javascript](https://tutorial.glimdown.com) and [Svelte](https://svelte.dev/). The main thing being focused around JavaScript without having a custom file format. The `<template>...</template>` blocks may as well be HTML, and `{{ }}` escapes out to JS. I don't have a strong preference on `{{ }}` vs `{ }`, the important thing is only to be consistent within an ecosystem.

### `@signal`

A utility decorator for easily creating signals

```ts
```jsx
import { signal } from 'signal-utils';

class State {
Expand All @@ -23,17 +29,34 @@ class State {

let state = new State();

state.doubled // 6
state.increment()
state.doubled // 8

// output: 6
// button clicked
// output: 8
<template>
<output>{{state.doubled}}</output>
<button onclick={{state.increment}}>+</button>
</template>
```

### `Array`

wip

A reactive Array

```jsx
import { ReactiveArray } from 'signal-utils/array';

let arr = new ReactiveArray([1, 2, 3]);

// output: 3
// button clicked
// output: 2
<template>
<output>{{arr.at(-1)}}</output>
<button onclick={{() => arr.pop()}}>pop</button>
</template>
```

### `Object`

A reactive Object
Expand All @@ -47,9 +70,13 @@ let obj = new ReactiveObject({
result: null,
});

obj.isLoading // true
obj.isLoading = false
obj.isLoading // false
// output: true
// button clicked
// output: false
<template>
<output>{{obj.isLoading}}</output>
<button onclick={{() => obj.isLoading = false}}>finish</button>
</template>
```

In this example, we could use a reactive object for quickly and dynamically creating an object of signals -- useful for when we don't know all the keys boforehand, or if we want a shorthand to creating many named signals.
Expand Down
15 changes: 15 additions & 0 deletions src/-private/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Signal } from "signal-polyfill";

/**
* equality check here is always false so that we can dirty the storage
* via setting to _anything_
*
*
* This is for a pattern where we don't *directly* use signals to back the values used in collections
* so that instanceof checks and getters and other native features "just work" without having
* to do nested proxying.
*
* (though, see deep.ts for nested / deep behavior)
*/
export const createStorage = () =>
new Signal.State(null, { equals: () => false });
219 changes: 216 additions & 3 deletions src/array.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,218 @@
export class Array {
constructor() {
throw new Error("Not implemented");
/* eslint-disable @typescript-eslint/no-explicit-any */
// Unfortunately, TypeScript's ability to do inference *or* type-checking in a
// `Proxy`'s body is very limited, so we have to use a number of casts `as any`
// to make the internal accesses work. The type safety of these is guaranteed at
// the *call site* instead of within the body: you cannot do `Array.blah` in TS,
// and it will blow up in JS in exactly the same way, so it is safe to assume
// that properties within the getter have the correct type in TS.

import { Signal } from "signal-polyfill";
import { createStorage } from "./-private/util.ts";

const ARRAY_GETTER_METHODS = new Set<string | symbol | number>([
Symbol.iterator,
"concat",
"entries",
"every",
"filter",
"find",
"findIndex",
"flat",
"flatMap",
"forEach",
"includes",
"indexOf",
"join",
"keys",
"lastIndexOf",
"map",
"reduce",
"reduceRight",
"slice",
"some",
"values",
]);

// For these methods, `Array` itself immediately gets the `.length` to return
// after invoking them.
const ARRAY_WRITE_THEN_READ_METHODS = new Set<string | symbol>([
"fill",
"push",
"unshift",
]);

function convertToInt(prop: number | string | symbol): number | null {
if (typeof prop === "symbol") return null;

const num = Number(prop);

if (isNaN(num)) return null;

return num % 1 === 0 ? num : null;
}

class ReactiveArrayImpl<T = unknown> {
/**
* Creates an array from an iterable object.
* @param iterable An iterable object to convert to an array.
*/
static from<T>(iterable: Iterable<T> | ArrayLike<T>): ReactiveArrayImpl<T>;

/**
* Creates an array from an iterable object.
* @param iterable An iterable object to convert to an array.
* @param mapfn A mapping function to call on every element of the array.
* @param thisArg Value of 'this' used to invoke the mapfn.
*/
static from<T, U>(
iterable: Iterable<T> | ArrayLike<T>,
mapfn: (v: T, k: number) => U,
thisArg?: unknown,
): ReactiveArrayImpl<U>;

static from<T, U>(
iterable: Iterable<T> | ArrayLike<T>,
mapfn?: (v: T, k: number) => U,
thisArg?: unknown,
): ReactiveArrayImpl<T> | ReactiveArrayImpl<U> {
return mapfn
? new ReactiveArrayImpl(Array.from(iterable, mapfn, thisArg))
: new ReactiveArrayImpl(Array.from(iterable));
}

static of<T>(...arr: T[]): ReactiveArrayImpl<T> {
return new ReactiveArrayImpl(arr);
}

constructor(arr: T[] = []) {
let clone = arr.slice();
// eslint-disable-next-line @typescript-eslint/no-this-alias
let self = this;

let boundFns = new Map<string | symbol, (...args: any[]) => any>();

/**
Flag to track whether we have *just* intercepted a call to `.push()` or
`.unshift()`, since in those cases (and only those cases!) the `Array`
itself checks `.length` to return from the function call.
*/
let nativelyAccessingLengthFromPushOrUnshift = false;

return new Proxy(clone, {
get(target, prop /*, _receiver */) {
let index = convertToInt(prop);

if (index !== null) {
self.#readStorageFor(index);
self.#collection.get();

return target[index];
}

if (prop === "length") {
// If we are reading `.length`, it may be a normal user-triggered
// read, or it may be a read triggered by Array itself. In the latter
// case, it is because we have just done `.push()` or `.unshift()`; in
// that case it is safe not to mark this as a *read* operation, since
// calling `.push()` or `.unshift()` cannot otherwise be part of a
// "read" operation safely, and if done during an *existing* read
// (e.g. if the user has already checked `.length` *prior* to this),
// that will still trigger the mutation-after-consumption assertion.
if (nativelyAccessingLengthFromPushOrUnshift) {
nativelyAccessingLengthFromPushOrUnshift = false;
} else {
self.#collection.get();
}

return target[prop];
}

// Here, track that we are doing a `.push()` or `.unshift()` by setting
// the flag to `true` so that when the `.length` is read by `Array` (see
// immediately above), it knows not to dirty the collection.
if (ARRAY_WRITE_THEN_READ_METHODS.has(prop)) {
nativelyAccessingLengthFromPushOrUnshift = true;
}

if (ARRAY_GETTER_METHODS.has(prop)) {
let fn = boundFns.get(prop);

if (fn === undefined) {
fn = (...args) => {
self.#collection.get();
return (target as any)[prop](...args);
};

boundFns.set(prop, fn);
}

return fn;
}

return (target as any)[prop];
},

set(target, prop, value /*, _receiver */) {
(target as any)[prop] = value;

let index = convertToInt(prop);

if (index !== null) {
self.#dirtyStorageFor(index);
self.#collection.set(null);
} else if (prop === "length") {
self.#collection.set(null);
}

return true;
},

getPrototypeOf() {
return ReactiveArrayImpl.prototype;
},
}) as ReactiveArrayImpl<T>;
}

#collection = createStorage();

#storages = new Map<PropertyKey, Signal.State<null>>();

#readStorageFor(index: number) {
let storage = this.#storages.get(index);

if (storage === undefined) {
storage = createStorage();
this.#storages.set(index, storage);
}

storage.get();
}

#dirtyStorageFor(index: number): void {
const storage = this.#storages.get(index);

if (storage) {
storage.set(null);
}
}
}

// This rule is correct in the general case, but it doesn't understand
// declaration merging, which is how we're using the interface here. This says
// `ReactiveArray` acts just like `Array<T>`, but also has the properties
// declared via the `class` declaration above -- but without the cost of a
// subclass, which is much slower that the proxied array behavior. That is: a
// `ReactiveArray` *is* an `Array`, just with a proxy in front of accessors and
// setters, rather than a subclass of an `Array` which would be de-optimized by
// the browsers.
//
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface ReactiveArrayImpl<T = unknown> extends Array<T> {}

type ReactiveArray = ReactiveArrayImpl;

export const ReactiveArray: ReactiveArray =
ReactiveArrayImpl as unknown as ReactiveArray;

// Ensure instanceof works correctly
Object.setPrototypeOf(ReactiveArrayImpl.prototype, Array.prototype);
7 changes: 1 addition & 6 deletions src/object.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import { Signal } from "signal-polyfill";

/**
* equality check here is always false so that we can dirty the storage
* via setting to _anything_
*/
const createStorage = () => new Signal.State(null, { equals: () => false });
import { createStorage } from "./-private/util.ts";

/**
* Implementation based of tracked-built-ins' TrackedObject
Expand Down
Loading

0 comments on commit c07b8c0

Please sign in to comment.