From e4c1327c4e33d201522456d99b0e6eef65f5bbc4 Mon Sep 17 00:00:00 2001 From: MazurDorian Date: Wed, 11 Mar 2026 16:27:25 +0100 Subject: [PATCH] feat: add observable naming, creation hook, and babel auto-naming transform --- index.ts | 3 +- src/babel/index.ts | 58 ++++++++++- src/createObservable.ts | 10 +- src/middleware.ts | 35 +++++++ src/observable.ts | 16 +-- src/observableInterfaces.ts | 5 + tests/babel.test.ts | 158 +++++++++++++++++++++++++++++ tests/devtools.test.ts | 196 ++++++++++++++++++++++++++++++++++++ 8 files changed, 471 insertions(+), 10 deletions(-) create mode 100644 tests/devtools.test.ts diff --git a/index.ts b/index.ts index 4dc9f5a03..563fea6a4 100644 --- a/index.ts +++ b/index.ts @@ -86,7 +86,7 @@ import { import { deepMerge, getValueAtPath, initializePathType, setAtPath } from './src/helpers'; import { tracking } from './src/tracking'; import { ObservablePrimitiveClass } from './src/ObservablePrimitive'; -import { registerMiddleware } from './src/middleware'; +import { onObservableCreated, registerMiddleware } from './src/middleware'; export const internal = { createPreviousHandler, @@ -111,6 +111,7 @@ export const internal = { optimized, peek, reactivateNode, + onObservableCreated, registerMiddleware, safeParse, safeStringify, diff --git a/src/babel/index.ts b/src/babel/index.ts index 7053bdead..ef84235e1 100644 --- a/src/babel/index.ts +++ b/src/babel/index.ts @@ -8,15 +8,25 @@ import { jsxIdentifier, jsxOpeningElement, jsxOpeningFragment, + objectExpression, + objectProperty, + identifier, + stringLiteral, } from '@babel/types'; +// Set of observable factory function names that should be auto-named +const OBSERVABLE_FACTORIES = new Set(['observable', 'observablePrimitive']); + export default function () { let hasLegendImport = false; + const observableImports = new Set(); return { visitor: { ImportDeclaration: { enter(path: { node: any; replaceWith: (param: any) => any; skip: () => void }) { - if (path.node.source.value === '@legendapp/state/react') { + const source = path.node.source.value; + + if (source === '@legendapp/state/react') { const specifiers = path.node.specifiers; for (let i = 0; i < specifiers.length; i++) { const s = specifiers[i].imported.name; @@ -26,6 +36,52 @@ export default function () { } } } + + if (source === '@legendapp/state' || source === '@legendapp/state/src/observable') { + const specifiers = path.node.specifiers; + for (let i = 0; i < specifiers.length; i++) { + const spec = specifiers[i]; + // Handle named imports: import { observable } or import { observable as obs } + if (spec.type === 'ImportSpecifier' && OBSERVABLE_FACTORIES.has(spec.imported.name)) { + observableImports.add(spec.local.name); + } + } + } + }, + }, + VariableDeclarator: { + enter(path: { node: any; skip: () => void }) { + if (observableImports.size === 0) return; + + const { id, init } = path.node; + // Only handle simple variable names: const foo = observable(...) + if (!id || id.type !== 'Identifier' || !init || init.type !== 'CallExpression') { + return; + } + + const callee = init.callee; + let isObservableCall = false; + if (callee.type === 'Identifier' && observableImports.has(callee.name)) { + isObservableCall = true; + } + + if (!isObservableCall) return; + + const varName = id.name; + const args = init.arguments; + + if (args.length >= 2) return; + + const nameOption = objectExpression([objectProperty(identifier('name'), stringLiteral(varName))]); + + if (args.length === 0) { + // observable() -> observable(undefined, { name: '...' }) + args.push(identifier('undefined')); + args.push(nameOption); + } else { + // observable(val) -> observable(val, { name: '...' }) + args.push(nameOption); + } }, }, JSXElement: { diff --git a/src/createObservable.ts b/src/createObservable.ts index 8c9ba15ce..2b23b604a 100644 --- a/src/createObservable.ts +++ b/src/createObservable.ts @@ -1,6 +1,7 @@ import { isObservable, setNodeValue } from './globals'; import { isActualPrimitive, isFunction, isPromise } from './is'; -import type { ClassConstructor, NodeInfo, ObservableRoot } from './observableInterfaces'; +import { notifyObservableCreated } from './middleware'; +import type { ClassConstructor, NodeInfo, ObservableOptions, ObservableRoot } from './observableInterfaces'; import { Observable, ObservablePrimitive } from './observableTypes'; export function createObservable( @@ -9,6 +10,7 @@ export function createObservable( extractPromise: Function, createObject: Function, createPrimitive?: Function, + options?: ObservableOptions, ): Observable { if (isObservable(value)) { return value as Observable; @@ -26,6 +28,10 @@ export function createObservable( numListenersRecursive: 0, }; + if (options?.name) { + node._name = options.name; + } + if (valueIsFunction) { node = Object.assign(() => {}, node); node.lazyFn = value; @@ -42,5 +48,7 @@ export function createObservable( extractPromise(node, value); } + notifyObservableCreated(node); + return obs as any; } diff --git a/src/middleware.ts b/src/middleware.ts index 2aeae6bbf..fbc964e1b 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,5 +1,40 @@ import type { NodeInfo, NodeListener } from './observableInterfaces'; +// Global observable creation tracking +type ObservableCreatedHandler = (node: NodeInfo) => void; +const creationHandlers = new Set(); + +/** + * Register a global handler that fires whenever an observable is created. + * Useful for devtools that need to auto-discover every observable. + * Returns an unsubscribe function. + * + */ +export function onObservableCreated(handler: ObservableCreatedHandler): () => void { + creationHandlers.add(handler); + return () => { + creationHandlers.delete(handler); + }; +} + +/** + * Notify all registered creation handlers about a new observable. + * Called internally from createObservable. Skipped when no handlers exist. + * @internal + */ +export function notifyObservableCreated(node: NodeInfo): void { + if (creationHandlers.size === 0) { + return; + } + for (const handler of creationHandlers) { + try { + handler(node); + } catch (error) { + console.error('Error in onObservableCreated handler:', error); + } + } +} + // Types for middleware events and handlers export type MiddlewareEventType = 'listener-added' | 'listener-removed' | 'listeners-cleared'; diff --git a/src/observable.ts b/src/observable.ts index 6f81fb1f4..5ce6d1f7d 100644 --- a/src/observable.ts +++ b/src/observable.ts @@ -1,19 +1,21 @@ import { extractPromise, getProxy } from './ObservableObject'; import { ObservablePrimitiveClass } from './ObservablePrimitive'; import { createObservable } from './createObservable'; +import type { ObservableOptions } from './observableInterfaces'; import type { Observable, ObservablePrimitive, RecursiveValueOrFunction } from './observableTypes'; export function observable(): Observable; export function observable( value: Promise> | (() => RecursiveValueOrFunction) | RecursiveValueOrFunction, + options?: ObservableOptions, ): Observable; -export function observable(value: T): Observable; -export function observable(value?: T): Observable { - return createObservable(value, false, extractPromise, getProxy, ObservablePrimitiveClass) as any; +export function observable(value: T, options?: ObservableOptions): Observable; +export function observable(value?: T, options?: ObservableOptions): Observable { + return createObservable(value, false, extractPromise, getProxy, ObservablePrimitiveClass, options) as any; } -export function observablePrimitive(value: Promise): ObservablePrimitive; -export function observablePrimitive(value?: T): ObservablePrimitive; -export function observablePrimitive(value?: T | Promise): ObservablePrimitive { - return createObservable(value, true, extractPromise, getProxy, ObservablePrimitiveClass) as any; +export function observablePrimitive(value: Promise, options?: ObservableOptions): ObservablePrimitive; +export function observablePrimitive(value?: T, options?: ObservableOptions): ObservablePrimitive; +export function observablePrimitive(value?: T | Promise, options?: ObservableOptions): ObservablePrimitive { + return createObservable(value, true, extractPromise, getProxy, ObservablePrimitiveClass, options) as any; } diff --git a/src/observableInterfaces.ts b/src/observableInterfaces.ts index 55da83fe9..5bc4a7689 100644 --- a/src/observableInterfaces.ts +++ b/src/observableInterfaces.ts @@ -68,6 +68,10 @@ export interface TrackingState { traceUpdates?: (fn: Function) => Function; } +export interface ObservableOptions { + name?: string; +} + interface BaseNodeInfo { children?: Map; proxy?: object; @@ -75,6 +79,7 @@ interface BaseNodeInfo { listeners?: Set; listenersImmediate?: Set; isEvent?: boolean; + _name?: string; linkedToNode?: NodeInfo; linkedToNodeDispose?: () => void; activatedObserveDispose?: () => void; diff --git a/tests/babel.test.ts b/tests/babel.test.ts index 2ad33e47f..2bf40deb6 100644 --- a/tests/babel.test.ts +++ b/tests/babel.test.ts @@ -253,3 +253,161 @@ describe('babel tests', () => { }, }); }); + +describe('babel auto-naming tests', () => { + pluginTester({ + plugin, + pluginName: 'babel-auto-naming', + tests: { + 'injects name option for observable with a value': { + code: ` + import { observable } from '@legendapp/state'; + const myStore = observable({ count: 0 }); + `, + output: ` + import { observable } from '@legendapp/state'; + const myStore = observable( + { + count: 0, + }, + { + name: 'myStore', + } + ); + `, + }, + 'injects name option for observable with no args': { + code: ` + import { observable } from '@legendapp/state'; + const myStore = observable(); + `, + output: ` + import { observable } from '@legendapp/state'; + const myStore = observable(undefined, { + name: 'myStore', + }); + `, + }, + 'injects name option for observablePrimitive': { + code: ` + import { observablePrimitive } from '@legendapp/state'; + const count = observablePrimitive(0); + `, + output: ` + import { observablePrimitive } from '@legendapp/state'; + const count = observablePrimitive(0, { + name: 'count', + }); + `, + }, + 'does not modify observable when options already provided': { + code: ` + import { observable } from '@legendapp/state'; + const myStore = observable({ count: 0 }, { name: 'custom' }); + `, + output: ` + import { observable } from '@legendapp/state'; + const myStore = observable( + { + count: 0, + }, + { + name: 'custom', + } + ); + `, + }, + 'does not modify if not imported from @legendapp/state': { + code: ` + import { observable } from 'other-package'; + const myStore = observable({ count: 0 }); + `, + output: ` + import { observable } from 'other-package'; + const myStore = observable({ + count: 0, + }); + `, + }, + 'handles aliased imports': { + code: ` + import { observable as obs } from '@legendapp/state'; + const myStore = obs({ count: 0 }); + `, + output: ` + import { observable as obs } from '@legendapp/state'; + const myStore = obs( + { + count: 0, + }, + { + name: 'myStore', + } + ); + `, + }, + 'does not modify destructuring patterns': { + code: ` + import { observable } from '@legendapp/state'; + const { count } = observable({ count: 0 }); + `, + output: ` + import { observable } from '@legendapp/state'; + const { count } = observable({ + count: 0, + }); + `, + }, + 'handles export const declarations': { + code: ` + import { observable } from '@legendapp/state'; + export const appState = observable({ theme: 'dark' }); + `, + output: ` + import { observable } from '@legendapp/state'; + export const appState = observable( + { + theme: 'dark', + }, + { + name: 'appState', + } + ); + `, + }, + 'handles multiple declarations in one file': { + code: ` + import { observable, observablePrimitive } from '@legendapp/state'; + const store = observable({ a: 1 }); + const count = observablePrimitive(0); + `, + output: ` + import { observable, observablePrimitive } from '@legendapp/state'; + const store = observable( + { + a: 1, + }, + { + name: 'store', + } + ); + const count = observablePrimitive(0, { + name: 'count', + }); + `, + }, + 'does not modify non-observable calls even with import present': { + code: ` + import { observable } from '@legendapp/state'; + const myStore = someOtherFunction({ count: 0 }); + `, + output: ` + import { observable } from '@legendapp/state'; + const myStore = someOtherFunction({ + count: 0, + }); + `, + }, + }, + }); +}); diff --git a/tests/devtools.test.ts b/tests/devtools.test.ts new file mode 100644 index 000000000..01f7df534 --- /dev/null +++ b/tests/devtools.test.ts @@ -0,0 +1,196 @@ +import { getNode, observable, observablePrimitive } from '@legendapp/state'; +import { onObservableCreated } from '../src/middleware'; +import type { NodeInfo } from '../src/observableInterfaces'; + +jest?.setTimeout?.(1000); + +describe('Devtools Support', () => { + describe('Observable Naming', () => { + test('observable accepts a name option', () => { + const store = observable({ count: 0 }, { name: 'myStore' }); + const node = getNode(store); + expect(node._name).toBe('myStore'); + }); + + test('observablePrimitive accepts a name option', () => { + const count = observablePrimitive(0, { name: 'count' }); + const node = getNode(count); + expect(node._name).toBe('count'); + }); + + test('observable without name option has no _name', () => { + const store = observable({ count: 0 }); + const node = getNode(store); + expect(node._name).toBeUndefined(); + }); + + test('observable with empty options has no _name', () => { + const store = observable({ count: 0 }, {}); + const node = getNode(store); + expect(node._name).toBeUndefined(); + }); + + test('child nodes derive path from parent _name and key', () => { + const store = observable({ user: { name: 'Alice' } }, { name: 'appStore' }); + const rootNode = getNode(store); + const userNode = getNode(store.user); + const nameNode = getNode(store.user.name); + + expect(rootNode._name).toBe('appStore'); + // Child nodes have keys that can be combined with root name for full path + expect(userNode.key).toBe('user'); + expect(nameNode.key).toBe('name'); + expect(nameNode.parent).toBe(userNode); + }); + }); + + describe('onObservableCreated', () => { + test('fires handler when observable is created', () => { + const handler = jest.fn(); + const unsub = onObservableCreated(handler); + + const store = observable({ count: 0 }); + expect(handler).toHaveBeenCalledTimes(1); + + const node = handler.mock.calls[0][0] as NodeInfo; + expect(node).toBe(getNode(store)); + + unsub(); + }); + + test('fires handler when observablePrimitive is created', () => { + const handler = jest.fn(); + const unsub = onObservableCreated(handler); + + const count = observablePrimitive(42); + expect(handler).toHaveBeenCalledTimes(1); + + const node = handler.mock.calls[0][0] as NodeInfo; + expect(node).toBe(getNode(count)); + + unsub(); + }); + + test('handler receives node with _name when name option is provided', () => { + const handler = jest.fn(); + const unsub = onObservableCreated(handler); + + observable({ count: 0 }, { name: 'myStore' }); + expect(handler).toHaveBeenCalledTimes(1); + + const node = handler.mock.calls[0][0] as NodeInfo; + expect(node._name).toBe('myStore'); + + unsub(); + }); + + test('unsubscribe stops notifications', () => { + const handler = jest.fn(); + const unsub = onObservableCreated(handler); + + observable({ a: 1 }); + expect(handler).toHaveBeenCalledTimes(1); + + unsub(); + + observable({ b: 2 }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + test('multiple handlers all fire', () => { + const handler1 = jest.fn(); + const handler2 = jest.fn(); + const unsub1 = onObservableCreated(handler1); + const unsub2 = onObservableCreated(handler2); + + observable({ x: 1 }); + expect(handler1).toHaveBeenCalledTimes(1); + expect(handler2).toHaveBeenCalledTimes(1); + + unsub1(); + unsub2(); + }); + + test('handler error does not prevent other handlers from firing', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + const handler1 = jest.fn(() => { + throw new Error('test error'); + }); + const handler2 = jest.fn(); + const unsub1 = onObservableCreated(handler1); + const unsub2 = onObservableCreated(handler2); + + observable({ x: 1 }); + expect(handler1).toHaveBeenCalledTimes(1); + expect(handler2).toHaveBeenCalledTimes(1); + expect(consoleError).toHaveBeenCalledTimes(1); + + unsub1(); + unsub2(); + consoleError.mockRestore(); + }); + + test('zero cost when no handlers registered', () => { + const store = observable({ count: 0 }); + expect(store.count.get()).toBe(0); + }); + + test('does not fire for already-observable values passed to observable()', () => { + const handler = jest.fn(); + const unsub = onObservableCreated(handler); + + const original = observable({ a: 1 }); + handler.mockClear(); + + const same = observable(original); + expect(handler).toHaveBeenCalledTimes(0); + expect(same).toBe(original); + + unsub(); + }); + }); + + describe('Path Derivation Helper', () => { + test('can derive full path from node chain', () => { + const store = observable( + { + users: { + alice: { age: 30 }, + }, + }, + { name: 'store' }, + ); + + const ageNode = getNode(store.users.alice.age); + + const path = getNodePath(ageNode); + expect(path).toBe('store.users.alice.age'); + }); + + test('path without root name uses anonymous prefix', () => { + const store = observable({ + x: { y: 1 }, + }); + + const yNode = getNode(store.x.y); + const path = getNodePath(yNode); + expect(path).toBe('.x.y'); + }); + }); +}); + +function getNodePath(node: NodeInfo): string { + const parts: string[] = []; + let current: NodeInfo | undefined = node; + while (current) { + if (current.key) { + parts.unshift(current.key); + } else if (current._name) { + parts.unshift(current._name); + } else if (!current.parent) { + parts.unshift(''); + } + current = current.parent; + } + return parts.join('.'); +}