Skip to content

Commit

Permalink
Implement localCopy() for use outside of classes (#29)
Browse files Browse the repository at this point in the history
* Support localCopy outside classes

* Types
  • Loading branch information
NullVoxPopuli authored Apr 7, 2024
1 parent a42bbe0 commit a788615
Show file tree
Hide file tree
Showing 3 changed files with 177 additions and 0 deletions.
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Utils for the [Signal's Proposal](https://github.com/proposal-signals/proposal-s
- general utilities
- [Promise](#promise)
- [async function](#async-function)
- [localCopy](#localcopy-function)
- class utilities
- [@signal](#signal)
- [@cached](#cached)
Expand Down Expand Up @@ -120,6 +121,26 @@ class Demo {

In this demo, the localValue can fork from the remote value, but the `localValue` property will re-set to the remote value if it changes.

#### `localCopy` function

```js
import { Signal } from 'signal-polyfill';
import { localCopy } from 'signal-utils/local-copy';

const remote = new Signal.State(3);

const local = localCopy(() => remote.get());
const updateLocal = (inputEvent) => local.set(inputEvent.target.value);

// A controlled input
<template>
<label>
Edit Name:
<input value={{local.get()}} oninput={{updateLocal}} />
</label>
</template>
```

Live, interactive demos of this concept:
- [Ember](https://limber.glimdown.com/edit?c=MQAgYg9gTg1glgOwOYgMoBcCG6CmIDuc6AFiAEo4C2EuIAqgA4Am2OAzgFAfHroNsAuAPRD0hXjigA6AMYRKQgPpQ2M4lDht0cTAiFbsAVzZCAjAHZzABgAcNgGwP7ATgDMAVlfnXzgCw2uAANgpAArNhAAGzgANxwOOEoGaHQQAG8oiBlMSIBhCAYATwBfEAAzKHkQAHJ0KEwZGBwmAFp0CAhIgCMIAA9qgG4EpJSQfJGEHARUiqrqgAEkaMpKSSE5Can0QeHkqFSMuoamphBS2coaxeXVqFF6xsQkHcS9g5AIBDPyysuFqi6a2oTDgZTgkh2HBkkUwbAiABEqBAQDhergEEwIuNkpNpukOCAQPNIlkcvkigAKaqYKBINhSGI5Qw4aoASky2UiADUmTghgSQIZmKwADKk7m8kAAXhAAsJFMQDEM6AAonFpuypQA%2BEAkTRSEmcnmRZnSkCK5VqrZSLC0nDoBm8-mE4i6JiRHCoQxdShEM1ykAUtje32q9XoTU6tIBwnBn1EK3TKQMKA4cOIsqYQyRdAU1lDQmF3XEfU0ulST5e%2BO5jKMk04ATF-WGnLG03FfMB4pcQkiEAAQRAcmmlUiHtOFvQAoAPLgkjDcFqA9OytBLmk0p8anHQ9Um-TXRiPVXQ8Vikui4TpzDAZEL5fCSqQakAHKYVaNwkxwvTycgOvMlKG56vSLYSvWZ7pJuXzVJOe4gVIQosLgYpGrykFCPeRbTkIN44HePaXtOXTKu0CBaieRA4SRvCfFhIA4auUCUEuy4pjgWpgW2DYPpewElqB4rcWexFQFqqbULQ8wATxG7SehxQ4exrHYVohQevRhJ-hkPRQEwkiNqYDC9CAbCdHATADGcy76Og6kcTOohUAwC4Od2HCom8ID6Zm2apNCsIRP2DAMCiaJTJiYzyDiWz4oS8xHI0zT-pKMoAEQALINHgADqiBMIYaUFkSiUnN52CYM6LpuseIZ%2BjKFLIZgkb7lITVmk1xVDoeSA4G%2Bqxmnm0o6ghMkgAA1DK1QAISQlec4uaw9HToi1BEjJQFpKNClEpWdXoJtCGHu6nr7RhKk-uoF2ErkWZsHgqK4FACA5N1ui9YGlCYIUgKmYk2bYE8ICYCAqYVOwpAQGUoNIrQTWsgIy40WRUFbtU0JwI08ECbIPV9e%2BOBnlqt3GHgKrhc9r25Hj1GkXRhFXldAYirCqSURITACKJy3KRuABSqAAPIvlIWgaMgoKFK17UINmkQgK4IlCMpjkLa5S7dsEgQcEAA&format=glimdown)
- [Preact](https://preactjs.com/repl?code=aW1wb3J0IHsgcmVuZGVyIH0gZnJvbSAncHJlYWN0JzsKaW1wb3J0IHsgdXNlUmVmLCB1c2VFZmZlY3QgfSBmcm9tICdwcmVhY3QvaG9va3MnOwppbXBvcnQgeyBzaWduYWwsIGVmZmVjdCwgdXNlU2lnbmFsIH0gZnJvbSAnQHByZWFjdC9zaWduYWxzJzsKaW1wb3J0IHsgaHRtbCB9IGZyb20gJ2h0bS9wcmVhY3QnOwoKZnVuY3Rpb24gdXNlTG9jYWxDb3B5KHJlbW90ZSkgewoJY29uc3QgbG9jYWwgPSB1c2VSZWYoKTsKCWlmICghbG9jYWwuY3VycmVudCkgewoJCWxvY2FsLmN1cnJlbnQgPSBzaWduYWwocmVtb3RlLnBlZWsoKSk7Cgl9CgoJdXNlRWZmZWN0KCgpID0%2BIHsKCSAgLy8gU3luY2hyb25vdXNseSB1cGRhdGUgdGhlIGxvY2FsIGNvcHkgd2hlbiByZW1vdGUgY2hhbmdlcy4KCSAgLy8gQ29yZSBlZmZlY3RzIGFyZSBqdXN0IGEgd2F5IHRvIGhhdmUgc3luY2hyb25vdXMgY2FsbGJhY2tzCgkgIC8vIHJlYWN0IHRvIHNpZ25hbCBjaGFuZ2VzIGluIGEgcHJldHR5IGVmZmljaWVudCB3YXkuCgkJcmV0dXJuIGVmZmVjdCgoKSA9PiB7CgkJCWxvY2FsLmN1cnJlbnQudmFsdWUgPSByZW1vdGUudmFsdWU7CgkJfSk7Cgl9LCBbcmVtb3RlXSk7CgoJcmV0dXJuIGxvY2FsLmN1cnJlbnQ7Cn0KCmZ1bmN0aW9uIERlbW8oeyBuYW1lLCBvblN1Ym1pdCB9KSB7CgkJY29uc3QgbG9jYWxOYW1lID0gdXNlTG9jYWxDb3B5KG5hbWUpOwoKICAgIGNvbnN0IHVwZGF0ZUxvY2FsTmFtZSA9IChpbnB1dEV2ZW50KSA9PiBsb2NhbE5hbWUudmFsdWUgPSBpbnB1dEV2ZW50LnRhcmdldC52YWx1ZTsKCiAgICBjb25zdCBoYW5kbGVTdWJtaXQgPSAoc3VibWl0RXZlbnQpID0%2BIHsKICAgICAgICBzdWJtaXRFdmVudC5wcmV2ZW50RGVmYXVsdCgpOwogICAgICAgIG9uU3VibWl0KHsgdmFsdWU6IGxvY2FsTmFtZS52YWx1ZSB9KTsKICAgIH0KCiAgICByZXR1cm4gaHRtbGAKICAgICAgICA8Zm9ybSBvblN1Ym1pdD0ke2hhbmRsZVN1Ym1pdH0%2BCiAgICAgICAgICAgIDxsYWJlbD4KICAgICAgICAgICAgICAgIEVkaXQgTmFtZTogICAKICAgICAgICAgICAgICAgIDxpbnB1dCB2YWx1ZT0ke2xvY2FsTmFtZS52YWx1ZX0gb25JbnB1dD0ke3VwZGF0ZUxvY2FsTmFtZX0gLz4KICAgICAgICAgICAgPC9sYWJlbD4KCiAgICAgICAgICAgIDxidXR0b24%2BU3VibWl0PC9idXR0b24%2BCiAgICAgICAgPC9mb3JtPgoKICAgICAgICA8cHJlPmxvY2FsVmFsdWU6ICR7bG9jYWxOYW1lfTxiciAvPnBhcmVudCB2YWx1ZTogJHtuYW1lfTwvcHJlPmA7Cn0KCmV4cG9ydCBmdW5jdGlvbiBBcHAoKSB7CiAgICBjb25zdCBuYW1lID0gdXNlU2lnbmFsKCdNYWNlIFdpbmR1Jyk7CiAgICBjb25zdCBkYXRhID0gdXNlU2lnbmFsKCcnKTsKCiAgICBjb25zdCBoYW5kbGVTdWJtaXQgPSAoZCkgPT4gZGF0YS52YWx1ZSA9IGQ7CiAgICBjb25zdCBjaGFuZ2VOYW1lID0gKCkgPT4gbmFtZS52YWx1ZSArPSAnISc7CgogICAgcmV0dXJuIGh0bWxgCiAgICAgICAgPCR7RGVtb30gbmFtZT0ke25hbWV9IG9uU3VibWl0PSR7aGFuZGxlU3VibWl0fSAvPgoKICAgICAgICA8aHIgLz4KCiAgICAgICAgQ2F1c2UgZXh0ZXJuYWwgY2hhbmdlIChtYXliZSBzaW11bGF0aW5nIGEgcmVmcmVzaCBvZiByZW1vdGUgZGF0YSk6CiAgICAgICAgPGJ1dHRvbiBvbkNsaWNrPSR7Y2hhbmdlTmFtZX0%2BQ2F1c2UgRXh0ZXJuYWwgQ2hhbmdlPC9idXR0b24%2BCgogICAgICAgIDxociAvPgogICAgICAgIExhc3QgU3VibWl0dGVkOjxiciAvPgogICAgICAgIDxwcmU%2BJHtKU09OLnN0cmluZ2lmeShkYXRhLnZhbHVlLCBudWxsLCAzKX08L3ByZT5gOwp9CgpyZW5kZXIoPEFwcCAvPiwgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2FwcCcpKTsK)
Expand Down
93 changes: 93 additions & 0 deletions src/local-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,104 @@ function get(obj: any, path: string) {
}

/**
* Forks remote state for local modification
*
* ```js
* import { Signal } from 'signal-polyfill';
* import { localCopy } from 'signal-utils';
*
* const remote = new Signal.State(0);
*
* const local = localCopy(() => remote.get());
* ```
*/
export function localCopy<Value = any>(
fn: () => Value,
): { get(): Value; set(v: Value): void };

/**
* Forks remote state for local modification
*
* ```js
* import { localCopy } from 'signal-utils';
*
* class Demo {
* @localCopy('remote.value') accessor localValue;
* }
* ```
*/
export function localCopy<Value = any, This extends WeakKey = WeakKey>(
memo: string,
initializer?: Value | (() => Value),
): (
_target: ClassAccessorDecoratorTarget<This, Value>,
_context: ClassAccessorDecoratorContext<This, Value>,
) => ClassAccessorDecoratorResult<This, Value>;

/**
* Forks remote state for local modification
*
* ```js
* import { localCopy } from 'signal-utils';
*
* class Demo {
* @localCopy('remote.value') accessor localValue;
* }
* ```
*/
export function localCopy<Value = any, This extends WeakKey = WeakKey>(
...args: any[]
) {
if (typeof args[0] === "function") {
return localCopyFn<Value, This>(args[0]);
}

let [first, second] = args;

return localCopyDecorator<Value, This>(
first,
second as undefined | Value | (() => Value),
);
}

function localCopyFn<Value = any, This extends WeakKey = WeakKey>(
memoFn: () => Value,
) {
let metas = new WeakMap<WeakKey, Meta<Value>>();

return {
get(this: This): Value {
let meta = getOrCreateMeta<Value>(this, metas);
let { prevRemote } = meta;

let incomingValue = memoFn();

if (prevRemote !== incomingValue) {
// If the incoming value is not the same as the previous incoming value,
// update the local value to match the new incoming value, and update
// the previous incoming value.
meta.value = meta.prevRemote = incomingValue;
}

return meta.value as Value;
},

set(this: WeakKey, value: Value) {
if (!metas.has(this)) {
let meta = getOrCreateMeta(this, metas);
meta.prevRemote = memoFn();
meta.value = value;
return;
}

getOrCreateMeta(this, metas).value = value;
},
};
}

function localCopyDecorator<Value = any, This extends WeakKey = WeakKey>(
memo: string,
initializer: undefined | Value | (() => Value),
) {
if (typeof memo !== "string") {
throw new Error(
Expand Down
63 changes: 63 additions & 0 deletions tests/local-copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, test, assert } from "vitest";
import { localCopy } from "../src/local-copy.ts";
import { Signal } from "signal-polyfill";
import { assertReactivelySettled, assertStable } from "./helpers.ts";

describe("localCopy()", () => {
test("it works as a Signal", () => {
let remote = new Signal.State(123);

let local = localCopy(() => remote.get());

assert.strictEqual(local.get(), 123, "defaults to the remote value");

assertReactivelySettled({
access: () => local.get(),
change: () => local.set(456),
});

assert.strictEqual(local.get(), 456, "local value updates correctly");
assert.strictEqual(remote.get(), 123, "remote value does not update");

remote.set(789);

assert.strictEqual(
local.get(),
789,
"local value updates to new remote value",
);
assert.strictEqual(remote.get(), 789, "remote value is updated");

assertStable(() => local.get());
});

test("it works as a Signal in a class", () => {
class State {
remote = new Signal.State(123);
local = localCopy(() => this.remote.get());
}

let state = new State();

assert.strictEqual(state.local.get(), 123, "defaults to the remote value");

assertReactivelySettled({
access: () => state.local.get(),
change: () => state.local.set(456),
});

assert.strictEqual(state.local.get(), 456, "local value updates correctly");
assert.strictEqual(state.remote.get(), 123, "remote value does not update");

state.remote.set(789);

assert.strictEqual(
state.local.get(),
789,
"local value updates to new remote value",
);
assert.strictEqual(state.remote.get(), 789, "remote value is updated");

assertStable(() => state.local.get());
});
});

0 comments on commit a788615

Please sign in to comment.