Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New Util: ReactiveArray #9

Merged
merged 6 commits into from
Apr 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading