Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -111,6 +111,7 @@ export const internal = {
optimized,
peek,
reactivateNode,
onObservableCreated,
registerMiddleware,
safeParse,
safeStringify,
Expand Down
58 changes: 57 additions & 1 deletion src/babel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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;
Expand All @@ -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);
Comment on lines +77 to +80

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the zero-arg call case, the transform injects an Identifier named undefined. Since undefined can be shadowed by a local binding, this can change runtime semantics in some files. Prefer generating an unshadowable undefined value (e.g., void 0) instead of identifier('undefined').

Copilot uses AI. Check for mistakes.
} else {
// observable(val) -> observable(val, { name: '...' })
args.push(nameOption);
}
},
},
JSXElement: {
Expand Down
10 changes: 9 additions & 1 deletion src/createObservable.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
Expand All @@ -9,6 +10,7 @@ export function createObservable<T>(
extractPromise: Function,
createObject: Function,
createPrimitive?: Function,
options?: ObservableOptions,
): Observable<T> {
if (isObservable(value)) {
return value as Observable<T>;
Expand All @@ -26,6 +28,10 @@ export function createObservable<T>(
numListenersRecursive: 0,
};

if (options?.name) {
node._name = options.name;
}

if (valueIsFunction) {
node = Object.assign(() => {}, node);
node.lazyFn = value;
Expand All @@ -42,5 +48,7 @@ export function createObservable<T>(
extractPromise(node, value);
}

notifyObservableCreated(node);

return obs as any;
}
35 changes: 35 additions & 0 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
import type { NodeInfo, NodeListener } from './observableInterfaces';

// Global observable creation tracking
type ObservableCreatedHandler = (node: NodeInfo) => void;
const creationHandlers = new Set<ObservableCreatedHandler>();

/**
* 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);
}
}
Comment on lines +29 to +35

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

notifyObservableCreated iterates directly over creationHandlers. If handlers are added/removed during notification (including a handler unsubscribing itself), iteration order/coverage can become surprising. Consider iterating over a snapshot (e.g., Array.from(creationHandlers)) similar to how middleware events snapshot handler sets later in this file.

Copilot uses AI. Check for mistakes.
}

// Types for middleware events and handlers
export type MiddlewareEventType = 'listener-added' | 'listener-removed' | 'listeners-cleared';

Expand Down
16 changes: 9 additions & 7 deletions src/observable.ts
Original file line number Diff line number Diff line change
@@ -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<T>(): Observable<T | undefined>;
export function observable<T>(
value: Promise<RecursiveValueOrFunction<T>> | (() => RecursiveValueOrFunction<T>) | RecursiveValueOrFunction<T>,
options?: ObservableOptions,
): Observable<T>;
export function observable<T>(value: T): Observable<T>;
export function observable<T>(value?: T): Observable<any> {
return createObservable(value, false, extractPromise, getProxy, ObservablePrimitiveClass) as any;
export function observable<T>(value: T, options?: ObservableOptions): Observable<T>;
export function observable<T>(value?: T, options?: ObservableOptions): Observable<any> {
return createObservable(value, false, extractPromise, getProxy, ObservablePrimitiveClass, options) as any;
}

export function observablePrimitive<T>(value: Promise<T>): ObservablePrimitive<T>;
export function observablePrimitive<T>(value?: T): ObservablePrimitive<T>;
export function observablePrimitive<T>(value?: T | Promise<T>): ObservablePrimitive<T> {
return createObservable(value, true, extractPromise, getProxy, ObservablePrimitiveClass) as any;
export function observablePrimitive<T>(value: Promise<T>, options?: ObservableOptions): ObservablePrimitive<T>;
export function observablePrimitive<T>(value?: T, options?: ObservableOptions): ObservablePrimitive<T>;
export function observablePrimitive<T>(value?: T | Promise<T>, options?: ObservableOptions): ObservablePrimitive<T> {
return createObservable(value, true, extractPromise, getProxy, ObservablePrimitiveClass, options) as any;
}
5 changes: 5 additions & 0 deletions src/observableInterfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,18 @@ export interface TrackingState {
traceUpdates?: (fn: Function) => Function;
}

export interface ObservableOptions {
name?: string;
}

interface BaseNodeInfo {
children?: Map<string, ChildNodeInfo>;
proxy?: object;
root: ObservableRoot;
listeners?: Set<NodeListener>;
listenersImmediate?: Set<NodeListener>;
isEvent?: boolean;
_name?: string;
linkedToNode?: NodeInfo;
linkedToNodeDispose?: () => void;
activatedObserveDispose?: () => void;
Expand Down
158 changes: 158 additions & 0 deletions tests/babel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
`,
},
},
});
});
Loading
Loading