Skip to content
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
2 changes: 1 addition & 1 deletion packages/agera/.size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
22 changes: 0 additions & 22 deletions packages/agera/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions packages/agera/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
28 changes: 21 additions & 7 deletions packages/agera/src/internals/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import type {
Destroy,
Compute,
NewValue,
Morph,
DeferredScope
} from './types.js'
import {
Expand Down Expand Up @@ -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<N extends ComputedNode | SignalNode>(
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

Expand Down Expand Up @@ -713,7 +720,14 @@ function flush(): void {
}
}

function computedOper<T>(this: ComputedNode<T>): 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<T>(this: ComputedNode<T>): T {
const flags = this.flags

if (
Expand Down
6 changes: 0 additions & 6 deletions packages/agera/src/internals/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,6 @@ export interface ReadableSignal<T> extends Accessor<T> {
node: ReadableNode
}

export interface Morph<T = unknown> {
source: WritableSignal<T>
get(): T
set(value: NewValue<T>): void
}

export interface WritableSignal<T> extends ReadableSignal<T> {
node: SignalNode<T>
(value: NewValue<T>): void
Expand Down
33 changes: 27 additions & 6 deletions packages/agera/src/signal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import {
expect
} from 'vitest'
import {
type SignalNode,
computed,
signal,
effect,
isSignal,
morph,
createSignal,
trigger
} from './index.js'

Expand Down Expand Up @@ -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<number> & {
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)
})
})

Expand Down
40 changes: 1 addition & 39 deletions packages/agera/src/signal.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -87,35 +81,3 @@ export function onMounted(
export function isMounted($signal: AnySignal): boolean {
return ($signal.node as ReadableNode).lcd === true
}

export function morph<T, C extends Partial<Morph<T>>>(
$signal: WritableSignal<T>,
context: C
): WritableSignal<T>

export function morph<T, C extends Partial<Morph<T>>>(
$signal: ReadableSignal<T>,
context: C
): ReadableSignal<T>

/* @__NO_SIDE_EFFECTS__ */
export function morph<T, C extends Partial<Morph<T>>>(
$signal: ReadableSignal<T> | WritableSignal<T>,
context: C
) {
const morph = context as unknown as Morph

morph.source ??= $signal as WritableSignal<unknown>
morph.get ??= $signal
morph.set ??= $signal as WritableSignal<unknown>

return createSignal(morphOper, $signal.node as SignalNode, morph)
}

function morphOper<T>(this: Morph<T>, ...value: [NewValue<T>]): T | void {
if (value.length) {
this.set(value[0])
} else {
return this.get()
}
}
8 changes: 4 additions & 4 deletions packages/kida/.size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"name": "All publics (Brotli)",
"path": "dist/index.js",
"import": "*",
"limit": "4.2 kB"
"limit": "4.25 kB"
},
{
"name": "Signal (Gzip)",
Expand All @@ -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"
}
]
86 changes: 57 additions & 29 deletions packages/kida/src/internals/child.ts
Original file line number Diff line number Diff line change
@@ -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<AnyObject>
/**
* Key to read from the parent, static or reactive.
*/
k: PropertyKey | Accessor<PropertyKey>
/**
* 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<unknown>]) {
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.
Expand Down Expand Up @@ -57,31 +94,22 @@ export function child<
key: K | Accessor<K>,
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<V>) => 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)
}
2 changes: 1 addition & 1 deletion packages/nanoviews/.size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
Loading