Skip to content

Commit 8423511

Browse files
authored
Add signal names (#730)
* Add signal names and debug pre/postamble in babel react * Add changeset
1 parent 8fe8dec commit 8423511

File tree

4 files changed

+356
-10
lines changed

4 files changed

+356
-10
lines changed

.changeset/old-cycles-swim.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@preact/signals-react-transform": minor
3+
---
4+
5+
Surface component-name and automatically name signals/computeds during the transform. This is gated behind the `experimental.debug` option

extension/src/components/Graph.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ export function GraphVisualization({
1414
}: {
1515
updates: Signal<(SignalUpdate | Divider)[]>;
1616
}) {
17-
console.log(updates);
1817
const svgRef = useRef<SVGSVGElement>(null);
1918

2019
// Build graph data from updates signal using a computed

packages/react-transform/src/index.ts

Lines changed: 203 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,119 @@ function isJSXAlternativeCall(
361361
return false;
362362
}
363363

364+
function isSignalCall(path: NodePath<BabelTypes.CallExpression>): boolean {
365+
const callee = path.get("callee");
366+
367+
// Check direct function calls like signal(), computed(), useSignal(), useComputed()
368+
if (callee.isIdentifier()) {
369+
const name = callee.node.name;
370+
return (
371+
name === "signal" ||
372+
name === "computed" ||
373+
name === "useSignal" ||
374+
name === "useComputed"
375+
);
376+
}
377+
378+
return false;
379+
}
380+
381+
function getVariableNameFromDeclarator(
382+
path: NodePath<BabelTypes.CallExpression>
383+
): string | null {
384+
// Walk up the AST to find a variable declarator
385+
let currentPath: NodePath | null = path;
386+
while (currentPath) {
387+
if (
388+
currentPath.isVariableDeclarator() &&
389+
currentPath.node.id.type === "Identifier"
390+
) {
391+
return currentPath.node.id.name;
392+
}
393+
currentPath = currentPath.parentPath;
394+
}
395+
return null;
396+
}
397+
398+
function hasNameInOptions(
399+
t: typeof BabelTypes,
400+
args: NodePath<
401+
| BabelTypes.Expression
402+
| BabelTypes.SpreadElement
403+
| BabelTypes.JSXNamespacedName
404+
| BabelTypes.ArgumentPlaceholder
405+
>[]
406+
): boolean {
407+
// Check if there's a second argument with a name property
408+
if (args.length >= 2) {
409+
const optionsArg = args[1];
410+
if (optionsArg.isObjectExpression()) {
411+
return optionsArg.node.properties.some(prop => {
412+
if (t.isObjectProperty(prop) && !prop.computed) {
413+
if (t.isIdentifier(prop.key, { name: "name" })) {
414+
return true;
415+
}
416+
if (t.isStringLiteral(prop.key) && prop.key.value === "name") {
417+
return true;
418+
}
419+
}
420+
return false;
421+
});
422+
}
423+
}
424+
return false;
425+
}
426+
427+
function injectSignalName(
428+
t: typeof BabelTypes,
429+
path: NodePath<BabelTypes.CallExpression>,
430+
variableName: string,
431+
filename: string | undefined
432+
): void {
433+
const args = path.get("arguments");
434+
435+
// Create enhanced name with filename and line number
436+
let nameValue = variableName;
437+
if (filename) {
438+
const baseName = basename(filename);
439+
const lineNumber = path.node.loc?.start.line;
440+
if (baseName && lineNumber) {
441+
nameValue = `${variableName} (${baseName}:${lineNumber})`;
442+
}
443+
}
444+
445+
const name = t.stringLiteral(nameValue);
446+
447+
if (args.length === 0) {
448+
// No arguments, add both value and options
449+
const nameOption = t.objectExpression([
450+
t.objectProperty(t.identifier("name"), name),
451+
]);
452+
path.node.arguments.push(t.identifier("undefined"), nameOption);
453+
} else if (args.length === 1) {
454+
// One argument (value), add options object
455+
const nameOption = t.objectExpression([
456+
t.objectProperty(t.identifier("name"), name),
457+
]);
458+
path.node.arguments.push(nameOption);
459+
} else if (args.length >= 2) {
460+
// Two or more arguments, modify existing options object
461+
const optionsArg = args[1];
462+
if (optionsArg.isObjectExpression()) {
463+
// Add name property to existing options object
464+
optionsArg.node.properties.push(
465+
t.objectProperty(t.identifier("name"), name)
466+
);
467+
} else {
468+
// Replace second argument with options object containing name
469+
const nameOption = t.objectExpression([
470+
t.objectProperty(t.identifier("name"), name),
471+
]);
472+
args[1].replaceWith(nameOption);
473+
}
474+
}
475+
}
476+
364477
function hasValuePropertyInPattern(pattern: BabelTypes.ObjectPattern): boolean {
365478
for (const property of pattern.properties) {
366479
if (BabelTypes.isObjectProperty(property)) {
@@ -381,24 +494,66 @@ try {
381494
STORE_IDENTIFIER.f();
382495
}`;
383496

497+
const debugTryCatchTemplate = template.statements(
498+
`var STORE_IDENTIFIER = HOOK_IDENTIFIER(HOOK_USAGE);
499+
try {
500+
if (window.__PREACT_SIGNALS_DEVTOOLS__) {
501+
window.__PREACT_SIGNALS_DEVTOOLS__.enterComponent(
502+
COMPONENT_NAME
503+
);
504+
}
505+
BODY
506+
} finally {
507+
STORE_IDENTIFIER.f();
508+
if (window.__PREACT_SIGNALS_DEVTOOLS__) {
509+
window.__PREACT_SIGNALS_DEVTOOLS__.exitComponent();
510+
}
511+
}`,
512+
{
513+
placeholderWhitelist: new Set([
514+
"STORE_IDENTIFIER",
515+
"HOOK_USAGE",
516+
"HOOK_IDENTIFIER",
517+
"BODY",
518+
"COMPONENT_NAME",
519+
"STORE_IDENTIFIER",
520+
]),
521+
placeholderPattern: false,
522+
}
523+
);
524+
384525
function wrapInTryFinally(
385526
t: typeof BabelTypes,
386527
path: NodePath<FunctionLike>,
387528
state: PluginPass,
388-
hookUsage: HookUsage
529+
hookUsage: HookUsage,
530+
componentName: string,
531+
isDebug: boolean
389532
): BabelTypes.BlockStatement {
390533
const stopTrackingIdentifier = path.scope.generateUidIdentifier("effect");
391534

392-
return t.blockStatement(
393-
tryCatchTemplate({
535+
if (isDebug) {
536+
const statements = debugTryCatchTemplate({
537+
COMPONENT_NAME: t.stringLiteral(componentName),
394538
STORE_IDENTIFIER: stopTrackingIdentifier,
395539
HOOK_IDENTIFIER: get(state, getHookIdentifier)(),
396540
HOOK_USAGE: hookUsage,
397541
BODY: t.isBlockStatement(path.node.body)
398-
? path.node.body.body // TODO: Is it okay to elide the block statement here?
542+
? path.node.body.body
399543
: t.returnStatement(path.node.body),
400-
})
401-
);
544+
});
545+
return t.blockStatement(statements);
546+
} else {
547+
const statements = tryCatchTemplate({
548+
STORE_IDENTIFIER: stopTrackingIdentifier,
549+
HOOK_IDENTIFIER: get(state, getHookIdentifier)(),
550+
HOOK_USAGE: hookUsage,
551+
BODY: t.isBlockStatement(path.node.body)
552+
? path.node.body.body
553+
: t.returnStatement(path.node.body),
554+
});
555+
return t.blockStatement(statements);
556+
}
402557
}
403558

404559
function prependUseSignals<T extends FunctionLike>(
@@ -426,7 +581,8 @@ function transformFunction(
426581
options: PluginOptions,
427582
path: NodePath<FunctionLike>,
428583
functionName: string | null,
429-
state: PluginPass
584+
state: PluginPass,
585+
filename: string
430586
) {
431587
const isHook = isCustomHookName(functionName);
432588
const isComponent = isComponentName(functionName);
@@ -440,7 +596,14 @@ function transformFunction(
440596

441597
let newBody: BabelTypes.BlockStatement;
442598
if (hookUsage !== UNMANAGED) {
443-
newBody = wrapInTryFinally(t, path, state, hookUsage);
599+
newBody = wrapInTryFinally(
600+
t,
601+
path,
602+
state,
603+
hookUsage,
604+
`${functionName || "Unknown"}:${basename(filename)}`,
605+
isComponent && !!options.experimental?.debug
606+
);
444607
} else {
445608
newBody = prependUseSignals(t, path, state);
446609
}
@@ -608,6 +771,17 @@ export interface PluginOptions {
608771
*/
609772
detectTransformedJSX?: boolean;
610773
experimental?: {
774+
/**
775+
* If set to true the plugin will inject names into all invocations of
776+
*
777+
* - computed/useComputed
778+
* - signal/useSignal
779+
*
780+
* these names hook into @preact/signals-debug.
781+
*
782+
* @default false
783+
*/
784+
debug?: boolean;
611785
/**
612786
* If set to true, the component body will not be wrapped in a try/finally
613787
* block and instead the next component render or a microtick will stop
@@ -673,7 +847,14 @@ export default function signalsTransform(
673847
}
674848

675849
if (shouldTransform(path, functionName, state.opts)) {
676-
transformFunction(t, state.opts, path, functionName, state);
850+
transformFunction(
851+
t,
852+
state.opts,
853+
path,
854+
functionName,
855+
state,
856+
this.filename || ""
857+
);
677858
log(true, path, functionName, this.filename);
678859
} else if (isComponentLike(path, functionName)) {
679860
log(false, path, functionName, this.filename);
@@ -719,6 +900,19 @@ export default function signalsTransform(
719900
setOnFunctionScope(path, containsJSX, true, this.filename);
720901
}
721902
}
903+
904+
// Handle signal naming
905+
if (options.experimental?.debug && isSignalCall(path)) {
906+
const args = path.get("arguments");
907+
908+
// Only inject name if it doesn't already have one
909+
if (!hasNameInOptions(t, args)) {
910+
const variableName = getVariableNameFromDeclarator(path);
911+
if (variableName) {
912+
injectSignalName(t, path, variableName, this.filename);
913+
}
914+
}
915+
}
722916
},
723917

724918
MemberExpression(path) {

0 commit comments

Comments
 (0)