diff --git a/packages/agera/.size-limit.json b/packages/agera/.size-limit.json index a3dcafb8..acb00000 100644 --- a/packages/agera/.size-limit.json +++ b/packages/agera/.size-limit.json @@ -23,7 +23,7 @@ "name": "Signal (Brotli)", "path": "dist/index.js", "import": "{ signal }", - "limit": "1.4 kB" + "limit": "1.35 kB" }, { "name": "Minimal set (Gzip)", diff --git a/packages/agera/README.md b/packages/agera/README.md index 2721c00c..f29894eb 100644 --- a/packages/agera/README.md +++ b/packages/agera/README.md @@ -293,28 +293,6 @@ $a(1) $b(2) ``` -### Morph - -`morph` methods allows to create signals that can change their getter and setter on the fly. - -```ts -import { signal, morph } from 'agera' - -const $string = signal('') -// Debounce signal updates -const $debouncedString = morph($string, { - set: debounce($string, 300) -}) -// Lazy initialization -const $lazyString = morph($string, { - get() { - this.set('Lazy string') - this.get = this.source - return 'Lazy string' - } -}) -``` - ### `isSignal` `isSignal` method checks if the value is a signal. diff --git a/packages/agera/src/index.ts b/packages/agera/src/index.ts index b16de9e9..e440e485 100644 --- a/packages/agera/src/index.ts +++ b/packages/agera/src/index.ts @@ -1,9 +1,15 @@ export type * from './internals/types.js' -export { ExternalModesBase } from './internals/flags.js' +export { + NoneFlag, + WritableMode, + ExternalModesBase +} from './internals/flags.js' export { untracked, trigger, - onSignal + onSignal, + createSignal, + computedOper } from './internals/system.js' export * from './signal.js' export * from './modes.js' diff --git a/packages/agera/src/internals/system.ts b/packages/agera/src/internals/system.ts index 7ec1bba2..631fc156 100644 --- a/packages/agera/src/internals/system.ts +++ b/packages/agera/src/internals/system.ts @@ -14,7 +14,6 @@ import type { Destroy, Compute, NewValue, - Morph, DeferredScope } from './types.js' import { @@ -431,12 +430,20 @@ export function onSignal(callback: ($signal: AnySignal) => void) { : callback } -export function createSignal( - constructor: (value?: unknown) => unknown, - node: ComputedNode | SignalNode, - ctx: ComputedNode | SignalNode | Morph = node +/** + * Create a signal function over a reactive node. The node is the operator's + * `this`, so whatever a call site needs at read or write time lives on the + * node and a signal costs one object and one bound function, nothing else. + * @private + * @param constructor - The operator: called with no arguments to read, with one to write. + * @param node - The node the signal is a face of. + * @returns A signal. + */ +export function createSignal( + constructor: (this: N, ...value: any[]) => unknown, + node: N ) { - const $signal = constructor.bind(ctx) as AnySignal + const $signal = constructor.bind(node) as AnySignal $signal.node = node @@ -713,7 +720,14 @@ function flush(): void { } } -function computedOper(this: ComputedNode): T { +/** + * The read operator of a computed node. Exported so that a call site can put + * its own operator in front of one: a writable computed is this one with a + * write branch before it. + * @private + * @returns The current value. + */ +export function computedOper(this: ComputedNode): T { const flags = this.flags if ( diff --git a/packages/agera/src/internals/types.ts b/packages/agera/src/internals/types.ts index b134cbe9..9b09ee07 100644 --- a/packages/agera/src/internals/types.ts +++ b/packages/agera/src/internals/types.ts @@ -66,12 +66,6 @@ export interface ReadableSignal extends Accessor { node: ReadableNode } -export interface Morph { - source: WritableSignal - get(): T - set(value: NewValue): void -} - export interface WritableSignal extends ReadableSignal { node: SignalNode (value: NewValue): void diff --git a/packages/agera/src/signal.spec.ts b/packages/agera/src/signal.spec.ts index 110f8161..8d469465 100644 --- a/packages/agera/src/signal.spec.ts +++ b/packages/agera/src/signal.spec.ts @@ -5,11 +5,12 @@ import { expect } from 'vitest' import { + type SignalNode, computed, signal, effect, isSignal, - morph, + createSignal, trigger } from './index.js' @@ -120,12 +121,32 @@ describe('agera', () => { }) }) - describe('morph', () => { - it('should pass isSignal check', () => { - const $num = signal(0) - const $morph = morph($num, {}) + describe('createSignal', () => { + it('should create a second face over the same node', () => { + const $num = signal(1) + const node = $num.node as SignalNode & { + get(): number + set(value: number): void + } + + node.get = () => $num() * 2 + node.set = value => $num(value / 2) + + const $double = createSignal(function doubleOper(this: typeof node, ...value: [number]) { + if (value.length) { + this.set(value[0]) + } else { + return this.get() + } + }, node) + + expect(isSignal($double)).toBe(true) + expect($double.node).toBe($num.node) + expect($double()).toBe(2) + + $double(10) - expect(isSignal($morph)).toBe(true) + expect($num()).toBe(5) }) }) diff --git a/packages/agera/src/signal.ts b/packages/agera/src/signal.ts index 7381578a..1a4072f4 100644 --- a/packages/agera/src/signal.ts +++ b/packages/agera/src/signal.ts @@ -1,20 +1,14 @@ import type { AnySignal, Destroy, - Morph, Mountable, MountedListener, - NewValue, - ReadableNode, - ReadableSignal, - SignalNode, - WritableSignal + ReadableNode } from './internals/types.js' import { signal, computed, batch, - createSignal, nextValue, signalNextValue, touchLifecycle, @@ -87,35 +81,3 @@ export function onMounted( export function isMounted($signal: AnySignal): boolean { return ($signal.node as ReadableNode).lcd === true } - -export function morph>>( - $signal: WritableSignal, - context: C -): WritableSignal - -export function morph>>( - $signal: ReadableSignal, - context: C -): ReadableSignal - -/* @__NO_SIDE_EFFECTS__ */ -export function morph>>( - $signal: ReadableSignal | WritableSignal, - context: C -) { - const morph = context as unknown as Morph - - morph.source ??= $signal as WritableSignal - morph.get ??= $signal - morph.set ??= $signal as WritableSignal - - return createSignal(morphOper, $signal.node as SignalNode, morph) -} - -function morphOper(this: Morph, ...value: [NewValue]): T | void { - if (value.length) { - this.set(value[0]) - } else { - return this.get() - } -} diff --git a/packages/kida/.size-limit.json b/packages/kida/.size-limit.json index 2de518f8..1bb0f77f 100644 --- a/packages/kida/.size-limit.json +++ b/packages/kida/.size-limit.json @@ -10,7 +10,7 @@ "name": "All publics (Brotli)", "path": "dist/index.js", "import": "*", - "limit": "4.2 kB" + "limit": "4.25 kB" }, { "name": "Signal (Gzip)", @@ -23,19 +23,19 @@ "name": "Signal (Brotli)", "path": "dist/index.js", "import": "{ signal }", - "limit": "1.4 kB" + "limit": "1.35 kB" }, { "name": "Popular set (Gzip)", "gzip": true, "path": "dist/index.js", "import": "{ signal, record, computed, effect, mountable, onMount }", - "limit": "2.45 kB" + "limit": "2.4 kB" }, { "name": "Popular set (Brotli)", "path": "dist/index.js", "import": "{ signal, record, computed, effect, mountable, onMount }", - "limit": "2.3 kB" + "limit": "2.25 kB" } ] diff --git a/packages/kida/src/internals/child.ts b/packages/kida/src/internals/child.ts index 9def92c0..7fd5d9be 100644 --- a/packages/kida/src/internals/child.ts +++ b/packages/kida/src/internals/child.ts @@ -1,18 +1,55 @@ import { type WritableSignal, type ReadableSignal, + type ComputedNode, type Accessor, type NewValue, - computed, - morph, + NoneFlag, + WritableMode, + computedOper, + createSignal, isWritable, - unsafeMarkWritable, nextValue, untracked } from 'agera' import type { AnyObject } from './types.js' import { $get } from './utils.js' +// A child is one node and one bound function: the parent, the key and the +// writer live on the node the operators are bound to, so nothing here +// allocates a closure per child +interface ChildNode extends ComputedNode { + /** + * Parent signal. + */ + p: WritableSignal + /** + * Key to read from the parent, static or reactive. + */ + k: PropertyKey | Accessor + /** + * Writes the key back into a copy of the parent value. + */ + sv(parentValue: AnyObject, key: PropertyKey, value: unknown): AnyObject +} + +function childCompute(this: ChildNode) { + return this.p()?.[$get(this.k)] +} + +function childOper(this: ChildNode, ...value: [NewValue]) { + if (value.length) { + untracked(() => { + const parent = this.p() + const key = $get(this.k) + + this.p(this.sv(parent, key, nextValue(parent[key], value[0]))) + }) + } else { + return computedOper.call(this) + } +} + /** * Create a writable child signal from a parent signal. * @param $parent - Parent signal. @@ -57,31 +94,22 @@ export function child< key: K | Accessor, setValue?: (parentValue: P, key: K, value: V) => P ) { - const getter = computed(() => { - const parent = $parent() - - return parent?.[$get(key)] - }) - - if (!isWritable($parent)) { - return getter - } - - const setter = (value: NewValue) => untracked(() => { - const parent = $parent() - const k = $get(key) - - $parent(setValue!( - parent, - k, - nextValue(parent[k], value) - )) - }) - - unsafeMarkWritable(getter) + const writable = isWritable($parent) - return morph(getter, { - get: getter, - set: setter - }) + // The mode is set in the literal rather than after the fact: the `onSignal` + // hook fires from inside `createSignal`, and it is what attaches `set` in + // the framework adapters + return createSignal(writable ? childOper : computedOper, { + value: undefined, + subs: undefined, + subsTail: undefined, + deps: undefined, + depsTail: undefined, + flags: NoneFlag, + modes: writable ? WritableMode : NoneFlag, + compute: childCompute, + p: $parent, + k: key, + sv: setValue + } as unknown as ChildNode) } diff --git a/packages/nanoviews/.size-limit.json b/packages/nanoviews/.size-limit.json index b72bfdca..b845a637 100644 --- a/packages/nanoviews/.size-limit.json +++ b/packages/nanoviews/.size-limit.json @@ -23,6 +23,6 @@ "name": "Average usage (Brotli)", "path": "dist/index.js", "import": "{ fragment, div, form, input, button, label, classList$, if_, for_, value$, $$children, effect }", - "limit": "4.05 kB" + "limit": "4 kB" } ] diff --git a/packages/store/.size-limit.json b/packages/store/.size-limit.json index 96744dbf..cbd83a91 100644 --- a/packages/store/.size-limit.json +++ b/packages/store/.size-limit.json @@ -4,13 +4,13 @@ "gzip": true, "path": "dist/index.js", "import": "*", - "limit": "6.1 kB" + "limit": "6.15 kB" }, { "name": "All publics (Brotli)", "path": "dist/index.js", "import": "*", - "limit": "5.6 kB" + "limit": "5.65 kB" }, { "name": "Signal (Gzip)", @@ -23,14 +23,14 @@ "name": "Signal (Brotli)", "path": "dist/index.js", "import": "{ signal }", - "limit": "1.4 kB" + "limit": "1.35 kB" }, { "name": "Popular set (Gzip)", "gzip": true, "path": "dist/index.js", "import": "{ signal, record, computed, effect, mountable, onMount }", - "limit": "2.45 kB" + "limit": "2.4 kB" }, { "name": "Popular set (Brotli)", diff --git a/packages/store/src/external.ts b/packages/store/src/external.ts index 9eecc362..9c91c4e3 100644 --- a/packages/store/src/external.ts +++ b/packages/store/src/external.ts @@ -1,35 +1,22 @@ import { + type Accessor, type WritableSignal, - type Morph, type NewValue, - morph, + createSignal, signal, untracked } from 'kida' +import { + type FacadeNode, + facadeOper +} from './facade.js' -export interface ExternalOverrides extends Partial, 'source'>> {} - -export type ExternalFactory = ($source: WritableSignal, ops: ExternalOverrides) => void - -export interface External extends Morph { - factory: ExternalFactory +export interface ExternalOverrides { + get?: () => T + set?: (value: NewValue) => void } -function lazyGetterSetter(this: External, ...value: [NewValue]): T | void { - const $source = this.source - const ops: ExternalOverrides = {} - - untracked(() => this.factory($source, ops)) - - this.get = ops.get ?? $source - this.set = ops.set ?? $source - - if (value.length) { - this.set(value[0]) - } else { - return this.get() - } -} +export type ExternalFactory = ($source: WritableSignal, ops: ExternalOverrides) => void /** * Create a signal that is controlled by an external source. @@ -40,9 +27,19 @@ function lazyGetterSetter(this: External, ...value: [NewValue]): T | vo export function external( factory: ExternalFactory ) { - return morph(signal(), { - get: lazyGetterSetter as () => T, - set: lazyGetterSetter, - factory - }) as WritableSignal + const $source = signal() as WritableSignal + const node = $source.node as FacadeNode + + // Both faces start as the installer: the factory runs at the first read or + // write, over a pair that already defaults to the source, and overrides + // whichever side it wants + node.get = node.set = ((...value: [NewValue]) => { + node.get = node.set = $source + + untracked(() => factory($source, node)) + + return value.length ? node.set(value[0]) : node.get() + }) as Accessor + + return createSignal(facadeOper, node) as WritableSignal } diff --git a/packages/store/src/facade.ts b/packages/store/src/facade.ts new file mode 100644 index 00000000..0dffc3bb --- /dev/null +++ b/packages/store/src/facade.ts @@ -0,0 +1,23 @@ +import type { + Accessor, + NewValue, + SignalNode +} from 'kida' + +/** + * A node wearing a second face: the signal function in front of it reads + * through `get` and writes through `set`, while the node itself - and the + * signal originally bound to it - stay exactly what they were. + */ +export interface FacadeNode extends SignalNode { + get: Accessor + set(value: NewValue): void +} + +export function facadeOper(this: FacadeNode, ...value: [NewValue]): T | void { + if (value.length) { + this.set(value[0]) + } else { + return this.get() + } +} diff --git a/packages/store/src/paced.ts b/packages/store/src/paced.ts index 43d67dd6..a20d3365 100644 --- a/packages/store/src/paced.ts +++ b/packages/store/src/paced.ts @@ -2,7 +2,7 @@ import { type Accessor, type NewValue, type WritableSignal, - morph, + createSignal, mountable, onMount, signal, @@ -10,6 +10,10 @@ import { untracked } from 'kida' import type { RateLimiter } from './types.js' +import { + type FacadeNode, + facadeOper +} from './facade.js' /** * Creates a compute function that returns a rate-limited value from an accessor. @@ -50,13 +54,16 @@ export function paced( ) { const $proxy = mountable(signal(untracked($signal))) const update = rateLimiter<[NewValue]>($signal) + const node = $proxy.node as FacadeNode onMount($proxy, () => subscribe($signal, $proxy)) - return morph($proxy, { - set(value: NewValue) { - $proxy(value) - update(value) - } - }) + node.get = $proxy + + node.set = (value) => { + $proxy(value) + update(value) + } + + return createSignal(facadeOper, node) as WritableSignal } diff --git a/website/src/content/docs/store/low-level.mdx b/website/src/content/docs/store/low-level.mdx index 1a3ef136..2a9a8f26 100644 --- a/website/src/content/docs/store/low-level.mdx +++ b/website/src/content/docs/store/low-level.mdx @@ -81,40 +81,6 @@ const stop = noMount($data, () => effect(() => { })) ``` -## Signal Morphing - -**`morph`** creates a new signal that wraps an existing one with custom get/set behavior. The key feature is that you can dynamically change these behaviors on the fly by modifying `this.get` and `this.set` within the methods themselves. - -```ts -import { signal, morph } from '@nano_kit/store' - -const $source = signal(0) -const $doubled = morph($source, { - get() { - return this.source() * 2 - }, - set(value) { - this.source(value / 2) - } -}) - -$doubled(10) -console.log($source()) /* 5 */ -console.log($doubled()) /* 10 */ - -/* You can change behavior on the fly */ -const $dynamic = morph($source, { - get() { - /* Dynamically change get behavior */ - if (this.source() > 10) { - this.get = () => this.source() * 3 - } - - return this.source() - } -}) -``` - ## Next Values **`nextValue`** resolves a signal setter value against the previous value. Use it when your low-level API accepts the same `value | updater` shape as writable signals. @@ -131,18 +97,16 @@ nextValue(prev, value => value + 1) /* 2 */ **`signalNextValue`** resolves a signal setter value against a writable signal's pending value. This is useful inside custom setters where several writes may happen during the same propagation cycle. ```ts -import { signal, morph, signalNextValue } from '@nano_kit/store' +import { signal, signalNextValue } from '@nano_kit/store' const $count = signal(0) -const $positiveCount = morph($count, { - set(next) { - const value = signalNextValue(this.source, next) +const setPositive = (next) => { + const value = signalNextValue($count, next) - this.source(Math.max(0, value)) - } -}) + $count(Math.max(0, value)) +} -$positiveCount(count => count + 1) +setPositive(count => count + 1) ``` ## External Signals