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
8 changes: 4 additions & 4 deletions packages/agera/.size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,24 @@
"name": "All publics",
"path": "dist/index.js",
"import": "*",
"limit": "2.72 kB"
"limit": "2.73 kB"
},
{
"name": "Signal",
"path": "dist/index.js",
"import": "{ signal }",
"limit": "1.34 kB"
"limit": "1.35 kB"
},
{
"name": "Minimal set",
"path": "dist/index.js",
"import": "{ signal, computed, effect }",
"limit": "1.51 kB"
"limit": "1.52 kB"
},
{
"name": "Popular set",
"path": "dist/index.js",
"import": "{ signal, computed, effect, mountable, onMounted }",
"limit": "1.92 kB"
"limit": "1.93 kB"
}
]
24 changes: 22 additions & 2 deletions packages/agera/src/internals/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ export function pushActiveSub(sub?: ReactiveNode) {
const prevSub = activeSub

activeSub = sub

return prevSub
}

Expand Down Expand Up @@ -751,8 +752,27 @@ function computedOper<T>(this: ComputedNode<T>): T {
return this.value!
}

export function nextValue<T>(prevValue: T, nextValue: NewValue<T>): T {
return isFunction(nextValue) ? nextValue(prevValue) : nextValue
/**
* Resolve the new value of a signal, calling the reducer form untracked.
* A reducer is user code that runs while some effect may be running: its
* reads must not become dependencies of that effect.
* @param prevValue - The current value.
* @param nextValue - The new value or a reducer of the current one.
* @param arg - Extra argument passed to the reducer.
* @returns The resolved value.
*/
export function nextValue<T, A>(prevValue: T, nextValue: NewValue<T, A>, arg?: A): T {
if (isFunction(nextValue)) {
const prevSub = pushActiveSub(undefined)

try {
return nextValue(prevValue, arg as A)
} finally {
popActiveSub(prevSub)
}
}

return nextValue
}

export function signalNextValue<T>($signal: WritableSignal<T>, newValue: NewValue<T>): T {
Expand Down
2 changes: 1 addition & 1 deletion packages/agera/src/internals/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type Compute<T> = (prevValue?: T) => T

export type Accessor<T> = () => T

export type NewValue<T> = T | ((prevValue: T) => T)
export type NewValue<T, A = void> = T | ((prevValue: T, arg: A) => T)

export type MountedListener = (mounted: boolean) => void

Expand Down
24 changes: 24 additions & 0 deletions packages/agera/src/signal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,30 @@ describe('agera', () => {
expect($num()).toBe(6)
})

it('should not subscribe the writing effect to signals read by the reducer', () => {
const $count = signal(0)
const $step = signal(1)
const listener = vi.fn()
let written = false
const stop = effect(() => {
listener($count())

if (!written) {
written = true

$count(count => count + $step())
}
})
const runs = listener.mock.calls.length

$step(10)

// the reducer's read must not be a dependency of the writing effect
expect(listener).toHaveBeenCalledTimes(runs)

stop()
})

it('should notify recursed effect by external update', () => {
const log: string[] = []
const $data = signal()
Expand Down
6 changes: 3 additions & 3 deletions packages/kida/.size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@
"name": "All publics",
"path": "dist/index.js",
"import": "*",
"limit": "4.18 kB"
"limit": "4.19 kB"
},
{
"name": "Signal",
"path": "dist/index.js",
"import": "{ signal }",
"limit": "1.34 kB"
"limit": "1.35 kB"
},
{
"name": "Popular set",
"path": "dist/index.js",
"import": "{ signal, record, computed, effect, mountable, onMount }",
"limit": "2.26 kB"
"limit": "2.28 kB"
}
]
26 changes: 26 additions & 0 deletions packages/kida/src/array.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,32 @@ describe('kida', () => {
expect(atIndex($array, 1)()).toBe(4)
})

it('should not subscribe the writing effect to signals read by the updater', () => {
const $array = signal([1, 2])
const $unrelated = signal(0)
const listener = vi.fn()
let written = false
const off = effect(() => {
listener($array())

if (!written) {
written = true

updateArray($array, (array) => {
array[1] = $unrelated()
})
}
})
const runs = listener.mock.calls.length

$unrelated(5)

// the updater's read must not be a dependency of the writing effect
expect(listener).toHaveBeenCalledTimes(runs)

off()
})

it('should update child by root and notify listeners', () => {
const $array = signal([
1,
Expand Down
4 changes: 2 additions & 2 deletions packages/kida/src/internals/child.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
morph,
isWritable,
unsafeMarkWritable,
isFunction,
nextValue,
untracked
} from 'agera'
import type { AnyObject } from './types.js'
Expand Down Expand Up @@ -74,7 +74,7 @@ export function child<
$parent(setValue!(
parent,
k,
isFunction(value) ? value(parent[k]) : value
nextValue(parent[k], value)
))
})

Expand Down
4 changes: 2 additions & 2 deletions packages/nanoviews/.size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
"name": "All publics",
"path": "dist/index.js",
"import": "*",
"limit": "6.83 kB"
"limit": "6.85 kB"
},
{
"name": "Average usage",
"path": "dist/index.js",
"import": "{ fragment, div, form, input, button, label, classList$, if_, for_, value$, $$children, effect }",
"limit": "4.04 kB"
"limit": "4.05 kB"
}
]
34 changes: 34 additions & 0 deletions packages/nanoviews/src/internals/effects.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getContext
} from 'kida'
import {
button,
div,
span
} from '../elements/elements.js'
Expand Down Expand Up @@ -751,6 +752,39 @@ describe('nanoviews', () => {
])
})
})

describe('tracking barriers', () => {
it('should not subscribe an effect that dispatches an event to what the handler reads', () => {
const $unrelated = signal(0)
const effectRuns: number[] = []
const handlerRuns: number[] = []
let el!: HTMLButtonElement

render(() => {
el = button({
onClick: () => {
handlerRuns.push($unrelated())
}
})('click')

effect(() => {
effectRuns.push(effectRuns.length)
el.click()
})

return el
})

expect(effectRuns).toHaveLength(1)
expect(handlerRuns).toHaveLength(1)

$unrelated(1)

// the handler's read must not be a dependency of the dispatching effect
expect(effectRuns).toHaveLength(1)
expect(handlerRuns).toHaveLength(1)
})
})
})
})
})
27 changes: 16 additions & 11 deletions packages/nanoviews/src/internals/elements/events.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { untracked } from 'kida'
import { defineProtoProp } from './utils.js'

type Target = EventTarget & {
Expand All @@ -16,22 +17,26 @@ function eventHandler(event: Event) {

const path = event.composedPath()

for (let i = 0, len = path.length - 4, handler; i < len; i++) {
node = path[i]
// A handler is user code: it must not subscribe whatever effect happens to
// be running when the event is dispatched synchronously from inside one
untracked(() => {
for (let i = 0, len = path.length - 4, handler; i < len; i++) {
node = path[i]

// @ts-expect-error Get monkey defined property
if ((handler = node[key] as EventListener | undefined) !== undefined && !node.disabled) {
handler.call(node, event)
// @ts-expect-error Get monkey defined property
if ((handler = node[key] as EventListener | undefined) !== undefined && !node.disabled) {
handler.call(node, event)

if (event.cancelBubble) {
break
if (event.cancelBubble) {
break
}
}
}

if (node.__mp) {
break
if (node.__mp) {
break
}
}
}
})

node = undefined
}
Expand Down
30 changes: 29 additions & 1 deletion packages/query/src/cache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import {
it,
expect
} from 'vitest'
import { effect } from '@nano_kit/store'
import {
effect,
signal
} from '@nano_kit/store'
import { CacheStorage } from './CacheStorage.js'
import {
queryKey,
Expand Down Expand Up @@ -103,6 +106,31 @@ describe('query', () => {
expect($cache(key)).toBe(42)
})

it('should not subscribe the writing effect to signals read by the updater', () => {
const $cache = dataCacheFacade(new CacheStorage())
const key = queryKey<[string], number>('test')('a')
const $unrelated = signal(1)
const listener = vi.fn()
let written = false
const off = effect(() => {
listener($cache(key))

if (!written) {
written = true

$cache(key, () => $unrelated())
}
})
const runs = listener.mock.calls.length

$unrelated(2)

// the updater's read must not be a dependency of the writing effect
expect(listener).toHaveBeenCalledTimes(runs)

off()
})

it('should revert value change', () => {
const $cache = dataCacheFacade(new CacheStorage())
const key = queryKey<[string], number>('test')('a')
Expand Down
12 changes: 6 additions & 6 deletions packages/query/src/cache.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
assignKey,
batch,
isFunction
nextValue
} from '@nano_kit/store'
import type {
CacheKeyBuilder,
Expand Down Expand Up @@ -86,11 +86,11 @@ function cacheGetterSetter<F extends 'data' | 'error', P extends unknown[], R>(
this.set(key, (entry = this.initial()) => {
prevEntry = entry

const next = isFunction(newValue)
? newValue(entry[field] as CacheEntry<P, R>[F], entry.params as P)
: newValue

return assignKey(entry, field, next)
return assignKey(entry, field, nextValue(
entry[field] as CacheEntry<P, R>[F],
newValue,
entry.params as P
))
})

return () => this.set(
Expand Down
6 changes: 3 additions & 3 deletions packages/store/.size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@
"name": "All publics",
"path": "dist/index.js",
"import": "*",
"limit": "5.57 kB"
"limit": "5.61 kB"
},
{
"name": "Signal",
"path": "dist/index.js",
"import": "{ signal }",
"limit": "1.34 kB"
"limit": "1.35 kB"
},
{
"name": "Popular set",
"path": "dist/index.js",
"import": "{ signal, record, computed, effect, mountable, onMount }",
"limit": "2.27 kB"
"limit": "2.28 kB"
}
]
1 change: 0 additions & 1 deletion todo.txt
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@

- hooks/effects naming convention

- wrap into untracked needed parts
- error boundaries: cleanup half-created scope on render throw (stopScope in catch) - rows/content are not parent-linked, teardown must be explicit
- for loop array or empty check for else
- for_ crashes on duplicate track keys (pre-existing, loop.ts reconcile matched/stashed)
Expand Down