diff --git a/.eslintrc.yaml b/.eslintrc.yaml index 0fd2e6e..9586fb7 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -26,7 +26,9 @@ plugins: rules: "@typescript-eslint/no-non-null-assertion": off - "@typescript-eslint/no-explicit-any": off + "@typescript-eslint/no-explicit-any": ["error", {"ignoreRestArgs": true}] + no-unused-vars: off + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] prettier/prettier: error no-console: warn require-atomic-updates: warn diff --git a/api/api.d.ts b/api/api.d.ts index 2ff2134..f3b330f 100644 --- a/api/api.d.ts +++ b/api/api.d.ts @@ -26,6 +26,9 @@ export interface IPNode { /** Forces the node to be finalized. You generaly don't need to call this. */ finalize(): void; + + /** The result of the node's callback, finalizing it if needed. */ + readonly result: unknown; } /** A probed component of which the return type is known. */ @@ -35,32 +38,17 @@ export interface PNode extends IPNode { } /** A function that implements the probe() functionality for a given set of intrinsics. */ -export type ProbingFunction> = any)>( +export type ProbingFunction = | ((...args: any[]) => unknown)>( cb: T, ...args: ProbedParams ) => AsPNode>; -/** - * Creates a probe() function that is bound against a set of intrinsic components. - * - * ex: const probe = createProber({"hi": x=>x+5, "bye": x=>x*2}); - * probe("hi", 12); - */ -export declare function createProber>(intrinsics: I): ProbingFunction; - -/** Default probe() function not bound to any intrinsic. */ -// eslint-disable-next-line @typescript-eslint/ban-types -export declare function probe any>(cb: T, ...args: Parameters): AsPNode>; - -// ***************** Hooks ***************** // - -/** Registers a callback that will be invoked when component currently being probed is disposed. */ -export declare function useOnDispose(op: () => void): void; - -// ***************** Dynamics ***************** // +/** Meta information available to components. */ +export interface ProbingContext { + componentName: string; // The name of the component. +} -/** Determines at runtime if a value is static or dynamic. */ -export declare function isDynamic(val: T | DynamicReader): val is DynamicReader; +export type Component = (...args: Args) => Ret; // ************ Dynamics - Readers ************ // @@ -70,6 +58,9 @@ export interface DynamicReaderBase { removeListener(lst: (v: T) => void): void; readonly current: T; + + /** gets string representation */ + toString(): string; } /** Consumer API for a dynamic primitive. */ @@ -105,6 +96,40 @@ export interface DynamicList> extends DynamicBase { push(v: ListValueType): void; } +/** + * Creates a probe() function that is bound against a set of intrinsic components. + * + * ex: const probe = createProber({"hi": x=>x+5, "bye": x=>x*2}); + * probe("hi", 12); + */ +export function createProber>(intrinsics: I): ProbingFunction; +export function createProber>( + intrinsics: Partial, + fallback: IntrinsicFallback, +): ProbingFunction; + +/** Default probe() function not bound to any intrinsic. */ +// eslint-disable-next-line @typescript-eslint/ban-types +export declare function probe unknown>( + cb: T, + ...args: Parameters +): AsPNode>; + +// ***************** Hooks ***************** // + +/** Registers a callback that will be invoked when component currently being probed is disposed. */ +export declare function useOnDispose(op: () => void): void; + +/** Obtains the probing context for the node being probed */ +export declare function useProbingContext(): ProbingContext; + +// ***************** Dynamics ***************** // + +/** Determines at runtime if a value is static or dynamic. */ +export declare function isDynamic(val: T | DynamicReader): val is DynamicReader; + +// ************ Dynamics - Readers ************ // + /** Creates a new Dynamic value. */ export declare function dynamic(init: T[]): DynamicList; export declare function dynamic(init: T): DynamicValue; @@ -124,31 +149,37 @@ export declare function valType(v: Reader): string; // ***************** Utility / not user-facing ***************** // -type DynamicReader = DynamicListReader ? T : never> | DynamicValueReader; - -type AsPNode = T extends PNode ? PNode : PNode; +export type DynamicReader = DynamicListReader ? T : never> | DynamicValueReader; -type Component = (...arg: ArgsT) => RetT; +export type AsPNode = T extends PNode ? PNode : PNode; -type Intrinsics = { - [_ in keyof I]: Component; +export type Intrinsics = { + [K in keyof T as T[K] extends (...args: any[]) => unknown ? K : never]: T[K]; }; -type IntrinsicParams> = Parameters; -type IntrinsicResult> = ReturnType; +export type IKeys = keyof T; -type Probed = Record> = keyof I | Component; +export type IntrinsicParams, I extends FuncMap> = Parameters; +export type IntrinsicResult, I extends FuncMap> = ReturnType; -type ProbedParams, I extends Intrinsics = Record> = T extends keyof I +export type Probed = IKeys | ((...args: any[]) => unknown); + +export type ProbedParams, I extends FuncMap> = T extends IKeys ? IntrinsicParams - : T extends Component + : T extends (...arg: infer P) => unknown ? P : never; -type ProbedResult, I extends Intrinsics = Record> = T extends keyof I +export type ProbedResult, I extends FuncMap> = T extends IKeys ? IntrinsicResult - : T extends Component + : T extends (...arg: any[]) => infer P ? P : never; -type ListValueType> = ArrayType[number]; +export type FuncMap = Record unknown>; + +type FallbackParams = Parameters; +type FallbackResult = ReturnType; +export type IntrinsicFallback = (...args: FallbackParams) => FallbackResult; + +export type ListValueType> = ArrayType[number]; diff --git a/jest.config.js b/jest.config.js index 6908af2..09905a1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -22,17 +22,17 @@ const fullConfig = { projects: [ { displayName: 'prod', - setupFiles: ['./tests/variants/prod.js'], + setupFiles: ['./tests/setup/prod.js'], ...baseConfig, }, { displayName: 'dev', - setupFiles: ['./tests/variants/dev.js'], + setupFiles: ['./tests/setup/dev.js'], ...baseConfig, }, { displayName: 'check', - setupFiles: ['./tests/variants/check.js'], + setupFiles: ['./tests/setup/check.js'], ...baseConfig, }, ], diff --git a/src/ApiTypes.ts b/src/ApiTypes.ts index 3505b56..891029a 100644 --- a/src/ApiTypes.ts +++ b/src/ApiTypes.ts @@ -26,6 +26,9 @@ export interface IPNode { /** Forces the node to be finalized. You generaly don't need to call this. */ finalize(): void; + + /** The result of the node's callback, finalizing it if needed. */ + readonly result: unknown; } /** A probed component of which the return type is known. */ @@ -35,11 +38,18 @@ export interface PNode extends IPNode { } /** A function that implements the probe() functionality for a given set of intrinsics. */ -export type ProbingFunction> = any)>( +export type ProbingFunction = | ((...args: any[]) => unknown)>( cb: T, ...args: ProbedParams ) => AsPNode>; +/** Meta information available to components. */ +export interface ProbingContext { + componentName: string; // The name of the component. +} + +export type Component = (...args: Args) => Ret; + // ************ Dynamics - Readers ************ // /** Consumer API for a dynamic value. */ @@ -92,27 +102,33 @@ export type DynamicReader = DynamicListReader ? T : export type AsPNode = T extends PNode ? PNode : PNode; -export type Component = (...arg: ArgsT) => RetT; - -export type Intrinsics = { - [_ in keyof I]: Component; +export type Intrinsics = { + [K in keyof T as T[K] extends (...args: any[]) => unknown ? K : never]: T[K]; }; -export type IntrinsicParams> = Parameters; -export type IntrinsicResult> = ReturnType; +export type IKeys = keyof T; + +export type IntrinsicParams, I extends FuncMap> = Parameters; +export type IntrinsicResult, I extends FuncMap> = ReturnType; -export type Probed = Record> = keyof I | Component; +export type Probed = IKeys | ((...args: any[]) => unknown); -export type ProbedParams, I extends Intrinsics = Record> = T extends keyof I +export type ProbedParams, I extends FuncMap> = T extends IKeys ? IntrinsicParams - : T extends Component + : T extends (...arg: infer P) => unknown ? P : never; -export type ProbedResult, I extends Intrinsics = Record> = T extends keyof I +export type ProbedResult, I extends FuncMap> = T extends IKeys ? IntrinsicResult - : T extends Component + : T extends (...arg: any[]) => infer P ? P : never; +export type FuncMap = Record unknown>; + +type FallbackParams = Parameters; +type FallbackResult = ReturnType; +export type IntrinsicFallback = (...args: FallbackParams) => FallbackResult; + export type ListValueType> = ArrayType[number]; diff --git a/src/Environment.ts b/src/Environment.ts index 1b4b447..41509cc 100644 --- a/src/Environment.ts +++ b/src/Environment.ts @@ -14,9 +14,12 @@ * limitations under the License. */ +import { ProbingContext } from './ApiTypes'; + export type DisposeOp = () => void; export interface Environment { _onDispose: (op: DisposeOp) => void; + _getProbingContext: () => ProbingContext | undefined; } let _currentEnv: Environment | null = null; @@ -41,3 +44,16 @@ export const useOnDispose = (op: DisposeOp): void => { _currentEnv!._onDispose(op); }; + +export function useProbingContext(): ProbingContext { + if (process.env.NODE_ENV !== 'production' && !_currentEnv) { + throw new Error('Environment underflow'); + } + + const result = _currentEnv!._getProbingContext(); + if (process.env.NODE_ENV !== 'production' && !result) { + throw new Error('There is no active probing context'); + } + + return result!; +} diff --git a/src/Node.ts b/src/Node.ts index 4d14dd3..2eab458 100644 --- a/src/Node.ts +++ b/src/Node.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { IPNode } from './ApiTypes'; +import { IPNode, ProbingContext } from './ApiTypes'; import { DisposeOp } from './Environment'; export interface IProber { @@ -22,12 +22,13 @@ export interface IProber { } export interface NodeBuildData { - _cb: (...arg: any[]) => any; - _args: any[]; + _cb: (...arg: unknown[]) => unknown; + _args: unknown[]; _next?: IPNode; _resolveAs?: IPNode; _prober: IProber; + _context: ProbingContext; } export abstract class BaseNode implements IPNode { @@ -48,6 +49,8 @@ export abstract class BaseNode implements IPNode { } } + abstract get result(): unknown; + _result?: unknown; _probed_pnodetype?: number; _onDispose?: DisposeOp[]; diff --git a/src/Prober.ts b/src/Prober.ts index 57568f2..888a446 100644 --- a/src/Prober.ts +++ b/src/Prober.ts @@ -1,8 +1,22 @@ import { DisposeOp, popEnv, pushEnv } from './Environment'; -import { IPNode, AsPNode, Component, Intrinsics, ProbedParams, ProbedResult, ProbingFunction } from './ApiTypes'; +import { + IPNode, + AsPNode, + Intrinsics, + ProbedParams, + ProbedResult, + IntrinsicFallback, + ProbingFunction, + IKeys, + FuncMap, + ProbingContext, +} from './ApiTypes'; import { IProber, BaseNode, NodeImpl, UnwrapPNode, isPNode } from './Node'; -const isIntrinsic = (cb: keyof I | ((...args: any[]) => any)): cb is keyof I => { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ComponentCb = (...args: any[]) => any; + +const isIntrinsic = (cb: IKeys | ((...args: unknown[]) => unknown)): cb is IKeys => { return typeof cb === 'string'; }; @@ -16,31 +30,42 @@ const addToDisposeQueue = (node: BaseNode, ops: DisposeOp[]) => { let _NextUniqueNodeId = 0; -class Prober> implements IProber { - private _intrinsics: I; +class Prober implements IProber { + private _intrinsics: Partial; + private _fallback?: IntrinsicFallback; + private _queueHead?: BaseNode; private _insert?: BaseNode; private _insertStack: (BaseNode | undefined)[] = []; private _end?: BaseNode; + private _currentNode?: BaseNode; + private _pendingOnDispose: DisposeOp[] = []; private _finalizeStack: { _end: BaseNode | undefined; _pendingOnDispose: DisposeOp[]; + _currentNode: BaseNode | undefined; }[] = []; _onDispose(op: DisposeOp): void { this._pendingOnDispose.push(op); } - constructor(intrinsics: I) { + _getProbingContext(): ProbingContext | undefined { + return this._currentNode!._buildData!._context; + } + + constructor(intrinsics: Partial, fallback?: IntrinsicFallback) { this._intrinsics = intrinsics; + this._fallback = fallback; } - _announce(what: T, ..._args: ProbedParams): AsPNode> { + _announce | ComponentCb>(what: T, ..._args: ProbedParams): AsPNode> { + const { _cb, _name } = this._getCb(what); if (process.env.NODE_ENV !== 'production') { if (isIntrinsic(what)) { - if (!this._intrinsics[what]) { + if (!this._intrinsics[what] && !this._fallback) { throw Error(`"${what}" is not a registered intrinsic in this Prober`); } } else { @@ -55,7 +80,6 @@ class Prober> implements IProber { newNode._uniqueNodeId = _NextUniqueNodeId++; } - const _cb = this._getCb(what); let _next: IPNode | undefined; if (this._queueHead) { @@ -71,7 +95,15 @@ class Prober> implements IProber { this._end = newNode; } - newNode._buildData = { _cb, _prober: this, _args, _next }; + newNode._buildData = { + _cb, + _prober: this, + _args, + _next, + _context: { + componentName: _name, + }, + }; return newNode as AsPNode>; } @@ -79,13 +111,19 @@ class Prober> implements IProber { _finalize(target: IPNode): void { // This can be called recursively, pushEnv(this); - this._finalizeStack.push({ _end: this._end, _pendingOnDispose: this._pendingOnDispose }); + this._finalizeStack.push({ + _end: this._end, + _pendingOnDispose: this._pendingOnDispose, + _currentNode: this._currentNode, + }); this._pendingOnDispose = []; this._end = target; let currentNode: BaseNode; do { currentNode = this._queueHead!; + this._currentNode = currentNode; + this._queueHead = currentNode._buildData!._next; // If a component returns a Node (as opposed to a value), then we short-circuit to the parent. @@ -98,7 +136,7 @@ class Prober> implements IProber { this._insertStack.push(this._insert); const { _cb, _args } = currentNode._buildData!; - const cbResult = _cb(..._args); + const cbResult = _cb(..._args, currentNode._buildData!._context); if (isPNode(cbResult)) { if (cbResult.finalized) { @@ -127,22 +165,40 @@ class Prober> implements IProber { const finalizePop = this._finalizeStack.pop()!; this._end = finalizePop._end; this._pendingOnDispose = finalizePop._pendingOnDispose; + this._currentNode = finalizePop._currentNode; + popEnv(); } - private _getCb(what: T): Component { + private _getCb | ComponentCb>(what: T): { _cb: ComponentCb; _name: string } { if (isIntrinsic(what)) { - return this._intrinsics[what]; + let _cb: ComponentCb | undefined = this._intrinsics[what]; + const _name = what.toString(); + if (!_cb) { + // This is safe, it's caught at the start of _announce() + _cb = this._fallback!; + } + + return { _cb: _cb!, _name }; } else { - return what as Component; + return { _cb: what as ComponentCb, _name: (what as ComponentCb).name }; } } } -export function createProber>(intrinsics: I): ProbingFunction { - const newProber = new Prober(intrinsics); +export function createProber>(intrinsics: I): ProbingFunction; +export function createProber>( + intrinsics: Partial, + fallback: IntrinsicFallback, +): ProbingFunction; + +export function createProber( + intrinsics: I | Partial, + fallback?: IntrinsicFallback, +): ProbingFunction { + const newProber = new Prober(intrinsics, fallback); - const probe = | Component>(what: T, ...args: ProbedParams) => + const probe = | ComponentCb>(what: T, ...args: ProbedParams) => newProber._announce(what, ...args); return probe; diff --git a/src/dynamic/operations.ts b/src/dynamic/operations.ts index 38c9474..89bde6f 100644 --- a/src/dynamic/operations.ts +++ b/src/dynamic/operations.ts @@ -21,10 +21,7 @@ import { DynamicValueImpl } from './Value'; export function listen(v: T | DynamicReaderBase, cb: (v: T) => void): void { if (isDynamic(v)) { - cb(v.current); v.addListener(cb); - } else { - return cb(v); } } diff --git a/tests/dynamicList.test.ts b/tests/dynamicList.test.ts index 4e07f47..7792124 100644 --- a/tests/dynamicList.test.ts +++ b/tests/dynamicList.test.ts @@ -27,6 +27,7 @@ const cleanup = () => { beforeEach(() => { pushEnv({ _onDispose: (op: DisposeOp) => disposeQueue.push(op), + _getProbingContext: () => undefined, }); }); @@ -60,5 +61,4 @@ describe('Dynamic Array', () => { x.current = [2, 2, 2]; expect(y.current).toEqual([4, 4, 4]); }); - }); diff --git a/tests/dynamicOperations.test.ts b/tests/dynamicOperations.test.ts index 5772184..f13f0ef 100644 --- a/tests/dynamicOperations.test.ts +++ b/tests/dynamicOperations.test.ts @@ -27,6 +27,7 @@ const cleanup = () => { beforeEach(() => { pushEnv({ _onDispose: (op: DisposeOp) => disposeQueue.push(op), + _getProbingContext: () => undefined, }); }); @@ -41,7 +42,7 @@ describe('listen', () => { const cb = (v: number) => (y += v); const v = 12; listen(v, cb); - expect(y).toBe(12); + expect(y).toBe(0); }); it('Works on dynamic values', () => { @@ -49,9 +50,12 @@ describe('listen', () => { const cb = (v: number) => (y += v); const v = dynamic(12); listen(v, cb); - expect(y).toBe(12); + expect(y).toBe(0); v.current = 13; + expect(y).toBe(13); + + v.current = 12; expect(y).toBe(25); }); }); diff --git a/tests/dynamicVal.test.ts b/tests/dynamicVal.test.ts index d7e64da..8294094 100644 --- a/tests/dynamicVal.test.ts +++ b/tests/dynamicVal.test.ts @@ -26,6 +26,7 @@ const cleanup = () => { beforeEach(() => { pushEnv({ _onDispose: (op: DisposeOp) => disposeQueue.push(op), + _getProbingContext: () => undefined, }); }); diff --git a/tests/environment.test.ts b/tests/environment.test.ts index e6b57ef..6938e1c 100644 --- a/tests/environment.test.ts +++ b/tests/environment.test.ts @@ -1,4 +1,5 @@ -import { popEnv, pushEnv, useOnDispose, Environment } from '../src/Environment'; +import { ProbingContext } from '../src'; +import { popEnv, pushEnv, useOnDispose, Environment, useProbingContext } from '../src/Environment'; import { expectThrowInNotProd } from './utils'; const noop = () => { @@ -19,10 +20,16 @@ describe('useOnDispose ', () => { class TestEnv implements Environment { count = 0; + probeContext?: ProbingContext; _onDispose(): void { this.count += 1; } + + _getProbingContext(): ProbingContext | undefined { + return this.probeContext; + } } + describe('Environment', () => { it('Catches underflows', () => { expectThrowInNotProd(popEnv); @@ -64,3 +71,24 @@ describe('Environment', () => { expectThrowInNotProd(popEnv); }); }); + +describe('useProbingContext ', () => { + it('Fails if used out of context', () => { + expectThrowInNotProd(useProbingContext); + }); + + it('Fails if probing context is set', () => { + const env = new TestEnv(); + pushEnv(env); + expectThrowInNotProd(useProbingContext); + popEnv(); + }); + + it('Works if a probing context is set', () => { + const env = new TestEnv(); + env.probeContext = { componentName: 'aaa' }; + pushEnv(env); + expect(useProbingContext().componentName).toBe('aaa'); + popEnv(); + }); +}); diff --git a/tests/probe.test.ts b/tests/probe.test.ts index c2d8272..4b9b928 100644 --- a/tests/probe.test.ts +++ b/tests/probe.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { probe, createProber, PNode, useOnDispose, Component } from '../src'; +import { probe, createProber, PNode, useOnDispose, ProbingContext, Component, useProbingContext } from '../src'; import { expectThrowInNotProd } from './utils'; describe('Basic prober', () => { @@ -63,13 +63,34 @@ describe('Basic prober', () => { //@ts-expect-error expectThrowInNotProd(() => probe('', {})); }); + + // These cannot be made into runtime tests, because there's + // No way to determine at runtime if a function expects a context + // or not. + it('Fails when not passing enough arguments', () => { + //@ts-expect-error + () => probe((v1: number, v2: number) => v1 + v2, 12); + }); + + it('Fails when passing too many arguments', () => { + //@ts-expect-error + () => expectThrowInNotProd(() => probe((v1: number) => v1, 12, 13)); + + //@ts-expect-error + () => expectThrowInNotProd(() => probe((v1: number, _ctx: ProbingContext) => v1, 12, 13)); + }); }); describe('Prober with intrinsics', () => { - const sutProbe = createProber({ - aaa: (v: number) => v + 1, + const mapping = { + aaa: (v: number) => { + expect(useProbingContext().componentName).toBe('aaa'); + return v + 1; + }, bbb: (v: number) => v + 4, - }); + }; + + const sutProbe = createProber(mapping); it('Produces a payload', () => { const resultA = sutProbe('aaa', 1); @@ -92,6 +113,44 @@ describe('Prober with intrinsics', () => { //@ts-expect-error expectThrowInNotProd(() => sutProbe('', {})); }); + + it('Still handles functional', () => { + const result = sutProbe((v: number) => v + 1, 1); + expect(result.result).toBe(2); + }); + + it('works with higher-order components', () => { + const HOC = (c: Component<[number], unknown>) => sutProbe(c, 12); + + expect(sutProbe(HOC, mapping.aaa).result).toBe(13); + expect(sutProbe(HOC, mapping.bbb).result).toBe(16); + }); +}); + +describe('Dynamic intrinsic lookup', () => { + it('works', () => { + interface Base { + x: string; + } + + interface Specialized extends Base { + y: number; + } + + interface TypeInfo { + aaa: (v: number) => Base; + bbb: (v: string) => Specialized; + } + + const componentImpl = (_: number | string): Base | Specialized => { + return { x: useProbingContext().componentName }; + }; + + const sutProbe = createProber({}, componentImpl); + + expect(sutProbe('aaa', 0).result.x).toBe('aaa'); + expect(sutProbe('bbb', 'allo').result.x).toBe('bbb'); + }); }); describe('Component With dispose', () => { diff --git a/tests/variants/check.js b/tests/setup/check.js similarity index 100% rename from tests/variants/check.js rename to tests/setup/check.js diff --git a/tests/variants/dev.js b/tests/setup/dev.js similarity index 100% rename from tests/variants/dev.js rename to tests/setup/dev.js diff --git a/tests/variants/prod.js b/tests/setup/prod.js similarity index 100% rename from tests/variants/prod.js rename to tests/setup/prod.js