Skip to content

Deferred tag helper lowering#12935

Draft
chsienki wants to merge 10 commits intodotnet:mainfrom
chsienki:deferred-tag-helper-lowering
Draft

Deferred tag helper lowering#12935
chsienki wants to merge 10 commits intodotnet:mainfrom
chsienki:deferred-tag-helper-lowering

Conversation

@chsienki
Copy link
Member

Summary

This PR introduces deferred tag helper lowering — a new compiler architecture that separates syntax-tree-to-IR lowering from tag helper binding resolution.

Motivation

Previously, the lowering phase needed to know whether an element was a tag helper during lowering, which required running the tag helper rewrite phase first on the syntax tree. This tightly coupled lowering to the rewritten syntax tree and made it difficult to reason about the pipeline.

The new architecture runs lowering before resolution: the lowering phase produces deferred \ElementOrTagHelperIntermediateNode\ and attribute nodes that store all necessary information (tag names, attribute metadata, source spans, pre-lowered HTML fallback forms). A new \DefaultTagHelperResolutionPhase\ then resolves these deferred nodes into their final forms using only the IR — no syntax tree access needed.

Pipeline Change

\
Before: Discovery → Rewrite → Lowering → ...
After: Discovery → Lowering → Resolution → Rewrite → ...
\\

Commits

This PR is structured as a series of logical commits for easier review:

  1. FindDescendant<T> test helper — small utility used by later test changes
  2. Deferred intermediate node types — 5 new IR node classes that represent elements/attributes whose final form is deferred
  3. Refactor lowering phase — the core change: produce deferred nodes instead of immediately resolving tag helpers
  4. New resolution phase — \DefaultTagHelperResolutionPhase\ (~4K lines) that resolves deferred nodes into final TagHelperIntermediateNode or plain HTML
  5. Wire pipeline + downstream fixes — insert the new phase, update source generator tracking, fix diagnostic propagation
  6. Update existing tests — adapt tests for new phase ordering
  7. Update IR baselines — mechanical .ir.txt updates (no codegen/mapping/diagnostic changes)
  8. Regression tests — end-to-end source generator tests for real-world Blazor patterns (Router, @Bind, nested components)

What doesn't change

  • No .codegen.cs, .codegen.html, .mappings.txt, or .diagnostics.txt\ baselines are modified
  • Only .ir.txt\ baselines change, with minor span adjustments and token type changes

chsienki and others added 8 commits March 20, 2026 00:02
Add a depth-first search helper on RazorProjectEngineTestBase for
finding the first node of a specific type in an IR tree. This
replaces brittle .Children.OfType<T>().Single() LINQ chains in tests
with a robust recursive search that works regardless of IR tree shape
changes from the deferred lowering pipeline.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Introduce new IR node types that represent elements and attributes
whose final form (plain HTML vs. bound tag helper) is not yet
determined at lowering time. These are the data types that enable
separating lowering from tag helper resolution:

- ElementOrTagHelperIntermediateNode: an element that may or may not
  be a tag helper. Stores all syntax-tree-derived information (tag
  name, attributes, spans, component hints) directly on the node so
  the resolution phase never needs syntax tree access.

- MarkupOrTagHelperAttributeIntermediateNode: an attribute whose
  final form is deferred. Stores pre-lowered fallback forms for both
  the merged-value path (FallbackNode) and the full-attribute path
  (FallbackAttributeNode) to avoid re-accessing the syntax tree.

- MarkupOrTagHelperAttributeValueIntermediateNode: a literal
  attribute value that resolves to either CSharpIntermediateToken
  (bound non-string), HtmlContentIntermediateNode (bound string),
  or HtmlAttributeValueIntermediateNode (unbound).

- CSharpOrTagHelperExpressionAttributeValueIntermediateNode: a
  dynamic/expression attribute value (e.g. Value="@expr") that
  resolves differently based on tag helper binding.

- ElementBoundaryMarker: sentinel nodes (StartTagEnd, BodyEnd) that
  prevent HtmlContent merging across element boundaries during
  deferred lowering.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Instead of immediately resolving elements to their final HTML or tag
helper form, the lowering phase now produces deferred
ElementOrTagHelperIntermediateNode and
MarkupOrTagHelperAttributeIntermediateNode nodes. This separates
syntax-tree-to-IR lowering from tag-helper-binding resolution.

Key changes:

- Remove VisitMarkupTagHelperElement/StartTag/Attribute methods that
  previously built TagHelperIntermediateNode directly during lowering.
  That responsibility moves to the resolution phase.

- All elements that could potentially be tag helpers are now lowered
  as ElementOrTagHelperIntermediateNode with pre-computed attribute
  metadata (structure, source spans, attribute data) and pre-lowered
  HTML fallback forms stored on the deferred nodes.

- Add ElementBoundaryMarker nodes to prevent HtmlContent merging
  across start tag / body / end tag boundaries.

- Extract helpers: LowerSyntaxNode(), InferAttributeStructure(),
  LowerAttributeAsHtml() and make LooksLikeAComponentName() internal
  static so the resolution phase can reuse them.

The lowering phase no longer needs to know about tag helper binding.
It only needs to know the set of potential tag helper tag names (from
discovery) to decide whether to create deferred nodes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Introduce a new compiler phase that runs after lowering and before
the tag helper rewrite phase. It resolves the deferred
ElementOrTagHelperIntermediateNode and attribute nodes produced by
lowering into their final forms:

- Elements that match tag helpers become TagHelperIntermediateNode
  with fully resolved attributes and binding information.
- Elements that don't match are unwrapped to plain HTML markup nodes,
  using the pre-lowered fallback forms stored during lowering.

The phase works entirely with IR nodes — it never accesses the syntax
tree. All information needed for resolution (tag names, attribute
metadata, source spans, fallback forms) was pre-computed and stored
on the deferred nodes during lowering.

Key responsibilities:
- Tag helper binding via ITagHelperBinder
- Attribute conversion (bound properties, directive attributes,
  minimized attributes, splat, key, ref, rendermode, formname)
- Diagnostic production (RZ1034 malformed, RZ1035 missing angle
  bracket, RZ1042 void elements, RZ2011 inconsistent TagStructure,
  RZ10012 component name mismatch)
- Special handling for end-tag-only elements, escaped elements,
  self-closing and void elements

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Insert DefaultTagHelperResolutionPhase into the pipeline between
lowering and the tag helper rewrite phase. The new phase order is:

  Discovery -> Lowering -> Resolution -> Rewrite -> ...

Previously, lowering ran after the rewrite phase and consumed the
rewritten syntax tree. Now lowering runs first on the original syntax
tree, and resolution handles the tag helper binding on IR nodes.

Pipeline changes:
- RazorProjectEngine: insert resolution phase after lowering
- SourceGeneratorProjectEngine: track resolution phase index instead
  of rewrite phase index, and re-run from discovery+1 (including
  lowering) when tag helpers change, since lowering now produces
  deferred nodes that depend on discovery results

Downstream fixes:
- ComponentLoweringPass: transfer diagnostics from
  TagHelperIntermediateNode to ComponentChildContentIntermediateNode
  during child content rewriting, since diagnostics are now produced
  by the resolution phase and stored on the tag helper node
- ComponentTypeArgumentIntermediateNode: handle HtmlContent form of
  type argument values produced by the deferred lowering pipeline

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adapt tests to the new compiler pipeline where resolution runs after
lowering:

- NodeWriter tests (DesignTime, Runtime, TagHelperHtmlAttribute):
  update ExecutePhasesThrough to target DefaultTagHelperResolutionPhase
  instead of IRazorIntermediateNodeLoweringPhase, since code generation
  tests need fully resolved IR. Use FindDescendant<T>() helper instead
  of brittle .Children.OfType<T>().Single() chains.

- DefaultRazorTagHelperBinderPhaseTest: refactor from syntax-tree-based
  assertions to IR-based assertions. The binder phase now feeds into
  lowering+resolution, so tests verify the final TagHelperIntermediateNode
  output rather than the rewritten syntax tree. Add FindTagHelperNodes()
  and CollectTagHelperNodes() helpers for recursive IR traversal.

- RazorProjectEngineTest: update phase ordering assertion to reflect
  the new pipeline: Discovery -> Lowering -> Resolution -> Rewrite.

- DefaultRazorIntermediateNodeLoweringPhaseIntegrationTest: update
  phase endpoint.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Mechanical baseline updates resulting from the lowering/resolution
pipeline change. The deferred pipeline produces slightly different
IR in a few systematic ways:

- Source span adjustments: some spans shift by small amounts because
  the new pipeline computes attribute boundaries differently (e.g.
  including/excluding whitespace around tag names).

- LazyIntermediateToken -> IntermediateToken: some token nodes in
  tag helper attribute values change type because the resolution
  phase produces them directly rather than deferring to lazy
  evaluation.

- HtmlContent span changes: whitespace-only content nodes between
  tag helper attributes may lose source span information when
  produced as fallback during resolution.

No codegen (.codegen.cs, .codegen.html), mapping (.mappings.txt), or
diagnostic (.diagnostics.txt) baselines are changed — only .ir.txt.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add end-to-end compilation tests that exercise real-world Blazor
component patterns through the source generator pipeline. These
validate that the deferred tag helper lowering correctly handles
component nesting, implicit context variables, and generic type
inference scenarios that have been problematic:

- Router with CascadingAuthenticationState wrapper (the pattern
  from the default Blazor template that triggered CS0103 errors)
- @Bind with generic components (CS7036 missing argument errors)
- RenderFragment<T> implicit context variables in nested wrappers
- AuthorizeView with nested ChildContent and RenderFragment
- Various combinations of component nesting depth

These tests compile actual C# to verify no compiler errors are
produced, catching regressions that IR-level tests would miss.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@chsienki chsienki requested a review from a team as a code owner March 20, 2026 07:04
@chsienki chsienki marked this pull request as draft March 20, 2026 07:54
@davidwengier
Copy link
Member

davidwengier commented Mar 20, 2026

Finally a good candidate to try out some new Copilot PR review tooling! It reckons there is a regression:

DefaultTagHelperResolutionPhase drops parent-tag context for nested elements inside legacy tag-helper bodies, which breaks RequireParentTag(...) matching. The key line is the component-only handoff:
var tagHelperParentForBody = elementNode.IsComponent ? tagHelperNode : null;
For legacy tag helpers, that leaves nested body elements with no parent tag-helper context during binding.

Test failures seem to indicate it's on to something. I'm a couple of commits into my own review, but pausing here for the evening... or maybe the weekend :P

Bug 1 - ComponentBindLoweringPass crash:
TryExtractEventNodeStaticText and ExtractEventNodeExpression access
node.Children[0] without bounds checking. The deferred pipeline can
produce TagHelperDirectiveAttributeParameterIntermediateNode with zero
children (e.g., empty @Bind:event=""), causing IndexOutOfRangeException.
Fix: add node.Children.Count > 0 guard.

Bug 2 - Missing RZ10012 in miscellaneous files:
When no tag helpers are discovered (tagHelperContext is null or empty),
the early return path calls UnwrapAllElements but did not produce
RZ10012 diagnostics for component-like elements. This caused
CSharpAndRazor_MiscellaneousFile diagnostic tests to fail.
Fix: UnwrapAllElements now accepts DocumentIntermediateNode and produces
RZ10012 diagnostics, with a local LooksLikeAComponentName helper.

Bug 3 - Formatting regression (FormatNestedComponents2_Range):
RZ10012 diagnostic source spans covered the entire element (start tag
to end tag) via elementNode.Source, instead of just the start tag.
RazorFormattingService aborts range formatting when any diagnostic
overlaps the formatting range. For multi-line elements like
<ChildContent>...</ChildContent>, the full-element span overlapped
interior content, preventing formatting.
Fix: add StartTagSpan property to ElementOrTagHelperIntermediateNode,
populate it during lowering from BuildSourceSpanFromNode(node.StartTag),
and use it for RZ10012 diagnostics.

Source generator pipeline fix:
SourceGeneratorProjectEngine split point changed from Resolution phase
to Rewrite phase. The source generator processes files twice (declaration
+ generation), and the split must include the Rewrite phase in the
tag-helper processing stage to produce correct output.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

private ElementBoundaryMarker() { }

public override IntermediateNodeCollection Children { get => field ??= []; }
Copy link
Contributor

@ToddGrun ToddGrun Mar 20, 2026

Choose a reason for hiding this comment

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

field

Why use a backing field? Couldn't this just return IntermediateNodeCollection.ReadOnly? (this is done in all 5 new IR node classes)


protected static AttributeStructure InferAttributeStructure(MarkupAttributeBlockSyntax node)
{
if (node.EqualsToken.Kind == SyntaxKind.None && node.Value == null)
Copy link
Contributor

Choose a reason for hiding this comment

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

node.Value == null

The first condition doesn't imply the second?

Copy link
Contributor

Choose a reason for hiding this comment

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

never mind, it looks like this method was removed in commit 7

ComponentFileKindVisitor.VisitMarkupAttributeBlock was missing zero-length
value handling that the base LoweringVisitor had. When ValueChanged=> (empty
unquoted value) was lowered, ValueSourceSpan was null, causing the resolution
phase to create a CSharp token without a source mapping. WriteCSharpToken then
skipped the mapping entirely, which made TryGetPositionInfoAsync return Razor
instead of CSharp for the cursor position.

Fix: Add the same else/else-if branches from the base class to handle
zero-length values. For unbound HTML attributes (e.g. onsubmit=''), pass
null to CreateEmptyHtmlContent in the resolution phase so the source span
does not propagate to the fallback IR (matching the old pipeline output).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants