Skip to content

Resolve consecutive WithoutEndTag tag helpers as siblings - #84771

Merged
chsienki merged 3 commits into
dotnet:mainfrom
chsienki:chsienki/fix-deferred-taghelper-consecutive-withoutendtag
Aug 6, 2026
Merged

Resolve consecutive WithoutEndTag tag helpers as siblings#84771
chsienki merged 3 commits into
dotnet:mainfrom
chsienki:chsienki/fix-deferred-taghelper-consecutive-withoutendtag

Conversation

@chsienki

@chsienki chsienki commented Aug 5, 2026

Copy link
Copy Markdown
Member

Summary

Consecutive tag helpers declared with TagStructure.WithoutEndTag and written without a self-closing slash (e.g. <meta-description><meta-keywords><head-custom>) only bind the first helper. The rest are emitted as literal, unprocessed markup.

This is a regression from the deferred tag helper lowering work (dotnet/razor#12957), which moved tag helper resolution after IR lowering. It was reported downstream as dotnet/razor#13212: a GrandNode app rendered a blank page (Uncaught ReferenceError: Vue is not defined) because the <head> script-registration tag helpers silently stopped running and the Vue bundle was never emitted.

Root cause

The HTML parser nests consecutive unclosed tags, so <a><b><c> parses as a > b > c. When DefaultTagHelperResolutionPhase.ResolveElement binds a as a StartTagOnly helper, it promotes a's parser-nested body children to be siblings inserted after a. But the element walker ResolveElements iterates children in reverse, so it has already moved past that position and never resolves the promoted siblings. They remain UnresolvedElementIntermediateNodes and are later unwrapped to literal HTML.

The ConvertToPlainElement path already re-resolves its promoted siblings; the direct ResolveElement StartTagOnly path was missing the equivalent step.

Fix

After promoting the StartTagOnly element's children to siblings, resolve them in place (in reverse, since resolving a promoted StartTagOnly sibling can itself insert further siblings). This mirrors the existing pattern in ConvertToPlainElementAndResolve.

Testing

Two new integration tests in TagHelpersIntegrationTest:

  • ConsecutiveWithoutEndTagTagHelpers_AllBind -- three consecutive WithoutEndTag helpers all bind.
  • MixedNestedStartTagOnlyAndHtmlTagHelpers_AllResolveCorrectly -- a tangled mix of a nestable helper, consecutive WithoutEndTag helpers, a normal helper, and real HTML (<section>, <div>); asserts every helper binds in document order and real markup is preserved.

Both fail without the fix (only the first helper binds) and pass with it (net10.0 and net472).

Note: this regression is also present in the shipping .NET 10 GA Razor compiler, so a servicing backport is likely warranted.

Microsoft Reviewers: Open in CodeFlow

When a StartTagOnly (TagStructure.WithoutEndTag) tag helper is bound, its
body children -- which the HTML parser nested underneath it because the tag
was left unclosed (`<a><b><c>` parses as a > b > c) -- are promoted to be
siblings of the tag helper. The element walker iterates children in reverse,
so it never revisits these newly inserted positions; the promoted siblings
were left as unresolved elements and later unwrapped to literal markup. As a
result only the first of a run of consecutive WithoutEndTag helpers bound,
and the rest were emitted as plain text.

Resolve the promoted siblings in place after the promotion, mirroring the
re-resolution already performed on the ConvertToPlainElement path. Resolution
runs in reverse because resolving a promoted StartTagOnly sibling can itself
insert further siblings after it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e4a482bf-ffc0-4a98-a673-863b5e84c6d8
Copilot AI review requested due to automatic review settings August 5, 2026 21:52
@chsienki
chsienki requested a review from a team as a code owner August 5, 2026 21:52
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 2 pipeline(s).
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI left a comment

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.

Pull request overview

This PR adjusts Razor’s post-lowering tag helper resolution so that consecutive elements bound as TagStructure.WithoutEndTag (i.e. StartTagOnly) are treated as siblings and subsequently resolved, instead of leaving later siblings unresolved and emitted as literal markup.

Changes:

  • Update DefaultTagHelperResolutionPhase.ResolveElement to immediately resolve nodes promoted to siblings when a StartTagOnly tag helper is bound.
  • Add integration coverage ensuring consecutive WithoutEndTag tag helpers all bind, including a mixed scenario with nested helpers and real HTML.
  • Extend the test helper CreateTagHelperDescriptor to allow specifying TagStructure in tag matching rules.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.cs Resolves promoted siblings in-place after StartTagOnly binding to ensure they can bind as tag helpers.
src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/IntegrationTests/TagHelpersIntegrationTest.cs Adds regression tests for consecutive/mixed WithoutEndTag tag helper binding and preserves HTML markup.

Comment on lines +211 to +214
if (parent.Children[j] is UnresolvedElementIntermediateNode promotedElement)
{
ResolveElement(parent, j, promotedElement, binder, prefix, usedHelpers, in context);
}
A StartTagOnly tag helper's promoted siblings live in the same container as
the helper, so they share its parent-tag context. Resolving them without that
context prevents parent-dependent bindings -- RequireParentTag rules and
component child-content matching -- from succeeding, so such a helper silently
fails to bind when it is reached only via promotion. Forward tagHelperParent
through the re-resolution so the promoted siblings bind against the correct
parent.

Strengthen the tests to assert the promoted helpers are genuine siblings
rather than nested (each WithoutEndTag helper has no tag-helper descendants),
and add a test where a RequireParentTag helper only binds through the promoted
path -- it fails without the forwarded context.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e4a482bf-ffc0-4a98-a673-863b5e84c6d8
Copilot AI review requested due to automatic review settings August 5, 2026 22:23

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Capture the entire intermediate-node tree for the tangled promotion scenario
via a .ir.txt baseline. Unlike the flattened FindDescendantNodes assertions,
the baseline pins the exact tree shape: the WithoutEndTag helpers resolve as
flat siblings inside the containing tag helper (each with an empty body) and
the interleaved real HTML stays as literal markup.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e4a482bf-ffc0-4a98-a673-863b5e84c6d8
Copilot AI review requested due to automatic review settings August 5, 2026 22:35

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

@chsienki
chsienki enabled auto-merge (squash) August 5, 2026 22:47
@chsienki

chsienki commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

/backport release/stable

@chsienki
chsienki merged commit 985209c into dotnet:main Aug 6, 2026
25 checks passed
@chsienki

chsienki commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

/backport to release/stable

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Started backporting to release/stable (link to workflow run)

chsienki added a commit that referenced this pull request Aug 7, 2026
## Summary

Consecutive tag helpers declared with `TagStructure.WithoutEndTag` and
written without a self-closing slash (e.g.
`<meta-description><meta-keywords><head-custom>`) only bind the
**first** helper. The rest are emitted as literal, unprocessed markup.

This is a regression from the deferred tag helper lowering work
(dotnet/razor#12957), which moved tag helper resolution after IR
lowering. It was reported downstream as
[dotnet/aspnetcore#68193](https://github.com/dotnet/aspnetcore/issues/68193):
a GrandNode app rendered a blank page (`Uncaught ReferenceError: Vue is
not defined`) because the `<head>` script-registration tag helpers
silently stopped running and the Vue bundle was never emitted.

## Root cause

The HTML parser nests consecutive unclosed tags, so `<a><b><c>` parses
as `a > b > c`. When `DefaultTagHelperResolutionPhase.ResolveElement`
binds `a` as a `StartTagOnly` helper, it promotes `a`'s parser-nested
body children to be **siblings** inserted *after* `a`. But the element
walker `ResolveElements` iterates children in reverse, so it has already
moved past that position and never resolves the promoted siblings. They
remain `UnresolvedElementIntermediateNode`s and are later unwrapped to
literal HTML.

The `ConvertToPlainElement` path already re-resolves its promoted
siblings; the direct `ResolveElement` `StartTagOnly` path was missing
the equivalent step.

## Fix

After promoting the `StartTagOnly` element's children to siblings,
resolve them in place (in reverse, since resolving a promoted
`StartTagOnly` sibling can itself insert further siblings). This mirrors
the existing pattern in `ConvertToPlainElementAndResolve`.

## Testing

Two new integration tests in `TagHelpersIntegrationTest`:

- `ConsecutiveWithoutEndTagTagHelpers_AllBind` -- three consecutive
`WithoutEndTag` helpers all bind.
- `MixedNestedStartTagOnlyAndHtmlTagHelpers_AllResolveCorrectly` -- a
tangled mix of a nestable helper, consecutive `WithoutEndTag` helpers, a
normal helper, and real HTML (`<section>`, `<div>`); asserts every
helper binds in document order and real markup is preserved.

Both fail without the fix (only the first helper binds) and pass with it
(net10.0 and net472).

> Note: this regression is also present in the shipping .NET 10 GA Razor
compiler, so a servicing backport is likely warranted.

###### Microsoft Reviewers: [Open in
CodeFlow](https://microsoft.github.io/open-pr/?codeflow=https://github.com/dotnet/roslyn/pull/84771)

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e4a482bf-ffc0-4a98-a673-863b5e84c6d8
chsienki added a commit that referenced this pull request Aug 10, 2026
…lings (#84782)

Backport of #84771 to release/stable

/cc @chsienki

## Customer Impact

## Regression

- [x] Yes
- [ ] No

dotnet/razor#12957

## Testing

Added unit tests. CTI caught the regression in .NET11 P7, but it existed
as early as P4. Unsure why it wasn't caught earlier.


## Risk

Low: an extra code path that only runs for this specific scenario, not
able to regress other previously working paths. Added tests to cover,
and all previous tests pass.

###### Microsoft Reviewers: [Open in
CodeFlow](https://microsoft.github.io/open-pr/?codeflow=https://github.com/dotnet/roslyn/pull/84782)

---------

Co-authored-by: Chris Sienkiewicz <chsienki@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e4a482bf-ffc0-4a98-a673-863b5e84c6d8
gunndabad added a commit to DFE-Digital/teaching-record-system that referenced this pull request Aug 13, 2026
### Context

Every `Tests (SupportUi.EndToEndTests)` run on CI has failed since
2026-08-11 20:40. It looks like flakiness — 13 of 142 tests fail, the
rest pass — but the split is deterministic: the failures are exactly the
tests that touch an accessible-autocomplete field (all 6 in
`AddRouteToProfessionalStatusTests`, the 5 autocomplete ones in
`EditRouteToProfessionalStatusTests`, plus `AddMq` and
`EditMqProvider`).

`global.json` pinned `"version": "10"`, which floats to any 10.x SDK, so
CI started using **SDK 10.0.400** the day it was released (2026-08-11) —
exactly when the failures began.

**Root cause (upstream):** on 10.0.400, only the *first* tag-helper
element inside a `@section` block is bound. Every subsequent element in
that section is emitted as literal markup, so `~/` is never resolved.
The build succeeds with no warning.

In `SupportUi/Pages/Shared/_Layout.cshtml`, `@section Head` opens with
`<meta name="htmx-config" …>` — that one binds, and everything after it
does not:

| SDK | Generated | Rendered |
| --- | --- | --- |
| 10.0.301 / 10.0.303 | `CreateTagHelper<UrlResolutionTagHelper>()` |
`<link href="/app.657ritalq7.css">` |
| 10.0.400 | `WriteLiteral("~/app.css")` | `<link href="~/app.css">` |

The browser resolves the literal `~/…` relative to the current page, so
`/routes/add/~/Components/accessible-autocomplete.min.js` 404s,
`accessibleAutocomplete` is undefined, the page's `window.onload`
handler throws, and the `<select>` is never enhanced into `input#{id}` —
which is what the tests wait for.

**Upstream tracking:**
[dotnet/razor#13216](dotnet/razor#13216),
[#13217](dotnet/razor#13217),
[#13218](dotnet/razor#13218) — all closed as
fixed by
[dotnet/roslyn#84771](dotnet/roslyn#84771),
backported to the 10.0.4xx band in
[dotnet/roslyn#84782](dotnet/roslyn#84782). The
fix has **not shipped yet** — 10.0.400 is still the only 10.0.4xx SDK
released.

### Changes proposed in this pull request

Pin `global.json` to the 10.0.3xx feature band:

```json
{ "sdk": { "version": "10.0.300", "rollForward": "latestPatch" } }
```

10.0.303 shipped the same day as 10.0.400 on the **same 10.0.11
runtime**, so this costs nothing but the SDK feature band — we stay on
the current runtime and keep getting patch updates within 10.0.3xx.

Since the upstream fix is already merged and backported, this pin should
be short-lived: drop it once a 10.0.4xx containing roslyn#84782 is
released.

### Guidance to review

**Exact blast radius.** I built both web projects under 10.0.400 with
`EmitCompilerGeneratedFiles` and grepped the generated code. Affected: 5
URLs, all in `SupportUi/Pages/Shared/_Layout.cshtml` (`app.css`, both
`accessible-autocomplete.min.*`, `moj-frontend-9.0.0.min.css`,
`htmx.min.js`). **AuthorizeAccess is not affected** — in both of its
`@section` blocks the `~/` element happens to be the first tag helper,
so it still binds. No `asp-*` attributes leak anywhere in either
project.

**This is a production bug, not just a test bug.** SupportUi built with
10.0.400 serves a page with no CSS and no working JS. We're only safe
today because the `Dockerfile` pins the SDK image by digest (unchanged
since June, so still 10.0.3xx). When dependabot next bumps that digest
past 10.0.400, the build would have silently shipped broken assets —
with this pin it fails loudly at build time instead.

**Narrower fixes were tried and rejected:**

- Adding `asp-append-version="true"` to force a tag-helper binding — no
effect, because the element still isn't the *first* tag helper in the
section. This is what ruled out a per-element workaround.
- Rewriting `~/` to `/` — works, but loses static-asset fingerprinting,
and would need re-checking against every future SDK.

### Verification

- Reproduced locally by installing SDK 10.0.400 — the same 6
`AddRouteToProfessionalStatusTests` failed with the identical Playwright
errors seen on CI.
- Confirmed SDK 10.0.303 renders the URLs correctly, and that the pin
rejects 10.0.400.
- `just build` — 0 errors.
- All 142 `SupportUi.EndToEndTests` pass.
- `just format-changed` — no changes.

### Checklist

-   [ ] Attach to Trello card
-   [x] Rebased master
-   [x] Cleaned commit history
-   [x] Tested by running locally

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@jjonescz jjonescz added this to the 18.11 milestone Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants