Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(core): exclude class attribute intended for projection matching f… #54800

Closed
wants to merge 4 commits into from

Conversation

JoostK
Copy link
Member

@JoostK JoostK commented Mar 10, 2024

…rom directive matching

This commit resolves a regression that was introduced when the compiler switched from TemplateDefinitionBuilder (TDB) to the template pipeline (TP) compiler. The TP compiler has changed the output of

if (false) { <div class="test"></div> }

from

defineComponent({
  consts: [['class', 'test'], [AttributeMarker.Classes, 'test']],
  template: function(rf) {
    if (rf & 1) {
      ɵɵtemplate(0, App_Conditional_0_Template, 2, 0, "div", 0)
    }
  }
});

to

defineComponent({
  consts: [[AttributeMarker.Classes, 'test']],
  template: function(rf) {
    if (rf & 1) {
      ɵɵtemplate(0, App_Conditional_0_Template, 2, 0, "div", 0)
    }
  }
});

The last argument to the ɵɵtemplate instruction (0 in both compilation outputs) corresponds with the index in consts of the element's attribute's, and we observe how TP has allocated only a single attribute array for the div, where there used to be two consts entries with TDB. Consequently, the ɵɵtemplate instruction is now effectively referencing a different attributes array, where the distinction between the "class" attribute vs. the AttributeMarker.Classes distinction affects the behavior: TP's emit causes the runtime to incorrectly match a directive with selector: '.foo' to be instantiated on the ɵɵtemplate instruction as if it corresponds with a structural directive!

Instead of changing TP to align with TDB's emit, this commit updates the runtime instead. This uncovered an inconsistency in selector matching for class names, where there used to be two paths dealing with class matching:

  1. The first check was commented to be a special-case for class matching, implemented in isCssClassMatching.
  2. The second path was part of the main class matching algorithm, where findAttrIndexInNode was being used to find the start position in tNode.attrs to match the selector's value against.

The second path only considers AttributeMarker.Classes values if matching for content projection, OR of the TNode is not an inline template. The special-case in 1. however does not make that distinction, so it would consider the AttributeMarker.Classes binding as a selector match, incorrectly causing a directive to match on the ɵɵtemplate itself.

The second path was also buggy for class bindings, as the return value of classIndexOf was incorrectly negated: it considered a matching class attribute as non-matching and vice-versa. This bug was not observable because of another issue, where the class-handling in part 2 was never relevant because of the special-case in part 1.

This commit separates path 1 entirely from path 2 and removes the buggy class-matching logic in part 2, as that is entirely handled by path 1 anyway. isCssClassMatching is updated to exclude class bindings from being matched for inline templates.

Fixes #54798

@JoostK JoostK added regression Indicates than the issue relates to something that worked in a previous version area: core Issues related to the framework runtime core: directive matching target: rc This PR is targeted for the next release-candidate risky Identifies a PR that has a level of risk to merging compiler: template pipeline labels Mar 10, 2024
@ngbot ngbot bot added this to the Backlog milestone Mar 10, 2024
@@ -51,14 +52,20 @@ function isCssClassMatching(
}
}
} else if (item === AttributeMarker.Classes) {
if (!isProjectionMode && isInlineTemplate(tNode)) {
Copy link
Member Author

Choose a reason for hiding this comment

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

This is the primary change to fix the bug.


const attrName = (mode & SelectorFlags.CLASS) ? 'class' : current;
} else {
const selectorAttrValue = selector[++i];
Copy link
Member Author

Choose a reason for hiding this comment

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

The below changes remove the class-matching logic from the path that uses findAttrIndexInNode to find a matching attribute, it was buggy (classIndexOf was incorrectly compared against !== -1, whereas it should have been === -1) and the logic is irrelevant because of the isCssClassMatching special-case (hence this was moved to its own else block entirely)

@JoostK JoostK force-pushed the core/directive-class-matching branch from 6515821 to 1c6781a Compare March 10, 2024 14:08
@JoostK JoostK modified the milestones: Backlog, v17.3 Mar 10, 2024
@JoostK JoostK marked this pull request as ready for review March 11, 2024 07:42
@JoostK JoostK added the action: review The PR is still awaiting reviews from at least one requested reviewer label Mar 11, 2024
@pullapprove pullapprove bot requested a review from crisbeto March 11, 2024 07:42
@JoostK
Copy link
Member Author

JoostK commented Mar 11, 2024

I pushed an additional commit that refactors the logic a bit, which I think makes it more clear but happy to drop it/move to a separate PR.

@dylhunn
Copy link
Contributor

dylhunn commented Mar 11, 2024

@JoostK Thank you so much for fixing this on the runtime side!

However, should we also try to get Template Pipeline to match the old TDB const array? It's a bit surprising to me that TP is not emitting ['class', 'test'].

Perhaps this PR resolves the potential issues around class selector matching, so it's not necessary to change the TP behavior?

@JoostK
Copy link
Member Author

JoostK commented Mar 11, 2024

However, should we also try to get Template Pipeline to match the old TDB const array? It's pretty surprising to me that TP is not emitting ['class', 'test'].

TP is only using ElementAttributes together with serializeAttributes to obtain the costs array. I haven't looked at TDB (it's gone 😝) but I suspect it differentiates between projection attributes on a template versus element attributes themselves.

I do appreciate how the runtime fix makes things more consistent and reduces code size, so from that perspective I don't think TP needs to revert to the TDB output. That said, I do recognise some risk involved with changing the runtime, but judging by the logic in findAttrIndexInNode I do believe it is the correct approach.

@JoostK
Copy link
Member Author

JoostK commented Mar 11, 2024

To elaborate on the risk, it's twofold:

  1. the runtime changes may impact existing patterns in an unexpected way, that is not discovered by our testing.
  2. there may be additional places in the runtime outside of the reported issue that are affected by the TP's output change.

Both are unknown unknowns and TGP would help confirm 1 is not an issue, but it won't help us find issues for 2 (as they'd been discovered during the rollout of TP earlier)

@pkozlowski-opensource
Copy link
Member

If we can run a TGP (I can do this during my day tomorrow) I think it would be reasonable to change the output - unless we uncover things.

Comment on lines +39 to +40
if (attrs[i] === 'class' &&
classIndexOf((attrs[i + 1] as string).toLowerCase(), cssClassToMatch, 0) !== -1) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Quick question: this line handles the case when we have class in the static attributes section (i.e. when consts array looks like this: ['class', 'a b']), do we still have this codepath in TP or we always process classes and generate [AttributeMarker.Classes, 'a', 'b']?

Copy link
Member Author

Choose a reason for hiding this comment

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

It's still present; I've tried removing this entirely but that causes a bunch of tests to fail. I don't know by heart which ones are affected.

There could potentially be additional cleanup to converge to AttributeMarker.Classes, not sure how feasible that is though.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for the reply. I've attempted to do the cleanup a while ago (via #39998), but didn't have a chance to land it due to the lack of time (and other priorities). Just wanted to leave a reference to that PR in case it might be useful somehow :)

Copy link
Member Author

Choose a reason for hiding this comment

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

That's an interesting PR, very similar in the changes on the runtime side except for dropping implicit class values entirely. I think you're on to something in that PR 😄

@pkozlowski-opensource pkozlowski-opensource added the requires: TGP This PR requires a passing TGP before merging is allowed label Mar 12, 2024
@pkozlowski-opensource
Copy link
Member

pkozlowski-opensource commented Mar 12, 2024

TGP and a de-flaked, green one

Copy link
Member

@pkozlowski-opensource pkozlowski-opensource left a comment

Choose a reason for hiding this comment

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

LGTM with one nit comment that I would love to see addressed.

…rom directive matching

This commit resolves a regression that was introduced when the compiler switched from
`TemplateDefinitionBuilder` (TDB) to the template pipeline (TP) compiler. The TP compiler
has changed the output of

```html
if (false) { <div class="test"></div> }
```

from

```ts
defineComponent({
  consts: [['class', 'test'], [AttributeMarker.Classes, 'test']],
  template: function(rf) {
    if (rf & 1) {
      ɵɵtemplate(0, App_Conditional_0_Template, 2, 0, "div", 0)
    }
  }
});
```

to

```ts
defineComponent({
  consts: [[AttributeMarker.Classes, 'test']],
  template: function(rf) {
    if (rf & 1) {
      ɵɵtemplate(0, App_Conditional_0_Template, 2, 0, "div", 0)
    }
  }
});
```

The last argument to the `ɵɵtemplate` instruction (0 in both compilation outputs) corresponds with
the index in `consts` of the element's attribute's, and we observe how TP has allocated only a single
attribute array for the `div`, where there used to be two `consts` entries with TDB. Consequently,
the `ɵɵtemplate` instruction is now effectively referencing a different attributes array, where the
distinction between the `"class"` attribute vs. the `AttributeMarker.Classes` distinction affects
the behavior: TP's emit causes the runtime to incorrectly match a directive with `selector: '.foo'` to
be instantiated on the `ɵɵtemplate` instruction as if it corresponds with a structural directive!

Instead of changing TP to align with TDB's emit, this commit updates the runtime instead. This uncovered
an inconsistency in selector matching for class names, where there used to be two paths dealing with
class matching:

1. The first check was commented to be a special-case for class matching, implemented in `isCssClassMatching`.
2. The second path was part of the main selector matching algorithm, where `findAttrIndexInNode` was being used
   to find the start position in `tNode.attrs` to match the selector's value against.

The second path only considers `AttributeMarker.Classes` values if matching for content projection, OR of the
`TNode` is not an inline template. The special-case in path 1 however does not make that distinction, so it
would consider the `AttributeMarker.Classes` binding as a selector match, incorrectly causing a directive to
match on the `ɵɵtemplate` itself.

The second path was also buggy for class bindings, as the return value of `classIndexOf` was incorrectly
negated: it considered a matching class attribute as non-matching and vice-versa. This bug was not observable
because of another issue, where the class-handling in part 2 was never relevant because of the special-case
in part 1.

This commit separates path 1 entirely from path 2 and removes the buggy class-matching logic in part 2, as
that is entirely handled by path 1 anyway. `isCssClassMatching` is updated to exclude class bindings from
being matched for inline templates.

Fixes angular#54798
@JoostK JoostK force-pushed the core/directive-class-matching branch from a7aa0ea to 36f9f37 Compare March 12, 2024 14:10
@JoostK JoostK added the merge: caretaker note Alert the caretaker performing the merge to check the PR for an out of normal action needed or note label Mar 12, 2024
@JoostK
Copy link
Member Author

JoostK commented Mar 12, 2024

caretaker note: although TGP is green for this PR, it's advised to sync on its own.

@JoostK JoostK added action: merge The PR is ready for merge by the caretaker and removed action: review The PR is still awaiting reviews from at least one requested reviewer labels Mar 12, 2024
The logic in `isCssClassMatching` is only interested in two areas in the attributes:
implicit attributes and the `AttributeMarker.Classes` area, with the first area only
of interest for projection matching, not directive matching. This commit splits these
two searches to make this more apparent.
@JoostK JoostK force-pushed the core/directive-class-matching branch from 36f9f37 to 9bf3c1a Compare March 12, 2024 15:31
@atscott atscott removed the requires: TGP This PR requires a passing TGP before merging is allowed label Mar 12, 2024
@atscott
Copy link
Contributor

atscott commented Mar 12, 2024

This PR was merged into the repository by commit edc1740.

@atscott atscott closed this in e6ee6d2 Mar 12, 2024
atscott pushed a commit that referenced this pull request Mar 12, 2024
…54800)

The logic in `isCssClassMatching` is only interested in two areas in the attributes:
implicit attributes and the `AttributeMarker.Classes` area, with the first area only
of interest for projection matching, not directive matching. This commit splits these
two searches to make this more apparent.

PR Close #54800
atscott pushed a commit that referenced this pull request Mar 12, 2024
…rom directive matching (#54800)

This commit resolves a regression that was introduced when the compiler switched from
`TemplateDefinitionBuilder` (TDB) to the template pipeline (TP) compiler. The TP compiler
has changed the output of

```html
if (false) { <div class="test"></div> }
```

from

```ts
defineComponent({
  consts: [['class', 'test'], [AttributeMarker.Classes, 'test']],
  template: function(rf) {
    if (rf & 1) {
      ɵɵtemplate(0, App_Conditional_0_Template, 2, 0, "div", 0)
    }
  }
});
```

to

```ts
defineComponent({
  consts: [[AttributeMarker.Classes, 'test']],
  template: function(rf) {
    if (rf & 1) {
      ɵɵtemplate(0, App_Conditional_0_Template, 2, 0, "div", 0)
    }
  }
});
```

The last argument to the `ɵɵtemplate` instruction (0 in both compilation outputs) corresponds with
the index in `consts` of the element's attribute's, and we observe how TP has allocated only a single
attribute array for the `div`, where there used to be two `consts` entries with TDB. Consequently,
the `ɵɵtemplate` instruction is now effectively referencing a different attributes array, where the
distinction between the `"class"` attribute vs. the `AttributeMarker.Classes` distinction affects
the behavior: TP's emit causes the runtime to incorrectly match a directive with `selector: '.foo'` to
be instantiated on the `ɵɵtemplate` instruction as if it corresponds with a structural directive!

Instead of changing TP to align with TDB's emit, this commit updates the runtime instead. This uncovered
an inconsistency in selector matching for class names, where there used to be two paths dealing with
class matching:

1. The first check was commented to be a special-case for class matching, implemented in `isCssClassMatching`.
2. The second path was part of the main selector matching algorithm, where `findAttrIndexInNode` was being used
   to find the start position in `tNode.attrs` to match the selector's value against.

The second path only considers `AttributeMarker.Classes` values if matching for content projection, OR of the
`TNode` is not an inline template. The special-case in path 1 however does not make that distinction, so it
would consider the `AttributeMarker.Classes` binding as a selector match, incorrectly causing a directive to
match on the `ɵɵtemplate` itself.

The second path was also buggy for class bindings, as the return value of `classIndexOf` was incorrectly
negated: it considered a matching class attribute as non-matching and vice-versa. This bug was not observable
because of another issue, where the class-handling in part 2 was never relevant because of the special-case
in part 1.

This commit separates path 1 entirely from path 2 and removes the buggy class-matching logic in part 2, as
that is entirely handled by path 1 anyway. `isCssClassMatching` is updated to exclude class bindings from
being matched for inline templates.

Fixes #54798

PR Close #54800
atscott pushed a commit that referenced this pull request Mar 12, 2024
…54800)

The logic in `isCssClassMatching` is only interested in two areas in the attributes:
implicit attributes and the `AttributeMarker.Classes` area, with the first area only
of interest for projection matching, not directive matching. This commit splits these
two searches to make this more apparent.

PR Close #54800
@angular-automatic-lock-bot
Copy link

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.

@angular-automatic-lock-bot angular-automatic-lock-bot bot locked and limited conversation to collaborators Apr 12, 2024
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: core Issues related to the framework runtime compiler: template pipeline core: directive matching merge: caretaker note Alert the caretaker performing the merge to check the PR for an out of normal action needed or note regression Indicates than the issue relates to something that worked in a previous version risky Identifies a PR that has a level of risk to merging target: rc This PR is targeted for the next release-candidate
Projects
None yet
5 participants