├── .gitignore ├── .prettierrc.cjs ├── .sandbox ├── favicon-32x32.png ├── index.html ├── launch.js └── main.ts ├── LICENSE ├── README.md ├── package.json ├── pnpm-lock.yaml ├── src ├── boundaries.ts ├── core │ ├── constants.ts │ ├── core.ts │ ├── effect.ts │ ├── error.ts │ ├── flags.ts │ ├── index.ts │ ├── owner.ts │ └── scheduler.ts ├── globals.d.ts ├── index.ts ├── map.ts ├── signals.ts └── store │ ├── index.ts │ ├── projection.ts │ ├── reconcile.ts │ ├── store.ts │ └── utils.ts ├── tests ├── context.test.ts ├── createAsync.test.ts ├── createEffect.test.ts ├── createErrorBoundary.test.ts ├── createMemo.test.ts ├── createRoot.test.ts ├── createSignal.test.ts ├── flushSync.test.ts ├── gc.test.ts ├── getOwner.test.ts ├── graph.test.ts ├── mapArray.test.ts ├── onCleanup.test.ts ├── repeat.test.ts ├── runWithObserver.test.ts ├── runWithOwner.test.ts ├── store │ ├── createProjection.test.ts │ ├── createStore.test.ts │ ├── reconcile.test.ts │ ├── recursive-effects.test.ts │ ├── shared-clone.ts │ ├── utilities.bench.ts │ └── utilities.test.ts └── untrack.test.ts ├── tsconfig.build.json ├── tsconfig.json ├── tsup.config.ts ├── vite.config.ts └── vitest.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | types/ 4 | sandbox/ 5 | .DS_STORE -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: false, 3 | semi: true, 4 | arrowParens: 'avoid', 5 | trailingComma: 'none', 6 | printWidth: 100, 7 | tabWidth: 2, 8 | plugins: [require.resolve('@ianvs/prettier-plugin-sort-imports')], 9 | }; 10 | -------------------------------------------------------------------------------- /.sandbox/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solidjs/signals/f464453012fd19997b757a9c000674f2ed0cd791/.sandbox/favicon-32x32.png -------------------------------------------------------------------------------- /.sandbox/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | X-Reactivity Sandbox 4 | 5 | 6 | 7 | 8 | 14 | 15 | 16 | 17 | 18 | 19 |

X-Reactivity Sandbox

20 |

21 | Play with X-Reactivity in your browser, see the 22 | `sandbox` 23 | directory. None of your changes will be commited (git-ignored folder). 24 |

25 | 26 | 27 | -------------------------------------------------------------------------------- /.sandbox/launch.js: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | import fs from "fs"; 3 | import path from "path"; 4 | 5 | const SANDBOX_TEMPLATE = path.resolve(process.cwd(), ".sandbox"), 6 | SANDBOX_DIR = path.resolve(process.cwd(), "sandbox"), 7 | IGNORED_FILES = new Set(["launch.js"]); 8 | 9 | // Copy files from .sandbox template directory to sandbox. 10 | if (!fs.existsSync(SANDBOX_DIR)) { 11 | fs.mkdirSync(SANDBOX_DIR); 12 | const files = fs.readdirSync(SANDBOX_TEMPLATE); 13 | for (const file of files) { 14 | if (IGNORED_FILES.has(file)) continue; 15 | const from = path.resolve(SANDBOX_TEMPLATE, file); 16 | const to = path.resolve(SANDBOX_DIR, file); 17 | fs.writeFileSync(to, fs.readFileSync(from, "utf-8")); 18 | } 19 | } 20 | 21 | execSync("vite --open=/sandbox/index.html --port=3100 --host", { 22 | stdio: "inherit", 23 | }); 24 | -------------------------------------------------------------------------------- /.sandbox/main.ts: -------------------------------------------------------------------------------- 1 | import { createSignal, createMemo, createEffect } from "../src"; 2 | 3 | // ... 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2025 Ryan Carniato 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @solidjs/signals 2 | 3 | Standalone Reactive implementation to serve as the basis of future (post 1.x) versions of SolidJS. This package aims to be core for Signals library made with rendering in mind so it may have more features and opinions than common Signals libraries, but are still necessary core to accomplish the type of capabilities we intend. 4 | 5 | This is not ready for production and should be considered pre-alpha. This package is completely experimental and every release may be breaking. It is also not tuned for performance as of yet as we are still focusing on capability. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@solidjs/signals", 3 | "version": "0.3.2", 4 | "description": "", 5 | "author": "Ryan Carniato", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/solidjs/signals" 10 | }, 11 | "type": "module", 12 | "main": "dist/node.cjs", 13 | "module": "dist/prod.js", 14 | "types": "dist/types/index.d.ts", 15 | "publishConfig": { 16 | "access": "public" 17 | }, 18 | "files": [ 19 | "dist" 20 | ], 21 | "scripts": { 22 | "build": "rimraf dist && tsup && pnpm types", 23 | "types": "tsc -p tsconfig.build.json", 24 | "format": "prettier src tests --write --log-level warn", 25 | "sandbox": "node ./.sandbox/launch.js", 26 | "test": "vitest run", 27 | "test:watch": "vitest watch tests", 28 | "test:gc": "node --expose-gc ./vitest.js", 29 | "test:gc:watch": "node --expose-gc ./vitest.js --watch", 30 | "test:coverage": "vitest run --coverage", 31 | "bench": "vitest bench --run" 32 | }, 33 | "devDependencies": { 34 | "@ianvs/prettier-plugin-sort-imports": "^4.1.1", 35 | "@types/node": "^20.5.1", 36 | "rimraf": "^5.0.1", 37 | "tsup": "^7.2.0", 38 | "typescript": "5.1.6", 39 | "vite": "^5.4.10", 40 | "vitest": "^2.0.0" 41 | }, 42 | "exports": { 43 | ".": { 44 | "types": "./dist/types/index.d.ts", 45 | "import": { 46 | "test": "./dist/dev.js", 47 | "development": "./dist/dev.js", 48 | "default": "./dist/prod.js" 49 | }, 50 | "require": "./dist/node.cjs" 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/boundaries.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Computation, 3 | compute, 4 | EagerComputation, 5 | ERROR_BIT, 6 | incrementClock, 7 | LOADING_BIT, 8 | NotReadyError, 9 | onCleanup, 10 | Owner, 11 | Queue, 12 | STATE_DIRTY, 13 | UNCHANGED, 14 | UNINITIALIZED_BIT 15 | } from "./core/index.js"; 16 | import type { Effect, IQueue } from "./core/index.js"; 17 | 18 | class BoundaryComputation extends EagerComputation { 19 | _propagationMask: number; 20 | constructor(compute: () => T, propagationMask: number) { 21 | super(undefined as any, compute, { defer: true }); 22 | this._propagationMask = propagationMask; 23 | } 24 | write(value: T | UNCHANGED, flags: number) { 25 | super.write(value, flags & ~this._propagationMask); 26 | if (this._propagationMask & LOADING_BIT && !(this._stateFlags & UNINITIALIZED_BIT)) { 27 | flags &= ~LOADING_BIT; 28 | } 29 | this._queue.notify(this as any, this._propagationMask, flags); 30 | return this._value; 31 | } 32 | } 33 | 34 | function createBoundChildren( 35 | owner: Owner, 36 | fn: () => T, 37 | queue: IQueue, 38 | mask: number 39 | ): Computation { 40 | const parentQueue = owner._queue; 41 | parentQueue.addChild((owner._queue = queue)); 42 | onCleanup(() => parentQueue.removeChild(owner._queue!)); 43 | return compute( 44 | owner, 45 | () => { 46 | const c = new Computation(undefined, fn); 47 | return new BoundaryComputation(() => flatten(c.wait()), mask); 48 | }, 49 | null 50 | ); 51 | } 52 | 53 | class ConditionalQueue extends Queue { 54 | _disabled: Computation; 55 | _errorNodes: Set = new Set(); 56 | _pendingNodes: Set = new Set(); 57 | constructor(disabled: Computation) { 58 | super(); 59 | this._disabled = disabled; 60 | } 61 | run(type: number) { 62 | if (!type || this._disabled.read()) return; 63 | return super.run(type); 64 | } 65 | notify(node: Effect, type: number, flags: number) { 66 | if (this._disabled.read()) { 67 | if (type & LOADING_BIT) { 68 | if (flags & LOADING_BIT) { 69 | this._pendingNodes.add(node); 70 | type &= ~LOADING_BIT; 71 | } else if (this._pendingNodes.delete(node)) type &= ~LOADING_BIT; 72 | } 73 | if (type & ERROR_BIT) { 74 | if (flags & ERROR_BIT) { 75 | this._errorNodes.add(node); 76 | type &= ~ERROR_BIT; 77 | } else if (this._errorNodes.delete(node)) type &= ~ERROR_BIT; 78 | } 79 | } 80 | return type ? super.notify(node, type, flags) : true; 81 | } 82 | } 83 | 84 | export class CollectionQueue extends Queue { 85 | _collectionType: number; 86 | _nodes: Set = new Set(); 87 | _disabled: Computation = new Computation(false, null); 88 | constructor(type: number) { 89 | super(); 90 | this._collectionType = type; 91 | } 92 | run(type: number) { 93 | if (!type || this._disabled.read()) return; 94 | return super.run(type); 95 | } 96 | notify(node: Effect, type: number, flags: number) { 97 | if (!(type & this._collectionType)) return super.notify(node, type, flags); 98 | if (flags & this._collectionType) { 99 | this._nodes.add(node); 100 | if (this._nodes.size === 1) this._disabled.write(true); 101 | } else { 102 | this._nodes.delete(node); 103 | if (this._nodes.size === 0) this._disabled.write(false); 104 | } 105 | type &= ~this._collectionType; 106 | return type ? super.notify(node, type, flags) : true; 107 | } 108 | } 109 | 110 | export enum BoundaryMode { 111 | VISIBLE = "visible", 112 | HIDDEN = "hidden" 113 | } 114 | export function createBoundary(fn: () => T, condition: () => BoundaryMode) { 115 | const owner = new Owner(); 116 | const queue = new ConditionalQueue( 117 | new Computation(undefined, () => condition() === BoundaryMode.HIDDEN) 118 | ); 119 | const tree = createBoundChildren(owner, fn, queue, 0); 120 | new EagerComputation(undefined, () => { 121 | const disabled = queue._disabled.read(); 122 | (tree as BoundaryComputation)._propagationMask = disabled ? ERROR_BIT | LOADING_BIT : 0; 123 | if (!disabled) { 124 | queue._pendingNodes.forEach(node => queue.notify(node, LOADING_BIT, LOADING_BIT)); 125 | queue._errorNodes.forEach(node => queue.notify(node, ERROR_BIT, ERROR_BIT)); 126 | queue._pendingNodes.clear(); 127 | queue._errorNodes.clear(); 128 | } 129 | }); 130 | return () => (queue._disabled.read() ? undefined : tree.read()); 131 | } 132 | 133 | function createCollectionBoundary( 134 | type: number, 135 | fn: () => any, 136 | fallback: (queue: CollectionQueue) => any 137 | ) { 138 | const owner = new Owner(); 139 | const queue = new CollectionQueue(type); 140 | const tree = createBoundChildren(owner, fn, queue, type); 141 | const decision = new Computation(undefined, () => { 142 | if (!queue._disabled.read()) { 143 | const resolved = tree.read(); 144 | if (!queue._disabled.read()) return resolved; 145 | } 146 | return fallback(queue); 147 | }); 148 | return decision.read.bind(decision); 149 | } 150 | 151 | export function createSuspense(fn: () => any, fallback: () => any) { 152 | return createCollectionBoundary(LOADING_BIT, fn, () => fallback()); 153 | } 154 | 155 | export function createErrorBoundary( 156 | fn: () => any, 157 | fallback: (error: unknown, reset: () => void) => U 158 | ) { 159 | return createCollectionBoundary(ERROR_BIT, fn, queue => 160 | fallback(queue._nodes!.values().next().value!._error, () => { 161 | incrementClock(); 162 | for (let node of queue._nodes) { 163 | (node as any)._state = STATE_DIRTY; 164 | (node as any)._queue?.enqueue((node as any)._type, node); 165 | } 166 | }) 167 | ); 168 | } 169 | 170 | export function flatten( 171 | children: any, 172 | options?: { skipNonRendered?: boolean; doNotUnwrap?: boolean } 173 | ): any { 174 | if (typeof children === "function" && !children.length) { 175 | if (options?.doNotUnwrap) return children; 176 | do { 177 | children = children(); 178 | } while (typeof children === "function" && !children.length); 179 | } 180 | if ( 181 | options?.skipNonRendered && 182 | (children == null || children === true || children === false || children === "") 183 | ) 184 | return; 185 | 186 | if (Array.isArray(children)) { 187 | let results: any[] = []; 188 | if (flattenArray(children, results, options)) { 189 | return () => { 190 | let nested = []; 191 | flattenArray(results, nested, { ...options, doNotUnwrap: false }); 192 | return nested; 193 | }; 194 | } 195 | return results; 196 | } 197 | return children; 198 | } 199 | 200 | function flattenArray( 201 | children: Array, 202 | results: any[] = [], 203 | options?: { skipNonRendered?: boolean; doNotUnwrap?: boolean } 204 | ): boolean { 205 | let notReady: NotReadyError | null = null; 206 | let needsUnwrap = false; 207 | for (let i = 0; i < children.length; i++) { 208 | try { 209 | let child = children[i]; 210 | if (typeof child === "function" && !child.length) { 211 | if (options?.doNotUnwrap) { 212 | results.push(child); 213 | needsUnwrap = true; 214 | continue; 215 | } 216 | do { 217 | child = child(); 218 | } while (typeof child === "function" && !child.length); 219 | } 220 | if (Array.isArray(child)) { 221 | needsUnwrap = flattenArray(child, results, options); 222 | } else if ( 223 | options?.skipNonRendered && 224 | (child == null || child === true || child === false || child === "") 225 | ) { 226 | // skip 227 | } else results.push(child); 228 | } catch (e) { 229 | if (!(e instanceof NotReadyError)) throw e; 230 | notReady = e; 231 | } 232 | } 233 | if (notReady) throw notReady; 234 | return needsUnwrap; 235 | } 236 | -------------------------------------------------------------------------------- /src/core/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * See https://dev.to/modderme123/super-charging-fine-grained-reactive-performance-47ph 3 | * State clean corresponds to a node where all the sources are fully up to date 4 | * State check corresponds to a node where some sources (including grandparents) may have changed 5 | * State dirty corresponds to a node where the direct parents of a node has changed 6 | */ 7 | export const STATE_CLEAN = 0; 8 | export const STATE_CHECK = 1; 9 | export const STATE_DIRTY = 2; 10 | export const STATE_DISPOSED = 3; 11 | 12 | export const EFFECT_PURE = 0; 13 | export const EFFECT_RENDER = 1; 14 | export const EFFECT_USER = 2; 15 | 16 | export const SUPPORTS_PROXY = typeof Proxy === "function"; 17 | -------------------------------------------------------------------------------- /src/core/effect.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EFFECT_PURE, 3 | EFFECT_RENDER, 4 | EFFECT_USER, 5 | STATE_CHECK, 6 | STATE_CLEAN, 7 | STATE_DIRTY, 8 | STATE_DISPOSED 9 | } from "./constants.js"; 10 | import { Computation, latest, UNCHANGED, type SignalOptions } from "./core.js"; 11 | import { EffectError } from "./error.js"; 12 | import { ERROR_BIT, LOADING_BIT } from "./flags.js"; 13 | import type { Owner } from "./owner.js"; 14 | import { getClock } from "./scheduler.js"; 15 | 16 | /** 17 | * Effects are the leaf nodes of our reactive graph. When their sources change, they are 18 | * automatically added to the queue of effects to re-execute, which will cause them to fetch their 19 | * sources and recompute 20 | */ 21 | export class Effect extends Computation { 22 | _effect: (val: T, prev: T | undefined) => void | (() => void); 23 | _onerror: ((err: unknown) => void | (() => void)) | undefined; 24 | _cleanup: (() => void) | undefined; 25 | _modified: boolean = false; 26 | _prevValue: T | undefined; 27 | _type: typeof EFFECT_RENDER | typeof EFFECT_USER; 28 | constructor( 29 | initialValue: T, 30 | compute: (val?: T) => T, 31 | effect: (val: T, prev: T | undefined) => void | (() => void), 32 | error?: (err: unknown) => void | (() => void), 33 | options?: SignalOptions & { render?: boolean; defer?: boolean } 34 | ) { 35 | super(initialValue, compute, options); 36 | this._effect = effect; 37 | this._onerror = error; 38 | this._prevValue = initialValue; 39 | this._type = options?.render ? EFFECT_RENDER : EFFECT_USER; 40 | if (this._type === EFFECT_RENDER) { 41 | this._compute = p => 42 | getClock() > this._queue.created && !(this._stateFlags & ERROR_BIT) 43 | ? latest(() => compute(p)) 44 | : compute(p); 45 | } 46 | this._updateIfNecessary(); 47 | !options?.defer && 48 | (this._type === EFFECT_USER 49 | ? this._queue.enqueue(this._type, this._runEffect.bind(this)) 50 | : this._runEffect(this._type)); 51 | if (__DEV__ && !this._parent) 52 | console.warn("Effects created outside a reactive context will never be disposed"); 53 | } 54 | 55 | override write(value: T, flags = 0): T { 56 | if (this._state == STATE_DIRTY) { 57 | const currentFlags = this._stateFlags; 58 | this._stateFlags = flags; 59 | if (this._type === EFFECT_RENDER) { 60 | this._queue.notify(this, LOADING_BIT | ERROR_BIT, flags); 61 | } 62 | } 63 | if (value === UNCHANGED) return this._value as T; 64 | this._value = value; 65 | this._modified = true; 66 | 67 | return value; 68 | } 69 | 70 | override _notify(state: number, skipQueue?: boolean): void { 71 | if (this._state >= state || skipQueue) return; 72 | 73 | if (this._state === STATE_CLEAN) this._queue.enqueue(this._type, this._runEffect.bind(this)); 74 | 75 | this._state = state; 76 | } 77 | 78 | override _setError(error: unknown): void { 79 | this._error = error; 80 | this._cleanup?.(); 81 | this._queue.notify(this, LOADING_BIT, 0); 82 | this._stateFlags = ERROR_BIT; 83 | if (this._type === EFFECT_USER) { 84 | try { 85 | return this._onerror 86 | ? (this._cleanup = this._onerror(error) as any) 87 | : console.error(new EffectError(this._effect, error)); 88 | } catch (e) { 89 | error = e; 90 | } 91 | } 92 | if (!this._queue.notify(this, ERROR_BIT, ERROR_BIT)) throw error; 93 | } 94 | 95 | override _disposeNode(): void { 96 | if (this._state === STATE_DISPOSED) return; 97 | this._effect = undefined as any; 98 | this._prevValue = undefined; 99 | this._onerror = undefined as any; 100 | this._cleanup?.(); 101 | this._cleanup = undefined; 102 | super._disposeNode(); 103 | } 104 | 105 | _runEffect(type: number): void { 106 | if (type) { 107 | if (this._modified && this._state !== STATE_DISPOSED) { 108 | this._cleanup?.(); 109 | try { 110 | this._cleanup = this._effect(this._value!, this._prevValue) as any; 111 | } catch (e) { 112 | if (!this._queue.notify(this, ERROR_BIT, ERROR_BIT)) throw e; 113 | } finally { 114 | this._prevValue = this._value; 115 | this._modified = false; 116 | } 117 | } 118 | } else this._state !== STATE_CLEAN && runTop(this); 119 | } 120 | } 121 | 122 | function runComputation(this: Computation): void { 123 | this._state !== STATE_CLEAN && runTop(this); 124 | } 125 | export class EagerComputation extends Computation { 126 | constructor(initialValue: T, compute: () => T, options?: SignalOptions & { defer?: boolean }) { 127 | super(initialValue, compute, options); 128 | !options?.defer && this._updateIfNecessary(); 129 | if (__DEV__ && !this._parent) 130 | console.warn("Eager Computations created outside a reactive context will never be disposed"); 131 | } 132 | 133 | override _notify(state: number, skipQueue?: boolean): void { 134 | if (this._state >= state && !this._forceNotify) return; 135 | 136 | if ( 137 | !skipQueue && 138 | (this._state === STATE_CLEAN || (this._state === STATE_CHECK && this._forceNotify)) 139 | ) 140 | this._queue.enqueue(EFFECT_PURE, runComputation.bind(this)); 141 | 142 | super._notify(state, skipQueue); 143 | } 144 | } 145 | 146 | export class ProjectionComputation extends Computation { 147 | constructor(compute: () => void) { 148 | super(undefined, compute); 149 | if (__DEV__ && !this._parent) 150 | console.warn("Eager Computations created outside a reactive context will never be disposed"); 151 | } 152 | _notify(state: number, skipQueue?: boolean): void { 153 | if (this._state >= state && !this._forceNotify) return; 154 | 155 | if ( 156 | !skipQueue && 157 | (this._state === STATE_CLEAN || (this._state === STATE_CHECK && this._forceNotify)) 158 | ) 159 | this._queue.enqueue(EFFECT_PURE, runComputation.bind(this)); 160 | 161 | super._notify(state, true); 162 | this._forceNotify = !!skipQueue; // they don't need to be forced themselves unless from above 163 | } 164 | } 165 | 166 | /** 167 | * When re-executing nodes, we want to be extra careful to avoid double execution of nested owners 168 | * In particular, it is important that we check all of our parents to see if they will rerun 169 | * See tests/createEffect: "should run parent effect before child effect" and "should run parent 170 | * memo before child effect" 171 | */ 172 | function runTop(node: Computation): void { 173 | const ancestors: Computation[] = []; 174 | 175 | for (let current: Owner | null = node; current !== null; current = current._parent) { 176 | if (current._state !== STATE_CLEAN) { 177 | ancestors.push(current as Computation); 178 | } 179 | } 180 | 181 | for (let i = ancestors.length - 1; i >= 0; i--) { 182 | if (ancestors[i]._state !== STATE_DISPOSED) ancestors[i]._updateIfNecessary(); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/core/error.ts: -------------------------------------------------------------------------------- 1 | export class NotReadyError extends Error {} 2 | 3 | export class NoOwnerError extends Error { 4 | constructor() { 5 | super(__DEV__ ? "Context can only be accessed under a reactive root." : ""); 6 | } 7 | } 8 | 9 | export class ContextNotFoundError extends Error { 10 | constructor() { 11 | super( 12 | __DEV__ 13 | ? "Context must either be created with a default value or a value must be provided before accessing it." 14 | : "" 15 | ); 16 | } 17 | } 18 | 19 | export class EffectError extends Error { 20 | constructor(effect: Function, cause: unknown) { 21 | super(__DEV__ ? `Uncaught error while running effect:\n\n ${effect.toString()}\n` : ""); 22 | this.cause = cause; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/core/flags.ts: -------------------------------------------------------------------------------- 1 | export type Flags = number; 2 | 3 | export const ERROR_OFFSET = 0; 4 | export const ERROR_BIT = 1 << ERROR_OFFSET; 5 | export const ERROR: unique symbol = Symbol(__DEV__ ? "ERROR" : 0); 6 | 7 | export const LOADING_OFFSET = 1; 8 | export const LOADING_BIT = 1 << LOADING_OFFSET; 9 | export const LOADING: unique symbol = Symbol(__DEV__ ? "LOADING" : 0); 10 | 11 | export const UNINITIALIZED_OFFSET = 2; 12 | export const UNINITIALIZED_BIT = 1 << UNINITIALIZED_OFFSET; 13 | export const UNINITIALIZED: unique symbol = Symbol(__DEV__ ? "UNINITIALIZED" : 0); 14 | 15 | export const DEFAULT_FLAGS = ERROR_BIT; 16 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export { ContextNotFoundError, NoOwnerError, NotReadyError } from "./error.js"; 2 | export { 3 | Owner, 4 | createContext, 5 | getContext, 6 | setContext, 7 | hasContext, 8 | getOwner, 9 | onCleanup, 10 | type Context, 11 | type ContextRecord, 12 | type Disposable 13 | } from "./owner.js"; 14 | export { 15 | Computation, 16 | getObserver, 17 | isEqual, 18 | untrack, 19 | hasUpdated, 20 | isPending, 21 | latest, 22 | UNCHANGED, 23 | compute, 24 | runWithObserver, 25 | type SignalOptions 26 | } from "./core.js"; 27 | export { Effect, EagerComputation } from "./effect.js"; 28 | export { flushSync, Queue, incrementClock, getClock, type IQueue } from "./scheduler.js"; 29 | export * from "./constants.js"; 30 | export * from "./flags.js"; 31 | -------------------------------------------------------------------------------- /src/core/owner.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Owner tracking is used to enable nested tracking scopes with automatic cleanup. 3 | * We also use owners to also keep track of which error handling context we are in. 4 | * 5 | * If you write the following 6 | * 7 | * const a = createOwner(() => { 8 | * const b = createOwner(() => {}); 9 | * 10 | * const c = createOwner(() => { 11 | * const d = createOwner(() => {}); 12 | * }); 13 | * 14 | * const e = createOwner(() => {}); 15 | * }); 16 | * 17 | * The owner tree will look like this: 18 | * 19 | * a 20 | * /|\ 21 | * b-c-e 22 | * | 23 | * d 24 | * 25 | * Following the _nextSibling pointers of each owner will first give you its children, and then its siblings (in reverse). 26 | * a -> e -> c -> d -> b 27 | * 28 | * Note that the owner tree is largely orthogonal to the reactivity tree, and is much closer to the component tree. 29 | */ 30 | 31 | import { STATE_CLEAN, STATE_DISPOSED } from "./constants.js"; 32 | import type { Computation } from "./core.js"; 33 | import { ContextNotFoundError, NoOwnerError } from "./error.js"; 34 | import { globalQueue, type IQueue } from "./scheduler.js"; 35 | 36 | export type ContextRecord = Record; 37 | 38 | export interface Disposable { 39 | (): void; 40 | } 41 | 42 | let currentOwner: Owner | null = null, 43 | defaultContext = {}; 44 | 45 | /** 46 | * Returns the currently executing parent owner. 47 | */ 48 | export function getOwner(): Owner | null { 49 | return currentOwner; 50 | } 51 | 52 | export function setOwner(owner: Owner | null): Owner | null { 53 | const out = currentOwner; 54 | currentOwner = owner; 55 | return out; 56 | } 57 | 58 | export class Owner { 59 | // We flatten the owner tree into a linked list so that we don't need a pointer to .firstChild 60 | // However, the children are actually added in reverse creation order 61 | // See comment at the top of the file for an example of the _nextSibling traversal 62 | _parent: Owner | null = null; 63 | _nextSibling: Owner | null = null; 64 | _prevSibling: Owner | null = null; 65 | 66 | _state: number = STATE_CLEAN; 67 | 68 | _disposal: Disposable | Disposable[] | null = null; 69 | _context: ContextRecord = defaultContext; 70 | _queue: IQueue = globalQueue; 71 | 72 | _childCount: number = 0; 73 | id: string | null = null; 74 | 75 | constructor(id: string | null = null, skipAppend = false) { 76 | this.id = id; 77 | if (currentOwner) { 78 | !skipAppend && currentOwner.append(this); 79 | } 80 | } 81 | 82 | append(child: Owner): void { 83 | child._parent = this; 84 | child._prevSibling = this; 85 | 86 | if (this._nextSibling) this._nextSibling._prevSibling = child; 87 | child._nextSibling = this._nextSibling; 88 | this._nextSibling = child; 89 | 90 | if (this.id != null && child.id == null) child.id = this.getNextChildId(); 91 | if (child._context !== this._context) { 92 | child._context = { ...this._context, ...child._context }; 93 | } 94 | 95 | if (this._queue) child._queue = this._queue; 96 | } 97 | 98 | dispose(this: Owner, self = true): void { 99 | if (this._state === STATE_DISPOSED) return; 100 | 101 | let head = self ? this._prevSibling || this._parent : this, 102 | current = this._nextSibling, 103 | next: Computation | null = null; 104 | 105 | while (current && current._parent === this) { 106 | current.dispose(true); 107 | current._disposeNode(); 108 | next = current._nextSibling as Computation | null; 109 | current._nextSibling = null; 110 | current = next; 111 | } 112 | 113 | this._childCount = 0; 114 | if (self) this._disposeNode(); 115 | if (current) current._prevSibling = !self ? this : this._prevSibling; 116 | if (head) head._nextSibling = current; 117 | } 118 | 119 | _disposeNode(): void { 120 | if (this._prevSibling) this._prevSibling._nextSibling = null; 121 | this._parent = null; 122 | this._prevSibling = null; 123 | this._context = defaultContext; 124 | this._state = STATE_DISPOSED; 125 | this.emptyDisposal(); 126 | } 127 | 128 | emptyDisposal(): void { 129 | if (!this._disposal) return; 130 | 131 | if (Array.isArray(this._disposal)) { 132 | for (let i = 0; i < this._disposal.length; i++) { 133 | const callable = this._disposal[i]; 134 | callable.call(callable); 135 | } 136 | } else { 137 | this._disposal.call(this._disposal); 138 | } 139 | 140 | this._disposal = null; 141 | } 142 | 143 | getNextChildId(): string { 144 | if (this.id != null) return formatId(this.id, this._childCount++); 145 | throw new Error("Cannot get child id from owner without an id"); 146 | } 147 | } 148 | 149 | export interface Context { 150 | readonly id: symbol; 151 | readonly defaultValue: T | undefined; 152 | } 153 | 154 | /** 155 | * Context provides a form of dependency injection. It is used to save from needing to pass 156 | * data as props through intermediate components. This function creates a new context object 157 | * that can be used with `getContext` and `setContext`. 158 | * 159 | * A default value can be provided here which will be used when a specific value is not provided 160 | * via a `setContext` call. 161 | */ 162 | export function createContext(defaultValue?: T, description?: string): Context { 163 | return { id: Symbol(description), defaultValue }; 164 | } 165 | 166 | /** 167 | * Attempts to get a context value for the given key. 168 | * 169 | * @throws `NoOwnerError` if there's no owner at the time of call. 170 | * @throws `ContextNotFoundError` if a context value has not been set yet. 171 | */ 172 | export function getContext(context: Context, owner: Owner | null = currentOwner): T { 173 | if (!owner) { 174 | throw new NoOwnerError(); 175 | } 176 | 177 | const value = hasContext(context, owner) 178 | ? (owner._context[context.id] as T) 179 | : context.defaultValue; 180 | 181 | if (isUndefined(value)) { 182 | throw new ContextNotFoundError(); 183 | } 184 | 185 | return value; 186 | } 187 | 188 | /** 189 | * Attempts to set a context value on the parent scope with the given key. 190 | * 191 | * @throws `NoOwnerError` if there's no owner at the time of call. 192 | */ 193 | export function setContext(context: Context, value?: T, owner: Owner | null = currentOwner) { 194 | if (!owner) { 195 | throw new NoOwnerError(); 196 | } 197 | 198 | // We're creating a new object to avoid child context values being exposed to parent owners. If 199 | // we don't do this, everything will be a singleton and all hell will break lose. 200 | owner._context = { 201 | ...owner._context, 202 | [context.id]: isUndefined(value) ? context.defaultValue : value 203 | }; 204 | } 205 | 206 | /** 207 | * Whether the given context is currently defined. 208 | */ 209 | export function hasContext(context: Context, owner: Owner | null = currentOwner): boolean { 210 | return !isUndefined(owner?._context[context.id]); 211 | } 212 | 213 | /** 214 | * Runs an effect once before the reactive scope is disposed 215 | * @param fn an effect that should run only once on cleanup 216 | * 217 | * @returns the same {@link fn} function that was passed in 218 | * 219 | * @description https://docs.solidjs.com/reference/lifecycle/on-cleanup 220 | */ 221 | export function onCleanup(fn: Disposable): Disposable { 222 | if (!currentOwner) return fn; 223 | 224 | const node = currentOwner; 225 | 226 | if (!node._disposal) { 227 | node._disposal = fn; 228 | } else if (Array.isArray(node._disposal)) { 229 | node._disposal.push(fn); 230 | } else { 231 | node._disposal = [node._disposal, fn]; 232 | } 233 | return fn; 234 | } 235 | 236 | function formatId(prefix: string, id: number) { 237 | const num = id.toString(36), 238 | len = num.length - 1; 239 | return prefix + (len ? String.fromCharCode(64 + len) : "") + num; 240 | } 241 | 242 | function isUndefined(value: any): value is undefined { 243 | return typeof value === "undefined"; 244 | } 245 | -------------------------------------------------------------------------------- /src/core/scheduler.ts: -------------------------------------------------------------------------------- 1 | import { EFFECT_PURE, EFFECT_RENDER, EFFECT_USER } from "./constants.js"; 2 | 3 | let clock = 0; 4 | export function getClock() { 5 | return clock; 6 | } 7 | export function incrementClock(): void { 8 | clock++; 9 | } 10 | 11 | let scheduled = false; 12 | function schedule() { 13 | if (scheduled) return; 14 | scheduled = true; 15 | if (!globalQueue._running) queueMicrotask(flushSync); 16 | } 17 | 18 | type QueueCallback = (type: number) => void; 19 | export interface IQueue { 20 | enqueue(type: number, fn: QueueCallback): void; 21 | run(type: number): boolean | void; 22 | flush(): void; 23 | addChild(child: IQueue): void; 24 | removeChild(child: IQueue): void; 25 | created: number; 26 | notify(...args: any[]): boolean; 27 | _parent: IQueue | null; 28 | } 29 | 30 | let pureQueue: QueueCallback[] = []; 31 | export class Queue implements IQueue { 32 | _parent: IQueue | null = null; 33 | _running: boolean = false; 34 | _queues: [QueueCallback[], QueueCallback[]] = [[], []]; 35 | _children: IQueue[] = []; 36 | created = clock; 37 | enqueue(type: number, fn: QueueCallback): void { 38 | pureQueue.push(fn); 39 | if (type) this._queues[type - 1].push(fn); 40 | schedule(); 41 | } 42 | run(type: number) { 43 | if (type === EFFECT_PURE) { 44 | pureQueue.length && runQueue(pureQueue, type); 45 | pureQueue = []; 46 | return; 47 | } else if (this._queues[type - 1].length) { 48 | const effects = this._queues[type - 1]; 49 | this._queues[type - 1] = []; 50 | runQueue(effects, type); 51 | } 52 | for (let i = 0; i < this._children.length; i++) { 53 | this._children[i].run(type); 54 | } 55 | } 56 | flush() { 57 | if (this._running) return; 58 | this._running = true; 59 | try { 60 | this.run(EFFECT_PURE); 61 | incrementClock(); 62 | scheduled = false; 63 | this.run(EFFECT_RENDER); 64 | this.run(EFFECT_USER); 65 | } finally { 66 | this._running = false; 67 | } 68 | } 69 | addChild(child: IQueue) { 70 | this._children.push(child); 71 | child._parent = this; 72 | } 73 | removeChild(child: IQueue) { 74 | const index = this._children.indexOf(child); 75 | if (index >= 0) this._children.splice(index, 1); 76 | } 77 | notify(...args: any[]) { 78 | if (this._parent) return this._parent.notify(...args); 79 | return false; 80 | } 81 | } 82 | 83 | export const globalQueue = new Queue(); 84 | 85 | /** 86 | * By default, changes are batched on the microtask queue which is an async process. You can flush 87 | * the queue synchronously to get the latest updates by calling `flushSync()`. 88 | */ 89 | export function flushSync(): void { 90 | let count = 0; 91 | while (scheduled) { 92 | if (__DEV__ && ++count === 1e5) throw new Error("Potential Infinite Loop Detected."); 93 | globalQueue.flush(); 94 | } 95 | } 96 | 97 | function runQueue(queue: QueueCallback[], type: number): void { 98 | for (let i = 0; i < queue.length; i++) queue[i](type); 99 | } 100 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | const __DEV__: boolean; 3 | const __TEST__: boolean; 4 | } 5 | 6 | export {}; 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | Computation, 3 | ContextNotFoundError, 4 | NoOwnerError, 5 | NotReadyError, 6 | Owner, 7 | Queue, 8 | createContext, 9 | flushSync, 10 | getContext, 11 | setContext, 12 | hasContext, 13 | getOwner, 14 | onCleanup, 15 | getObserver, 16 | isEqual, 17 | untrack, 18 | hasUpdated, 19 | isPending, 20 | latest, 21 | runWithObserver, 22 | SUPPORTS_PROXY 23 | } from "./core/index.js"; 24 | export type { SignalOptions, Context, ContextRecord, Disposable, IQueue } from "./core/index.js"; 25 | export { mapArray, repeat, type Maybe } from "./map.js"; 26 | export * from "./signals.js"; 27 | export * from "./store/index.js"; 28 | export { 29 | createSuspense, 30 | createErrorBoundary, 31 | createBoundary, 32 | flatten, 33 | type BoundaryMode 34 | } from "./boundaries.js"; 35 | -------------------------------------------------------------------------------- /src/map.ts: -------------------------------------------------------------------------------- 1 | import { Computation, compute, Owner } from "./core/index.js"; 2 | import { runWithOwner } from "./signals.js"; 3 | import type { Accessor } from "./signals.js"; 4 | import { $TRACK } from "./store/index.js"; 5 | 6 | export type Maybe = T | void | null | undefined | false; 7 | 8 | /** 9 | * Reactively transforms an array with a callback function - underlying helper for the `` control flow 10 | * 11 | * similar to `Array.prototype.map`, but gets the value and index as accessors, transforms only values that changed and returns an accessor and reactively tracks changes to the list. 12 | * 13 | * @description https://docs.solidjs.com/reference/reactive-utilities/map-array 14 | */ 15 | export function mapArray( 16 | list: Accessor>, 17 | map: (value: Accessor, index: Accessor) => MappedItem, 18 | options?: { keyed?: boolean | ((item: Item) => any); fallback?: Accessor } 19 | ): Accessor { 20 | const keyFn = typeof options?.keyed === "function" ? options.keyed : undefined; 21 | return updateKeyedMap.bind({ 22 | _owner: new Owner(), 23 | _len: 0, 24 | _list: list, 25 | _items: [], 26 | _map: map, 27 | _mappings: [], 28 | _nodes: [], 29 | _key: keyFn, 30 | _rows: keyFn || options?.keyed === false ? [] : undefined, 31 | _indexes: map.length > 1 ? [] : undefined, 32 | _fallback: options?.fallback 33 | }); 34 | } 35 | 36 | function updateKeyedMap(this: MapData): any[] { 37 | const newItems = this._list() || [], 38 | newLen = newItems.length; 39 | (newItems as any)[$TRACK]; // top level tracking 40 | 41 | runWithOwner(this._owner, () => { 42 | let i: number, 43 | j: number, 44 | mapper = this._rows 45 | ? () => { 46 | this._rows![j] = new Computation(newItems[j], null); 47 | this._indexes && (this._indexes![j] = new Computation(j, null)); 48 | return this._map( 49 | Computation.prototype.read.bind(this._rows![j]), 50 | this._indexes 51 | ? Computation.prototype.read.bind(this._indexes![j]) 52 | : (undefined as any) 53 | ); 54 | } 55 | : this._indexes 56 | ? () => { 57 | const item = newItems[j]; 58 | this._indexes![j] = new Computation(j, null); 59 | return this._map(() => item, Computation.prototype.read.bind(this._indexes![j])); 60 | } 61 | : () => { 62 | const item = newItems[j]; 63 | return (this._map as (value: () => Item) => MappedItem)(() => item); 64 | }; 65 | 66 | // fast path for empty arrays 67 | if (newLen === 0) { 68 | if (this._len !== 0) { 69 | this._owner.dispose(false); 70 | this._nodes = []; 71 | this._items = []; 72 | this._mappings = []; 73 | this._len = 0; 74 | this._rows && (this._rows = []); 75 | this._indexes && (this._indexes = []); 76 | } 77 | if (this._fallback && !this._mappings[0]) { 78 | // create fallback 79 | this._mappings[0] = compute( 80 | (this._nodes[0] = new Owner()), 81 | this._fallback, 82 | null 83 | ); 84 | } 85 | } 86 | // fast path for new create 87 | else if (this._len === 0) { 88 | // dispose previous fallback 89 | if (this._nodes[0]) this._nodes[0].dispose(); 90 | this._mappings = new Array(newLen); 91 | 92 | for (j = 0; j < newLen; j++) { 93 | this._items[j] = newItems[j]; 94 | this._mappings[j] = compute((this._nodes[j] = new Owner()), mapper, null); 95 | } 96 | 97 | this._len = newLen; 98 | } else { 99 | let start: number, 100 | end: number, 101 | newEnd: number, 102 | item: Item, 103 | key: any, 104 | newIndices: Map, 105 | newIndicesNext: number[], 106 | temp: MappedItem[] = new Array(newLen), 107 | tempNodes: Owner[] = new Array(newLen), 108 | tempRows: Computation[] | undefined = this._rows ? new Array(newLen) : undefined, 109 | tempIndexes: Computation[] | undefined = this._indexes 110 | ? new Array(newLen) 111 | : undefined; 112 | 113 | // skip common prefix 114 | for ( 115 | start = 0, end = Math.min(this._len, newLen); 116 | start < end && 117 | (this._items[start] === newItems[start] || 118 | (this._rows && compare(this._key, this._items[start], newItems[start]))); 119 | start++ 120 | ) { 121 | if (this._rows) this._rows[start].write(newItems[start]); 122 | } 123 | 124 | // common suffix 125 | for ( 126 | end = this._len - 1, newEnd = newLen - 1; 127 | end >= start && 128 | newEnd >= start && 129 | (this._items[end] === newItems[newEnd] || 130 | (this._rows && compare(this._key, this._items[end], newItems[newEnd]))); 131 | end--, newEnd-- 132 | ) { 133 | temp[newEnd] = this._mappings[end]; 134 | tempNodes[newEnd] = this._nodes[end]; 135 | tempRows && (tempRows[newEnd] = this._rows![end]); 136 | tempIndexes && (tempIndexes[newEnd] = this._indexes![end]); 137 | } 138 | 139 | // 0) prepare a map of all indices in newItems, scanning backwards so we encounter them in natural order 140 | newIndices = new Map(); 141 | newIndicesNext = new Array(newEnd + 1); 142 | for (j = newEnd; j >= start; j--) { 143 | item = newItems[j]; 144 | key = this._key ? this._key(item) : item; 145 | i = newIndices.get(key)!; 146 | newIndicesNext[j] = i === undefined ? -1 : i; 147 | newIndices.set(key, j); 148 | } 149 | 150 | // 1) step through all old items and see if they can be found in the new set; if so, save them in a temp array and mark them moved; if not, exit them 151 | for (i = start; i <= end; i++) { 152 | item = this._items[i]; 153 | key = this._key ? this._key(item) : item; 154 | j = newIndices.get(key)!; 155 | if (j !== undefined && j !== -1) { 156 | temp[j] = this._mappings[i]; 157 | tempNodes[j] = this._nodes[i]; 158 | tempRows && (tempRows[j] = this._rows![i]); 159 | tempIndexes && (tempIndexes[j] = this._indexes![i]); 160 | j = newIndicesNext[j]; 161 | newIndices.set(key, j); 162 | } else this._nodes[i].dispose(); 163 | } 164 | 165 | // 2) set all the new values, pulling from the temp array if copied, otherwise entering the new value 166 | for (j = start; j < newLen; j++) { 167 | if (j in temp) { 168 | this._mappings[j] = temp[j]; 169 | this._nodes[j] = tempNodes[j]; 170 | if (tempRows) { 171 | this._rows![j] = tempRows[j]; 172 | this._rows![j].write(newItems[j]); 173 | } 174 | if (tempIndexes) { 175 | this._indexes![j] = tempIndexes[j]; 176 | this._indexes![j].write(j); 177 | } 178 | } else { 179 | this._mappings[j] = compute((this._nodes[j] = new Owner()), mapper, null); 180 | } 181 | } 182 | 183 | // 3) in case the new set is shorter than the old, set the length of the mapped array 184 | this._mappings = this._mappings.slice(0, (this._len = newLen)); 185 | 186 | // 4) save a copy of the mapped items for the next update 187 | this._items = newItems.slice(0); 188 | } 189 | }); 190 | 191 | return this._mappings; 192 | } 193 | 194 | /** 195 | * Reactively repeats a callback function the count provided - underlying helper for the `` control flow 196 | * 197 | * @description https://docs.solidjs.com/reference/reactive-utilities/repeat 198 | */ 199 | export function repeat( 200 | count: Accessor, 201 | map: (index: number) => any, 202 | options?: { 203 | from?: Accessor; 204 | fallback?: Accessor; 205 | } 206 | ): Accessor { 207 | return updateRepeat.bind({ 208 | _owner: new Owner(), 209 | _len: 0, 210 | _offset: 0, 211 | _count: count, 212 | _map: map, 213 | _nodes: [], 214 | _mappings: [], 215 | _from: options?.from, 216 | _fallback: options?.fallback 217 | }); 218 | } 219 | 220 | function updateRepeat(this: RepeatData): any[] { 221 | const newLen = this._count(); 222 | const from = this._from?.() || 0; 223 | runWithOwner(this._owner, () => { 224 | if (newLen === 0) { 225 | if (this._len !== 0) { 226 | this._owner.dispose(false); 227 | this._nodes = []; 228 | this._mappings = []; 229 | this._len = 0; 230 | } 231 | if (this._fallback && !this._mappings[0]) { 232 | // create fallback 233 | this._mappings[0] = compute( 234 | (this._nodes[0] = new Owner()), 235 | this._fallback, 236 | null 237 | ); 238 | } 239 | return; 240 | } 241 | const to = from + newLen; 242 | const prevTo = this._offset + this._len; 243 | 244 | // remove fallback 245 | if (this._len === 0 && this._nodes[0]) this._nodes[0].dispose(); 246 | 247 | // clear the end 248 | for (let i = to; i < prevTo; i++) this._nodes[i - this._offset].dispose(); 249 | 250 | if (this._offset < from) { 251 | // clear beginning 252 | let i = this._offset; 253 | while (i < from && i < this._len) this._nodes[i++].dispose(); 254 | // shift indexes 255 | this._nodes.splice(0, from - this._offset); 256 | this._mappings.splice(0, from - this._offset); 257 | } else if (this._offset > from) { 258 | // shift indexes 259 | let i = prevTo - this._offset - 1; 260 | let difference = this._offset - from; 261 | this._nodes.length = this._mappings.length = newLen; 262 | while (i >= difference) { 263 | this._nodes[i] = this._nodes[i - difference]; 264 | this._mappings[i] = this._mappings[i - difference]; 265 | i--; 266 | } 267 | for (let i = 0; i < difference; i++) { 268 | this._mappings[i] = compute( 269 | (this._nodes[i] = new Owner()), 270 | () => this._map(i + from), 271 | null 272 | ); 273 | } 274 | } 275 | 276 | for (let i = prevTo; i < to; i++) { 277 | this._mappings[i - from] = compute( 278 | (this._nodes[i - from] = new Owner()), 279 | () => this._map(i), 280 | null 281 | ); 282 | } 283 | this._mappings = this._mappings.slice(0, newLen); 284 | this._offset = from; 285 | this._len = newLen; 286 | }); 287 | return this._mappings; 288 | } 289 | 290 | function compare(key: ((i: any) => any) | undefined, a: Item, b: Item): boolean { 291 | return key ? key(a) === key(b) : true; 292 | } 293 | 294 | interface RepeatData { 295 | _owner: Owner; 296 | _len: number; 297 | _count: Accessor; 298 | _map: (index: number) => MappedItem; 299 | _mappings: MappedItem[]; 300 | _nodes: Owner[]; 301 | _offset: number; 302 | _from?: Accessor; 303 | _fallback?: Accessor; 304 | } 305 | 306 | interface MapData { 307 | _owner: Owner; 308 | _len: number; 309 | _list: Accessor>; 310 | _items: Item[]; 311 | _mappings: MappedItem[]; 312 | _nodes: Owner[]; 313 | _map: (value: Accessor, index: Accessor) => any; 314 | _key: ((i: any) => any) | undefined; 315 | _rows?: Computation[]; 316 | _indexes?: Computation[]; 317 | _fallback?: Accessor; 318 | } 319 | -------------------------------------------------------------------------------- /src/signals.ts: -------------------------------------------------------------------------------- 1 | import type { SignalOptions } from "./core/index.js"; 2 | import { 3 | Computation, 4 | compute, 5 | EagerComputation, 6 | Effect, 7 | ERROR_BIT, 8 | getOwner, 9 | NotReadyError, 10 | onCleanup, 11 | Owner, 12 | STATE_DISPOSED, 13 | untrack 14 | } from "./core/index.js"; 15 | 16 | export type Accessor = () => T; 17 | 18 | export type Setter = { 19 | ( 20 | ...args: undefined extends T ? [] : [value: Exclude | ((prev: T) => U)] 21 | ): undefined extends T ? undefined : U; 22 | (value: (prev: T) => U): U; 23 | (value: Exclude): U; 24 | (value: Exclude | ((prev: T) => U)): U; 25 | }; 26 | 27 | export type Signal = [get: Accessor, set: Setter]; 28 | 29 | export type ComputeFunction = (v: Prev) => Next; 30 | export type EffectFunction = ( 31 | v: Next, 32 | p?: Prev 33 | ) => (() => void) | void; 34 | 35 | export interface EffectOptions { 36 | name?: string; 37 | defer?: boolean; 38 | } 39 | export interface MemoOptions { 40 | name?: string; 41 | equals?: false | ((prev: T, next: T) => boolean); 42 | } 43 | 44 | // Magic type that when used at sites where generic types are inferred from, will prevent those sites from being involved in the inference. 45 | // https://github.com/microsoft/TypeScript/issues/14829 46 | // TypeScript Discord conversation: https://discord.com/channels/508357248330760243/508357248330760249/911266491024949328 47 | export type NoInfer = [T][T extends any ? 0 : never]; 48 | 49 | /** 50 | * Creates a simple reactive state with a getter and setter 51 | * ```typescript 52 | * const [state: Accessor, setState: Setter] = createSignal( 53 | * value: T, 54 | * options?: { name?: string, equals?: false | ((prev: T, next: T) => boolean) } 55 | * ) 56 | * ``` 57 | * @param value initial value of the state; if empty, the state's type will automatically extended with undefined; otherwise you need to extend the type manually if you want setting to undefined not be an error 58 | * @param options optional object with a name for debugging purposes and equals, a comparator function for the previous and next value to allow fine-grained control over the reactivity 59 | * 60 | * @returns ```typescript 61 | * [state: Accessor, setState: Setter] 62 | * ``` 63 | * * the Accessor is a function that returns the current value and registers each call to the reactive root 64 | * * the Setter is a function that allows directly setting or mutating the value: 65 | * ```typescript 66 | * const [count, setCount] = createSignal(0); 67 | * setCount(count => count + 1); 68 | * ``` 69 | * 70 | * @description https://docs.solidjs.com/reference/basic-reactivity/create-signal 71 | */ 72 | export function createSignal(): Signal; 73 | export function createSignal(value: Exclude, options?: SignalOptions): Signal; 74 | export function createSignal( 75 | fn: ComputeFunction, 76 | initialValue?: T, 77 | options?: SignalOptions 78 | ): Signal; 79 | export function createSignal( 80 | first?: T | ComputeFunction, 81 | second?: T | SignalOptions, 82 | third?: SignalOptions 83 | ): Signal { 84 | if (typeof first === "function") { 85 | const memo = createMemo>(p => { 86 | const node = new Computation( 87 | (first as (prev?: T) => T)(p ? untrack(p[0]) : (second as T)), 88 | null, 89 | third 90 | ); 91 | return [node.read.bind(node), node.write.bind(node)] as Signal; 92 | }); 93 | return [() => memo()[0](), (value => memo()[1](value)) as Setter]; 94 | } 95 | const o = getOwner(); 96 | const needsId = o?.id != null; 97 | const node = new Computation( 98 | first, 99 | null, 100 | needsId ? { id: o.getNextChildId(), ...second } : (second as SignalOptions) 101 | ); 102 | return [node.read.bind(node), node.write.bind(node) as Setter]; 103 | } 104 | 105 | /** 106 | * Creates a readonly derived reactive memoized signal 107 | * ```typescript 108 | * export function createMemo( 109 | * compute: (v: T) => T, 110 | * value?: T, 111 | * options?: { name?: string, equals?: false | ((prev: T, next: T) => boolean) } 112 | * ): () => T; 113 | * ``` 114 | * @param compute a function that receives its previous or the initial value, if set, and returns a new value used to react on a computation 115 | * @param value an optional initial value for the computation; if set, fn will never receive undefined as first argument 116 | * @param options allows to set a name in dev mode for debugging purposes and use a custom comparison function in equals 117 | * 118 | * @description https://docs.solidjs.com/reference/basic-reactivity/create-memo 119 | */ 120 | // The extra Prev generic parameter separates inference of the compute input 121 | // parameter type from inference of the compute return type, so that the effect 122 | // return type is always used as the memo Accessor's return type. 123 | export function createMemo( 124 | compute: ComputeFunction, Next> 125 | ): Accessor; 126 | export function createMemo( 127 | compute: ComputeFunction, 128 | value: Init, 129 | options?: MemoOptions 130 | ): Accessor; 131 | export function createMemo( 132 | compute: ComputeFunction, 133 | value?: Init, 134 | options?: MemoOptions 135 | ): Accessor { 136 | let node: Computation | undefined = new Computation( 137 | value as any, 138 | compute as any, 139 | options 140 | ); 141 | let resolvedValue: Next; 142 | return () => { 143 | if (node) { 144 | if (node._state === STATE_DISPOSED) { 145 | node = undefined; 146 | return resolvedValue; 147 | } 148 | resolvedValue = node.wait(); 149 | // no sources so will never update so can be disposed. 150 | // additionally didn't create nested reactivity so can be disposed. 151 | if (!node._sources?.length && node._nextSibling?._parent !== node) { 152 | node.dispose(); 153 | node = undefined; 154 | } 155 | } 156 | return resolvedValue; 157 | }; 158 | } 159 | 160 | /** 161 | * Creates a readonly derived async reactive memoized signal 162 | * ```typescript 163 | * export function createAsync( 164 | * compute: (v: T) => Promise | T, 165 | * value?: T, 166 | * options?: { name?: string, equals?: false | ((prev: T, next: T) => boolean) } 167 | * ): () => T; 168 | * ``` 169 | * @param compute a function that receives its previous or the initial value, if set, and returns a new value used to react on a computation 170 | * @param value an optional initial value for the computation; if set, fn will never receive undefined as first argument 171 | * @param options allows to set a name in dev mode for debugging purposes and use a custom comparison function in equals 172 | * 173 | * @description https://docs.solidjs.com/reference/basic-reactivity/create-async 174 | */ 175 | export function createAsync( 176 | compute: (prev?: T) => Promise | AsyncIterable | T, 177 | value?: T, 178 | options?: MemoOptions 179 | ): Accessor { 180 | const node = new EagerComputation( 181 | value as T, 182 | (p?: T) => { 183 | const source = compute(p); 184 | const isPromise = source instanceof Promise; 185 | const iterator = source[Symbol.asyncIterator]; 186 | if (!isPromise && !iterator) { 187 | return source as T; 188 | } 189 | let abort = false; 190 | onCleanup(() => (abort = true)); 191 | if (isPromise) { 192 | source.then( 193 | value3 => { 194 | if (abort) return; 195 | node.write(value3, 0, true); 196 | }, 197 | error => { 198 | if (abort) return; 199 | node._setError(error); 200 | } 201 | ); 202 | } else { 203 | (async () => { 204 | try { 205 | for await (let value3 of source as AsyncIterable) { 206 | if (abort) return; 207 | node.write(value3, 0, true); 208 | } 209 | } catch (error: any) { 210 | if (abort) return; 211 | node.write(error, ERROR_BIT); 212 | } 213 | })(); 214 | } 215 | throw new NotReadyError(); 216 | }, 217 | options 218 | ); 219 | return node.wait.bind(node) as Accessor; 220 | } 221 | 222 | /** 223 | * Creates a reactive effect that runs after the render phase 224 | * ```typescript 225 | * export function createEffect( 226 | * compute: (prev: T) => T, 227 | * effect: (v: T, prev: T) => (() => void) | void, 228 | * value?: T, 229 | * options?: { name?: string } 230 | * ): void; 231 | * ``` 232 | * @param compute a function that receives its previous or the initial value, if set, and returns a new value used to react on a computation 233 | * @param effect a function that receives the new value and is used to perform side effects, return a cleanup function to run on disposal 234 | * @param error an optional function that receives an error if thrown during the computation 235 | * @param value an optional initial value for the computation; if set, fn will never receive undefined as first argument 236 | * @param options allows to set a name in dev mode for debugging purposes 237 | * 238 | * @description https://docs.solidjs.com/reference/basic-reactivity/create-effect 239 | */ 240 | export function createEffect( 241 | compute: ComputeFunction, Next>, 242 | effect: EffectFunction, Next>, 243 | error?: (err: unknown) => void 244 | ): void; 245 | export function createEffect( 246 | compute: ComputeFunction, 247 | effect: EffectFunction, 248 | error: ((err: unknown) => void) | undefined, 249 | value: Init, 250 | options?: EffectOptions 251 | ): void; 252 | export function createEffect( 253 | compute: ComputeFunction, 254 | effect: EffectFunction, 255 | error?: (err: unknown) => void, 256 | value?: Init, 257 | options?: EffectOptions 258 | ): void { 259 | void new Effect( 260 | value as any, 261 | compute as any, 262 | effect, 263 | error, 264 | __DEV__ ? { ...options, name: options?.name ?? "effect" } : options 265 | ); 266 | } 267 | 268 | /** 269 | * Creates a reactive computation that runs during the render phase as DOM elements are created and updated but not necessarily connected 270 | * ```typescript 271 | * export function createRenderEffect( 272 | * compute: (prev: T) => T, 273 | * effect: (v: T, prev: T) => (() => void) | void, 274 | * value?: T, 275 | * options?: { name?: string } 276 | * ): void; 277 | * ``` 278 | * @param compute a function that receives its previous or the initial value, if set, and returns a new value used to react on a computation 279 | * @param effect a function that receives the new value and is used to perform side effects 280 | * @param value an optional initial value for the computation; if set, fn will never receive undefined as first argument 281 | * @param options allows to set a name in dev mode for debugging purposes 282 | * 283 | * @description https://docs.solidjs.com/reference/secondary-primitives/create-render-effect 284 | */ 285 | export function createRenderEffect( 286 | compute: ComputeFunction, Next>, 287 | effect: EffectFunction, Next> 288 | ): void; 289 | export function createRenderEffect( 290 | compute: ComputeFunction, 291 | effect: EffectFunction, 292 | value: Init, 293 | options?: EffectOptions 294 | ): void; 295 | export function createRenderEffect( 296 | compute: ComputeFunction, 297 | effect: EffectFunction, 298 | value?: Init, 299 | options?: EffectOptions 300 | ): void { 301 | void new Effect(value as any, compute as any, effect, undefined, { 302 | render: true, 303 | ...(__DEV__ ? { ...options, name: options?.name ?? "effect" } : options) 304 | }); 305 | } 306 | 307 | /** 308 | * Creates a new non-tracked reactive context with manual disposal 309 | * 310 | * @param fn a function in which the reactive state is scoped 311 | * @returns the output of `fn`. 312 | * 313 | * @description https://docs.solidjs.com/reference/reactive-utilities/create-root 314 | */ 315 | export function createRoot( 316 | init: ((dispose: () => void) => T) | (() => T), 317 | options?: { id: string } 318 | ): T { 319 | const owner = new Owner(options?.id); 320 | return compute(owner, !init.length ? (init as () => T) : () => init(() => owner.dispose()), null); 321 | } 322 | 323 | /** 324 | * Runs the given function in the given owner to move ownership of nested primitives and cleanups. 325 | * This method untracks the current scope. 326 | * 327 | * Warning: Usually there are simpler ways of modeling a problem that avoid using this function 328 | */ 329 | export function runWithOwner(owner: Owner | null, run: () => T): T { 330 | return compute(owner, run, null); 331 | } 332 | 333 | /** 334 | * Returns a promise of the resolved value of a reactive expression 335 | * @param fn a reactive expression to resolve 336 | */ 337 | export function resolve(fn: () => T): Promise { 338 | return new Promise((res, rej) => { 339 | createRoot(dispose => { 340 | new EagerComputation(undefined, () => { 341 | try { 342 | res(fn()); 343 | } catch (err) { 344 | if (err instanceof NotReadyError) throw err; 345 | rej(err); 346 | } 347 | dispose(); 348 | }); 349 | }); 350 | }); 351 | } 352 | 353 | export type TryCatchResult = [undefined, T] | [E]; 354 | export function tryCatch(fn: () => Promise): Promise>; 355 | export function tryCatch(fn: () => T): TryCatchResult; 356 | export function tryCatch( 357 | fn: () => T | Promise 358 | ): TryCatchResult | Promise> { 359 | try { 360 | const v = fn(); 361 | if (v instanceof Promise) { 362 | return v.then( 363 | v => [undefined, v], 364 | e => { 365 | if (e instanceof NotReadyError) throw e; 366 | return [e as E]; 367 | } 368 | ); 369 | } 370 | return [undefined, v]; 371 | } catch (e) { 372 | if (e instanceof NotReadyError) throw e; 373 | return [e as E]; 374 | } 375 | } 376 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | export type { Store, StoreSetter, StoreNode, NotWrappable, SolidStore } from "./store.js"; 2 | export type { Merge, Omit } from "./utils.js"; 3 | 4 | export { unwrap, isWrappable, createStore, deep, $RAW, $TRACK, $PROXY, $TARGET } from "./store.js"; 5 | 6 | export { createProjection } from "./projection.js"; 7 | 8 | export { reconcile } from "./reconcile.js"; 9 | 10 | export { merge, omit } from "./utils.js"; 11 | -------------------------------------------------------------------------------- /src/store/projection.ts: -------------------------------------------------------------------------------- 1 | import { ProjectionComputation } from "../core/effect.js"; 2 | import { createStore, isWrappable, type Store, type StoreSetter } from "./store.js"; 3 | 4 | /** 5 | * Creates a mutable derived value 6 | * 7 | * @see {@link https://github.com/solidjs/x-reactivity#createprojection} 8 | */ 9 | export function createProjection( 10 | fn: (draft: T) => void, 11 | initialValue: T = {} as T 12 | ): Store { 13 | const [store] = createStore(fn, initialValue); 14 | return store; 15 | } 16 | 17 | export function wrapProjection( 18 | fn: (draft: T) => void, 19 | store: Store, 20 | setStore: StoreSetter 21 | ): [Store, StoreSetter] { 22 | const node = new ProjectionComputation(() => { 23 | setStore(fn); 24 | }); 25 | const wrapped = new WeakMap(); 26 | return [wrap(store, node, wrapped), setStore]; 27 | } 28 | 29 | function wrap(source, node, wrapped) { 30 | if (wrapped.has(source)) return wrapped.get(source); 31 | const wrap = new Proxy(source, { 32 | get(target, property) { 33 | node.read(); 34 | const v = target[property]; 35 | return isWrappable(v) ? wrap(v, node, wrapped) : v; 36 | }, 37 | set() { 38 | throw new Error("Projections are readonly"); 39 | }, 40 | deleteProperty() { 41 | throw new Error("Projections are readonly"); 42 | } 43 | }); 44 | wrapped.set(source, wrap); 45 | return wrap; 46 | } 47 | -------------------------------------------------------------------------------- /src/store/reconcile.ts: -------------------------------------------------------------------------------- 1 | import { 2 | $PROXY, 3 | $TARGET, 4 | $TRACK, 5 | isWrappable, 6 | STORE_HAS, 7 | STORE_NODE, 8 | STORE_VALUE, 9 | unwrap, 10 | wrap 11 | } from "./store.js"; 12 | 13 | function applyState(next: any, state: any, keyFn: (item: NonNullable) => any) { 14 | const target = state?.[$TARGET]; 15 | if (!target) return; 16 | const previous = target[STORE_VALUE]; 17 | if (next === previous) return; 18 | 19 | // swap 20 | Object.defineProperty(next, $PROXY, { 21 | value: previous[$PROXY], 22 | writable: true 23 | }); 24 | previous[$PROXY] = null; 25 | target[STORE_VALUE] = next; 26 | 27 | // merge 28 | if (Array.isArray(previous)) { 29 | let changed = false; 30 | if (next.length && previous.length && next[0] && keyFn(next[0]) != null) { 31 | let i, j, start, end, newEnd, item, newIndicesNext, keyVal; // common prefix 32 | 33 | for ( 34 | start = 0, end = Math.min(previous.length, next.length); 35 | start < end && 36 | (previous[start] === next[start] || 37 | (previous[start] && next[start] && keyFn(previous[start]) === keyFn(next[start]))); 38 | start++ 39 | ) { 40 | applyState(next[start], wrap(previous[start]), keyFn); 41 | } 42 | 43 | const temp = new Array(next.length), 44 | newIndices = new Map(); 45 | 46 | for ( 47 | end = previous.length - 1, newEnd = next.length - 1; 48 | end >= start && 49 | newEnd >= start && 50 | (previous[end] === next[newEnd] || 51 | (previous[end] && next[newEnd] && keyFn(previous[end]) === keyFn(next[newEnd]))); 52 | end--, newEnd-- 53 | ) { 54 | temp[newEnd] = previous[end]; 55 | } 56 | 57 | if (start > newEnd || start > end) { 58 | for (j = start; j <= newEnd; j++) { 59 | changed = true; 60 | target[STORE_NODE][j]?.write(wrap(next[j])); 61 | } 62 | 63 | for (; j < next.length; j++) { 64 | changed = true; 65 | const wrapped = wrap(temp[j]); 66 | target[STORE_NODE][j]?.write(wrapped); 67 | applyState(next[j], wrapped, keyFn); 68 | } 69 | 70 | changed && target[STORE_NODE][$TRACK]?.write(void 0); 71 | previous.length !== next.length && target[STORE_NODE].length?.write(next.length); 72 | return; 73 | } 74 | 75 | newIndicesNext = new Array(newEnd + 1); 76 | 77 | for (j = newEnd; j >= start; j--) { 78 | item = next[j]; 79 | keyVal = item ? keyFn(item) : item; 80 | i = newIndices.get(keyVal); 81 | newIndicesNext[j] = i === undefined ? -1 : i; 82 | newIndices.set(keyVal, j); 83 | } 84 | 85 | for (i = start; i <= end; i++) { 86 | item = previous[i]; 87 | keyVal = item ? keyFn(item) : item; 88 | j = newIndices.get(keyVal); 89 | 90 | if (j !== undefined && j !== -1) { 91 | temp[j] = previous[i]; 92 | j = newIndicesNext[j]; 93 | newIndices.set(keyVal, j); 94 | } 95 | } 96 | 97 | for (j = start; j < next.length; j++) { 98 | if (j in temp) { 99 | const wrapped = wrap(temp[j]); 100 | target[STORE_NODE][j]?.write(wrapped); 101 | applyState(next[j], wrapped, keyFn); 102 | } else target[STORE_NODE][j]?.write(wrap(next[j])); 103 | } 104 | if (start < next.length) changed = true; 105 | } else if (previous.length && next.length) { 106 | for (let i = 0, len = next.length; i < len; i++) { 107 | isWrappable(previous[i]) && applyState(next[i], wrap(previous[i]), keyFn); 108 | } 109 | } 110 | 111 | if (previous.length !== next.length) { 112 | changed = true; 113 | target[STORE_NODE].length?.write(next.length); 114 | } 115 | changed && target[STORE_NODE][$TRACK]?.write(void 0); 116 | return; 117 | } 118 | 119 | // values 120 | let nodes = target[STORE_NODE]; 121 | if (nodes) { 122 | const keys = Object.keys(nodes); 123 | for (let i = 0, len = keys.length; i < len; i++) { 124 | const node = nodes[keys[i]]; 125 | const previousValue = unwrap(previous[keys[i]], false); 126 | let nextValue = unwrap(next[keys[i]], false); 127 | if (previousValue === nextValue) continue; 128 | if ( 129 | !previousValue || 130 | !isWrappable(previousValue) || 131 | (keyFn(previousValue) != null && keyFn(previousValue) !== keyFn(nextValue)) 132 | ) 133 | node.write(isWrappable(nextValue) ? wrap(nextValue) : nextValue); 134 | else applyState(nextValue, wrap(previousValue), keyFn); 135 | } 136 | } 137 | 138 | // has 139 | if ((nodes = target[STORE_HAS])) { 140 | const keys = Object.keys(nodes); 141 | for (let i = 0, len = keys.length; i < len; i++) { 142 | nodes[keys[i]].write(keys[i] in next); 143 | } 144 | } 145 | } 146 | 147 | export function reconcile( 148 | value: T, 149 | key: string | ((item: NonNullable) => any) 150 | ) { 151 | return (state: U) => { 152 | const keyFn = typeof key === "string" ? item => item[key] : key; 153 | if (keyFn(value) !== keyFn(state)) 154 | throw new Error("Cannot reconcile states with different identity"); 155 | applyState(value, state, keyFn); 156 | return state as T; 157 | }; 158 | } 159 | -------------------------------------------------------------------------------- /src/store/store.ts: -------------------------------------------------------------------------------- 1 | import { Computation, getObserver, isEqual, untrack } from "../core/index.js"; 2 | import { wrapProjection } from "./projection.js"; 3 | 4 | export type Store = Readonly; 5 | export type StoreSetter = (fn: (state: T) => void) => void; 6 | 7 | type DataNode = Computation; 8 | type DataNodes = Record; 9 | 10 | const $RAW = Symbol(__DEV__ ? "STORE_RAW" : 0), 11 | $TRACK = Symbol(__DEV__ ? "STORE_TRACK" : 0), 12 | $DEEP = Symbol(__DEV__ ? "STORE_DEEP" : 0), 13 | $TARGET = Symbol(__DEV__ ? "STORE_TARGET" : 0), 14 | $PROXY = Symbol(__DEV__ ? "STORE_PROXY" : 0); 15 | 16 | const PARENTS = new WeakMap>(); 17 | 18 | export const STORE_VALUE = "v", 19 | STORE_NODE = "n", 20 | STORE_HAS = "h"; 21 | 22 | export { $PROXY, $TRACK, $RAW, $TARGET }; 23 | export type StoreNode = { 24 | [STORE_VALUE]: Record; 25 | [STORE_NODE]?: DataNodes; 26 | [STORE_HAS]?: DataNodes; 27 | }; 28 | 29 | export namespace SolidStore { 30 | export interface Unwrappable {} 31 | } 32 | 33 | export type NotWrappable = 34 | | string 35 | | number 36 | | bigint 37 | | symbol 38 | | boolean 39 | | Function 40 | | null 41 | | undefined 42 | | SolidStore.Unwrappable[keyof SolidStore.Unwrappable]; 43 | 44 | export function wrap>(value: T): T { 45 | let p = value[$PROXY]; 46 | if (!p) { 47 | let target; 48 | if (Array.isArray(value)) { 49 | target = []; 50 | target.v = value; 51 | } else target = { v: value }; 52 | Object.defineProperty(value, $PROXY, { 53 | value: (p = new Proxy(target, proxyTraps)), 54 | writable: true 55 | }); 56 | } 57 | return p; 58 | } 59 | 60 | export function isWrappable(obj: T | NotWrappable): obj is T; 61 | export function isWrappable(obj: any) { 62 | return obj != null && typeof obj === "object" && !Object.isFrozen(obj); 63 | } 64 | 65 | /** 66 | * Returns the underlying data in the store without a proxy. 67 | * @param item store proxy object 68 | * @example 69 | * ```js 70 | * const initial = {z...}; 71 | * const [state, setState] = createStore(initial); 72 | * initial === state; // => false 73 | * initial === unwrap(state); // => true 74 | * ``` 75 | */ 76 | export function unwrap(item: T, deep?: boolean, set?: Set): T; 77 | export function unwrap(item: any, deep = true, set?: Set): T { 78 | let result, unwrapped, v, prop; 79 | if ((result = item != null && item[$RAW])) return result; 80 | if (!deep) return item; 81 | if (!isWrappable(item) || set?.has(item)) return item; 82 | if (!set) set = new Set(); 83 | set.add(item); 84 | if (Array.isArray(item)) { 85 | for (let i = 0, l = item.length; i < l; i++) { 86 | v = item[i]; 87 | if ((unwrapped = unwrap(v, deep, set)) !== v) item[i] = unwrapped; 88 | } 89 | } else { 90 | if (!deep) return item; 91 | const keys = Object.keys(item); 92 | for (let i = 0, l = keys.length; i < l; i++) { 93 | prop = keys[i]; 94 | const desc = Object.getOwnPropertyDescriptor(item, prop)!; 95 | if (desc.get) continue; 96 | v = item[prop]; 97 | if ((unwrapped = unwrap(v, deep, set)) !== v) item[prop] = unwrapped; 98 | } 99 | } 100 | return item; 101 | } 102 | 103 | function getNodes(target: StoreNode, type: typeof STORE_NODE | typeof STORE_HAS): DataNodes { 104 | let nodes = target[type]; 105 | if (!nodes) target[type] = nodes = Object.create(null) as DataNodes; 106 | return nodes; 107 | } 108 | 109 | function getNode( 110 | nodes: DataNodes, 111 | property: PropertyKey, 112 | value?: any, 113 | equals: false | ((a: any, b: any) => boolean) = isEqual 114 | ): DataNode { 115 | if (nodes[property]) return nodes[property]!; 116 | return (nodes[property] = new Computation(value, null, { 117 | equals: equals, 118 | unobserved() { 119 | delete nodes[property]; 120 | } 121 | })); 122 | } 123 | 124 | function proxyDescriptor(target: StoreNode, property: PropertyKey) { 125 | if (property === $PROXY) return { value: target[$PROXY], writable: true, configurable: true }; 126 | const desc = Reflect.getOwnPropertyDescriptor(target[STORE_VALUE], property); 127 | if (!desc || desc.get || !desc.configurable) return desc; 128 | delete desc.value; 129 | delete desc.writable; 130 | desc.get = () => target[STORE_VALUE][$PROXY][property]; 131 | return desc; 132 | } 133 | 134 | function trackSelf(target: StoreNode, symbol: symbol = $TRACK) { 135 | getObserver() && getNode(getNodes(target, STORE_NODE), symbol, undefined, false).read(); 136 | } 137 | 138 | function ownKeys(target: StoreNode) { 139 | trackSelf(target); 140 | return Reflect.ownKeys(target[STORE_VALUE]); 141 | } 142 | 143 | let Writing: Set | null = null; 144 | const proxyTraps: ProxyHandler = { 145 | get(target, property, receiver) { 146 | if (property === $TARGET) return target; 147 | if (property === $RAW) return target[STORE_VALUE]; 148 | if (property === $PROXY) return receiver; 149 | if (property === $TRACK || property === $DEEP) { 150 | trackSelf(target, property); 151 | return receiver; 152 | } 153 | const nodes = getNodes(target, STORE_NODE); 154 | const storeValue = target[STORE_VALUE]; 155 | const tracked = nodes[property]; 156 | if (!tracked) { 157 | const desc = Object.getOwnPropertyDescriptor(storeValue, property); 158 | if (desc && desc.get) return desc.get.call(receiver); 159 | } 160 | if (Writing?.has(storeValue)) { 161 | const value = tracked ? tracked._value : storeValue[property]; 162 | return isWrappable(value) ? (Writing.add(value[$RAW] || value), wrap(value)) : value; 163 | } 164 | let value = tracked ? nodes[property].read() : storeValue[property]; 165 | if (!tracked) { 166 | if (typeof value === "function" && !storeValue.hasOwnProperty(property)) { 167 | let proto; 168 | return !Array.isArray(storeValue) && 169 | (proto = Object.getPrototypeOf(storeValue)) && 170 | proto !== Object.prototype 171 | ? value.bind(storeValue) 172 | : value; 173 | } else if (getObserver()) { 174 | return getNode(nodes, property, isWrappable(value) ? wrap(value) : value).read(); 175 | } 176 | } 177 | return isWrappable(value) ? wrap(value) : value; 178 | }, 179 | 180 | has(target, property) { 181 | if (property === $RAW || property === $PROXY || property === $TRACK || property === "__proto__") 182 | return true; 183 | const has = property in target[STORE_VALUE]; 184 | getObserver() && getNode(getNodes(target, STORE_HAS), property, has).read(); 185 | return has; 186 | }, 187 | 188 | set(target, property, value) { 189 | Writing?.has(target[STORE_VALUE]) && 190 | setProperty(target[STORE_VALUE], property, unwrap(value, false)); 191 | return true; 192 | }, 193 | 194 | deleteProperty(target, property) { 195 | Writing?.has(target[STORE_VALUE]) && 196 | setProperty(target[STORE_VALUE], property, undefined, true); 197 | return true; 198 | }, 199 | 200 | ownKeys: ownKeys, 201 | 202 | getOwnPropertyDescriptor: proxyDescriptor, 203 | 204 | getPrototypeOf(target) { 205 | return Object.getPrototypeOf(target[STORE_VALUE]); 206 | } 207 | }; 208 | 209 | function setProperty( 210 | state: Record, 211 | property: PropertyKey, 212 | value: any, 213 | deleting: boolean = false 214 | ): void { 215 | const prev = state[property]; 216 | if (!deleting && prev === value) return; 217 | const len = state.length; 218 | 219 | if (deleting) delete state[property]; 220 | else state[property] = value; 221 | const wrappable = isWrappable(value); 222 | if (isWrappable(prev)) { 223 | const parents = PARENTS.get(prev); 224 | parents && (parents instanceof Set ? parents.delete(state) : PARENTS.delete(prev)); 225 | } 226 | if (recursivelyNotify(state) && wrappable) recursivelyAddParent(value[$RAW] || value, state); 227 | const target = state[$PROXY]?.[$TARGET] as StoreNode | undefined; 228 | if (!target) return; 229 | if (deleting) target[STORE_HAS]?.[property]?.write(false); 230 | else target[STORE_HAS]?.[property]?.write(true); 231 | const nodes = getNodes(target, STORE_NODE); 232 | nodes[property]?.write(wrappable ? wrap(value) : value); 233 | // notify length change 234 | Array.isArray(state) && state.length !== len && nodes.length?.write(state.length); 235 | // notify self 236 | nodes[$TRACK]?.write(undefined); 237 | } 238 | 239 | function recursivelyNotify(state: object): boolean { 240 | let target = state[$PROXY]?.[$TARGET] as StoreNode | undefined; 241 | let notified = false; 242 | target && (getNodes(target, STORE_NODE)[$DEEP]?.write(undefined), (notified = true)); 243 | 244 | // trace parents 245 | const parents = PARENTS.get(state); 246 | if (!parents) return notified; 247 | if (parents instanceof Set) { 248 | for (let parent of parents) notified = recursivelyNotify(parent) || notified; 249 | } else notified = recursivelyNotify(parents) || notified; 250 | return notified; 251 | } 252 | 253 | function recursivelyAddParent(state: any, parent?: any): void { 254 | if (parent) { 255 | let parents = PARENTS.get(state); 256 | if (!parents) PARENTS.set(state, parent); 257 | else if (parents !== parent) { 258 | if (!(parents instanceof Set)) 259 | PARENTS.set(state, (parents = /* @__PURE__ */ new Set([parents]))); 260 | else if (parents.has(parent)) return; 261 | parents.add(parent); 262 | } else return; 263 | } 264 | 265 | if (Array.isArray(state)) { 266 | for (let i = 0; i < state.length; i++) { 267 | const item = state[i]; 268 | isWrappable(item) && recursivelyAddParent(item[$RAW] || item, state); 269 | } 270 | } else { 271 | const keys = Object.keys(state); 272 | for (let i = 0; i < keys.length; i++) { 273 | const item = state[keys[i]]; 274 | isWrappable(item) && recursivelyAddParent(item[$RAW] || item, state); 275 | } 276 | } 277 | } 278 | 279 | export function createStore( 280 | store: T | Store 281 | ): [get: Store, set: StoreSetter]; 282 | export function createStore( 283 | fn: (store: T) => void, 284 | store: T | Store 285 | ): [get: Store, set: StoreSetter]; 286 | export function createStore( 287 | first: T | ((store: T) => void), 288 | second?: T | Store 289 | ): [get: Store, set: StoreSetter] { 290 | const derived = typeof first === "function", 291 | store = derived ? second! : first; 292 | 293 | const unwrappedStore = unwrap(store!); 294 | let wrappedStore = wrap(unwrappedStore); 295 | const setStore = (fn: (draft: T) => void): void => { 296 | const prevWriting = Writing; 297 | Writing = new Set(); 298 | Writing.add(unwrappedStore); 299 | try { 300 | fn(wrappedStore); 301 | } finally { 302 | Writing.clear(); 303 | Writing = prevWriting; 304 | } 305 | }; 306 | 307 | if (derived) return wrapProjection(first as (store: T) => void, wrappedStore, setStore); 308 | 309 | return [wrappedStore, setStore]; 310 | } 311 | 312 | export function deep(store: Store): Store { 313 | recursivelyAddParent(store[$RAW] || store); 314 | return store[$DEEP]; 315 | } 316 | -------------------------------------------------------------------------------- /src/store/utils.ts: -------------------------------------------------------------------------------- 1 | import { SUPPORTS_PROXY } from "../core/index.js"; 2 | import { createMemo } from "../signals.js"; 3 | import { $PROXY } from "./store.js"; 4 | 5 | function trueFn() { 6 | return true; 7 | } 8 | 9 | const propTraps: ProxyHandler<{ 10 | get: (k: string | number | symbol) => any; 11 | has: (k: string | number | symbol) => boolean; 12 | keys: () => string[]; 13 | }> = { 14 | get(_, property, receiver) { 15 | if (property === $PROXY) return receiver; 16 | return _.get(property); 17 | }, 18 | has(_, property) { 19 | if (property === $PROXY) return true; 20 | return _.has(property); 21 | }, 22 | set: trueFn, 23 | deleteProperty: trueFn, 24 | getOwnPropertyDescriptor(_, property) { 25 | return { 26 | configurable: true, 27 | enumerable: true, 28 | get() { 29 | return _.get(property); 30 | }, 31 | set: trueFn, 32 | deleteProperty: trueFn 33 | }; 34 | }, 35 | ownKeys(_) { 36 | return _.keys(); 37 | } 38 | }; 39 | 40 | type DistributeOverride = T extends undefined ? F : T; 41 | type Override = T extends any 42 | ? U extends any 43 | ? { 44 | [K in keyof T]: K extends keyof U ? DistributeOverride : T[K]; 45 | } & { 46 | [K in keyof U]: K extends keyof T ? DistributeOverride : U[K]; 47 | } 48 | : T & U 49 | : T & U; 50 | type OverrideSpread = T extends any 51 | ? { 52 | [K in keyof ({ [K in keyof T]: any } & { [K in keyof U]?: any } & { 53 | [K in U extends any ? keyof U : keyof U]?: any; 54 | })]: K extends keyof T 55 | ? Exclude | T[K] 56 | : U extends any 57 | ? U[K & keyof U] 58 | : never; 59 | } 60 | : T & U; 61 | type Simplify = T extends any ? { [K in keyof T]: T[K] } : T; 62 | type _Merge = T extends [ 63 | infer Next | (() => infer Next), 64 | ...infer Rest 65 | ] 66 | ? _Merge> 67 | : T extends [...infer Rest, infer Next | (() => infer Next)] 68 | ? Override<_Merge, Next> 69 | : T extends [] 70 | ? Curr 71 | : T extends (infer I | (() => infer I))[] 72 | ? OverrideSpread 73 | : Curr; 74 | 75 | export type Merge = Simplify<_Merge>; 76 | 77 | function resolveSource(s: any) { 78 | return !(s = typeof s === "function" ? s() : s) ? {} : s; 79 | } 80 | 81 | const $SOURCES = Symbol(__DEV__ ? "MERGE_SOURCE" : 0); 82 | export function merge(...sources: T): Merge { 83 | if (sources.length === 1 && typeof sources[0] !== "function") return sources[0] as any; 84 | let proxy = false; 85 | const flattened: T[] = []; 86 | for (let i = 0; i < sources.length; i++) { 87 | const s = sources[i]; 88 | proxy = proxy || (!!s && $PROXY in (s as object)); 89 | const childSources = !!s && (s as object)[$SOURCES]; 90 | if (childSources) flattened.push(...childSources); 91 | else 92 | flattened.push( 93 | typeof s === "function" ? ((proxy = true), createMemo(s as () => any)) : (s as any) 94 | ); 95 | } 96 | if (SUPPORTS_PROXY && proxy) { 97 | return new Proxy( 98 | { 99 | get(property: string | number | symbol) { 100 | if (property === $SOURCES) return flattened; 101 | for (let i = flattened.length - 1; i >= 0; i--) { 102 | const s = resolveSource(flattened[i]); 103 | if (property in s) return s[property]; 104 | } 105 | }, 106 | has(property: string | number | symbol) { 107 | for (let i = flattened.length - 1; i >= 0; i--) { 108 | if (property in resolveSource(flattened[i])) return true; 109 | } 110 | return false; 111 | }, 112 | keys() { 113 | const keys: Array = []; 114 | for (let i = 0; i < flattened.length; i++) 115 | keys.push(...Object.keys(resolveSource(flattened[i]))); 116 | return [...new Set(keys)]; 117 | } 118 | }, 119 | propTraps 120 | ) as unknown as Merge; 121 | } 122 | 123 | const defined: Record = Object.create(null); 124 | let nonTargetKey = false; 125 | let lastIndex = flattened.length - 1; 126 | for (let i = lastIndex; i >= 0; i--) { 127 | const source = flattened[i] as Record; 128 | if (!source) { 129 | i === lastIndex && lastIndex--; 130 | continue; 131 | } 132 | const sourceKeys = Object.getOwnPropertyNames(source); 133 | for (let j = sourceKeys.length - 1; j >= 0; j--) { 134 | const key = sourceKeys[j]; 135 | if (key === "__proto__" || key === "constructor") continue; 136 | if (!defined[key]) { 137 | nonTargetKey = nonTargetKey || i !== lastIndex; 138 | const desc = Object.getOwnPropertyDescriptor(source, key)!; 139 | defined[key] = desc.get 140 | ? { 141 | enumerable: true, 142 | configurable: true, 143 | get: desc.get.bind(source) 144 | } 145 | : desc; 146 | } 147 | } 148 | } 149 | if (!nonTargetKey) return flattened[lastIndex] as any; 150 | const target: Record = {}; 151 | const definedKeys = Object.keys(defined); 152 | for (let i = definedKeys.length - 1; i >= 0; i--) { 153 | const key = definedKeys[i], 154 | desc = defined[key]; 155 | if (desc.get) Object.defineProperty(target, key, desc); 156 | else target[key] = desc.value; 157 | } 158 | (target as any)[$SOURCES] = flattened; 159 | return target as any; 160 | } 161 | 162 | export type Omit = { 163 | [P in keyof T as Exclude]: T[P]; 164 | }; 165 | 166 | export function omit, K extends readonly (keyof T)[]>( 167 | props: T, 168 | ...keys: K 169 | ): Omit { 170 | const blocked = new Set(keys); 171 | if (SUPPORTS_PROXY && $PROXY in props) { 172 | return new Proxy( 173 | { 174 | get(property) { 175 | return blocked.has(property) ? undefined : props[property as any]; 176 | }, 177 | has(property) { 178 | return !blocked.has(property) && property in props; 179 | }, 180 | keys() { 181 | return Object.keys(props).filter(k => !blocked.has(k)); 182 | } 183 | }, 184 | propTraps 185 | ) as unknown as Omit; 186 | } 187 | const result: Record = {}; 188 | 189 | for (const propName of Object.getOwnPropertyNames(props)) { 190 | if (!blocked.has(propName)) { 191 | const desc = Object.getOwnPropertyDescriptor(props, propName)!; 192 | !desc.get && !desc.set && desc.enumerable && desc.writable && desc.configurable 193 | ? (result[propName] = desc.value) 194 | : Object.defineProperty(result, propName, desc); 195 | } 196 | } 197 | return result as any; 198 | } 199 | -------------------------------------------------------------------------------- /tests/context.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ContextNotFoundError, 3 | createContext, 4 | createRoot, 5 | getContext, 6 | hasContext, 7 | NoOwnerError, 8 | setContext 9 | } from "../src/index.js"; 10 | 11 | it("should create context", () => { 12 | const context = createContext(1); 13 | 14 | expect(context.id).toBeDefined(); 15 | expect(context.defaultValue).toEqual(1); 16 | 17 | createRoot(() => { 18 | setContext(context); 19 | expect(getContext(context)).toEqual(1); 20 | }); 21 | }); 22 | 23 | it("should forward context across roots", () => { 24 | const context = createContext(1); 25 | createRoot(() => { 26 | setContext(context, 2); 27 | createRoot(() => { 28 | expect(getContext(context)).toEqual(2); 29 | createRoot(() => { 30 | expect(getContext(context)).toEqual(2); 31 | }); 32 | }); 33 | }); 34 | }); 35 | 36 | it("should not expose context on parent when set in child", () => { 37 | const context = createContext(1); 38 | createRoot(() => { 39 | createRoot(() => { 40 | setContext(context, 4); 41 | }); 42 | 43 | expect(getContext(context)).toEqual(1); 44 | }); 45 | }); 46 | 47 | it("should return true if context has been provided", () => { 48 | const context = createContext(); 49 | createRoot(() => { 50 | setContext(context, 1); 51 | expect(hasContext(context)).toBeTruthy(); 52 | }); 53 | }); 54 | 55 | it("should return false if context has not been provided", () => { 56 | const context = createContext(); 57 | createRoot(() => { 58 | expect(hasContext(context)).toBeFalsy(); 59 | }); 60 | }); 61 | 62 | it("should throw error when trying to get context outside owner", () => { 63 | const context = createContext(); 64 | expect(() => getContext(context)).toThrowError(NoOwnerError); 65 | }); 66 | 67 | it("should throw error when trying to set context outside owner", () => { 68 | const context = createContext(); 69 | expect(() => setContext(context)).toThrowError(NoOwnerError); 70 | }); 71 | 72 | it("should throw error when trying to get context without setting it first", () => { 73 | const context = createContext(); 74 | expect(() => createRoot(() => getContext(context))).toThrowError(ContextNotFoundError); 75 | }); 76 | -------------------------------------------------------------------------------- /tests/createAsync.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createAsync, 3 | createEffect, 4 | createMemo, 5 | createRoot, 6 | createSignal, 7 | flushSync, 8 | isPending, 9 | latest, 10 | resolve 11 | } from "../src/index.js"; 12 | 13 | it("diamond should not cause waterfalls on read", async () => { 14 | // 15 | // s 16 | // / \ 17 | // / \ 18 | // b c 19 | // \ / 20 | // \ / 21 | // e 22 | // 23 | const [s, set] = createSignal(1); 24 | const effect = vi.fn(); 25 | const async1 = vi.fn(() => Promise.resolve(s())); 26 | const async2 = vi.fn(() => Promise.resolve(s())); 27 | 28 | createRoot(() => { 29 | const b = createAsync(async1); 30 | const c = createAsync(async2); 31 | createEffect( 32 | () => [b(), c()], 33 | v => effect(...v) 34 | ); 35 | }); 36 | 37 | expect(async1).toHaveBeenCalledTimes(1); 38 | expect(async2).toHaveBeenCalledTimes(1); 39 | expect(effect).toHaveBeenCalledTimes(0); 40 | await new Promise(r => setTimeout(r, 0)); 41 | expect(async1).toHaveBeenCalledTimes(1); 42 | expect(async2).toHaveBeenCalledTimes(1); 43 | expect(effect).toHaveBeenCalledTimes(1); 44 | expect(effect).toHaveBeenCalledWith(1, 1); 45 | set(2); 46 | expect(async1).toHaveBeenCalledTimes(1); 47 | expect(async2).toHaveBeenCalledTimes(1); 48 | expect(effect).toHaveBeenCalledTimes(1); 49 | flushSync(); 50 | expect(async1).toHaveBeenCalledTimes(2); 51 | expect(async2).toHaveBeenCalledTimes(2); 52 | expect(effect).toHaveBeenCalledTimes(1); 53 | await new Promise(r => setTimeout(r, 0)); 54 | expect(async1).toHaveBeenCalledTimes(2); 55 | expect(async2).toHaveBeenCalledTimes(2); 56 | expect(effect).toHaveBeenCalledTimes(2); 57 | expect(effect).toHaveBeenCalledWith(2, 2); 58 | }); 59 | 60 | it("should waterfall when dependent on another async with shared source", async () => { 61 | // 62 | // s 63 | // /| 64 | // a | 65 | // \| 66 | // b 67 | // | 68 | // e 69 | // 70 | let a; 71 | const [s, set] = createSignal(1); 72 | const effect = vi.fn(); 73 | const async1 = vi.fn(() => Promise.resolve(s())); 74 | const async2 = vi.fn(() => Promise.resolve(s() + a())); 75 | 76 | createRoot(() => { 77 | a = createAsync(async1); 78 | const b = createAsync(async2); 79 | 80 | createEffect( 81 | () => b(), 82 | v => effect(v) 83 | ); 84 | }); 85 | 86 | expect(async1).toHaveBeenCalledTimes(1); 87 | expect(async2).toHaveBeenCalledTimes(1); 88 | expect(effect).toHaveBeenCalledTimes(0); 89 | await new Promise(r => setTimeout(r, 0)); 90 | expect(async1).toHaveBeenCalledTimes(1); 91 | expect(async2).toHaveBeenCalledTimes(2); 92 | expect(effect).toHaveBeenCalledTimes(1); 93 | expect(effect).toHaveBeenCalledWith(2); 94 | set(2); 95 | expect(async1).toHaveBeenCalledTimes(1); 96 | expect(async2).toHaveBeenCalledTimes(2); 97 | expect(effect).toHaveBeenCalledTimes(1); 98 | flushSync(); 99 | expect(async1).toHaveBeenCalledTimes(2); 100 | expect(async2).toHaveBeenCalledTimes(3); 101 | expect(effect).toHaveBeenCalledTimes(1); 102 | await new Promise(r => setTimeout(r, 0)); 103 | expect(async1).toHaveBeenCalledTimes(2); 104 | expect(async2).toHaveBeenCalledTimes(4); 105 | expect(effect).toHaveBeenCalledTimes(2); 106 | expect(effect).toHaveBeenCalledWith(4); 107 | }); 108 | 109 | it("should should show stale state with `isPending`", async () => { 110 | const [s, set] = createSignal(1); 111 | const async1 = vi.fn(() => Promise.resolve(s())); 112 | const a = createRoot(() => createAsync(async1)); 113 | const b = createMemo(() => (isPending(a) ? "stale" : "not stale")); 114 | expect(b).toThrow(); 115 | await new Promise(r => setTimeout(r, 0)); 116 | expect(b()).toBe("not stale"); 117 | set(2); 118 | expect(b()).toBe("stale"); 119 | flushSync(); 120 | expect(b()).toBe("stale"); 121 | await new Promise(r => setTimeout(r, 0)); 122 | expect(b()).toBe("not stale"); 123 | }); 124 | 125 | it("should get latest value with `latest`", async () => { 126 | const [s, set] = createSignal(1); 127 | const async1 = vi.fn(() => Promise.resolve(s())); 128 | const a = createRoot(() => createAsync(async1)); 129 | const b = createMemo(() => latest(a)); 130 | expect(b).toThrow(); 131 | await new Promise(r => setTimeout(r, 0)); 132 | expect(b()).toBe(1); 133 | set(2); 134 | expect(b()).toBe(1); 135 | flushSync(); 136 | expect(b()).toBe(1); 137 | await new Promise(r => setTimeout(r, 0)); 138 | expect(b()).toBe(2); 139 | }); 140 | 141 | it("should resolve to a value with resolveAsync", async () => { 142 | const [s, set] = createSignal(1); 143 | const async1 = vi.fn(() => Promise.resolve(s())); 144 | let value: number | undefined; 145 | createRoot(() => { 146 | const a = createAsync(async1); 147 | createEffect( 148 | () => {}, 149 | () => { 150 | (async () => { 151 | value = await resolve(a); 152 | })(); 153 | } 154 | ); 155 | }); 156 | expect(value).toBe(undefined); 157 | await new Promise(r => setTimeout(r, 0)); 158 | expect(value).toBe(1); 159 | set(2); 160 | expect(value).toBe(1); 161 | flushSync(); 162 | expect(value).toBe(1); 163 | await new Promise(r => setTimeout(r, 0)); 164 | // doesn't update because not tracked 165 | expect(value).toBe(1); 166 | }); 167 | 168 | it("should handle streams", async () => { 169 | const effect = vi.fn(); 170 | createRoot(() => { 171 | const v = createAsync(async function* () { 172 | yield await Promise.resolve(1); 173 | yield await Promise.resolve(2); 174 | yield await Promise.resolve(3); 175 | }); 176 | createEffect(v, v => effect(v)); 177 | }); 178 | flushSync(); 179 | expect(effect).toHaveBeenCalledTimes(0); 180 | await Promise.resolve(); 181 | await Promise.resolve(); 182 | await Promise.resolve(); 183 | await Promise.resolve(); 184 | expect(effect).toHaveBeenCalledTimes(1); 185 | expect(effect).toHaveBeenCalledWith(1); 186 | await Promise.resolve(); 187 | await Promise.resolve(); 188 | await Promise.resolve(); 189 | expect(effect).toHaveBeenCalledTimes(2); 190 | expect(effect).toHaveBeenCalledWith(2); 191 | await Promise.resolve(); 192 | await Promise.resolve(); 193 | await Promise.resolve(); 194 | expect(effect).toHaveBeenCalledTimes(3); 195 | expect(effect).toHaveBeenCalledWith(3); 196 | }); 197 | -------------------------------------------------------------------------------- /tests/createEffect.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createEffect, 3 | createMemo, 4 | createRenderEffect, 5 | createRoot, 6 | createSignal, 7 | flushSync, 8 | onCleanup 9 | } from "../src/index.js"; 10 | 11 | afterEach(() => flushSync()); 12 | 13 | it("should run effect", () => { 14 | const [$x, setX] = createSignal(0), 15 | compute = vi.fn($x), 16 | effect = vi.fn(); 17 | 18 | createRoot(() => createEffect(compute, effect)); 19 | expect(compute).toHaveBeenCalledTimes(1); 20 | expect(effect).toHaveBeenCalledTimes(0); 21 | flushSync(); 22 | expect(compute).toHaveBeenCalledTimes(1); 23 | expect(effect).toHaveBeenCalledTimes(1); 24 | expect(effect).toHaveBeenCalledWith(0, undefined); 25 | 26 | setX(1); 27 | flushSync(); 28 | expect(compute).toHaveBeenCalledTimes(2); 29 | expect(effect).toHaveBeenCalledTimes(2); 30 | expect(effect).toHaveBeenCalledWith(1, 0); 31 | }); 32 | 33 | it("should run effect on change", () => { 34 | const effect = vi.fn(); 35 | 36 | const [$x, setX] = createSignal(10); 37 | const [$y, setY] = createSignal(10); 38 | 39 | const $a = createMemo(() => $x() + $y()); 40 | const $b = createMemo(() => $a()); 41 | 42 | createRoot(() => createEffect($b, effect)); 43 | 44 | expect(effect).to.toHaveBeenCalledTimes(0); 45 | 46 | setX(20); 47 | flushSync(); 48 | expect(effect).to.toHaveBeenCalledTimes(1); 49 | 50 | setY(20); 51 | flushSync(); 52 | expect(effect).to.toHaveBeenCalledTimes(2); 53 | 54 | setX(20); 55 | setY(20); 56 | flushSync(); 57 | expect(effect).to.toHaveBeenCalledTimes(2); 58 | }); 59 | 60 | it("should handle nested effect", () => { 61 | const [$x, setX] = createSignal(0); 62 | const [$y, setY] = createSignal(0); 63 | 64 | const outerEffect = vi.fn(); 65 | const innerEffect = vi.fn(); 66 | const innerPureDispose = vi.fn(); 67 | const innerEffectDispose = vi.fn(); 68 | 69 | const stopEffect = createRoot(dispose => { 70 | createEffect(() => { 71 | $x(); 72 | createEffect( 73 | () => { 74 | $y(); 75 | onCleanup(innerPureDispose); 76 | }, 77 | () => { 78 | innerEffect(); 79 | return () => { 80 | innerEffectDispose(); 81 | }; 82 | } 83 | ); 84 | }, outerEffect); 85 | 86 | return dispose; 87 | }); 88 | 89 | flushSync(); 90 | expect(outerEffect).toHaveBeenCalledTimes(1); 91 | expect(innerEffect).toHaveBeenCalledTimes(1); 92 | expect(innerPureDispose).toHaveBeenCalledTimes(0); 93 | expect(innerEffectDispose).toHaveBeenCalledTimes(0); 94 | 95 | setY(1); 96 | flushSync(); 97 | expect(outerEffect).toHaveBeenCalledTimes(1); 98 | expect(innerEffect).toHaveBeenCalledTimes(2); 99 | expect(innerPureDispose).toHaveBeenCalledTimes(1); 100 | expect(innerEffectDispose).toHaveBeenCalledTimes(1); 101 | 102 | setY(2); 103 | flushSync(); 104 | expect(outerEffect).toHaveBeenCalledTimes(1); 105 | expect(innerEffect).toHaveBeenCalledTimes(3); 106 | expect(innerPureDispose).toHaveBeenCalledTimes(2); 107 | expect(innerEffectDispose).toHaveBeenCalledTimes(2); 108 | 109 | innerEffect.mockReset(); 110 | innerPureDispose.mockReset(); 111 | innerEffectDispose.mockReset(); 112 | 113 | setX(1); 114 | flushSync(); 115 | expect(outerEffect).toHaveBeenCalledTimes(2); 116 | expect(innerEffect).toHaveBeenCalledTimes(1); // new one is created 117 | expect(innerPureDispose).toHaveBeenCalledTimes(1); 118 | expect(innerEffectDispose).toHaveBeenCalledTimes(1); 119 | 120 | setY(3); 121 | flushSync(); 122 | expect(outerEffect).toHaveBeenCalledTimes(2); 123 | expect(innerEffect).toHaveBeenCalledTimes(2); 124 | expect(innerPureDispose).toHaveBeenCalledTimes(2); 125 | expect(innerEffectDispose).toHaveBeenCalledTimes(2); 126 | 127 | stopEffect(); 128 | setX(10); 129 | setY(10); 130 | expect(outerEffect).toHaveBeenCalledTimes(2); 131 | expect(innerEffect).toHaveBeenCalledTimes(2); 132 | expect(innerPureDispose).toHaveBeenCalledTimes(3); 133 | expect(innerEffectDispose).toHaveBeenCalledTimes(3); 134 | }); 135 | 136 | it("should stop effect", () => { 137 | const effect = vi.fn(); 138 | 139 | const [$x, setX] = createSignal(10); 140 | 141 | const stopEffect = createRoot(dispose => { 142 | createEffect($x, effect); 143 | return dispose; 144 | }); 145 | 146 | stopEffect(); 147 | 148 | setX(20); 149 | flushSync(); 150 | expect(effect).toHaveBeenCalledTimes(0); 151 | }); 152 | 153 | it("should run all disposals before each new run", () => { 154 | const effect = vi.fn(); 155 | const disposeA = vi.fn(); 156 | const disposeB = vi.fn(); 157 | const disposeC = vi.fn(); 158 | 159 | function fnA() { 160 | onCleanup(disposeA); 161 | } 162 | 163 | function fnB() { 164 | onCleanup(disposeB); 165 | } 166 | 167 | const [$x, setX] = createSignal(0); 168 | 169 | createRoot(() => 170 | createEffect( 171 | () => { 172 | fnA(), fnB(); 173 | return $x(); 174 | }, 175 | () => { 176 | effect(); 177 | return disposeC; 178 | } 179 | ) 180 | ); 181 | flushSync(); 182 | 183 | expect(effect).toHaveBeenCalledTimes(1); 184 | expect(disposeA).toHaveBeenCalledTimes(0); 185 | expect(disposeB).toHaveBeenCalledTimes(0); 186 | expect(disposeC).toHaveBeenCalledTimes(0); 187 | 188 | for (let i = 1; i <= 3; i += 1) { 189 | setX(i); 190 | flushSync(); 191 | expect(effect).toHaveBeenCalledTimes(i + 1); 192 | expect(disposeA).toHaveBeenCalledTimes(i); 193 | expect(disposeB).toHaveBeenCalledTimes(i); 194 | expect(disposeC).toHaveBeenCalledTimes(i); 195 | } 196 | }); 197 | 198 | it("should dispose of nested effect", () => { 199 | const [$x, setX] = createSignal(0); 200 | const innerEffect = vi.fn(); 201 | 202 | const stopEffect = createRoot(dispose => { 203 | createEffect( 204 | () => { 205 | createEffect($x, innerEffect); 206 | }, 207 | () => {} 208 | ); 209 | 210 | return dispose; 211 | }); 212 | 213 | stopEffect(); 214 | 215 | setX(10); 216 | flushSync(); 217 | expect(innerEffect).toHaveBeenCalledTimes(0); 218 | expect(innerEffect).not.toHaveBeenCalledWith(10); 219 | }); 220 | 221 | it("should conditionally observe", () => { 222 | const [$x, setX] = createSignal(0); 223 | const [$y, setY] = createSignal(0); 224 | const [$condition, setCondition] = createSignal(true); 225 | 226 | const $a = createMemo(() => ($condition() ? $x() : $y())); 227 | const effect = vi.fn(); 228 | 229 | createRoot(() => createEffect($a, effect)); 230 | flushSync(); 231 | 232 | expect(effect).toHaveBeenCalledTimes(1); 233 | 234 | setY(1); 235 | flushSync(); 236 | expect(effect).toHaveBeenCalledTimes(1); 237 | 238 | setX(1); 239 | flushSync(); 240 | expect(effect).toHaveBeenCalledTimes(2); 241 | 242 | setCondition(false); 243 | flushSync(); 244 | expect(effect).toHaveBeenCalledTimes(2); 245 | 246 | setY(2); 247 | flushSync(); 248 | expect(effect).toHaveBeenCalledTimes(3); 249 | 250 | setX(3); 251 | flushSync(); 252 | expect(effect).toHaveBeenCalledTimes(3); 253 | }); 254 | 255 | it("should dispose of nested conditional effect", () => { 256 | const [$condition, setCondition] = createSignal(true); 257 | 258 | const disposeA = vi.fn(); 259 | const disposeB = vi.fn(); 260 | 261 | function fnA() { 262 | createEffect( 263 | () => { 264 | onCleanup(disposeA); 265 | }, 266 | () => {} 267 | ); 268 | } 269 | 270 | function fnB() { 271 | createEffect( 272 | () => { 273 | onCleanup(disposeB); 274 | }, 275 | () => {} 276 | ); 277 | } 278 | 279 | createRoot(() => 280 | createEffect( 281 | () => ($condition() ? fnA() : fnB()), 282 | () => {} 283 | ) 284 | ); 285 | flushSync(); 286 | setCondition(false); 287 | flushSync(); 288 | expect(disposeA).toHaveBeenCalledTimes(1); 289 | }); 290 | 291 | // https://github.com/preactjs/signals/issues/152 292 | it("should handle looped effects", () => { 293 | let values: number[] = [], 294 | loop = 2; 295 | 296 | const [$value, setValue] = createSignal(0); 297 | 298 | let x = 0; 299 | createRoot(() => 300 | createEffect( 301 | () => { 302 | x++; 303 | values.push($value()); 304 | for (let i = 0; i < loop; i++) { 305 | createEffect( 306 | () => { 307 | values.push($value() + i); 308 | }, 309 | () => {} 310 | ); 311 | } 312 | }, 313 | () => {} 314 | ) 315 | ); 316 | 317 | flushSync(); 318 | 319 | expect(values).toHaveLength(3); 320 | expect(values.join(",")).toBe("0,0,1"); 321 | 322 | loop = 1; 323 | values = []; 324 | setValue(1); 325 | flushSync(); 326 | 327 | expect(values).toHaveLength(2); 328 | expect(values.join(",")).toBe("1,1"); 329 | 330 | values = []; 331 | setValue(2); 332 | flushSync(); 333 | 334 | expect(values).toHaveLength(2); 335 | expect(values.join(",")).toBe("2,2"); 336 | }); 337 | 338 | it("should apply changes in effect in same flush", async () => { 339 | const [$x, setX] = createSignal(0), 340 | [$y, setY] = createSignal(0); 341 | 342 | const $a = createMemo(() => { 343 | return $x() + 1; 344 | }), 345 | $b = createMemo(() => { 346 | return $a() + 2; 347 | }); 348 | 349 | createRoot(() => 350 | createEffect($y, () => { 351 | setX(n => n + 1); 352 | }) 353 | ); 354 | flushSync(); 355 | 356 | expect($x()).toBe(1); 357 | expect($b()).toBe(4); 358 | expect($a()).toBe(2); 359 | 360 | setY(1); 361 | 362 | flushSync(); 363 | 364 | expect($x()).toBe(2); 365 | expect($b()).toBe(5); 366 | expect($a()).toBe(3); 367 | 368 | setY(2); 369 | 370 | flushSync(); 371 | 372 | expect($x()).toBe(3); 373 | expect($b()).toBe(6); 374 | expect($a()).toBe(4); 375 | }); 376 | 377 | it("should run parent effect before child effect", () => { 378 | const [$x, setX] = createSignal(0); 379 | const $condition = createMemo(() => $x()); 380 | 381 | let calls = 0; 382 | 383 | createRoot(() => 384 | createEffect( 385 | () => { 386 | createEffect( 387 | () => { 388 | $x(); 389 | calls++; 390 | }, 391 | () => {} 392 | ); 393 | 394 | $condition(); 395 | }, 396 | () => {} 397 | ) 398 | ); 399 | 400 | setX(1); 401 | flushSync(); 402 | expect(calls).toBe(2); 403 | }); 404 | 405 | it("should run render effect before user effects", () => { 406 | const [$x, setX] = createSignal(0); 407 | 408 | let mark = ""; 409 | createRoot(() => { 410 | createEffect($x, () => { 411 | mark += "b"; 412 | }); 413 | createRenderEffect($x, () => { 414 | mark += "a"; 415 | }); 416 | }); 417 | 418 | flushSync(); 419 | expect(mark).toBe("ab"); 420 | setX(1); 421 | flushSync(); 422 | expect(mark).toBe("abab"); 423 | }); 424 | 425 | it("should defer user effects with the defer option", () => { 426 | let mark = ""; 427 | const [$x, setX] = createSignal(0); 428 | createRoot(() => { 429 | createEffect( 430 | $x, 431 | () => { 432 | mark += "b"; 433 | }, 434 | undefined, 435 | undefined, 436 | { defer: true } 437 | ); 438 | }); 439 | flushSync(); 440 | expect(mark).toBe(""); 441 | setX(1); 442 | flushSync(); 443 | expect(mark).toBe("b"); 444 | }); 445 | -------------------------------------------------------------------------------- /tests/createErrorBoundary.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createErrorBoundary, 3 | createMemo, 4 | createRenderEffect, 5 | createRoot, 6 | createSignal, 7 | flushSync 8 | } from "../src/index.js"; 9 | 10 | it("should let errors bubble up when not handled", () => { 11 | const error = new Error(); 12 | expect(() => { 13 | createRoot(() => { 14 | createRenderEffect( 15 | () => { 16 | throw error; 17 | }, 18 | () => {} 19 | ); 20 | }); 21 | flushSync(); 22 | }).toThrowError(error.cause as Error); 23 | }); 24 | 25 | it("should handle error", () => { 26 | const error = new Error(); 27 | 28 | const b = createRoot(() => 29 | createErrorBoundary( 30 | () => { 31 | throw error; 32 | }, 33 | () => "errored" 34 | ) 35 | ); 36 | 37 | expect(b()).toBe("errored"); 38 | }); 39 | 40 | it("should forward error to another handler", () => { 41 | const error = new Error(); 42 | 43 | const b = createRoot(() => 44 | createErrorBoundary( 45 | () => { 46 | const inner = createErrorBoundary( 47 | () => { 48 | throw error; 49 | }, 50 | e => { 51 | expect(e).toBe(error); 52 | throw e; 53 | } 54 | ); 55 | createRenderEffect(inner, () => {}); 56 | }, 57 | () => "errored" 58 | ) 59 | ); 60 | 61 | expect(b()).toBe("errored"); 62 | }); 63 | 64 | it("should not duplicate error handler", () => { 65 | const error = new Error(), 66 | handler = vi.fn(); 67 | 68 | let [$x, setX] = createSignal(0), 69 | shouldThrow = false; 70 | 71 | createRoot(() => { 72 | const b = createErrorBoundary(() => { 73 | $x(); 74 | if (shouldThrow) throw error; 75 | }, handler); 76 | createRenderEffect(b, () => {}); 77 | }); 78 | 79 | setX(1); 80 | flushSync(); 81 | 82 | shouldThrow = true; 83 | setX(2); 84 | flushSync(); 85 | expect(handler).toHaveBeenCalledTimes(1); 86 | }); 87 | 88 | it("should not trigger wrong handler", () => { 89 | const error = new Error(), 90 | rootHandler = vi.fn(), 91 | handler = vi.fn(); 92 | 93 | let [$x, setX] = createSignal(0), 94 | shouldThrow = false; 95 | 96 | createRoot(() => { 97 | const b = createErrorBoundary(() => { 98 | createRenderEffect( 99 | () => { 100 | $x(); 101 | if (shouldThrow) throw error; 102 | }, 103 | () => {} 104 | ); 105 | 106 | const b2 = createErrorBoundary(() => { 107 | // no-op 108 | }, handler); 109 | createRenderEffect(b2, () => {}); 110 | }, rootHandler); 111 | createRenderEffect(b, () => {}); 112 | }); 113 | 114 | expect(rootHandler).toHaveBeenCalledTimes(0); 115 | shouldThrow = true; 116 | setX(1); 117 | flushSync(); 118 | 119 | expect(rootHandler).toHaveBeenCalledTimes(1); 120 | expect(handler).not.toHaveBeenCalledWith(error); 121 | }); 122 | 123 | it("should throw error if there are no handlers left", () => { 124 | const error = new Error(), 125 | handler = vi.fn(e => { 126 | throw e; 127 | }); 128 | 129 | expect(() => { 130 | createErrorBoundary(() => { 131 | createErrorBoundary(() => { 132 | throw error; 133 | }, handler)(); 134 | }, handler)(); 135 | }).toThrow(error); 136 | 137 | expect(handler).toHaveBeenCalledTimes(2); 138 | }); 139 | 140 | it("should handle errors when the effect is on the outside", async () => { 141 | const error = new Error(), 142 | rootHandler = vi.fn(); 143 | 144 | const [$x, setX] = createSignal(0); 145 | 146 | createRoot(() => { 147 | const b = createErrorBoundary( 148 | () => { 149 | if ($x()) throw error; 150 | createErrorBoundary( 151 | () => { 152 | throw error; 153 | }, 154 | e => { 155 | expect(e).toBe(error); 156 | } 157 | ); 158 | }, 159 | err => rootHandler(err) 160 | ); 161 | createRenderEffect( 162 | () => b()?.(), 163 | () => {} 164 | ); 165 | }); 166 | expect(rootHandler).toHaveBeenCalledTimes(0); 167 | setX(1); 168 | flushSync(); 169 | expect(rootHandler).toHaveBeenCalledWith(error); 170 | expect(rootHandler).toHaveBeenCalledTimes(1); 171 | }); 172 | 173 | it("should handle errors when the effect is on the outside and memo in the middle", async () => { 174 | const error = new Error(), 175 | rootHandler = vi.fn(); 176 | 177 | createRoot(() => { 178 | const b = createErrorBoundary( 179 | () => 180 | createMemo(() => { 181 | throw error; 182 | }), 183 | rootHandler 184 | ); 185 | createRenderEffect(b, () => {}); 186 | }); 187 | expect(rootHandler).toHaveBeenCalledTimes(1); 188 | }); 189 | -------------------------------------------------------------------------------- /tests/createMemo.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createEffect, 3 | createErrorBoundary, 4 | createMemo, 5 | createRoot, 6 | createSignal, 7 | flushSync, 8 | hasUpdated 9 | } from "../src/index.js"; 10 | 11 | afterEach(() => flushSync()); 12 | 13 | it("should store and return value on read", () => { 14 | const [$x] = createSignal(1); 15 | const [$y] = createSignal(1); 16 | 17 | const $a = createMemo(() => $x() + $y()); 18 | 19 | expect($a()).toBe(2); 20 | flushSync(); 21 | 22 | // Try again to ensure state is maintained. 23 | expect($a()).toBe(2); 24 | }); 25 | 26 | it("should update when dependency is updated", () => { 27 | const [$x, setX] = createSignal(1); 28 | const [$y, setY] = createSignal(1); 29 | 30 | const $a = createMemo(() => $x() + $y()); 31 | 32 | setX(2); 33 | expect($a()).toBe(3); 34 | 35 | setY(2); 36 | expect($a()).toBe(4); 37 | }); 38 | 39 | it("should update when deep dependency is updated", () => { 40 | const [$x, setX] = createSignal(1); 41 | const [$y] = createSignal(1); 42 | 43 | const $a = createMemo(() => $x() + $y()); 44 | const $b = createMemo(() => $a()); 45 | 46 | setX(2); 47 | expect($b()).toBe(3); 48 | }); 49 | 50 | it("should update when deep computed dependency is updated", () => { 51 | const [$x, setX] = createSignal(10); 52 | const [$y] = createSignal(10); 53 | 54 | const $a = createMemo(() => $x() + $y()); 55 | const $b = createMemo(() => $a()); 56 | const $c = createMemo(() => $b()); 57 | 58 | setX(20); 59 | expect($c()).toBe(30); 60 | }); 61 | 62 | it("should only re-compute when needed", () => { 63 | const computed = vi.fn(); 64 | 65 | const [$x, setX] = createSignal(10); 66 | const [$y, setY] = createSignal(10); 67 | 68 | const $a = createMemo(() => computed($x() + $y())); 69 | 70 | expect(computed).not.toHaveBeenCalled(); 71 | 72 | $a(); 73 | expect(computed).toHaveBeenCalledTimes(1); 74 | expect(computed).toHaveBeenCalledWith(20); 75 | 76 | $a(); 77 | expect(computed).toHaveBeenCalledTimes(1); 78 | 79 | setX(20); 80 | $a(); 81 | expect(computed).toHaveBeenCalledTimes(2); 82 | 83 | setY(20); 84 | $a(); 85 | expect(computed).toHaveBeenCalledTimes(3); 86 | 87 | $a(); 88 | expect(computed).toHaveBeenCalledTimes(3); 89 | }); 90 | 91 | it("should only re-compute whats needed", () => { 92 | const memoA = vi.fn(n => n); 93 | const memoB = vi.fn(n => n); 94 | 95 | const [$x, setX] = createSignal(10); 96 | const [$y, setY] = createSignal(10); 97 | 98 | const $a = createMemo(() => memoA($x())); 99 | const $b = createMemo(() => memoB($y())); 100 | const $c = createMemo(() => $a() + $b()); 101 | 102 | expect(memoA).not.toHaveBeenCalled(); 103 | expect(memoB).not.toHaveBeenCalled(); 104 | 105 | $c(); 106 | expect(memoA).toHaveBeenCalledTimes(1); 107 | expect(memoB).toHaveBeenCalledTimes(1); 108 | expect($c()).toBe(20); 109 | 110 | setX(20); 111 | flushSync(); 112 | 113 | $c(); 114 | expect(memoA).toHaveBeenCalledTimes(2); 115 | expect(memoB).toHaveBeenCalledTimes(1); 116 | expect($c()).toBe(30); 117 | 118 | setY(20); 119 | flushSync(); 120 | 121 | $c(); 122 | expect(memoA).toHaveBeenCalledTimes(2); 123 | expect(memoB).toHaveBeenCalledTimes(2); 124 | expect($c()).toBe(40); 125 | }); 126 | 127 | it("should discover new dependencies", () => { 128 | const [$x, setX] = createSignal(1); 129 | const [$y, setY] = createSignal(0); 130 | 131 | const $c = createMemo(() => { 132 | if ($x()) { 133 | return $x(); 134 | } else { 135 | return $y(); 136 | } 137 | }); 138 | 139 | expect($c()).toBe(1); 140 | 141 | setX(0); 142 | flushSync(); 143 | expect($c()).toBe(0); 144 | 145 | setY(10); 146 | flushSync(); 147 | expect($c()).toBe(10); 148 | }); 149 | 150 | it("should accept equals option", () => { 151 | const [$x, setX] = createSignal(0); 152 | 153 | const $a = createMemo(() => $x(), 0, { 154 | // Skip even numbers. 155 | equals: (prev, next) => prev + 1 === next 156 | }); 157 | 158 | const effectA = vi.fn(); 159 | createRoot(() => createEffect($a, effectA)); 160 | flushSync(); 161 | 162 | expect($a()).toBe(0); 163 | expect(effectA).toHaveBeenCalledTimes(1); 164 | 165 | setX(2); 166 | flushSync(); 167 | expect($a()).toBe(2); 168 | expect(effectA).toHaveBeenCalledTimes(2); 169 | 170 | // no-change 171 | setX(3); 172 | flushSync(); 173 | expect($a()).toBe(2); 174 | expect(effectA).toHaveBeenCalledTimes(2); 175 | }); 176 | 177 | it("should use fallback if error is thrown during init", () => { 178 | createRoot(() => { 179 | createErrorBoundary( 180 | () => { 181 | const $a = createMemo(() => { 182 | if (1) throw Error(); 183 | return ""; 184 | }, "foo"); 185 | 186 | expect($a()).toBe("foo"); 187 | }, 188 | () => {} 189 | )(); 190 | }); 191 | }); 192 | 193 | it("should detect which signal triggered it", () => { 194 | const [$x, setX] = createSignal(0); 195 | const [$y, setY] = createSignal(0); 196 | 197 | const $a = createMemo(() => { 198 | const uX = hasUpdated($x); 199 | const uY = hasUpdated($y); 200 | return uX && uY ? "both" : uX ? "x" : uY ? "y" : "neither"; 201 | }); 202 | createRoot(() => createEffect($a, () => {})); 203 | expect($a()).toBe("neither"); 204 | flushSync(); 205 | expect($a()).toBe("neither"); 206 | 207 | setY(1); 208 | flushSync(); 209 | expect($a()).toBe("y"); 210 | 211 | setX(1); 212 | flushSync(); 213 | expect($a()).toBe("x"); 214 | 215 | setY(2); 216 | flushSync(); 217 | expect($a()).toBe("y"); 218 | 219 | setX(2); 220 | setY(3); 221 | flushSync(); 222 | expect($a()).toBe("both"); 223 | }); 224 | -------------------------------------------------------------------------------- /tests/createRoot.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Computation, 3 | createEffect, 4 | createMemo, 5 | createRenderEffect, 6 | createRoot, 7 | createSignal, 8 | flushSync, 9 | getOwner, 10 | onCleanup, 11 | Owner, 12 | type Accessor, 13 | type Signal 14 | } from "../src/index.js"; 15 | 16 | afterEach(() => flushSync()); 17 | 18 | it("should dispose of inner computations", () => { 19 | let $x: Signal; 20 | let $y: Accessor; 21 | 22 | const memo = vi.fn(() => $x[0]() + 10); 23 | 24 | createRoot(dispose => { 25 | $x = createSignal(10); 26 | $y = createMemo(memo); 27 | $y(); 28 | dispose(); 29 | }); 30 | 31 | // expect($y!).toThrow(); 32 | expect(memo).toHaveBeenCalledTimes(1); 33 | 34 | flushSync(); 35 | 36 | $x![1](50); 37 | flushSync(); 38 | 39 | // expect($y!).toThrow(); 40 | expect(memo).toHaveBeenCalledTimes(1); 41 | }); 42 | 43 | it("should return result", () => { 44 | const result = createRoot(dispose => { 45 | dispose(); 46 | return 10; 47 | }); 48 | 49 | expect(result).toBe(10); 50 | }); 51 | 52 | it("should create new tracking scope", () => { 53 | const [$x, setX] = createSignal(0); 54 | const effect = vi.fn(); 55 | 56 | const stopEffect = createRoot(dispose => { 57 | createEffect( 58 | () => { 59 | $x(); 60 | createRoot(() => void createEffect($x, effect)); 61 | }, 62 | () => {} 63 | ); 64 | 65 | return dispose; 66 | }); 67 | flushSync(); 68 | 69 | expect(effect).toHaveBeenCalledWith(0, undefined); 70 | expect(effect).toHaveBeenCalledTimes(1); 71 | 72 | stopEffect(); 73 | 74 | setX(10); 75 | flushSync(); 76 | expect(effect).not.toHaveBeenCalledWith(10); 77 | expect(effect).toHaveBeenCalledTimes(1); 78 | }); 79 | 80 | it("should not be reactive", () => { 81 | let $x: Signal; 82 | 83 | const root = vi.fn(); 84 | 85 | createRoot(() => { 86 | $x = createSignal(0); 87 | $x[0](); 88 | root(); 89 | }); 90 | 91 | expect(root).toHaveBeenCalledTimes(1); 92 | 93 | $x![1](1); 94 | flushSync(); 95 | expect(root).toHaveBeenCalledTimes(1); 96 | }); 97 | 98 | it("should hold parent tracking", () => { 99 | createRoot(() => { 100 | const parent = getOwner(); 101 | createRoot(() => { 102 | expect(getOwner()!._parent).toBe(parent); 103 | }); 104 | }); 105 | }); 106 | 107 | it("should not observe", () => { 108 | const [$x] = createSignal(0); 109 | createRoot(() => { 110 | $x(); 111 | const owner = getOwner() as Computation; 112 | expect(owner._sources).toBeUndefined(); 113 | expect(owner._observers).toBeUndefined(); 114 | }); 115 | }); 116 | 117 | it("should not throw if dispose called during active disposal process", () => { 118 | createRoot(dispose => { 119 | onCleanup(() => dispose()); 120 | dispose(); 121 | }); 122 | }); 123 | 124 | it("should not generate ids if no id is provided", () => { 125 | let o: Owner | null; 126 | let m: Owner | null; 127 | 128 | createRoot(() => { 129 | o = getOwner(); 130 | const c = createMemo(() => { 131 | m = getOwner(); 132 | }); 133 | c(); 134 | }); 135 | 136 | expect(o!.id).toEqual(null); 137 | expect(m!.id).toEqual(null); 138 | }); 139 | 140 | it("should generate ids if id is provided", () => { 141 | let o: Owner | null; 142 | let m: Owner | null; 143 | let m2: Owner | null; 144 | let c: string; 145 | let c2: string; 146 | let c3: string; 147 | let r: Owner | null; 148 | 149 | createRoot( 150 | () => { 151 | o = getOwner(); 152 | const memo = createMemo(() => { 153 | m = getOwner()!; 154 | c = m.getNextChildId(); 155 | return createMemo(() => { 156 | m2 = getOwner()!; 157 | c2 = m2.getNextChildId(); 158 | c3 = m2.getNextChildId(); 159 | }); 160 | }); 161 | createRenderEffect( 162 | () => { 163 | r = getOwner(); 164 | memo()(); 165 | }, 166 | () => {} 167 | ); 168 | }, 169 | { id: "" } 170 | ); 171 | 172 | expect(o!.id).toEqual(""); 173 | expect(m!.id).toEqual("0"); 174 | expect(c!).toEqual("00"); 175 | expect(m2!.id).toEqual("01"); 176 | expect(r!.id).toEqual("1"); 177 | expect(c2!).toEqual("010"); 178 | expect(c3!).toEqual("011"); 179 | }); 180 | -------------------------------------------------------------------------------- /tests/createSignal.test.ts: -------------------------------------------------------------------------------- 1 | import { createSignal, flushSync } from "../src/index.js"; 2 | 3 | afterEach(() => flushSync()); 4 | 5 | it("should store and return value on read", () => { 6 | const [$x] = createSignal(1); 7 | expect($x).toBeInstanceOf(Function); 8 | expect($x()).toBe(1); 9 | }); 10 | 11 | it("should update signal via setter", () => { 12 | const [$x, setX] = createSignal(1); 13 | setX(2); 14 | expect($x()).toBe(2); 15 | }); 16 | 17 | it("should update signal via update function", () => { 18 | const [$x, setX] = createSignal(1); 19 | setX(n => n + 1); 20 | expect($x()).toBe(2); 21 | }); 22 | 23 | it("should accept equals option", () => { 24 | const [$x, setX] = createSignal(1, { 25 | // Skip even numbers. 26 | equals: (prev, next) => prev + 1 === next 27 | }); 28 | 29 | setX(11); 30 | expect($x()).toBe(11); 31 | 32 | setX(12); 33 | expect($x()).toBe(11); 34 | 35 | setX(13); 36 | expect($x()).toBe(13); 37 | 38 | setX(14); 39 | expect($x()).toBe(13); 40 | }); 41 | 42 | it("should update signal with functional value", () => { 43 | const [$x, setX] = createSignal<() => number>(() => () => 10); 44 | expect($x()()).toBe(10); 45 | setX(() => () => 20); 46 | expect($x()()).toBe(20); 47 | }); 48 | 49 | it("should create signal derived from another signal", () => { 50 | const [$x, setX] = createSignal(1); 51 | const [$y, setY] = createSignal(() => $x() + 1); 52 | expect($y()).toBe(2); 53 | setY(1); 54 | expect($y()).toBe(1); 55 | setX(2); 56 | expect($y()).toBe(3); 57 | }); 58 | -------------------------------------------------------------------------------- /tests/flushSync.test.ts: -------------------------------------------------------------------------------- 1 | import { createEffect, createRoot, createSignal, flushSync } from "../src/index.js"; 2 | 3 | afterEach(() => flushSync()); 4 | 5 | it("should batch updates", () => { 6 | const [$x, setX] = createSignal(10); 7 | const effect = vi.fn(); 8 | 9 | createRoot(() => createEffect($x, effect)); 10 | flushSync(); 11 | 12 | setX(20); 13 | setX(30); 14 | setX(40); 15 | 16 | expect(effect).to.toHaveBeenCalledTimes(1); 17 | flushSync(); 18 | expect(effect).to.toHaveBeenCalledTimes(2); 19 | }); 20 | 21 | it("should wait for queue to flush", () => { 22 | const [$x, setX] = createSignal(10); 23 | const $effect = vi.fn(); 24 | 25 | createRoot(() => createEffect($x, $effect)); 26 | flushSync(); 27 | 28 | expect($effect).to.toHaveBeenCalledTimes(1); 29 | 30 | setX(20); 31 | flushSync(); 32 | expect($effect).to.toHaveBeenCalledTimes(2); 33 | 34 | setX(30); 35 | flushSync(); 36 | expect($effect).to.toHaveBeenCalledTimes(3); 37 | }); 38 | 39 | it("should not fail if called while flushing", () => { 40 | const [$a, setA] = createSignal(10); 41 | 42 | const effect = vi.fn(() => { 43 | flushSync(); 44 | }); 45 | 46 | createRoot(() => createEffect($a, effect)); 47 | flushSync(); 48 | 49 | expect(effect).to.toHaveBeenCalledTimes(1); 50 | 51 | setA(20); 52 | flushSync(); 53 | expect(effect).to.toHaveBeenCalledTimes(2); 54 | }); 55 | -------------------------------------------------------------------------------- /tests/gc.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createEffect, 3 | createMemo, 4 | createRoot, 5 | createSignal, 6 | flushSync, 7 | getOwner 8 | } from "../src/index.js"; 9 | 10 | function gc() { 11 | return new Promise(resolve => 12 | setTimeout(async () => { 13 | flushSync(); // flush call stack (holds a reference) 14 | global.gc!(); 15 | resolve(void 0); 16 | }, 0) 17 | ); 18 | } 19 | 20 | if (global.gc) { 21 | it("should gc computed if there are no observers", async () => { 22 | const [$x] = createSignal(0), 23 | ref = new WeakRef(createMemo(() => $x())); 24 | 25 | await gc(); 26 | expect(ref.deref()).toBeUndefined(); 27 | }); 28 | 29 | it("should _not_ gc computed if there are observers", async () => { 30 | let [$x] = createSignal(0), 31 | pointer; 32 | 33 | const ref = new WeakRef((pointer = createMemo(() => $x()))); 34 | 35 | ref.deref()!(); 36 | 37 | await gc(); 38 | expect(ref.deref()).toBeDefined(); 39 | 40 | pointer = undefined; 41 | await gc(); 42 | expect(ref.deref()).toBeUndefined(); 43 | }); 44 | 45 | it("should gc root if disposed", async () => { 46 | let [$x] = createSignal(0), 47 | ref!: WeakRef, 48 | pointer; 49 | 50 | const dispose = createRoot(dispose => { 51 | ref = new WeakRef( 52 | (pointer = createMemo(() => { 53 | $x(); 54 | })) 55 | ); 56 | 57 | return dispose; 58 | }); 59 | 60 | await gc(); 61 | expect(ref.deref()).toBeDefined(); 62 | 63 | dispose(); 64 | await gc(); 65 | expect(ref.deref()).toBeDefined(); 66 | 67 | pointer = undefined; 68 | await gc(); 69 | expect(ref.deref()).toBeUndefined(); 70 | }); 71 | 72 | it("should gc effect lazily", async () => { 73 | let [$x, setX] = createSignal(0), 74 | ref!: WeakRef; 75 | 76 | const dispose = createRoot(dispose => { 77 | createEffect($x, () => { 78 | ref = new WeakRef(getOwner()!); 79 | }); 80 | 81 | return dispose; 82 | }); 83 | 84 | await gc(); 85 | expect(ref.deref()).toBeDefined(); 86 | 87 | dispose(); 88 | setX(1); 89 | 90 | await gc(); 91 | expect(ref.deref()).toBeUndefined(); 92 | }); 93 | } else { 94 | it("", () => {}); 95 | } 96 | -------------------------------------------------------------------------------- /tests/getOwner.test.ts: -------------------------------------------------------------------------------- 1 | import { createEffect, createRoot, getOwner, untrack } from "../src/index.js"; 2 | 3 | it("should return current owner", () => { 4 | createRoot(() => { 5 | const owner = getOwner(); 6 | expect(owner).toBeDefined(); 7 | createEffect( 8 | () => { 9 | expect(getOwner()).toBeDefined(); 10 | expect(getOwner()).not.toBe(owner); 11 | }, 12 | () => {} 13 | ); 14 | }); 15 | }); 16 | 17 | it("should return parent scope from inside untrack", () => { 18 | createRoot(() => { 19 | untrack(() => { 20 | expect(getOwner()).toBeDefined(); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /tests/graph.test.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/preactjs/signals/blob/17c0155997f47f4bb81e5715b46a55d1fafa22a2/packages/core/test/signal.test.tsx#L1383 2 | 3 | import { createMemo, createSignal } from "../src/index.js"; 4 | 5 | it("should drop X->B->X updates", () => { 6 | // X 7 | // / | 8 | // A | <- Looks like a flag doesn't it? :D 9 | // \ | 10 | // B 11 | // | 12 | // C 13 | 14 | const [$x, setX] = createSignal(2); 15 | 16 | const $a = createMemo(() => $x() - 1); 17 | const $b = createMemo(() => $x() + $a()); 18 | 19 | const compute = vi.fn(() => "c: " + $b()); 20 | const $c = createMemo(compute); 21 | 22 | expect($c()).toBe("c: 3"); 23 | expect(compute).toHaveBeenCalledTimes(1); 24 | compute.mockReset(); 25 | 26 | setX(4); 27 | $c(); 28 | expect(compute).toHaveBeenCalledTimes(1); 29 | }); 30 | 31 | it("should only update every signal once (diamond graph)", () => { 32 | // In this scenario "C" should only update once when "X" receive an update. This is sometimes 33 | // referred to as the "diamond" scenario. 34 | // X 35 | // / \ 36 | // A B 37 | // \ / 38 | // C 39 | 40 | const [$x, setX] = createSignal("a"); 41 | const $a = createMemo(() => $x()); 42 | const $b = createMemo(() => $x()); 43 | 44 | const spy = vi.fn(() => $a() + " " + $b()); 45 | const $c = createMemo(spy); 46 | 47 | expect($c()).toBe("a a"); 48 | expect(spy).toHaveBeenCalledTimes(1); 49 | 50 | setX("aa"); 51 | expect($c()).toBe("aa aa"); 52 | expect(spy).toHaveBeenCalledTimes(2); 53 | }); 54 | 55 | it("should only update every signal once (diamond graph + tail)", () => { 56 | // "D" will be likely updated twice if our mark+sweep logic is buggy. 57 | // X 58 | // / \ 59 | // A B 60 | // \ / 61 | // C 62 | // | 63 | // D 64 | 65 | const [$x, setX] = createSignal("a"); 66 | 67 | const $a = createMemo(() => $x()); 68 | const $b = createMemo(() => $x()); 69 | const $c = createMemo(() => $a() + " " + $b()); 70 | 71 | const spy = vi.fn(() => $c()); 72 | const $d = createMemo(spy); 73 | 74 | expect($d()).toBe("a a"); 75 | expect(spy).toHaveBeenCalledTimes(1); 76 | 77 | setX("aa"); 78 | expect($d()).toBe("aa aa"); 79 | expect(spy).toHaveBeenCalledTimes(2); 80 | }); 81 | 82 | it("should bail out if result is the same", () => { 83 | // Bail out if value of "A" never changes 84 | // X->A->B 85 | 86 | const [$x, setX] = createSignal("a"); 87 | 88 | const $a = createMemo(() => { 89 | $x(); 90 | return "foo"; 91 | }); 92 | 93 | const spy = vi.fn(() => $a()); 94 | const $b = createMemo(spy); 95 | 96 | expect($b()).toBe("foo"); 97 | expect(spy).toHaveBeenCalledTimes(1); 98 | 99 | setX("aa"); 100 | expect($b()).toBe("foo"); 101 | expect(spy).toHaveBeenCalledTimes(1); 102 | }); 103 | 104 | it("should only update every signal once (jagged diamond graph + tails)", () => { 105 | // "E" and "F" will be likely updated >3 if our mark+sweep logic is buggy. 106 | // X 107 | // / \ 108 | // A B 109 | // | | 110 | // | C 111 | // \ / 112 | // D 113 | // / \ 114 | // E F 115 | 116 | const [$x, setX] = createSignal("a"); 117 | 118 | const $a = createMemo(() => $x()); 119 | const $b = createMemo(() => $x()); 120 | const $c = createMemo(() => $b()); 121 | 122 | const dSpy = vi.fn(() => $a() + " " + $c()); 123 | const $d = createMemo(dSpy); 124 | 125 | const eSpy = vi.fn(() => $d()); 126 | const $e = createMemo(eSpy); 127 | const fSpy = vi.fn(() => $d()); 128 | const $f = createMemo(fSpy); 129 | 130 | expect($e()).toBe("a a"); 131 | expect(eSpy).toHaveBeenCalledTimes(1); 132 | 133 | expect($f()).toBe("a a"); 134 | expect(fSpy).toHaveBeenCalledTimes(1); 135 | 136 | setX("b"); 137 | 138 | expect($d()).toBe("b b"); 139 | expect(dSpy).toHaveBeenCalledTimes(2); 140 | 141 | expect($e()).toBe("b b"); 142 | expect(eSpy).toHaveBeenCalledTimes(2); 143 | 144 | expect($f()).toBe("b b"); 145 | expect(fSpy).toHaveBeenCalledTimes(2); 146 | 147 | setX("c"); 148 | 149 | expect($d()).toBe("c c"); 150 | expect(dSpy).toHaveBeenCalledTimes(3); 151 | 152 | expect($e()).toBe("c c"); 153 | expect(eSpy).toHaveBeenCalledTimes(3); 154 | 155 | expect($f()).toBe("c c"); 156 | expect(fSpy).toHaveBeenCalledTimes(3); 157 | }); 158 | 159 | it("should ensure subs update even if one dep is static", () => { 160 | // X 161 | // / \ 162 | // A *B <- returns same value every time 163 | // \ / 164 | // C 165 | 166 | const [$x, setX] = createSignal("a"); 167 | 168 | const $a = createMemo(() => $x()); 169 | const $b = createMemo(() => { 170 | $x(); 171 | return "c"; 172 | }); 173 | 174 | const spy = vi.fn(() => $a() + " " + $b()); 175 | const $c = createMemo(spy); 176 | 177 | expect($c()).toBe("a c"); 178 | 179 | setX("aa"); 180 | 181 | expect($c()).toBe("aa c"); 182 | expect(spy).toHaveBeenCalledTimes(2); 183 | }); 184 | 185 | it("should ensure subs update even if two deps mark it clean", () => { 186 | // In this scenario both "B" and "C" always return the same value. But "D" must still update 187 | // because "X" marked it. If "D" isn't updated, then we have a bug. 188 | // X 189 | // / | \ 190 | // A *B *C 191 | // \ | / 192 | // D 193 | 194 | const [$x, setX] = createSignal("a"); 195 | 196 | const $b = createMemo(() => $x()); 197 | const $c = createMemo(() => { 198 | $x(); 199 | return "c"; 200 | }); 201 | const $d = createMemo(() => { 202 | $x(); 203 | return "d"; 204 | }); 205 | 206 | const spy = vi.fn(() => $b() + " " + $c() + " " + $d()); 207 | const $e = createMemo(spy); 208 | 209 | expect($e()).toBe("a c d"); 210 | 211 | setX("aa"); 212 | 213 | expect($e()).toBe("aa c d"); 214 | expect(spy).toHaveBeenCalledTimes(2); 215 | }); 216 | 217 | it("propagates in topological order", () => { 218 | // 219 | // c1 220 | // / \ 221 | // / \ 222 | // b1 b2 223 | // \ / 224 | // \ / 225 | // a1 226 | // 227 | var seq = "", 228 | [a1, setA1] = createSignal(false), 229 | b1 = createMemo( 230 | () => { 231 | a1(); 232 | seq += "b1"; 233 | }, 234 | undefined, 235 | { equals: false } 236 | ), 237 | b2 = createMemo( 238 | () => { 239 | a1(); 240 | seq += "b2"; 241 | }, 242 | undefined, 243 | { equals: false } 244 | ), 245 | c1 = createMemo( 246 | () => { 247 | b1(), b2(); 248 | seq += "c1"; 249 | }, 250 | undefined, 251 | { equals: false } 252 | ); 253 | 254 | c1(); 255 | seq = ""; 256 | setA1(true); 257 | c1(); 258 | expect(seq).toBe("b1b2c1"); 259 | }); 260 | 261 | it("only propagates once with linear convergences", () => { 262 | // d 263 | // | 264 | // +---+---+---+---+ 265 | // v v v v v 266 | // f1 f2 f3 f4 f5 267 | // | | | | | 268 | // +---+---+---+---+ 269 | // v 270 | // g 271 | var [d, setD] = createSignal(0), 272 | f1 = createMemo(() => d()), 273 | f2 = createMemo(() => d()), 274 | f3 = createMemo(() => d()), 275 | f4 = createMemo(() => d()), 276 | f5 = createMemo(() => d()), 277 | gcount = 0, 278 | g = createMemo(() => { 279 | gcount++; 280 | return f1() + f2() + f3() + f4() + f5(); 281 | }); 282 | 283 | g(); 284 | gcount = 0; 285 | setD(1); 286 | g(); 287 | expect(gcount).toBe(1); 288 | }); 289 | 290 | it("only propagates once with exponential convergence", () => { 291 | // d 292 | // | 293 | // +---+---+ 294 | // v v v 295 | // f1 f2 f3 296 | // \ | / 297 | // O 298 | // / | \ 299 | // v v v 300 | // g1 g2 g3 301 | // +---+---+ 302 | // v 303 | // h 304 | var [d, setD] = createSignal(0), 305 | f1 = createMemo(() => { 306 | return d(); 307 | }), 308 | f2 = createMemo(() => { 309 | return d(); 310 | }), 311 | f3 = createMemo(() => { 312 | return d(); 313 | }), 314 | g1 = createMemo(() => { 315 | return f1() + f2() + f3(); 316 | }), 317 | g2 = createMemo(() => { 318 | return f1() + f2() + f3(); 319 | }), 320 | g3 = createMemo(() => { 321 | return f1() + f2() + f3(); 322 | }), 323 | hcount = 0, 324 | h = createMemo(() => { 325 | hcount++; 326 | return g1() + g2() + g3(); 327 | }); 328 | h(); 329 | hcount = 0; 330 | setD(1); 331 | h(); 332 | expect(hcount).toBe(1); 333 | }); 334 | 335 | it("does not trigger downstream computations unless changed", () => { 336 | const [s1, set] = createSignal(1, { equals: false }); 337 | let order = ""; 338 | const t1 = createMemo(() => { 339 | order += "t1"; 340 | return s1(); 341 | }); 342 | const t2 = createMemo(() => { 343 | order += "c1"; 344 | t1(); 345 | }); 346 | t2(); 347 | expect(order).toBe("c1t1"); 348 | order = ""; 349 | set(1); 350 | t2(); 351 | expect(order).toBe("t1"); 352 | order = ""; 353 | set(2); 354 | t2(); 355 | expect(order).toBe("t1c1"); 356 | }); 357 | 358 | it("applies updates to changed dependees in same order as createMemo", () => { 359 | const [s1, set] = createSignal(0); 360 | let order = ""; 361 | const t1 = createMemo(() => { 362 | order += "t1"; 363 | return s1() === 0; 364 | }); 365 | const t2 = createMemo(() => { 366 | order += "c1"; 367 | return s1(); 368 | }); 369 | const t3 = createMemo(() => { 370 | order += "c2"; 371 | return t1(); 372 | }); 373 | t2(); 374 | t3(); 375 | expect(order).toBe("c1c2t1"); 376 | order = ""; 377 | set(1); 378 | t2(); 379 | t3(); 380 | expect(order).toBe("c1t1c2"); 381 | }); 382 | 383 | it("updates downstream pending computations", () => { 384 | const [s1, set] = createSignal(0); 385 | const [s2] = createSignal(0); 386 | let order = ""; 387 | const t1 = createMemo(() => { 388 | order += "t1"; 389 | return s1() === 0; 390 | }); 391 | const t2 = createMemo(() => { 392 | order += "c1"; 393 | return s1(); 394 | }); 395 | const t3 = createMemo(() => { 396 | order += "c2"; 397 | t1(); 398 | return createMemo(() => { 399 | order += "c2_1"; 400 | return s2(); 401 | }); 402 | }); 403 | order = ""; 404 | set(1); 405 | t2(); 406 | t3()(); 407 | expect(order).toBe("c1c2t1c2_1"); 408 | }); 409 | 410 | describe("with changing dependencies", () => { 411 | let i: () => boolean, setI: (v: boolean) => void; 412 | let t: () => number, setT: (v: number) => void; 413 | let e: () => number, setE: (v: number) => void; 414 | let fevals: number; 415 | let f: () => number; 416 | 417 | function init() { 418 | [i, setI] = createSignal(true); 419 | [t, setT] = createSignal(1); 420 | [e, setE] = createSignal(2); 421 | fevals = 0; 422 | f = createMemo(() => { 423 | fevals++; 424 | return i() ? t() : e(); 425 | }); 426 | f(); 427 | fevals = 0; 428 | } 429 | 430 | it("updates on active dependencies", () => { 431 | init(); 432 | setT(5); 433 | expect(f()).toBe(5); 434 | expect(fevals).toBe(1); 435 | }); 436 | 437 | it("does not update on inactive dependencies", () => { 438 | init(); 439 | setE(5); 440 | expect(f()).toBe(1); 441 | expect(fevals).toBe(0); 442 | }); 443 | 444 | it("deactivates obsolete dependencies", () => { 445 | init(); 446 | setI(false); 447 | f(); 448 | fevals = 0; 449 | setT(5); 450 | f(); 451 | expect(fevals).toBe(0); 452 | }); 453 | 454 | it("activates new dependencies", () => { 455 | init(); 456 | setI(false); 457 | fevals = 0; 458 | setE(5); 459 | f(); 460 | expect(fevals).toBe(1); 461 | }); 462 | 463 | it("ensures that new dependencies are updated before dependee", () => { 464 | var order = "", 465 | [a, setA] = createSignal(0), 466 | b = createMemo(() => { 467 | order += "b"; 468 | return a() + 1; 469 | }), 470 | c = createMemo(() => { 471 | order += "c"; 472 | const check = b(); 473 | if (check) { 474 | return check; 475 | } 476 | return e(); 477 | }), 478 | d = createMemo(() => { 479 | return a(); 480 | }), 481 | e = createMemo(() => { 482 | order += "d"; 483 | return d() + 10; 484 | }); 485 | 486 | c(); 487 | e(); 488 | expect(order).toBe("cbd"); 489 | 490 | order = ""; 491 | setA(-1); 492 | c(); 493 | e(); 494 | 495 | expect(order).toBe("bcd"); 496 | expect(c()).toBe(9); 497 | 498 | order = ""; 499 | setA(0); 500 | c(); 501 | e(); 502 | expect(order).toBe("bcd"); 503 | expect(c()).toBe(1); 504 | }); 505 | }); 506 | 507 | it("does not update subsequent pending computations after stale invocations", () => { 508 | const [s1, set1] = createSignal(1); 509 | const [s2, set2] = createSignal(false); 510 | let count = 0; 511 | /* 512 | s1 513 | | 514 | +---+---+ 515 | t1 t2 c1 t3 516 | \ / 517 | c3 518 | [PN,PN,STL,void] 519 | */ 520 | const t1 = createMemo(() => s1() > 0); 521 | const t2 = createMemo(() => s1() > 0); 522 | const c1 = createMemo(() => s1()); 523 | const t3 = createMemo(() => { 524 | const a = s1(); 525 | const b = s2(); 526 | return a && b; 527 | }); 528 | const c3 = createMemo(() => { 529 | t1(); 530 | t2(); 531 | c1(); 532 | t3(); 533 | count++; 534 | }); 535 | c3(); 536 | set2(true); 537 | c3(); 538 | expect(count).toBe(2); 539 | set1(2); 540 | c3(); 541 | expect(count).toBe(3); 542 | }); 543 | 544 | it("evaluates stale computations before dependees when trackers stay unchanged", () => { 545 | let [s1, set] = createSignal(1, { equals: false }); 546 | let order = ""; 547 | let t1 = createMemo(() => { 548 | order += "t1"; 549 | return s1() > 2; 550 | }); 551 | let t2 = createMemo(() => { 552 | order += "t2"; 553 | return s1() > 2; 554 | }); 555 | let c1 = createMemo( 556 | () => { 557 | order += "c1"; 558 | s1(); 559 | }, 560 | undefined, 561 | { equals: false } 562 | ); 563 | const c2 = createMemo(() => { 564 | order += "c2"; 565 | t1(); 566 | t2(); 567 | c1(); 568 | }); 569 | c2(); 570 | order = ""; 571 | set(1); 572 | c2(); 573 | expect(order).toBe("t1t2c1c2"); 574 | order = ""; 575 | set(3); 576 | c2(); 577 | expect(order).toBe("t1c2t2c1"); 578 | }); 579 | 580 | it("correctly marks downstream computations as stale on change", () => { 581 | const [s1, set] = createSignal(1); 582 | let order = ""; 583 | const t1 = createMemo(() => { 584 | order += "t1"; 585 | return s1(); 586 | }); 587 | const c1 = createMemo(() => { 588 | order += "c1"; 589 | return t1(); 590 | }); 591 | const c2 = createMemo(() => { 592 | order += "c2"; 593 | return c1(); 594 | }); 595 | const c3 = createMemo(() => { 596 | order += "c3"; 597 | return c2(); 598 | }); 599 | c3(); 600 | order = ""; 601 | set(2); 602 | c3(); 603 | expect(order).toBe("t1c1c2c3"); 604 | }); 605 | -------------------------------------------------------------------------------- /tests/mapArray.test.ts: -------------------------------------------------------------------------------- 1 | import { createEffect, createRoot, createSignal, flushSync, mapArray } from "../src/index.js"; 2 | 3 | it("should compute keyed map", () => { 4 | const [$source, setSource] = createSignal([{ id: "a" }, { id: "b" }, { id: "c" }]); 5 | 6 | const computed = vi.fn(); 7 | 8 | const map = mapArray($source, (value, index) => { 9 | computed(); 10 | return { 11 | get id() { 12 | return value().id; 13 | }, 14 | get index() { 15 | return index(); 16 | } 17 | }; 18 | }); 19 | 20 | const [a, b, c] = map(); 21 | expect(a.id).toBe("a"); 22 | expect(a.index).toBe(0); 23 | expect(b.id).toBe("b"); 24 | expect(b.index).toBe(1); 25 | expect(c.id).toBe("c"); 26 | expect(c.index).toBe(2); 27 | expect(computed).toHaveBeenCalledTimes(3); 28 | 29 | // Move values around 30 | setSource(p => { 31 | const tmp = p[1]; 32 | p[1] = p[0]; 33 | p[0] = tmp; 34 | return [...p]; 35 | }); 36 | 37 | const [a2, b2, c2] = map(); 38 | expect(a2.id).toBe("b"); 39 | expect(a === b2).toBeTruthy(); 40 | expect(a2.index).toBe(0); 41 | expect(b2.id).toBe("a"); 42 | expect(b2.index).toBe(1); 43 | expect(b === a2).toBeTruthy(); 44 | expect(c2.id).toBe("c"); 45 | expect(c2.index).toBe(2); 46 | expect(c === c2).toBeTruthy(); 47 | expect(computed).toHaveBeenCalledTimes(3); 48 | 49 | // Add new value 50 | setSource(p => [...p, { id: "d" }]); 51 | 52 | expect(map().length).toBe(4); 53 | expect(map()[map().length - 1].id).toBe("d"); 54 | expect(map()[map().length - 1].index).toBe(3); 55 | expect(computed).toHaveBeenCalledTimes(4); 56 | 57 | // Remove value 58 | setSource(p => p.slice(1)); 59 | 60 | expect(map().length).toBe(3); 61 | expect(map()[0].id).toBe("a"); 62 | expect(map()[0] === b2 && map()[0] === a).toBeTruthy(); 63 | expect(computed).toHaveBeenCalledTimes(4); 64 | 65 | // Empty 66 | setSource([]); 67 | 68 | expect(map().length).toBe(0); 69 | expect(computed).toHaveBeenCalledTimes(4); 70 | }); 71 | 72 | it("should notify observer", () => { 73 | const [$source, setSource] = createSignal([{ id: "a" }, { id: "b" }, { id: "c" }]); 74 | 75 | const map = mapArray($source, value => { 76 | return { 77 | get id() { 78 | return value().id; 79 | } 80 | }; 81 | }); 82 | 83 | const effect = vi.fn(); 84 | createRoot(() => createEffect(map, effect)); 85 | flushSync(); 86 | 87 | setSource(prev => prev.slice(1)); 88 | flushSync(); 89 | expect(effect).toHaveBeenCalledTimes(2); 90 | }); 91 | 92 | it("should compute map when key by index", () => { 93 | const [$source, setSource] = createSignal([1, 2, 3]); 94 | 95 | const computed = vi.fn(); 96 | const map = mapArray( 97 | $source, 98 | (value, index) => { 99 | computed(); 100 | return { 101 | get id() { 102 | return value() * 2; 103 | }, 104 | get index() { 105 | return index(); 106 | } 107 | }; 108 | }, 109 | { keyed: false } 110 | ); 111 | 112 | const [a, b, c] = map(); 113 | expect(a.index).toBe(0); 114 | expect(a.id).toBe(2); 115 | expect(b.index).toBe(1); 116 | expect(b.id).toBe(4); 117 | expect(c.index).toBe(2); 118 | expect(c.id).toBe(6); 119 | expect(computed).toHaveBeenCalledTimes(3); 120 | 121 | // Move values around 122 | setSource([3, 2, 1]); 123 | 124 | const [a2, b2, c2] = map(); 125 | expect(a2.index).toBe(0); 126 | expect(a2.id).toBe(6); 127 | expect(a === a2).toBeTruthy(); 128 | expect(b2.index).toBe(1); 129 | expect(b2.id).toBe(4); 130 | expect(b === b2).toBeTruthy(); 131 | expect(c2.index).toBe(2); 132 | expect(c2.id).toBe(2); 133 | expect(c === c2).toBeTruthy(); 134 | expect(computed).toHaveBeenCalledTimes(3); 135 | 136 | // Add new value 137 | setSource([3, 2, 1, 4]); 138 | 139 | expect(map().length).toBe(4); 140 | expect(map()[map().length - 1].index).toBe(3); 141 | expect(map()[map().length - 1].id).toBe(8); 142 | expect(computed).toHaveBeenCalledTimes(4); 143 | 144 | // Remove value 145 | setSource([2, 1, 4]); 146 | 147 | expect(map().length).toBe(3); 148 | expect(map()[0].id).toBe(4); 149 | 150 | // Empty 151 | setSource([]); 152 | 153 | expect(map().length).toBe(0); 154 | expect(computed).toHaveBeenCalledTimes(4); 155 | }); 156 | 157 | it("should compute custom keyed map", () => { 158 | const [$source, setSource] = createSignal([{ id: "a" }, { id: "b" }, { id: "c" }]); 159 | 160 | const computed = vi.fn(); 161 | 162 | const map = mapArray( 163 | $source, 164 | (value, index) => { 165 | computed(); 166 | return { 167 | get id() { 168 | return value().id; 169 | }, 170 | get index() { 171 | return index(); 172 | } 173 | }; 174 | }, 175 | { 176 | keyed: item => item.id 177 | } 178 | ); 179 | 180 | const [a, b, c] = map(); 181 | expect(a.id).toBe("a"); 182 | expect(a.index).toBe(0); 183 | expect(b.id).toBe("b"); 184 | expect(b.index).toBe(1); 185 | expect(c.id).toBe("c"); 186 | expect(c.index).toBe(2); 187 | expect(computed).toHaveBeenCalledTimes(3); 188 | 189 | // Move values around 190 | setSource(p => { 191 | const tmp = p[1]; 192 | p[1] = p[0]; 193 | p[0] = tmp; 194 | return [...p]; 195 | }); 196 | 197 | const [a2, b2, c2] = map(); 198 | expect(a2.id).toBe("b"); 199 | expect(a === b2).toBeTruthy(); 200 | expect(a2.index).toBe(0); 201 | expect(b2.id).toBe("a"); 202 | expect(b2.index).toBe(1); 203 | expect(b === a2).toBeTruthy(); 204 | expect(c2.id).toBe("c"); 205 | expect(c2.index).toBe(2); 206 | expect(c === c2).toBeTruthy(); 207 | expect(computed).toHaveBeenCalledTimes(3); 208 | 209 | // Add new value 210 | setSource(p => [...p, { id: "d" }]); 211 | 212 | expect(map().length).toBe(4); 213 | expect(map()[map().length - 1].id).toBe("d"); 214 | expect(map()[map().length - 1].index).toBe(3); 215 | expect(computed).toHaveBeenCalledTimes(4); 216 | 217 | // Remove value 218 | setSource(p => p.slice(1)); 219 | 220 | expect(map().length).toBe(3); 221 | expect(map()[0].id).toBe("a"); 222 | expect(map()[0] === b2 && map()[0] === a).toBeTruthy(); 223 | expect(computed).toHaveBeenCalledTimes(4); 224 | 225 | // Empty 226 | setSource([]); 227 | 228 | expect(map().length).toBe(0); 229 | expect(computed).toHaveBeenCalledTimes(4); 230 | }); 231 | -------------------------------------------------------------------------------- /tests/onCleanup.test.ts: -------------------------------------------------------------------------------- 1 | import { createEffect, createRoot, flushSync, onCleanup } from "../src/index.js"; 2 | 3 | afterEach(() => flushSync()); 4 | 5 | it("should be invoked when computation is disposed", () => { 6 | const disposeA = vi.fn(); 7 | const disposeB = vi.fn(); 8 | const disposeC = vi.fn(); 9 | 10 | const stopEffect = createRoot(dispose => { 11 | createEffect( 12 | () => { 13 | onCleanup(disposeA); 14 | onCleanup(disposeB); 15 | onCleanup(disposeC); 16 | }, 17 | () => {} 18 | ); 19 | 20 | return dispose; 21 | }); 22 | flushSync(); 23 | 24 | stopEffect(); 25 | 26 | expect(disposeA).toHaveBeenCalled(); 27 | expect(disposeB).toHaveBeenCalled(); 28 | expect(disposeC).toHaveBeenCalled(); 29 | }); 30 | 31 | it("should not trigger wrong onCleanup", () => { 32 | const dispose = vi.fn(); 33 | 34 | createRoot(() => { 35 | createEffect( 36 | () => { 37 | onCleanup(dispose); 38 | }, 39 | () => {} 40 | ); 41 | 42 | const stopEffect = createRoot(dispose => { 43 | createEffect( 44 | () => {}, 45 | () => {} 46 | ); 47 | return dispose; 48 | }); 49 | 50 | stopEffect(); 51 | flushSync(); 52 | 53 | expect(dispose).toHaveBeenCalledTimes(0); 54 | }); 55 | }); 56 | 57 | it("should clean up in reverse order", () => { 58 | const disposeParent = vi.fn(); 59 | const disposeA = vi.fn(); 60 | const disposeB = vi.fn(); 61 | 62 | let calls = 0; 63 | 64 | const stopEffect = createRoot(dispose => { 65 | createEffect( 66 | () => { 67 | onCleanup(() => disposeParent(++calls)); 68 | 69 | createEffect( 70 | () => { 71 | onCleanup(() => disposeA(++calls)); 72 | }, 73 | () => {} 74 | ); 75 | 76 | createEffect( 77 | () => { 78 | onCleanup(() => disposeB(++calls)); 79 | }, 80 | () => {} 81 | ); 82 | }, 83 | () => {} 84 | ); 85 | 86 | return dispose; 87 | }); 88 | flushSync(); 89 | 90 | stopEffect(); 91 | 92 | expect(disposeB).toHaveBeenCalled(); 93 | expect(disposeA).toHaveBeenCalled(); 94 | expect(disposeParent).toHaveBeenCalled(); 95 | 96 | expect(disposeB).toHaveBeenCalledWith(1); 97 | expect(disposeA).toHaveBeenCalledWith(2); 98 | expect(disposeParent).toHaveBeenCalledWith(3); 99 | }); 100 | 101 | it("should dispose all roots", () => { 102 | const disposals: string[] = []; 103 | 104 | const dispose = createRoot(dispose => { 105 | createRoot(() => { 106 | onCleanup(() => disposals.push("SUBTREE 1")); 107 | createEffect( 108 | () => onCleanup(() => disposals.push("+A1")), 109 | () => {} 110 | ); 111 | createEffect( 112 | () => onCleanup(() => disposals.push("+B1")), 113 | () => {} 114 | ); 115 | createEffect( 116 | () => onCleanup(() => disposals.push("+C1")), 117 | () => {} 118 | ); 119 | }); 120 | 121 | createRoot(() => { 122 | onCleanup(() => disposals.push("SUBTREE 2")); 123 | createEffect( 124 | () => onCleanup(() => disposals.push("+A2")), 125 | () => {} 126 | ); 127 | createEffect( 128 | () => onCleanup(() => disposals.push("+B2")), 129 | () => {} 130 | ); 131 | createEffect( 132 | () => onCleanup(() => disposals.push("+C2")), 133 | () => {} 134 | ); 135 | }); 136 | 137 | onCleanup(() => disposals.push("ROOT")); 138 | 139 | return dispose; 140 | }); 141 | 142 | flushSync(); 143 | dispose(); 144 | 145 | expect(disposals).toMatchInlineSnapshot(` 146 | [ 147 | "+C2", 148 | "+B2", 149 | "+A2", 150 | "SUBTREE 2", 151 | "+C1", 152 | "+B1", 153 | "+A1", 154 | "SUBTREE 1", 155 | "ROOT", 156 | ] 157 | `); 158 | }); 159 | -------------------------------------------------------------------------------- /tests/repeat.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createEffect, 3 | createRoot, 4 | createSignal, 5 | createStore, 6 | flushSync, 7 | repeat 8 | } from "../src/index.js"; 9 | 10 | it("should compute keyed map", () => { 11 | const [source, setSource] = createStore>([ 12 | { id: "a" }, 13 | { id: "b" }, 14 | { id: "c" } 15 | ]); 16 | 17 | const computed = vi.fn(); 18 | 19 | const map = repeat( 20 | () => source.length, 21 | index => { 22 | computed(); 23 | return { 24 | get id() { 25 | return source[index].id; 26 | }, 27 | get index() { 28 | return index; 29 | } 30 | }; 31 | } 32 | ); 33 | 34 | const [a, b, c] = map(); 35 | expect(a.id).toBe("a"); 36 | expect(a.index).toBe(0); 37 | expect(b.id).toBe("b"); 38 | expect(b.index).toBe(1); 39 | expect(c.id).toBe("c"); 40 | expect(c.index).toBe(2); 41 | expect(computed).toHaveBeenCalledTimes(3); 42 | 43 | // Move values around 44 | setSource(p => { 45 | [p[0], p[1]] = [p[1], p[0]]; 46 | }); 47 | 48 | const [a2, b2, c2] = map(); 49 | expect(a2.id).toBe("b"); 50 | expect(a === a2).toBeTruthy(); 51 | expect(a2.index).toBe(0); 52 | expect(b2.id).toBe("a"); 53 | expect(b2.index).toBe(1); 54 | expect(b === b2).toBeTruthy(); 55 | expect(c2.id).toBe("c"); 56 | expect(c2.index).toBe(2); 57 | expect(c === c2).toBeTruthy(); 58 | expect(computed).toHaveBeenCalledTimes(3); 59 | 60 | // Add new value 61 | setSource(p => p.push({ id: "d" })); 62 | 63 | expect(map().length).toBe(4); 64 | expect(map()[map().length - 1].id).toBe("d"); 65 | expect(map()[map().length - 1].index).toBe(3); 66 | expect(computed).toHaveBeenCalledTimes(4); 67 | 68 | // Remove value 69 | setSource(p => p.pop()); 70 | 71 | expect(map().length).toBe(3); 72 | expect(map()[0].id).toBe("b"); 73 | expect(map()[0] === a2 && map()[0] === a).toBeTruthy(); 74 | expect(computed).toHaveBeenCalledTimes(4); 75 | 76 | // Empty 77 | setSource(p => (p.length = 0)); 78 | 79 | expect(map().length).toBe(0); 80 | expect(computed).toHaveBeenCalledTimes(4); 81 | }); 82 | 83 | it("should notify observer", () => { 84 | const [source, setSource] = createStore([{ id: "a" }, { id: "b" }, { id: "c" }]); 85 | 86 | const map = repeat( 87 | () => source.length, 88 | index => { 89 | return { 90 | get id() { 91 | return source[index].id; 92 | } 93 | }; 94 | } 95 | ); 96 | 97 | const effect = vi.fn(); 98 | createRoot(() => createEffect(map, effect)); 99 | flushSync(); 100 | 101 | setSource(prev => prev.pop()); 102 | flushSync(); 103 | expect(effect).toHaveBeenCalledTimes(2); 104 | }); 105 | 106 | it("should compute map when key by index", () => { 107 | const [source, setSource] = createSignal([1, 2, 3]); 108 | 109 | const computed = vi.fn(); 110 | const map = repeat( 111 | () => source().length, 112 | index => { 113 | computed(); 114 | return { 115 | get id() { 116 | return source()[index] * 2; 117 | }, 118 | get index() { 119 | return index; 120 | } 121 | }; 122 | } 123 | ); 124 | 125 | const [a, b, c] = map(); 126 | expect(a.index).toBe(0); 127 | expect(a.id).toBe(2); 128 | expect(b.index).toBe(1); 129 | expect(b.id).toBe(4); 130 | expect(c.index).toBe(2); 131 | expect(c.id).toBe(6); 132 | expect(computed).toHaveBeenCalledTimes(3); 133 | 134 | // Move values around 135 | setSource([3, 2, 1]); 136 | 137 | const [a2, b2, c2] = map(); 138 | expect(a2.index).toBe(0); 139 | expect(a2.id).toBe(6); 140 | expect(a === a2).toBeTruthy(); 141 | expect(b2.index).toBe(1); 142 | expect(b2.id).toBe(4); 143 | expect(b === b2).toBeTruthy(); 144 | expect(c2.index).toBe(2); 145 | expect(c2.id).toBe(2); 146 | expect(c === c2).toBeTruthy(); 147 | expect(computed).toHaveBeenCalledTimes(3); 148 | 149 | // Add new value 150 | setSource([3, 2, 1, 4]); 151 | 152 | expect(map().length).toBe(4); 153 | expect(map()[map().length - 1].index).toBe(3); 154 | expect(map()[map().length - 1].id).toBe(8); 155 | expect(computed).toHaveBeenCalledTimes(4); 156 | 157 | // Remove value 158 | setSource([2, 1, 4]); 159 | 160 | expect(map().length).toBe(3); 161 | expect(map()[0].id).toBe(4); 162 | 163 | // Empty 164 | setSource([]); 165 | 166 | expect(map().length).toBe(0); 167 | expect(computed).toHaveBeenCalledTimes(4); 168 | }); 169 | 170 | it("should retain instances when only `offset` changes", () => { 171 | const [source] = createStore>([ 172 | { id: "a" }, 173 | { id: "b" }, 174 | { id: "c" }, 175 | { id: "d" }, 176 | { id: "e" } 177 | ]); 178 | const [count, setCount] = createSignal(3); 179 | const [from, setFrom] = createSignal(0); 180 | 181 | const computed = vi.fn(); 182 | 183 | const map = repeat( 184 | count, 185 | index => { 186 | computed(); 187 | return { 188 | get id() { 189 | return source[index].id; 190 | }, 191 | get index() { 192 | return index; 193 | } 194 | }; 195 | }, 196 | { from } 197 | ); 198 | 199 | const [a, b, c, d] = map(); 200 | expect(a.id).toBe("a"); 201 | expect(a.index).toBe(0); 202 | expect(b.id).toBe("b"); 203 | expect(b.index).toBe(1); 204 | expect(c.id).toBe("c"); 205 | expect(c.index).toBe(2); 206 | expect(d).toBeUndefined(); 207 | expect(computed).toHaveBeenCalledTimes(3); 208 | 209 | setFrom(2); 210 | const [c2, d2, e2] = map(); 211 | expect(c2.id).toBe("c"); 212 | expect(c2.index).toBe(2); 213 | expect(d2.id).toBe("d"); 214 | expect(d2.index).toBe(3); 215 | expect(e2.id).toBe("e"); 216 | expect(e2.index).toBe(4); 217 | expect(computed).toHaveBeenCalledTimes(5); 218 | 219 | setFrom(1); 220 | const [b3, c3, d3, e3] = map(); 221 | expect(b3.id).toBe("b"); 222 | expect(b3.index).toBe(1); 223 | expect(c3.id).toBe("c"); 224 | expect(c3.index).toBe(2); 225 | expect(d3.id).toBe("d"); 226 | expect(d3.index).toBe(3); 227 | expect(e3).toBeUndefined(); 228 | expect(computed).toHaveBeenCalledTimes(6); 229 | 230 | setCount(4); 231 | const [b4, c4, d4, e4] = map(); 232 | expect(b4.id).toBe("b"); 233 | expect(b4.index).toBe(1); 234 | expect(c4.id).toBe("c"); 235 | expect(c4.index).toBe(2); 236 | expect(d4.id).toBe("d"); 237 | expect(d4.index).toBe(3); 238 | expect(e4.id).toBe("e"); 239 | expect(e4.index).toBe(4); 240 | expect(computed).toHaveBeenCalledTimes(7); 241 | }); 242 | -------------------------------------------------------------------------------- /tests/runWithObserver.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createEffect, 3 | createRoot, 4 | createSignal, 5 | flushSync, 6 | getObserver, 7 | runWithObserver, 8 | type Computation 9 | } from "../src/index.js"; 10 | 11 | it("should return value", () => { 12 | let observer!: Computation | null; 13 | 14 | createRoot(() => { 15 | createEffect( 16 | () => { 17 | observer = getObserver()!; 18 | }, 19 | () => {} 20 | ); 21 | }); 22 | expect(runWithObserver(observer!, () => 100)).toBe(100); 23 | }); 24 | 25 | it("should add dependencies to no deps", () => { 26 | let count = 0; 27 | 28 | const [a, setA] = createSignal(0); 29 | createRoot(() => { 30 | createEffect( 31 | () => getObserver()!, 32 | o => { 33 | runWithObserver(o, () => { 34 | a(); 35 | count++; 36 | }); 37 | } 38 | ); 39 | }); 40 | expect(count).toBe(0); 41 | flushSync(); 42 | expect(count).toBe(1); 43 | setA(1); 44 | flushSync(); 45 | expect(count).toBe(2); 46 | }); 47 | 48 | it("should add dependencies to existing deps", () => { 49 | let count = 0; 50 | 51 | const [a, setA] = createSignal(0); 52 | const [b, setB] = createSignal(0); 53 | createRoot(() => { 54 | createEffect( 55 | () => (a(), getObserver()!), 56 | o => { 57 | runWithObserver(o, () => { 58 | b(); 59 | count++; 60 | }); 61 | } 62 | ); 63 | }); 64 | expect(count).toBe(0); 65 | flushSync(); 66 | expect(count).toBe(1); 67 | setB(1); 68 | flushSync(); 69 | expect(count).toBe(2); 70 | setA(1); 71 | flushSync(); 72 | expect(count).toBe(3); 73 | }); 74 | -------------------------------------------------------------------------------- /tests/runWithOwner.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createErrorBoundary, 3 | createRenderEffect, 4 | createRoot, 5 | flushSync, 6 | getOwner, 7 | Owner, 8 | runWithOwner 9 | } from "../src/index.js"; 10 | 11 | it("should scope function to current scope", () => { 12 | let owner!: Owner | null; 13 | 14 | createRoot(() => { 15 | owner = getOwner()!; 16 | owner._context = { foo: 1 }; 17 | }); 18 | 19 | runWithOwner(owner, () => { 20 | expect(getOwner()!._context?.foo).toBe(1); 21 | }); 22 | }); 23 | 24 | it("should return value", () => { 25 | expect(runWithOwner(null, () => 100)).toBe(100); 26 | }); 27 | 28 | it("should handle errors", () => { 29 | const error = new Error(), 30 | handler = vi.fn(); 31 | 32 | let owner!: Owner | null; 33 | const b = createErrorBoundary( 34 | () => { 35 | owner = getOwner(); 36 | }, 37 | err => handler(err) 38 | ); 39 | b(); 40 | 41 | runWithOwner(owner, () => { 42 | createRenderEffect( 43 | () => { 44 | throw error; 45 | }, 46 | () => {} 47 | ); 48 | }); 49 | 50 | b(); 51 | flushSync(); 52 | expect(handler).toHaveBeenCalledWith(error); 53 | }); 54 | -------------------------------------------------------------------------------- /tests/store/createProjection.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createMemo, 3 | createProjection, 4 | createRenderEffect, 5 | createRoot, 6 | createSignal, 7 | flushSync 8 | } from "../../src/index.js"; 9 | 10 | describe("Projection basics", () => { 11 | it("should observe key changes", () => { 12 | createRoot(dispose => { 13 | let previous; 14 | const [$source, setSource] = createSignal(0), 15 | selected = createProjection( 16 | draft => { 17 | const s = $source(); 18 | if (s !== previous) draft[previous] = false; 19 | draft[s] = true; 20 | previous = s; 21 | }, 22 | [false, false, false] 23 | ), 24 | effect0 = vi.fn(() => selected[0]), 25 | effect1 = vi.fn(() => selected[1]), 26 | effect2 = vi.fn(() => selected[2]); 27 | 28 | let $effect0 = createMemo(effect0), 29 | $effect1 = createMemo(effect1), 30 | $effect2 = createMemo(effect2); 31 | 32 | expect($effect0()).toBe(true); 33 | expect($effect1()).toBe(false); 34 | expect($effect2()).toBe(false); 35 | 36 | expect(effect0).toHaveBeenCalledTimes(1); 37 | expect(effect1).toHaveBeenCalledTimes(1); 38 | expect(effect2).toHaveBeenCalledTimes(1); 39 | 40 | setSource(1); 41 | 42 | expect($effect0()).toBe(false); 43 | expect($effect1()).toBe(true); 44 | expect($effect2()).toBe(false); 45 | 46 | expect(effect0).toHaveBeenCalledTimes(2); 47 | expect(effect1).toHaveBeenCalledTimes(2); 48 | expect(effect2).toHaveBeenCalledTimes(1); 49 | 50 | setSource(2); 51 | 52 | expect($effect0()).toBe(false); 53 | expect($effect1()).toBe(false); 54 | expect($effect2()).toBe(true); 55 | 56 | expect(effect0).toHaveBeenCalledTimes(2); 57 | expect(effect1).toHaveBeenCalledTimes(3); 58 | expect(effect2).toHaveBeenCalledTimes(2); 59 | 60 | setSource(-1); 61 | 62 | expect($effect0()).toBe(false); 63 | expect($effect1()).toBe(false); 64 | expect($effect2()).toBe(false); 65 | 66 | expect(effect0).toHaveBeenCalledTimes(2); 67 | expect(effect1).toHaveBeenCalledTimes(3); 68 | expect(effect2).toHaveBeenCalledTimes(3); 69 | 70 | dispose(); 71 | 72 | setSource(0); 73 | setSource(1); 74 | setSource(2); 75 | 76 | // expect($effect0).toThrow(); 77 | // expect($effect1).toThrow(); 78 | // expect($effect2).toThrow(); 79 | 80 | expect(effect0).toHaveBeenCalledTimes(2); 81 | expect(effect1).toHaveBeenCalledTimes(3); 82 | expect(effect2).toHaveBeenCalledTimes(3); 83 | }); 84 | }); 85 | 86 | it("should not self track", () => { 87 | const spy = vi.fn(); 88 | const [bar, setBar] = createSignal("foo"); 89 | const projection = createRoot(() => 90 | createProjection( 91 | draft => { 92 | draft.foo = draft.bar; 93 | draft.bar = bar(); 94 | spy(); 95 | }, 96 | { foo: "foo", bar: "bar" } 97 | ) 98 | ); 99 | expect(projection.foo).toBe("bar"); 100 | expect(projection.bar).toBe("foo"); 101 | expect(spy).toHaveBeenCalledTimes(1); 102 | setBar("baz"); 103 | flushSync(); 104 | expect(projection.foo).toBe("foo"); 105 | expect(projection.bar).toBe("baz"); 106 | expect(spy).toHaveBeenCalledTimes(2); 107 | }); 108 | 109 | it("should work for chained projections", () => { 110 | const [$x, setX] = createSignal(1); 111 | 112 | const tmp = vi.fn(); 113 | 114 | createRoot(() => { 115 | const a = createProjection( 116 | state => { 117 | state.v = $x(); 118 | }, 119 | { 120 | v: 0 121 | } 122 | ); 123 | 124 | const b = createProjection( 125 | state => { 126 | state.v = a.v; 127 | }, 128 | { 129 | v: 0 130 | } 131 | ); 132 | 133 | createRenderEffect( 134 | () => b.v, 135 | (v, p) => tmp(v, p) 136 | ); 137 | }); 138 | flushSync(); 139 | 140 | expect(tmp).toBeCalledTimes(1); 141 | expect(tmp).toBeCalledWith(1, undefined); 142 | 143 | tmp.mockReset(); 144 | setX(2); 145 | flushSync(); 146 | 147 | expect(tmp).toBeCalledWith(2, 1); 148 | expect(tmp); 149 | }); 150 | }); 151 | 152 | describe("selection with projections", () => { 153 | test("simple selection", () => { 154 | let prev: number | undefined; 155 | const [s, set] = createSignal(); 156 | let count = 0; 157 | const list: Array = []; 158 | 159 | createRoot(() => { 160 | const isSelected = createProjection>(state => { 161 | const selected = s(); 162 | if (prev !== undefined && prev !== selected) delete state[prev]; 163 | if (selected) state[selected] = true; 164 | prev = selected; 165 | }); 166 | Array.from({ length: 100 }, (_, i) => 167 | createRenderEffect( 168 | () => isSelected[i], 169 | v => { 170 | count++; 171 | list[i] = v ? "selected" : "no"; 172 | } 173 | ) 174 | ); 175 | }); 176 | expect(count).toBe(100); 177 | expect(list[3]).toBe("no"); 178 | 179 | count = 0; 180 | set(3); 181 | flushSync(); 182 | expect(count).toBe(1); 183 | expect(list[3]).toBe("selected"); 184 | 185 | count = 0; 186 | set(6); 187 | flushSync(); 188 | expect(count).toBe(2); 189 | expect(list[3]).toBe("no"); 190 | expect(list[6]).toBe("selected"); 191 | set(undefined); 192 | flushSync(); 193 | expect(count).toBe(3); 194 | expect(list[6]).toBe("no"); 195 | set(5); 196 | flushSync(); 197 | expect(count).toBe(4); 198 | expect(list[5]).toBe("selected"); 199 | }); 200 | 201 | test("double selection", () => { 202 | let prev: number | undefined; 203 | const [s, set] = createSignal(); 204 | let count = 0; 205 | const list: Array[] = []; 206 | 207 | createRoot(() => { 208 | const isSelected = createProjection>(state => { 209 | const selected = s(); 210 | if (prev !== undefined && prev !== selected) delete state[prev]; 211 | if (selected) state[selected] = true; 212 | prev = selected; 213 | }); 214 | Array.from({ length: 100 }, (_, i) => { 215 | list[i] = []; 216 | createRenderEffect( 217 | () => isSelected[i], 218 | v => { 219 | count++; 220 | list[i][0] = v ? "selected" : "no"; 221 | } 222 | ); 223 | createRenderEffect( 224 | () => isSelected[i], 225 | v => { 226 | count++; 227 | list[i][1] = v ? "oui" : "non"; 228 | } 229 | ); 230 | }); 231 | }); 232 | expect(count).toBe(200); 233 | expect(list[3][0]).toBe("no"); 234 | expect(list[3][1]).toBe("non"); 235 | 236 | count = 0; 237 | set(3); 238 | flushSync(); 239 | expect(count).toBe(2); 240 | expect(list[3][0]).toBe("selected"); 241 | expect(list[3][1]).toBe("oui"); 242 | 243 | count = 0; 244 | set(6); 245 | flushSync(); 246 | expect(count).toBe(4); 247 | expect(list[3][0]).toBe("no"); 248 | expect(list[6][0]).toBe("selected"); 249 | expect(list[3][1]).toBe("non"); 250 | expect(list[6][1]).toBe("oui"); 251 | }); 252 | }); 253 | -------------------------------------------------------------------------------- /tests/store/reconcile.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { createStore, reconcile, unwrap } from "../../src/index.js"; 3 | 4 | describe("setState with reconcile", () => { 5 | test("Reconcile a simple object", () => { 6 | const [state, setState] = createStore<{ data: number; missing?: string }>({ 7 | data: 2, 8 | missing: "soon" 9 | }); 10 | expect(state.data).toBe(2); 11 | expect(state.missing).toBe("soon"); 12 | setState(reconcile({ data: 5 }, "id")); 13 | expect(state.data).toBe(5); 14 | expect(state.missing).toBeUndefined(); 15 | }); 16 | 17 | test("Reconcile array with nulls", () => { 18 | const [state, setState] = createStore>([null, "a"]); 19 | expect(state[0]).toBe(null); 20 | expect(state[1]).toBe("a"); 21 | setState(reconcile(["b", null], "id")); 22 | expect(state[0]).toBe("b"); 23 | expect(state[1]).toBe(null); 24 | }); 25 | 26 | test("Reconcile a simple object on a nested path", () => { 27 | const [state, setState] = createStore<{ 28 | data: { user: { firstName: string; middleName: string; lastName?: string } }; 29 | }>({ 30 | data: { user: { firstName: "John", middleName: "", lastName: "Snow" } } 31 | }); 32 | expect(state.data.user.firstName).toBe("John"); 33 | expect(state.data.user.lastName).toBe("Snow"); 34 | setState(s => { 35 | s.data.user = reconcile({ firstName: "Jake", middleName: "R" }, "id")(s.data.user); 36 | }); 37 | expect(state.data.user.firstName).toBe("Jake"); 38 | expect(state.data.user.middleName).toBe("R"); 39 | expect(state.data.user.lastName).toBeUndefined(); 40 | }); 41 | 42 | test("Reconcile reorder a keyed array", () => { 43 | const JOHN = { id: 1, firstName: "John", lastName: "Snow" }, 44 | NED = { id: 2, firstName: "Ned", lastName: "Stark" }, 45 | BRANDON = { id: 3, firstName: "Brandon", lastName: "Start" }, 46 | ARYA = { id: 4, firstName: "Arya", lastName: "Start" }; 47 | const [state, setState] = createStore({ users: [JOHN, NED, BRANDON] }); 48 | expect(Object.is(unwrap(state.users[0]), JOHN)).toBe(true); 49 | expect(Object.is(unwrap(state.users[1]), NED)).toBe(true); 50 | expect(Object.is(unwrap(state.users[2]), BRANDON)).toBe(true); 51 | setState(s => { 52 | s.users = reconcile([NED, JOHN, BRANDON], "id")(s.users); 53 | }); 54 | expect(Object.is(unwrap(state.users[0]), NED)).toBe(true); 55 | expect(Object.is(unwrap(state.users[1]), JOHN)).toBe(true); 56 | expect(Object.is(unwrap(state.users[2]), BRANDON)).toBe(true); 57 | setState(s => { 58 | s.users = reconcile([NED, BRANDON, JOHN], "id")(s.users); 59 | }); 60 | expect(Object.is(unwrap(state.users[0]), NED)).toBe(true); 61 | expect(Object.is(unwrap(state.users[1]), BRANDON)).toBe(true); 62 | expect(Object.is(unwrap(state.users[2]), JOHN)).toBe(true); 63 | setState(s => { 64 | s.users = reconcile([NED, BRANDON, JOHN, ARYA], "id")(s.users); 65 | }); 66 | expect(Object.is(unwrap(state.users[0]), NED)).toBe(true); 67 | expect(Object.is(unwrap(state.users[1]), BRANDON)).toBe(true); 68 | expect(Object.is(unwrap(state.users[2]), JOHN)).toBe(true); 69 | expect(Object.is(unwrap(state.users[3]), ARYA)).toBe(true); 70 | setState(s => { 71 | s.users = reconcile([BRANDON, JOHN, ARYA], "id")(s.users); 72 | }); 73 | expect(Object.is(unwrap(state.users[0]), BRANDON)).toBe(true); 74 | expect(Object.is(unwrap(state.users[1]), JOHN)).toBe(true); 75 | expect(Object.is(unwrap(state.users[2]), ARYA)).toBe(true); 76 | }); 77 | 78 | test("Reconcile overwrite in non-keyed merge mode", () => { 79 | const JOHN = { id: 1, firstName: "John", lastName: "Snow" }, 80 | NED = { id: 2, firstName: "Ned", lastName: "Stark" }, 81 | BRANDON = { id: 3, firstName: "Brandon", lastName: "Start" }; 82 | const [state, setState] = createStore({ 83 | users: [{ ...JOHN }, { ...NED }, { ...BRANDON }] 84 | }); 85 | expect(state.users[0].id).toBe(1); 86 | expect(state.users[0].firstName).toBe("John"); 87 | expect(state.users[1].id).toBe(2); 88 | expect(state.users[1].firstName).toBe("Ned"); 89 | expect(state.users[2].id).toBe(3); 90 | expect(state.users[2].firstName).toBe("Brandon"); 91 | setState(s => { 92 | s.users = reconcile([{ ...NED }, { ...JOHN }, { ...BRANDON }], "")(s.users); 93 | }); 94 | expect(state.users[0].id).toBe(2); 95 | expect(state.users[0].firstName).toBe("Ned"); 96 | expect(state.users[1].id).toBe(1); 97 | expect(state.users[1].firstName).toBe("John"); 98 | expect(state.users[2].id).toBe(3); 99 | expect(state.users[2].firstName).toBe("Brandon"); 100 | }); 101 | 102 | test("Reconcile top level key mismatch", () => { 103 | const JOHN = { id: 1, firstName: "John", lastName: "Snow" }, 104 | NED = { id: 2, firstName: "Ned", lastName: "Stark" }; 105 | 106 | const [user, setUser] = createStore(JOHN); 107 | expect(user.id).toBe(1); 108 | expect(user.firstName).toBe("John"); 109 | expect(() => setUser(reconcile(NED, "id"))).toThrow(); 110 | // expect(user.id).toBe(2); 111 | // expect(user.firstName).toBe("Ned"); 112 | }); 113 | 114 | test("Reconcile nested top level key mismatch", () => { 115 | const JOHN = { id: 1, firstName: "John", lastName: "Snow" }, 116 | NED = { id: 2, firstName: "Ned", lastName: "Stark" }; 117 | 118 | const [user, setUser] = createStore({ user: JOHN }); 119 | expect(user.user.id).toBe(1); 120 | expect(user.user.firstName).toBe("John"); 121 | expect(() => 122 | setUser(s => { 123 | s.user = reconcile(NED, "id")(s.user); 124 | }) 125 | ).toThrow(); 126 | // expect(user.user.id).toBe(2); 127 | // expect(user.user.firstName).toBe("Ned"); 128 | }); 129 | 130 | test("Reconcile top level key missing", () => { 131 | const [store, setStore] = createStore<{ id?: number; value?: string }>({ 132 | id: 0, 133 | value: "value" 134 | }); 135 | expect(() => setStore(reconcile({}, "id"))).toThrow(); 136 | // expect(store.id).toBe(undefined); 137 | // expect(store.value).toBe(undefined); 138 | }); 139 | 140 | test("Reconcile overwrite an object with an array", () => { 141 | const [store, setStore] = createStore<{ value: {} | [] }>({ 142 | value: { a: { b: 1 } } 143 | }); 144 | 145 | setStore(reconcile({ value: { c: [1, 2, 3] } }, "id")); 146 | expect(store.value).toEqual({ c: [1, 2, 3] }); 147 | }); 148 | 149 | test("Reconcile overwrite an array with an object", () => { 150 | const [store, setStore] = createStore<{ value: {} | [] }>({ 151 | value: [1, 2, 3] 152 | }); 153 | setStore(reconcile({ value: { name: "John" } }, "id")); 154 | expect(Array.isArray(store.value)).toBeFalsy(); 155 | expect(store.value).toEqual({ name: "John" }); 156 | setStore(reconcile({ value: [1, 2, 3] }, "id")); 157 | expect(store.value).toEqual([1, 2, 3]); 158 | setStore(reconcile({ value: { q: "aa" } }, "id")); 159 | expect(store.value).toEqual({ q: "aa" }); 160 | }); 161 | }); 162 | // type tests 163 | 164 | // reconcile 165 | () => { 166 | const [state, setState] = createStore<{ data: number; missing: string; partial?: { v: number } }>( 167 | { 168 | data: 2, 169 | missing: "soon" 170 | } 171 | ); 172 | // @ts-expect-error should not be able to reconcile partial type 173 | setState(reconcile({ data: 5 })); 174 | }; 175 | -------------------------------------------------------------------------------- /tests/store/recursive-effects.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createEffect, 3 | createMemo, 4 | createRoot, 5 | createSignal, 6 | createStore, 7 | flushSync, 8 | untrack, 9 | unwrap 10 | } from "../../src/index.js"; 11 | import { sharedClone } from "./shared-clone.js"; 12 | 13 | describe("recursive effects", () => { 14 | it("can track deeply with cloning", () => { 15 | const [store, setStore] = createStore({ foo: "foo", bar: { baz: "baz" } }); 16 | 17 | let called = 0; 18 | let next: any; 19 | 20 | createRoot(() => { 21 | createEffect( 22 | () => { 23 | next = sharedClone(next, store); 24 | called++; 25 | }, 26 | () => {} 27 | ); 28 | }); 29 | flushSync(); 30 | 31 | setStore(s => { 32 | s.foo = "1"; 33 | }); 34 | 35 | setStore(s => { 36 | s.bar.baz = "2"; 37 | }); 38 | 39 | flushSync(); 40 | expect(called).toBe(2); 41 | }); 42 | 43 | it("respects untracked", () => { 44 | const [store, setStore] = createStore({ foo: "foo", bar: { baz: "baz" } }); 45 | 46 | let called = 0; 47 | let next: any; 48 | 49 | createRoot(() => { 50 | createEffect( 51 | () => { 52 | next = sharedClone(next, untrack(() => store).bar); 53 | called++; 54 | }, 55 | () => {} 56 | ); 57 | }); 58 | flushSync(); 59 | 60 | setStore(s => { 61 | s.foo = "1"; 62 | }); 63 | 64 | setStore(s => { 65 | s.bar.baz = "2"; 66 | }); 67 | 68 | setStore(s => { 69 | s.bar = { 70 | baz: "3" 71 | }; 72 | }); 73 | 74 | flushSync(); 75 | expect(called).toBe(2); 76 | }); 77 | 78 | it("supports unwrapped values", () => { 79 | const [store, setStore] = createStore({ foo: "foo", bar: { baz: "baz" } }); 80 | 81 | let called = 0; 82 | let prev: any; 83 | let next: any; 84 | 85 | createRoot(() => { 86 | createEffect( 87 | () => { 88 | prev = next; 89 | next = unwrap(sharedClone(next, store)); 90 | called++; 91 | }, 92 | () => {} 93 | ); 94 | }); 95 | flushSync(); 96 | 97 | setStore(s => { 98 | s.foo = "1"; 99 | }); 100 | 101 | setStore(s => { 102 | s.bar.baz = "2"; 103 | }); 104 | 105 | flushSync(); 106 | expect(next).not.toBe(prev); 107 | expect(called).toBe(2); 108 | }); 109 | 110 | it("runs parent effects before child effects", () => { 111 | const [x, setX] = createSignal(0); 112 | const simpleM = createMemo(() => x()); 113 | let calls = 0; 114 | createRoot(() => { 115 | createEffect( 116 | () => { 117 | createEffect( 118 | () => { 119 | void x(); 120 | calls++; 121 | }, 122 | () => {} 123 | ); 124 | void simpleM(); 125 | }, 126 | () => {} 127 | ); 128 | }); 129 | flushSync(); 130 | setX(1); 131 | flushSync(); 132 | expect(calls).toBe(2); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /tests/store/shared-clone.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This function returns `a` if `b` is deeply equal. 3 | * 4 | * If not, it will replace any deeply equal children of `b` with those of `a`. 5 | * This can be used for structural sharing between JSON values for example. 6 | */ 7 | export function sharedClone(prev: any, next: T, touchAll?: boolean): T { 8 | const things = new Map(); 9 | 10 | function recurse(prev: any, next: any) { 11 | if (prev === next) { 12 | return prev; 13 | } 14 | 15 | if (things.has(next)) { 16 | return things.get(next); 17 | } 18 | 19 | const prevIsArray = Array.isArray(prev); 20 | const nextIsArray = Array.isArray(next); 21 | const prevIsObj = isPlainObject(prev); 22 | const nextIsObj = isPlainObject(next); 23 | 24 | const isArray = prevIsArray && nextIsArray; 25 | const isObj = prevIsObj && nextIsObj; 26 | 27 | const isSameStructure = isArray || isObj; 28 | 29 | // Both are arrays or objects 30 | if (isSameStructure) { 31 | const aSize = isArray ? prev.length : Object.keys(prev).length; 32 | const bItems = isArray ? next : Object.keys(next); 33 | const bSize = bItems.length; 34 | const copy: any = isArray ? [] : {}; 35 | 36 | let equalItems = 0; 37 | 38 | for (let i = 0; i < bSize; i++) { 39 | const key = isArray ? i : bItems[i]; 40 | if (copy[key] === prev[key]) { 41 | equalItems++; 42 | } 43 | } 44 | if (aSize === bSize && equalItems === aSize) { 45 | things.set(next, prev); 46 | return prev; 47 | } 48 | things.set(next, copy); 49 | for (let i = 0; i < bSize; i++) { 50 | const key = isArray ? i : bItems[i]; 51 | if (typeof bItems[i] === "function") { 52 | copy[key] = prev[key]; 53 | } else { 54 | copy[key] = recurse(prev[key], next[key]); 55 | } 56 | if (copy[key] === prev[key]) { 57 | equalItems++; 58 | } 59 | } 60 | 61 | return copy; 62 | } 63 | 64 | if (nextIsArray) { 65 | const copy: any[] = []; 66 | things.set(next, copy); 67 | for (let i = 0; i < next.length; i++) { 68 | copy[i] = recurse(undefined, next[i]); 69 | } 70 | return copy as T; 71 | } 72 | 73 | if (nextIsObj) { 74 | const copy = {} as any; 75 | things.set(next, copy); 76 | const nextKeys = Object.keys(next); 77 | for (let i = 0; i < nextKeys.length; i++) { 78 | const key = nextKeys[i]!; 79 | copy[key] = recurse(undefined, next[key]); 80 | } 81 | return copy as T; 82 | } 83 | 84 | return next; 85 | } 86 | 87 | return recurse(prev, next); 88 | } 89 | 90 | // Copied from: https://github.com/jonschlinkert/is-plain-object 91 | function isPlainObject(o: any) { 92 | if (!hasObjectPrototype(o)) { 93 | return false; 94 | } 95 | 96 | // If has modified constructor 97 | const ctor = o.constructor; 98 | if (typeof ctor === "undefined") { 99 | return true; 100 | } 101 | 102 | // If has modified prototype 103 | const prot = ctor.prototype; 104 | if (!hasObjectPrototype(prot)) { 105 | return false; 106 | } 107 | 108 | // If constructor does not have an Object-specific method 109 | if (!prot.hasOwnProperty("isPrototypeOf")) { 110 | return false; 111 | } 112 | 113 | // Most likely a plain Object 114 | return true; 115 | } 116 | 117 | function hasObjectPrototype(o: any) { 118 | return Object.prototype.toString.call(o) === "[object Object]"; 119 | } 120 | -------------------------------------------------------------------------------- /tests/store/utilities.bench.ts: -------------------------------------------------------------------------------- 1 | import { bench } from "vitest"; 2 | import { merge, omit } from "../../src/index.js"; 3 | 4 | const staticDesc = { 5 | value: 1, 6 | writable: true, 7 | configurable: true, 8 | enumerable: true 9 | }; 10 | 11 | const signalDesc = { 12 | get() { 13 | return 1; 14 | }, 15 | configurable: true, 16 | enumerable: true 17 | }; 18 | 19 | const cache = new Map(); 20 | 21 | const createObject = ( 22 | name: string, 23 | amount: number, 24 | desc: (index: number) => PropertyDescriptor 25 | ) => { 26 | const key = `${name}-${amount}`; 27 | const cached = cache.get(key); 28 | if (cached) return cached; 29 | const proto: Record = {}; 30 | for (let index = 0; index < amount; ++index) proto[`${name}${index}`] = desc(index); 31 | const result = Object.defineProperties({}, proto) as Record; 32 | cache.set(key, result); 33 | return result; 34 | }; 35 | 36 | const keys = (o: Record) => Object.keys(o); 37 | 38 | type Test = { 39 | title: string; 40 | benchs: { title: string; func: any }[]; 41 | }; 42 | 43 | function createTest any, G extends (...args: any[]) => any>(options: { 44 | name: string; 45 | /** 46 | * `vitest bench -t "FILTER"` does not work 47 | */ 48 | filter?: RegExp; 49 | subjects: { 50 | name: string; 51 | func: T; 52 | }[]; 53 | generator: Record; 54 | inputs: (generator: G) => Record>; 55 | }) { 56 | const tests: Test[] = []; 57 | for (const generatorName in options.generator) { 58 | const generator = options.generator[generatorName]; 59 | const inputs = options.inputs(generator); 60 | for (const inputName in inputs) { 61 | const args = inputs[inputName]; 62 | const test: Test = { 63 | title: `${options.name}-${generatorName}${inputName}`, 64 | benchs: [] 65 | }; 66 | if (options.filter && !options.filter.exec(test.title)) continue; 67 | for (const subject of options.subjects) { 68 | test.benchs.push({ 69 | title: subject.name, 70 | func: () => subject.func(...args) 71 | }); 72 | } 73 | tests.push(test); 74 | } 75 | } 76 | return tests; 77 | } 78 | 79 | type omit = (...args: any[]) => Record[]; 80 | 81 | const generator = { 82 | static: (amount: number) => createObject("static", amount, () => staticDesc), 83 | signal: (amount: number) => createObject("signal", amount, () => signalDesc), 84 | mixed: (amount: number) => createObject("mixed", amount, v => (v % 2 ? staticDesc : signalDesc)) 85 | } as const; 86 | 87 | const filter = new RegExp(process.env.FILTER || ".+"); 88 | 89 | const omitTests = createTest({ 90 | filter, 91 | name: "omit", 92 | subjects: [ 93 | { 94 | name: "omit", 95 | func: omit as omit 96 | } 97 | ], 98 | generator, 99 | inputs: g => ({ 100 | "(5, 1)": [g(5), ...keys(g(1))], 101 | "(5, 1, 2)": [g(5), ...keys(g(1)), ...keys(g(2))], 102 | "(0, 15)": [g(0), ...keys(g(15))], 103 | "(0, 3, 2)": [g(0), ...keys(g(3)), ...keys(g(2))], 104 | "(0, 100)": [g(0), ...keys(g(100))], 105 | "(0, 100, 3, 2)": [g(0), ...keys(g(100)), ...keys(g(3)), ...keys(g(2))], 106 | "(25, 100)": [g(25), ...keys(g(100))], 107 | "(50, 100)": [g(50), ...keys(g(100))], 108 | "(100, 25)": [g(100), ...keys(g(25))] 109 | }) 110 | }); 111 | 112 | const mergeTest = createTest({ 113 | name: "merge", 114 | filter, 115 | subjects: [ 116 | { 117 | name: "merge", 118 | func: merge 119 | } 120 | ], 121 | generator, 122 | inputs: g => ({ 123 | "(5, 1)": [g(5), g(1)], 124 | "(5, 1, 2)": [g(5), g(1), g(2)], 125 | "(0, 15)": [g(0), g(15)], 126 | "(0, 3, 2)": [g(0), g(3), g(2)], 127 | "(0, 100)": [g(0), g(100)], 128 | "(0, 100, 3, 2)": [g(0), g(100), g(3), g(2)], 129 | "(25, 100)": [g(25), g(100)], 130 | "(50, 100)": [g(50), g(100)], 131 | "(100, 25)": [g(100), g(25)] 132 | }) 133 | }); 134 | 135 | for (const test of omitTests) { 136 | describe(test.title, () => { 137 | for (const { title, func } of test.benchs) bench(title, func); 138 | }); 139 | } 140 | 141 | for (const test of mergeTest) { 142 | describe(test.title, () => { 143 | for (const { title, func } of test.benchs) bench(title, func); 144 | }); 145 | } 146 | -------------------------------------------------------------------------------- /tests/store/utilities.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Computation, 3 | createEffect, 4 | createRoot, 5 | createSignal, 6 | createStore, 7 | deep, 8 | flushSync, 9 | getOwner, 10 | merge, 11 | omit 12 | } from "../../src/index.js"; 13 | 14 | type SimplePropTypes = { 15 | a?: string | null; 16 | b?: string | null; 17 | c?: string | null; 18 | d?: string | null; 19 | }; 20 | 21 | const Comp2 = (props: { greeting: string; name: string; optional?: string }) => { 22 | const q = omit(props, "greeting", "optional"); 23 | expect((q as any).greeting).toBeUndefined(); 24 | return `${props.greeting} ${q.name}`; 25 | }; 26 | 27 | describe("merge", () => { 28 | test("falsey values", () => { 29 | let props: SimplePropTypes = { 30 | get a() { 31 | return "ji"; 32 | }, 33 | b: null, 34 | c: "j" 35 | }; 36 | props = merge(props, false, null, undefined); 37 | expect(props.a).toBe("ji"); 38 | expect(props.b).toBe(null); 39 | expect(props.c).toBe("j"); 40 | }); 41 | it("overrides undefined values", () => { 42 | let bValue: number | undefined; 43 | const a = { value: 1 }; 44 | const b = { 45 | get value() { 46 | return bValue; 47 | } 48 | }; 49 | const c = { 50 | get value() { 51 | return undefined; 52 | } 53 | }; 54 | const d = { value: undefined }; 55 | const props = merge(a, b, c, d); 56 | expect(props.value).toBe(undefined); 57 | bValue = 2; 58 | expect(props.value).toBe(undefined); 59 | }); 60 | it("includes undefined property", () => { 61 | const value = { a: undefined }; 62 | const getter = { 63 | get a() { 64 | return undefined; 65 | } 66 | }; 67 | expect("a" in merge(value)).toBeTruthy(); 68 | expect("a" in merge(getter)).toBeTruthy(); 69 | expect("a" in merge(value, getter)).toBeTruthy(); 70 | expect("a" in merge(getter, value)).toBeTruthy(); 71 | }); 72 | it("doesn't keep references for non-getters", () => { 73 | const a = { value1: 1 }; 74 | const b = { value2: 2 }; 75 | const props = merge(a, b); 76 | a.value1 = b.value2 = 3; 77 | expect(props.value1).toBe(1); 78 | expect(props.value2).toBe(2); 79 | expect(Object.keys(props).join()).toBe("value1,value2"); 80 | }); 81 | it("without getter transfers only value", () => { 82 | const a = { value1: 1 }; 83 | const b = { 84 | get value2() { 85 | return undefined; 86 | } 87 | }; 88 | const props = merge(a, b); 89 | a.value1 = 3; 90 | expect(props.value1).toBe(1); 91 | expect(Object.keys(props).join()).toBe("value1,value2"); 92 | }); 93 | it("overrides enumerables", () => { 94 | const a = Object.defineProperties( 95 | {}, 96 | { 97 | value1: { 98 | enumerable: false, 99 | value: 2 100 | } 101 | } 102 | ); 103 | const props = merge(a, {}); 104 | expect((props as any).value1).toBe(2); 105 | expect(Object.getOwnPropertyDescriptor(props, "value1")?.enumerable).toBeTruthy(); 106 | expect(Object.keys(props).join()).toBe("value1"); 107 | }); 108 | it("does not write the target", () => { 109 | const props = { value1: 1 }; 110 | merge(props, { 111 | value2: 2, 112 | get value3() { 113 | return 3; 114 | } 115 | }); 116 | expect(Object.keys(props).join("")).toBe("value1"); 117 | }); 118 | it("returns same reference when only one argument", () => { 119 | const props = {}; 120 | const newProps = merge(props); 121 | expect(props === newProps).toBeTruthy(); 122 | }); 123 | it("returns same reference with trailing falsy arguements", () => { 124 | const props = {}; 125 | const newProps = merge(props, null, undefined); 126 | expect(props === newProps).toBeTruthy(); 127 | }); 128 | it("returns same reference when all keys are covered", () => { 129 | const props = { a: 1, b: 2 }; 130 | const newProps = merge({ a: 2 }, { b: 2 }, props); 131 | expect(props === newProps).toBeTruthy(); 132 | }); 133 | it("returns new reference when all keys are not covered", () => { 134 | const props = { a: 1 }; 135 | const newProps = merge({ a: 2 }, { b: 2 }, props); 136 | expect(props === newProps).toBeFalsy(); 137 | }); 138 | it("uses the source instances", () => { 139 | const source1 = { 140 | get a() { 141 | return this; 142 | } 143 | }; 144 | const source2 = { 145 | get b() { 146 | return this; 147 | } 148 | }; 149 | const props = merge(source1, source2); 150 | expect(props.a === source1).toBeTruthy(); 151 | expect(props.b === source2).toBeTruthy(); 152 | }); 153 | it("does not clone nested objects", () => { 154 | const b = { value: 1 }; 155 | const props = merge({ a: 1 }, { b }); 156 | b.value = 2; 157 | expect(props.b.value).toBe(2); 158 | }); 159 | it("handles undefined values", () => { 160 | const props = merge({ a: 1 }, { a: undefined }); 161 | expect(props.a).toBe(undefined); 162 | }); 163 | it("handles null values", () => { 164 | const props = merge({ a: 1 }, { a: null }); 165 | expect(props.a).toBeNull(); 166 | }); 167 | it("contains null values", () => { 168 | const props = merge({ 169 | a: null, 170 | get b() { 171 | return null; 172 | } 173 | }); 174 | expect(props.a).toBeNull(); 175 | expect(props.b).toBeNull(); 176 | }); 177 | it("contains undefined values", () => { 178 | const props = merge({ 179 | a: undefined, 180 | get b() { 181 | return undefined; 182 | } 183 | }); 184 | expect(Object.keys(props).join()).toBe("a,b"); 185 | expect("a" in props).toBeTruthy(); 186 | expect("b" in props).toBeTruthy(); 187 | expect(props.a).toBeUndefined(); 188 | expect(props.b).toBeUndefined(); 189 | }); 190 | it("ignores falsy sources", () => { 191 | const props = merge(undefined, null, { value: 1 }, null, undefined); 192 | expect(Object.keys(props).join()).toBe("value"); 193 | }); 194 | it("fails with non objects sources", () => { 195 | expect(() => merge({ value: 1 }, true)).toThrowError(); 196 | expect(() => merge({ value: 1 }, 1)).toThrowError(); 197 | }); 198 | it("works with a array source", () => { 199 | const props = merge({ value: 1 }, [2]); 200 | expect(Object.keys(props).join()).toBe("0,value,length"); 201 | expect(props.value).toBe(1); 202 | expect(props.length).toBe(1); 203 | expect(props[0]).toBe(2); 204 | }); 205 | it("is safe", () => { 206 | merge({}, JSON.parse('{ "__proto__": { "evil": true } }')); 207 | expect(({} as any).evil).toBeUndefined(); 208 | merge({}, JSON.parse('{ "prototype": { "evil": true } }')); 209 | expect(({} as any).evil).toBeUndefined(); 210 | merge({ value: 1 }, JSON.parse('{ "__proto__": { "evil": true } }')); 211 | expect(({} as any).evil).toBeUndefined(); 212 | merge({ value: 1 }, JSON.parse('{ "prototype": { "evil": true } }')); 213 | expect(({} as any).evil).toBeUndefined(); 214 | }); 215 | it("sets already prototyped properties", () => { 216 | expect(merge({ toString: 1 }).toString).toBe(1); 217 | expect({}.toString).toBeTypeOf("function"); 218 | }); 219 | }); 220 | 221 | describe("Set Default Props", () => { 222 | test("simple set", () => { 223 | let props: SimplePropTypes = { 224 | get a() { 225 | return "ji"; 226 | }, 227 | b: null, 228 | c: "j" 229 | }, 230 | defaults: SimplePropTypes = { a: "yy", b: "ggg", d: "DD" }; 231 | props = merge(defaults, props); 232 | expect(props.a).toBe("ji"); 233 | expect(props.b).toBe(null); 234 | expect(props.c).toBe("j"); 235 | expect(props.d).toBe("DD"); 236 | }); 237 | }); 238 | 239 | describe("Clone Props", () => { 240 | test("simple set", () => { 241 | let reactive = false; 242 | const props: SimplePropTypes = { 243 | get a() { 244 | reactive = true; 245 | return "ji"; 246 | }, 247 | b: null, 248 | c: "j" 249 | }; 250 | const newProps = merge(props, {}); 251 | expect(reactive).toBe(false); 252 | expect(newProps.a).toBe("ji"); 253 | expect(reactive).toBe(true); 254 | expect(newProps.b).toBe(null); 255 | expect(newProps.c).toBe("j"); 256 | expect(newProps.d).toBe(undefined); 257 | }); 258 | }); 259 | 260 | describe("Clone Store", () => { 261 | test("simple set", () => { 262 | const [state, setState] = createStore<{ a: string; b: string; c?: string }>({ 263 | a: "Hi", 264 | b: "Jo" 265 | }); 266 | const clone = merge(state, {}); 267 | expect(state === clone).toBeFalsy(); 268 | expect(clone.a).toBe("Hi"); 269 | expect(clone.b).toBe("Jo"); 270 | setState(v => { 271 | v.a = "Greetings"; 272 | v.c = "John"; 273 | }); 274 | expect(clone.a).toBe("Greetings"); 275 | expect(clone.b).toBe("Jo"); 276 | expect(clone.c).toBe("John"); 277 | }); 278 | it("returns same reference when only one argument", () => { 279 | const [state, setState] = createStore<{ a: string; b: string; c?: string }>({ 280 | a: "Hi", 281 | b: "Jo" 282 | }); 283 | const clone = merge(state); 284 | expect(state === clone).toBeTruthy(); 285 | }); 286 | }); 287 | 288 | describe("Merge Signal", () => { 289 | test("simple set", () => { 290 | const [s, set] = createSignal({ 291 | get a() { 292 | return "ji"; 293 | }, 294 | b: null, 295 | c: "j" 296 | }), 297 | defaults: SimplePropTypes = { a: "yy", b: "ggg", d: "DD" }; 298 | let props!: SimplePropTypes; 299 | const res: string[] = []; 300 | createRoot(() => { 301 | props = merge(defaults, s); 302 | createEffect( 303 | () => props.a as string, 304 | v => { 305 | res.push(v); 306 | } 307 | ); 308 | }); 309 | flushSync(); 310 | expect(props.a).toBe("ji"); 311 | expect(props.b).toBe(null); 312 | expect(props.c).toBe("j"); 313 | expect(props.d).toBe("DD"); 314 | set({ a: "h" }); 315 | flushSync(); 316 | expect(props.a).toBe("h"); 317 | expect(props.b).toBe("ggg"); 318 | expect(props.c).toBeUndefined(); 319 | expect(props.d).toBe("DD"); 320 | expect(res[0]).toBe("ji"); 321 | expect(res[1]).toBe("h"); 322 | expect(res.length).toBe(2); 323 | }); 324 | 325 | test("null/undefined/false are ignored", () => { 326 | const props = merge({ a: 1 }, null, undefined, false); 327 | expect((props as any).a).toBe(1); 328 | }); 329 | }); 330 | 331 | describe("omit Props", () => { 332 | test("omit in two", () => { 333 | createRoot(() => { 334 | const out = Comp2({ 335 | greeting: "Hi", 336 | get name() { 337 | return "dynamic"; 338 | } 339 | }); 340 | expect(out).toBe("Hi dynamic"); 341 | }); 342 | }); 343 | test("omit in two with store", () => { 344 | createRoot(() => { 345 | const [state] = createStore({ greeting: "Yo", name: "Bob" }); 346 | const out = Comp2(state); 347 | expect(out).toBe("Yo Bob"); 348 | }); 349 | }); 350 | test("omit result is immutable", () => { 351 | const props = { first: 1, second: 2 }; 352 | const otherProps = omit(props, "first"); 353 | props.first = props.second = 3; 354 | expect(props.first).toBe(3); 355 | expect(otherProps.second).toBe(2); 356 | }); 357 | test("omit clones the descriptor", () => { 358 | let signalValue = 1; 359 | const desc = { 360 | signal: { 361 | enumerable: true, 362 | get() { 363 | return signalValue; 364 | } 365 | }, 366 | static: { 367 | configurable: true, 368 | enumerable: false, 369 | value: 2 370 | } 371 | }; 372 | const props = Object.defineProperties({}, desc) as { 373 | signal: number; 374 | value1: number; 375 | }; 376 | const otherProps = omit(props, "signal"); 377 | 378 | expect(props.signal).toBe(1); 379 | signalValue++; 380 | expect(props.signal).toBe(2); 381 | 382 | const signalDesc = Object.getOwnPropertyDescriptor(props, "signal")!; 383 | expect(signalDesc.get === desc.signal.get).toBeTruthy(); 384 | expect(signalDesc.set).toBeUndefined(); 385 | expect(signalDesc.enumerable).toBeTruthy(); 386 | expect(signalDesc.configurable).toBeFalsy(); 387 | 388 | const staticDesc = Object.getOwnPropertyDescriptor(otherProps, "static")!; 389 | expect(staticDesc.value).toBe(2); 390 | expect(staticDesc.get).toBeUndefined(); 391 | expect(staticDesc.set).toBeUndefined(); 392 | expect(staticDesc.enumerable).toBeFalsy(); 393 | expect(staticDesc.configurable).toBeTruthy(); 394 | }); 395 | test("omit with multiple keys", () => { 396 | const props: { 397 | id?: string; 398 | color?: string; 399 | margin?: number; 400 | padding?: number; 401 | variant?: string; 402 | description?: string; 403 | } = { 404 | id: "input", 405 | color: "red", 406 | margin: 3, 407 | variant: "outlined", 408 | description: "test" 409 | }; 410 | 411 | const otherProps = omit(props, "color", "margin", "padding", "variant", "description"); 412 | 413 | expect(otherProps.id).toBe("input"); 414 | expect(Object.keys(otherProps).length).toBe(1); 415 | }); 416 | test("omit returns same prop descriptors", () => { 417 | const props = { 418 | a: 1, 419 | b: 2, 420 | get c() { 421 | return 3; 422 | }, 423 | d: undefined, 424 | x: 1, 425 | y: 2, 426 | get w() { 427 | return 3; 428 | }, 429 | z: undefined 430 | }; 431 | const otherProps = omit(props, "a", "b", "c", "d", "e" as "d"); 432 | 433 | const otherDesc = Object.getOwnPropertyDescriptors(otherProps); 434 | expect(otherDesc.w).toMatchObject(otherDesc.w); 435 | expect(otherDesc.x).toMatchObject(otherDesc.x); 436 | expect(otherDesc.y).toMatchObject(otherDesc.y); 437 | expect(otherDesc.z).toMatchObject(otherDesc.z); 438 | }); 439 | test("omit is safe", () => { 440 | const props = JSON.parse('{"__proto__": { "evil": true } }'); 441 | const evilProps1 = omit(props); 442 | 443 | expect(evilProps1.__proto__?.evil).toBeTruthy(); 444 | expect(({} as any).evil).toBeUndefined(); 445 | 446 | const evilProps2 = omit(props, "__proto__"); 447 | 448 | expect(evilProps2.__proto__?.evil).toBeFalsy(); 449 | expect(({} as any).evil).toBeUndefined(); 450 | }); 451 | 452 | test("Merge omit", () => { 453 | let value: string | undefined = "green"; 454 | const splittedProps = omit( 455 | { color: "blue", component() {} } as { color: string; component: Function; other?: string }, 456 | "component" 457 | ); 458 | const mergedProps = merge(splittedProps, { 459 | get color() { 460 | return value; 461 | }, 462 | other: "value" 463 | }); 464 | expect(mergedProps.color).toBe("green"); 465 | value = "red"; 466 | expect(mergedProps.color).toBe("red"); 467 | }); 468 | }); 469 | 470 | describe("deep", () => { 471 | test("correct number of subscriptions", () => { 472 | const [state, setState] = createStore({ list: [{ a: 1 }, { b: 2 }] }); 473 | let o: Computation; 474 | createRoot(() => { 475 | createEffect( 476 | () => { 477 | o = getOwner() as Computation; 478 | return deep(state); 479 | }, 480 | v => {} 481 | ); 482 | }); 483 | flushSync(); 484 | expect(o!._sources!.length).toBe(1); 485 | }); 486 | test("tests tracks deep updates", () => { 487 | const effect = vi.fn(); 488 | const [state, setState] = createStore<{ list: Record[] }>({ 489 | list: [{ a: 1 }, { b: 2 }] 490 | }); 491 | createRoot(() => { 492 | createEffect( 493 | () => deep(state), 494 | v => effect(v) 495 | ); 496 | }); 497 | expect(effect).toHaveBeenCalledTimes(0); 498 | flushSync(); 499 | expect(effect).toHaveBeenCalledTimes(1); 500 | 501 | setState(s => { 502 | s.list[0].a = 2; 503 | }); 504 | flushSync(); 505 | expect(effect).toHaveBeenCalledTimes(2); 506 | expect(effect.mock.calls[1][0]).toEqual({ list: [{ a: 2 }, { b: 2 }] }); 507 | setState(s => { 508 | s.list.push({ c: 3 }); 509 | }); 510 | flushSync(); 511 | expect(effect).toHaveBeenCalledTimes(3); 512 | expect(effect.mock.calls[2][0]).toEqual({ list: [{ a: 2 }, { b: 2 }, { c: 3 }] }); 513 | setState(s => { 514 | s.list = [{ d: 4 }]; 515 | }); 516 | flushSync(); 517 | expect(effect).toHaveBeenCalledTimes(4); 518 | expect(effect.mock.calls[3][0]).toEqual({ list: [{ d: 4 }] }); 519 | }); 520 | test("handles shared references", () => { 521 | const sharedReference = { 522 | a: 1, 523 | b: 2 524 | }; 525 | const sharedReference2 = { 526 | a: 1, 527 | b: 2 528 | }; 529 | const effect = vi.fn(); 530 | const [store, setStore] = createStore({ 531 | first: { 532 | nested: { shared: sharedReference } 533 | }, 534 | second: { 535 | nested: { shared: sharedReference } 536 | } 537 | }); 538 | createRoot(() => { 539 | createEffect(() => deep(store.first), effect); 540 | }); 541 | flushSync(); 542 | expect(effect).toHaveBeenCalledTimes(1); 543 | setStore(s => (s.second.nested.shared.b = 3)); 544 | flushSync(); 545 | expect(effect).toHaveBeenCalledTimes(2); 546 | setStore(s => { 547 | s.first.nested.shared = sharedReference2; 548 | s.second.nested.shared = sharedReference2; 549 | }); 550 | flushSync(); 551 | expect(effect).toHaveBeenCalledTimes(3); 552 | setStore(s => (s.second.nested.shared.b = 4)); 553 | flushSync(); 554 | expect(effect).toHaveBeenCalledTimes(4); 555 | }); 556 | }); 557 | -------------------------------------------------------------------------------- /tests/untrack.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createEffect, 3 | createMemo, 4 | createRoot, 5 | createSignal, 6 | flushSync, 7 | onCleanup, 8 | untrack 9 | } from "../src/index.js"; 10 | 11 | afterEach(() => flushSync()); 12 | 13 | it("should not create dependency", () => { 14 | const effect = vi.fn(); 15 | const memo = vi.fn(); 16 | 17 | const [$x, setX] = createSignal(10); 18 | 19 | const $a = createMemo(() => $x() + 10); 20 | const $b = createMemo(() => { 21 | memo(); 22 | return untrack($a) + 10; 23 | }); 24 | 25 | createRoot(() => 26 | createEffect( 27 | () => { 28 | effect(); 29 | expect(untrack($x)).toBe(10); 30 | expect(untrack($a)).toBe(20); 31 | expect(untrack($b)).toBe(30); 32 | }, 33 | () => {} 34 | ) 35 | ); 36 | flushSync(); 37 | 38 | expect(effect).toHaveBeenCalledTimes(1); 39 | expect(memo).toHaveBeenCalledTimes(1); 40 | 41 | setX(20); 42 | flushSync(); 43 | expect(effect).toHaveBeenCalledTimes(1); 44 | expect(memo).toHaveBeenCalledTimes(1); 45 | }); 46 | 47 | it("should not affect deep dependency being created", () => { 48 | const effect = vi.fn(); 49 | const memo = vi.fn(); 50 | 51 | const [$x, setX] = createSignal(10); 52 | const [$y, setY] = createSignal(10); 53 | const [$z, setZ] = createSignal(10); 54 | 55 | const $a = createMemo(() => { 56 | memo(); 57 | return $x() + untrack($y) + untrack($z) + 10; 58 | }); 59 | 60 | createRoot(() => 61 | createEffect( 62 | () => { 63 | effect(); 64 | expect(untrack($x)).toBe(10); 65 | expect(untrack($a)).toBe(40); 66 | }, 67 | () => {} 68 | ) 69 | ); 70 | flushSync(); 71 | 72 | expect(effect).toHaveBeenCalledTimes(1); 73 | expect($a()).toBe(40); 74 | expect(memo).toHaveBeenCalledTimes(1); 75 | 76 | setX(20); 77 | flushSync(); 78 | expect(effect).toHaveBeenCalledTimes(1); 79 | expect($a()).toBe(50); 80 | expect(memo).toHaveBeenCalledTimes(2); 81 | 82 | setY(20); 83 | flushSync(); 84 | expect(effect).toHaveBeenCalledTimes(1); 85 | expect($a()).toBe(50); 86 | expect(memo).toHaveBeenCalledTimes(2); 87 | 88 | setZ(20); 89 | flushSync(); 90 | expect(effect).toHaveBeenCalledTimes(1); 91 | expect($a()).toBe(50); 92 | expect(memo).toHaveBeenCalledTimes(2); 93 | }); 94 | 95 | it("should track owner across peeks", () => { 96 | const [$x, setX] = createSignal(0); 97 | 98 | const childCompute = vi.fn(); 99 | const childDispose = vi.fn(); 100 | 101 | function createChild() { 102 | const $a = createMemo(() => $x() * 2); 103 | createRoot(() => 104 | createEffect( 105 | () => { 106 | childCompute($a()); 107 | onCleanup(childDispose); 108 | }, 109 | () => {} 110 | ) 111 | ); 112 | } 113 | 114 | const dispose = createRoot(dispose => { 115 | untrack(() => createChild()); 116 | return dispose; 117 | }); 118 | flushSync(); 119 | 120 | setX(1); 121 | flushSync(); 122 | expect(childCompute).toHaveBeenCalledWith(2); 123 | expect(childDispose).toHaveBeenCalledTimes(1); 124 | 125 | dispose(); 126 | expect(childDispose).toHaveBeenCalledTimes(2); 127 | 128 | setX(2); 129 | flushSync(); 130 | expect(childCompute).not.toHaveBeenCalledWith(4); 131 | expect(childDispose).toHaveBeenCalledTimes(2); 132 | }); 133 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { "types": [] }, 3 | "extends": "./tsconfig.json", 4 | "include": ["src"] 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "emitDeclarationOnly": true, 5 | "target": "ESNext", 6 | "newLine": "LF", 7 | "moduleResolution": "NodeNext", 8 | "noImplicitAny": false, 9 | "strict": true, 10 | "outDir": "dist/types", 11 | "module": "NodeNext", 12 | "types": ["vitest/globals"], 13 | "verbatimModuleSyntax": true 14 | }, 15 | "include": ["src", "tests"], 16 | "exclude": ["dist", "node_modules"] 17 | } 18 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, type Options } from 'tsup'; 2 | 3 | interface BundleOptions { 4 | dev?: boolean; 5 | node?: boolean; 6 | } 7 | 8 | function options({ dev, node }: BundleOptions): Options { 9 | return { 10 | entry: { 11 | [node ? 'node' : dev ? 'dev' : 'prod']: 'src/index.ts', 12 | }, 13 | outDir: 'dist', 14 | treeshake: true, 15 | bundle: true, 16 | format: node ? 'cjs' : 'esm', 17 | // minify: true, 18 | platform: node ? 'node' : 'browser', 19 | target: node ? 'node16' : 'esnext', 20 | define: { 21 | __DEV__: dev ? 'true' : 'false', 22 | __TEST__: 'false', 23 | }, 24 | esbuildOptions(opts) { 25 | opts.mangleProps = !dev ? /^_/ : undefined; 26 | }, 27 | }; 28 | } 29 | 30 | export default defineConfig([ 31 | options({ dev: true }), // dev 32 | options({ dev: false }), // prod 33 | options({ node: true }), // server 34 | ]); 35 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | define: { 5 | __DEV__: "true", 6 | __TEST__: "true", 7 | }, 8 | test: { 9 | globals: true, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /vitest.js: -------------------------------------------------------------------------------- 1 | import { createVitest } from "vitest/node"; 2 | 3 | const vitest = await createVitest("test", { 4 | include: [`tests/gc.test.ts`], 5 | globals: true, 6 | watch: process.argv.includes("--watch"), 7 | }); 8 | 9 | await vitest.start(); 10 | --------------------------------------------------------------------------------