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
55 changes: 55 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: CI

on:
push:
branches: [main, next]
pull_request:
branches: [main, next]

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Typecheck
run: bunx tsc --noEmit

- name: Lint and format
run: bunx biome check .

- name: Unit tests
run: bun run test

- name: Bundle size regression
run: bun test test/regression-bundle.test.ts

- name: Build
run: bun run build

performance:
runs-on: ubuntu-latest
continue-on-error: true # informational only — shared runners are too noisy for timing assertions
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Performance regression (informational)
run: bun test test/regression-performance.test.ts
3 changes: 3 additions & 0 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ jobs:
- name: Install all dependencies (including devDependencies for build)
run: bun install

- name: Typecheck, lint, test, and bundle regression
run: bun run check

- name: Build package
run: bun run build

Expand Down
16 changes: 8 additions & 8 deletions .zed/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,22 @@
"language_servers": ["!eslint", "..."],
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true,
},
"source.organizeImports.biome": true
}
},
"JavaScript": {
"language_servers": ["!eslint", "..."],
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true,
},
"source.organizeImports.biome": true
}
},
"TSX": {
"language_servers": ["!eslint", "..."],
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true,
},
},
},
"source.organizeImports.biome": true
}
}
}
}
3 changes: 1 addition & 2 deletions bench/reactivity.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,8 +648,7 @@ group('Collection: chain 5 derivations (100 items)', () => {
bench('cause-effect', () => {
const list = createList(Array.from({ length: 100 }, (_, i) => i + 1))
let col = list.deriveCollection((v: number) => v * 2)
for (let i = 1; i < 5; i++)
col = col.deriveCollection((v: number) => v + 1)
for (let i = 1; i < 5; i++) col = col.deriveCollection((v: number) => v + 1)
col.get()
})
})
Expand Down
4 changes: 3 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
},
"javascript": {
"formatter": {
"quoteStyle": "double"
"quoteStyle": "single",
"semicolons": "asNeeded",
"arrowParentheses": "asNeeded"
}
},
"assist": {
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"bench": "bun run bench/reactivity.bench.ts",
"test": "bun test --path-ignore-patterns=**/regression*",
"regression": "bun test test/regression-performance.test.ts && bun test test/regression-bundle.test.ts",
"lint": "bunx biome lint --write"
"check": "bunx tsc --noEmit && bunx biome check . && bun run test && bun test test/regression-bundle.test.ts",
"lint": "bunx biome check --write"
}
}
5 changes: 1 addition & 4 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,7 @@ class InvalidStoreMutationError extends TypeError {
* @param prop - The property name that was directly mutated.
* @param action - The kind of mutation attempted (`'assign to'`, `'delete'`, or `'define'`).
*/
constructor(
prop: string,
action: 'assign to' | 'delete' | 'define',
) {
constructor(prop: string, action: 'assign to' | 'delete' | 'define') {
const guidance =
action === 'delete'
? `use store.remove(${JSON.stringify(prop)})`
Expand Down
22 changes: 5 additions & 17 deletions src/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,12 +236,7 @@ const deepEqualInner = (
): boolean => {
if (Object.is(a, b)) return true
if (typeof a !== typeof b) return false
if (
a == null ||
typeof a !== 'object' ||
b == null ||
typeof b !== 'object'
)
if (a == null || typeof a !== 'object' || b == null || typeof b !== 'object')
return false

// Cycle guard: if `a` is already on the current recursion path, treat this
Expand Down Expand Up @@ -406,8 +401,7 @@ function propagate(node: SinkNode, newFlag = FLAG_DIRTY): void {
}

// Propagate Check to sinks
for (let e = node.sinks; e; e = e.nextSink)
propagate(e.sink, FLAG_CHECK)
for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink, FLAG_CHECK)
} else {
if ((flags & (FLAG_DIRTY | FLAG_CHECK)) >= newFlag) return

Expand Down Expand Up @@ -520,8 +514,7 @@ function recomputeTask(node: TaskNode<unknown & {}>): void {
if (node.error || !node.equals(next, node.value)) {
node.value = next
node.error = undefined
for (let e = node.sinks; e; e = e.nextSink)
propagate(e.sink)
for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
}
setState(node.pendingNode, false)
})
Expand All @@ -539,8 +532,7 @@ function recomputeTask(node: TaskNode<unknown & {}>): void {
) {
// We don't clear old value on errors
node.error = error
for (let e = node.sinks; e; e = e.nextSink)
propagate(e.sink)
for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
}
setState(node.pendingNode, false)
})
Expand Down Expand Up @@ -580,11 +572,7 @@ function refresh(node: SinkNode): void {

if (node.flags & FLAG_RUNNING) {
throw new CircularDependencyError(
'controller' in node
? TYPE_TASK
: 'value' in node
? TYPE_MEMO
: 'Effect',
'controller' in node ? TYPE_TASK : 'value' in node ? TYPE_MEMO : 'Effect',
)
}

Expand Down
9 changes: 2 additions & 7 deletions src/nodes/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,7 @@ type Collection<T extends {}, S extends Signal<T> = Signal<T>> = {
byKey(key: string): S | undefined
keyAt(index: number): string | undefined
indexOfKey(key: string): number
deriveCollection<R extends {}>(
callback: (sourceValue: T) => R,
): Collection<R>
deriveCollection<R extends {}>(callback: (sourceValue: T) => R): Collection<R>
deriveCollection<R extends {}>(
callback: (sourceValue: T, abort: AbortSignal) => Promise<R>,
): Collection<R>
Expand Down Expand Up @@ -154,10 +152,7 @@ function deriveCollection<T extends {}, U extends {}>(
const sourceValue = itemSignal.get() as U
if (sourceValue == null) return prev as T
return (
callback as (
sourceValue: U,
abort: AbortSignal,
) => Promise<T>
callback as (sourceValue: U, abort: AbortSignal) => Promise<T>
)(sourceValue, abort)
})
: createMemo(() => {
Expand Down
24 changes: 14 additions & 10 deletions src/nodes/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import {
registerCleanup,
runCleanup,
runEffect,
scheduleEffect,
type Signal,
scheduleEffect,
trimSources,
} from '../graph'
import { isTask } from './task'
Expand All @@ -32,9 +32,11 @@ type MaybePromise<T> = T | Promise<T>
*/
type MatchHandlers<T extends readonly Signal<unknown & {}>[]> = {
/** Called when all signals have a value. Receives a tuple of resolved values. */
ok: (values: {
[K in keyof T]: T[K] extends Signal<infer V> ? V : never
}) => MaybePromise<MaybeCleanup>
ok: (
values: {
[K in keyof T]: T[K] extends Signal<infer V> ? V : never
},
) => MaybePromise<MaybeCleanup>
/** Called when one or more signals hold an error. Defaults to `console.error`. */
err?: (errors: readonly Error[]) => MaybePromise<MaybeCleanup>
/** Called when one or more signals are unset (pending). */
Expand Down Expand Up @@ -221,12 +223,14 @@ function match(
const owner = activeOwner
const controller = new AbortController()
registerCleanup(owner, () => controller.abort())
out.then(cleanup => {
if (!controller.signal.aborted && typeof cleanup === 'function')
registerCleanup(owner, cleanup)
}).catch(e => {
err([e instanceof Error ? e : new Error(String(e))])
})
out
.then(cleanup => {
if (!controller.signal.aborted && typeof cleanup === 'function')
registerCleanup(owner, cleanup)
})
.catch(e => {
err([e instanceof Error ? e : new Error(String(e))])
})
}
}

Expand Down
36 changes: 10 additions & 26 deletions src/nodes/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,7 @@ type List<T extends {}, S extends MutableSignal<T> = MutableSignal<T>> = {
replace(key: string, value: T): void
sort(compareFn?: (a: T, b: T) => number): void
splice(start: number, deleteCount?: number, ...items: T[]): T[]
deriveCollection<R extends {}>(
callback: (sourceValue: T) => R,
): Collection<R>
deriveCollection<R extends {}>(callback: (sourceValue: T) => R): Collection<R>
deriveCollection<R extends {}>(
callback: (sourceValue: T, abort: AbortSignal) => Promise<R>,
): Collection<R>
Expand Down Expand Up @@ -281,9 +279,7 @@ function createList<
const [generateKey, contentBased] = getKeyGenerator(options?.keyConfig)
const itemEquals = options?.itemEquals ?? DEEP_EQUALITY
const itemFactory = (options?.createItem ??
((item: T) => createState(item, { equals: itemEquals }))) as (
value: T,
) => S
((item: T) => createState(item, { equals: itemEquals }))) as (value: T) => S

// --- Internal helpers ---

Expand Down Expand Up @@ -334,10 +330,7 @@ function createList<
batch(() => {
for (const key in changes.change) {
const val = changes.change[key]
validateSignalValue(
`${TYPE_LIST} item for key "${key}"`,
val,
)
validateSignalValue(`${TYPE_LIST} item for key "${key}"`, val)
const signal = signals.get(key)
if (signal) signal.set(val as T)
}
Expand All @@ -362,8 +355,7 @@ function createList<
// --- Initialize ---
for (let i = 0; i < value.length; i++) {
const val = value[i]
if (val == null)
throw new NullishSignalValueError(`${TYPE_LIST} item ${i}`)
if (val == null) throw new NullishSignalValueError(`${TYPE_LIST} item ${i}`)
let key = keys[i]
if (!key) {
key = generateKey(val)
Expand Down Expand Up @@ -425,8 +417,7 @@ function createList<
// Use cached value if clean, recompute if dirty. untrack prevents
// buildValue's child .get() calls from leaking edges into whatever
// effect is currently active (which would cause over-broad re-runs).
const prev =
node.flags & FLAG_DIRTY ? untrack(buildValue) : node.value
const prev = node.flags & FLAG_DIRTY ? untrack(buildValue) : node.value
const changes = diffArrays(
prev,
next,
Expand Down Expand Up @@ -476,8 +467,7 @@ function createList<

add(value: T) {
const key = generateKey(value)
if (signals.has(key))
throw new DuplicateKeyError(TYPE_LIST, key, value)
if (signals.has(key)) throw new DuplicateKeyError(TYPE_LIST, key, value)
keys.push(key)
validateSignalValue(`${TYPE_LIST} item for key "${key}"`, value)
signals.set(key, itemFactory(value))
Expand All @@ -488,15 +478,12 @@ function createList<
},

remove(keyOrIndex: string | number) {
const key =
typeof keyOrIndex === 'number' ? keys[keyOrIndex] : keyOrIndex
const key = typeof keyOrIndex === 'number' ? keys[keyOrIndex] : keyOrIndex
if (key === undefined) return
const ok = signals.delete(key)
if (ok) {
const index =
typeof keyOrIndex === 'number'
? keyOrIndex
: keys.indexOf(key)
typeof keyOrIndex === 'number' ? keyOrIndex : keys.indexOf(key)
if (index >= 0) keys.splice(index, 1)
node.flags |= FLAG_DIRTY | FLAG_RELINK
for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
Expand Down Expand Up @@ -554,14 +541,11 @@ function createList<
splice(start: number, deleteCount?: number, ...items: T[]) {
const length = keys.length
const actualStart =
start < 0
? Math.max(0, length + start)
: Math.min(start, length)
start < 0 ? Math.max(0, length + start) : Math.min(start, length)
const actualDeleteCount = Math.max(
0,
Math.min(
deleteCount ??
Math.max(0, length - Math.max(0, actualStart)),
deleteCount ?? Math.max(0, length - Math.max(0, actualStart)),
length - actualStart,
),
)
Expand Down
4 changes: 1 addition & 3 deletions src/nodes/slot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,7 @@ function createSlot<T extends {}>(
}
}

const replace = <U extends T>(
next: Signal<U> | SlotDescriptor<U>,
): void => {
const replace = <U extends T>(next: Signal<U> | SlotDescriptor<U>): void => {
validateSignalValue(TYPE_SLOT, next, isSignalOrDescriptor)

delegated = next
Expand Down
Loading
Loading