Skip to content

Transform logic to extract signal debug names#57348

Closed
AleksanderBodurri wants to merge 1 commit intoangular:mainfrom
AleksanderBodurri:signal-debug-name-transform
Closed

Transform logic to extract signal debug names#57348
AleksanderBodurri wants to merge 1 commit intoangular:mainfrom
AleksanderBodurri:signal-debug-name-transform

Conversation

@AleksanderBodurri
Copy link
Copy Markdown
Member

@AleksanderBodurri AleksanderBodurri commented Aug 12, 2024

Implements a compiler transform that attempts to statically analyze variable names and apply them to usages of signal functions like signal, computed, effect, etc as debug names.

Related: #57074

Depends on: #57073

@AleksanderBodurri AleksanderBodurri added state: blocked area: compiler Issues related to `ngc`, Angular's template compiler labels Aug 12, 2024
@ngbot ngbot bot modified the milestone: Backlog Aug 12, 2024
@pullapprove pullapprove bot added requires: TGP This PR requires a passing TGP before merging is allowed labels Aug 12, 2024
@angular-robot angular-robot bot added detected: feature PR contains a feature commit area: core Issues related to the framework runtime labels Aug 12, 2024
Comment thread packages/compiler-cli/src/ngtsc/transform/src/debug_transform.ts Outdated
Comment thread packages/compiler-cli/src/ngtsc/transform/src/debug_transform.ts Outdated
Comment thread packages/compiler-cli/src/ngtsc/transform/src/debug_transform.ts Outdated
Comment thread packages/compiler-cli/src/ngtsc/transform/src/debug_transform.ts Outdated
Comment thread packages/compiler-cli/src/ngtsc/transform/src/debug_transform.ts Outdated
Comment thread packages/compiler-cli/src/ngtsc/transform/src/debug_transform.ts Outdated
Comment thread packages/compiler-cli/test/ngtsc/debug_transform_spec.ts Outdated
Comment thread packages/compiler-cli/test/ngtsc/debug_transform_spec.ts Outdated
Copy link
Copy Markdown
Member

@josephperrott josephperrott left a comment

Choose a reason for hiding this comment

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

LGTM

@AleksanderBodurri AleksanderBodurri force-pushed the signal-debug-name-transform branch from c3d0bf5 to fbbc8c0 Compare August 26, 2024 05:57
Copy link
Copy Markdown
Member

@alxhub alxhub left a comment

Choose a reason for hiding this comment

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

First round of comments!

Additionally, in the commit history, use refactor commits for implementation details. A given feature (debug names for example) should have exactly 1 feat commit, which is the commit which actually makes the feature available for end users to use.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same comment about ts.factory.update*.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is still a bit sketchy to me. This creates a node without any context (e.g. sourcemaps).

Ideally, we wouldn't have to copy the user's code like this, but we should still use ts.update* instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We discussed this briefly in the DevTools sync today. We can definitely update this object literal to just be existingArgument. How should this work for the duplicated node though?

We're effectively converting signal(0, { equals }) to signal(0, ...(ngDevMode ? [{ debugName: 'test', equals }] : [{ equals }])). That moves { equals } to the false case, but also duplicate its in the true case. I presume we just need to ts.factory.create*(...) for the true case and can't update the existing one because we need it for the false case?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Tested over the weekend with ng new and the compiler cli code with the transform. Source maps are working as expected even with the transformation applied. I think this is working as expected now

@angular-robot angular-robot bot added area: core Issues related to the framework runtime and removed area: compiler Issues related to `ngc`, Angular's template compiler area: core Issues related to the framework runtime labels Sep 30, 2024
@ngbot ngbot bot modified the milestone: Backlog Sep 30, 2024
Comment thread packages/compiler-cli/test/ngtsc/debug_transform_spec.ts Outdated
Comment thread packages/compiler-cli/test/ngtsc/debug_transform_spec.ts Outdated
@AleksanderBodurri AleksanderBodurri force-pushed the signal-debug-name-transform branch from 252d2cd to 5f59f91 Compare October 19, 2024 19:34
@angular-robot angular-robot bot added area: core Issues related to the framework runtime and removed area: core Issues related to the framework runtime labels Oct 19, 2024
@ngbot ngbot bot added this to the Backlog milestone Jan 9, 2025
@AleksanderBodurri AleksanderBodurri force-pushed the signal-debug-name-transform branch from dc33c27 to 9c8ba7d Compare January 10, 2025 15:45
@angular-robot angular-robot bot added area: compiler-cli and removed area: compiler Issues related to `ngc`, Angular's template compiler labels Jan 10, 2025
@thePunderWoman thePunderWoman added area: compiler Issues related to `ngc`, Angular's template compiler and removed area: compiler-cli labels Jan 10, 2025
@AleksanderBodurri AleksanderBodurri force-pushed the signal-debug-name-transform branch from 9c8ba7d to 9d25903 Compare January 13, 2025 20:11
@dgp1130
Copy link
Copy Markdown
Contributor

dgp1130 commented Jan 15, 2025

I ran a presubmit and got some duplicate message errors. Debugging further, I see a problem with how we're generating this code.

Context

Internally, i18n messages outside of Angular templates are defined via Closure's goog.getMsg API (roughly equivalent to Angular's $localize).

/** @desc Say hello. */
const MSG_HELLO = goog.getMsg('Hello, Alex!');
console.log(MSG_HELLO); // 'Hello, Alex!' (or localized equivalent)

It's somewhat reasonable to parameterize such messages in a computed like so:

const name = signal('Alex');
const hello = computed(() => {
  /** @desc Say hello. */
  const MSG_HELLO = goog.getMsg('Hello, {$name}', {name: name()});
  return MSG_HELLO;
});

Bug

Running the transform on this, the body of the computed gets duplicated like so:

const name = signal(...(ngDevMode ? ['Alex', {debugName: 'name'}] : ['Alex']));
const hello = computed(...(ngDevMode ? [
  () => {
    /** @desc Say hello. */
    const MSG_HELLO = goog.getMsg('Hello, {$name}', {name: name()});
    return MSG_HELLO;
  },
  {debugName: 'hello'},
] : [
  () => {
    /** @desc Say hello. */
    const MSG_HELLO = goog.getMsg('Hello, {$name}', {name: name()});
    return MSG_HELLO;
  },
]);

Note that we now have two goog.getMsg calls. Closure Compiler extracts all i18n messages in the bundle by analyzing the AST, meaning it now extracts two MSG_HELLO symbols and errors because of the conflict. It would not be able to disambiguate between the two. I wonder if $localize might actually have a similar problem. 🤔

We could ask Closure Compiler to be a little more lenient here, as the two messages are literally identical, it could probably make this work. However I do expect that erroring on duplicate messages is a generally reasonable thing for the compiler to do and I wouldn't want to turn that off just because of this case. Looking at this code it is also clear that we're duplicating a significant amount of code in the computed expression and dev builds typically run uncompiled, meaning both branches are shipped to the browser (even if only one is executed), so this is a potentially significant performance regression for developers, even if it ultimately gets compiled out for production users.

I remember we had some discussion about how safe it was to duplicate the expression in a signal and whether that might produce duplicated side effects. I think we ultimately decided it didn't because the ternary short circuits and only the true branch gets evaluated at runtime. However I think this issue makes it clear that duplicating the code at all, even if never executed, is still a potential issue.

Solution 1

I think the path forward here is to avoid duplicating the expression by putting it in a variable used in both branches of the ngDevMode ternary. My initial attempt was to use an IIFE using an inlined parameter like so:

const name = signal(...(ngDevMode ? ['Alex', {debugName: 'name'}] : ['Alex']));
const hello = computed(...(
  (msg) => ngDevMode
    ? [msg, {debugName: 'hello'}]
    : [msg]
)(() => {
  /** @desc Say hello. */
  const MSG_HELLO = goog.getMsg('Hello, {$name}', {name: name()});
  return MSG_HELLO;
});

This keeps a single definition of the function and only one goog.getMsg while ultimately evaluating to the same thing based on ngDevMode.

The problem with this is that it might be just a little too complicated an expression for bundlers. Testing this out I see:

// Ideally:
computed(() => "Hello, Alex!");

// Closure Compiler:
computed(() => "Hello, Alex!");

// Angular CLI: esbuild + terser
computed(...(t=>[t])(()=>"Hello, Alex!"));

So Closure does seem smart enough to make this work, but esbuild and Terser are coming up a little short. I suspect the problem is that esbuild/Terser don't want to inline t in the IIFE because if t is used multiple times the expression would be duplicated. It's only safe to inline that variable because it's only used once in t=>[t]. Based on that, we can maybe rework this to explicitly put the computed function into a variable so the bundler doesn't need to inline an IIFE with a parameter.

Solution 2

const hello = (() => {
  const x = () => {
    /** @desc Say hello. */
    const MSG_HELLO = goog.getMsg('Hello, Alex!');
    return MSG_HELLO;
  };
  return computed(...(ngDevMode ? [x, {debugName: 'hello'}] : [x]);
})();

This outputs:

// Closure Compiler:
(() => computed(() => "Hello world"))();

// Angular CLI: esbuild + Terser
computed(()=>"Hello, World!");

So now we've got an extra IIFE in Closure which is failing to be inlined. It's a little better than this doesn't have a spread operator in the computed call, which is likely a runtime perf deoptimization as the engine probably needs to do extra work to expand the array.

I'm not sure why Closure Compiler can't inline that IIFE, especially since it inlined a much more complicated IIFE in my previous attempt. I'll ask around and see if we can find a pattern which optimizes ideally for both sets of bundlers.

@dgp1130
Copy link
Copy Markdown
Contributor

dgp1130 commented Jan 15, 2025

Hmm, retrying the second solution I'm actually now seeing that it outputs computed(() => 'Hello, Alex!') in both cases, I'm not sure why my initial testing came up different.

If that's reliable then it seems like that transform would be valid path forward, I'll keep an eye out if I can replicate the extra IIFE I saw previously.

@AleksanderBodurri AleksanderBodurri force-pushed the signal-debug-name-transform branch 2 times, most recently from 17ba062 to 216e2c1 Compare February 18, 2025 15:28
@dgp1130 dgp1130 force-pushed the signal-debug-name-transform branch from 216e2c1 to c86bb3b Compare February 18, 2025 19:15
@dgp1130
Copy link
Copy Markdown
Contributor

dgp1130 commented Feb 20, 2025

Global presubmit is fully passing 🎉, so I think this output format is looking good.

http://test/OCL:728286513:BASE:728422677:1739934312484:4bfbb98b

@AleksanderBodurri AleksanderBodurri force-pushed the signal-debug-name-transform branch 8 times, most recently from 43e0707 to 850cea2 Compare March 3, 2025 15:54
Copy link
Copy Markdown
Contributor

@dgp1130 dgp1130 left a comment

Choose a reason for hiding this comment

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

Thanks for working through all these changes, it's looking great!

No major concerns on my side, just a few minor suggestions and questions. I think a couple existing comments are still open too when you get a chance.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Question: @alxhub, do we care that this is using the type checker to resolve to the imported symbol?

I know we're trying to avoid the type checker for single-file transpilations, so I'm wondering if there's a better way or if this is ok because we're keeping it within the source file.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Question: What exactly does it mean to have multiple declarations? Are these shadowed declarations like:

import { signal } from '@angular/core';

function foo() {
  const signal = () => {};
  console.log(signal); // Has two declarations?
}

Copy link
Copy Markdown
Member

@alxhub alxhub Mar 12, 2025

Choose a reason for hiding this comment

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

No, multiple declarations would be something like:

interface Foo {
  bar: string;
}

interface Foo {
  baz: number;
}

Multiple declarations really only applies to types. When looking for the declaration of a value, you can use symbol.valueDeclaration instead, since a value can only ever be defined once.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is it safe to just look at the first declaration? I guess if it is a real signal function then it will always have just one declaration from the import?

@dgp1130
Copy link
Copy Markdown
Contributor

dgp1130 commented Apr 23, 2025

@dgp1130
Copy link
Copy Markdown
Contributor

dgp1130 commented May 14, 2025

…om signal functions

Implements a compiler transform that attempts to statically analyze variable names and apply them to usages of signal functions like signal, computed, effect, etc.
@kirjs
Copy link
Copy Markdown
Contributor

kirjs commented Jun 4, 2025

This PR was merged into the repository by commit 3a9a70d.

The changes were merged into the following branches: main

@angular-automatic-lock-bot
Copy link
Copy Markdown

This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

action: merge The PR is ready for merge by the caretaker area: compiler Issues related to `ngc`, Angular's template compiler area: devtools target: minor This PR is targeted for the next minor release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants