Skip to content

Sonic 3: Partial Declarations - #83681

Closed
chsienki wants to merge 10 commits into
dotnet:features/sonicfrom
chsienki:sonic/3_partial_decls
Closed

chsienki wants to merge 10 commits into
dotnet:features/sonicfrom
chsienki:sonic/3_partial_decls

Conversation

@chsienki

@chsienki chsienki commented May 13, 2026 •

Copy link
Copy Markdown
Member

This is the last of the really big sonic PRs, and despite the file count, I promise this one is actually smaller than the previous one.

This PR splits the generation of razor components into two files: a declaration file and an implementation file. The declaration is just the public API shape of the component (broadly, the stuff inside a @code block). The implementation file contains the BuildRenderTree method (broadly, the html/razor stuff in a file).

The key insight is that we can generate the declaration of a component without needing to know what tag helpers the file uses, as they are only used inside the BuildRenderTree method. This PR doesn't take advantage of that yet, it just generates the decl, then immediately generates the impl, but they are in different stages so that we can move it earlier in the next PR.

One wrinkle is right now, due to some code being shared between the two files, if there is a diagnostic on the shared code you end up with duplicate diagnostics (one from each file). It's really, really expensive to fix that until we've done some of the structural moves in the future PR, so instead I've opted to just call it out with a //PROTOTYPE comment. As such this PR can't go into main as it's not a finished product. I've made a feature branch where we can merge this PR, then merge the follow ups onto, to address the //PROTOTYPE comments. I think Roslyn even has a CI check that there aren't //PROTOTYPE comments in main, so we can't accidentally merge until its done.

I highly recommend a commit-by-commit review of this PR. I've tried to structure it so that it follows logically with each step building on the one before.

Most of the 1000+ changes in this PR are just test baseline updates. Rather than update them at each commit and make noisy diff's, I've just updated 1 or 2 representative tests with each change so you can see the difference without having to crawl through 100s of files. The very final commit just updates all the baselines to match, so feel free to just skim that, there shouldn't be anything new in them that you didn't already see in earlier commits.

CI Toolset build: https://dev.azure.com/dnceng/internal/_build/results?buildId=2974324&view=results

Microsoft Reviewers: Open in CodeFlow

chsienki and others added 2 commits May 12, 2026 14:11
Threads a second optional RazorCSharpDocument? _declCSharpDocument
field through the constructor, the Create factory, and every existing
With* method, and exposes internal GetDeclCSharpDocument() and
WithDeclCSharpDocument(RazorCSharpDocument).

Pure infrastructure: the slot is null today. The upcoming
DefaultRazorDeclCSharpLoweringPhase (Sonic part 3) will populate it
for components.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Lifts WriteDocument and the private Visitor nested class out of
DefaultRazorCSharpLoweringPhase into a new internal static
RazorCSharpDocumentWriter so neither C# lowering phase owns it. The
impl phase shrinks back to a thin wrapper that calls
RazorCSharpDocumentWriter.Write(...).

While moving, the entry point gains an explicit DocumentIntermediateNode
parameter (instead of fetching it from the codeDocument internally) so
callers can lower a tree they have already prepared -- the upcoming
DefaultRazorDeclCSharpLoweringPhase needs that to write the decl half
from a tree it has stripped of the primary render method.

Pure refactor: no semantic change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 13, 2026 21:36
@chsienki
chsienki requested a review from a team as a code owner May 13, 2026 21:36

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.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

@chsienki
chsienki marked this pull request as draft May 13, 2026 23:41
chsienki and others added 8 commits May 13, 2026 18:56
Adds bool reportDiagnostics = true parameters to the CodeRenderingContext
constructor and RazorCSharpDocumentWriter.Write entry point. When false,
the constructor skips collecting diagnostics from documentNode via
GetAllDiagnostics(); when true (the default), behavior is unchanged.

Pure infrastructure for the upcoming DefaultRazorDeclCSharpLoweringPhase
(Sonic part 3). The decl write needs to suppress diagnostic collection
so any diagnostics attached to nodes shared between decl + impl synthetic
trees aren't reported twice; the impl write keeps the default true.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds an internal IsSynthesizedHelper init-only bool on the base
IntermediateNode so any node type can be flagged as compiler plumbing
rather than content derived from user-authored source.

ComponentRenderModeDirectivePass sets it on both nodes it synthesizes:
the __PrivateComponentRenderModeAttribute helper class and the
[__PrivateComponentRenderModeAttribute] decoration it adds to the
primary class. Together this lets the upcoming decl/impl partial-file
split route both nodes to the impl half so they stay colocated -- which
keeps the file-scoped variant (Razor 11 + generic + C# 11) in a single
file and keeps the nested variant'"'"'s decoration with its definition.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds DefaultRazorDeclCSharpLoweringPhase, registered immediately before
DefaultRazorCSharpLoweringPhase. For Razor components whose primary method
body is not being suppressed, the phase produces just the decl half --
the user'"'"'s component API surface, free of render-method body and any
compiler-synthesized plumbing -- by constructing a synthetic
DocumentIntermediateNode spine that shares children with the original
tree by reference. The original IR tree is never mutated, so callers
that walk it (IDE/cohosting code paths like ComponentNamespaceMatches
and ExtractToCodeBehindCodeActionResolver) continue to see the
pre-split shape.

The decl half is everything reachable from the user'"'"'s component API
surface: the partial class declaration with type parameters, base type,
interfaces, user-authored class-level attributes, properties, fields,
parameters, inject members, sibling methods, and document-level
metadata. It deliberately excludes:
  - the render method body
  - any IsSynthesizedHelper node (the
    __PrivateComponentRenderModeAttribute helper class together with its
    [__PrivateComponentRenderModeAttribute] decoration)
  - IsGenericTyped helper namespaces (__Blazor.X.Y.TypeInference)

Routing all those into the impl half keeps the file-scoped rendermode
variant in a single file, keeps the nested variant'"'"'s decoration
colocated with its definition, and -- importantly -- means the decl
synthesis depends only on user source. It does not need tag helpers
to have been resolved, so the phase can be moved earlier in the
pipeline in future Sonic stages without any further restructuring.

Diagnostics attached to documentNode / primaryNamespace / primaryClass
themselves are seeded onto the synthetic root so they still surface.
The decl writer suppresses diagnostic collection because every
diagnostic on the decl tree is also reachable from the original tree
the impl half lowers from; letting both writers report would surface
every Razor-detected issue twice.

This commit is infrastructure only: GetDeclCSharpDocument() is now
populated for splittable components, but no consumer reads it yet and
GetCSharpDocument() is unaffected (DefaultRazorCSharpLoweringPhase
still produces the original single-file output from the unmutated IR).
A follow-up commit teaches the lowering phase to switch to producing
the impl half when a decl document is present, and updates the source
generator to emit both halves as separate .g.cs files.

Two representative .decl.codegen.cs baselines are included as
illustrations (the impl baselines are unchanged from main, since
GetCSharpDocument() output is unchanged at this commit):
  * Component_WithDocType -- the simplest case: a minimal API-surface
    file containing just the partial class header.
  * RenderMode_Directive_FullyQualified -- a component with @rendermode:
    decl omits both the helper class and its decoration. The remaining
    component baselines and the test-infra change that wires
    .decl.codegen.cs comparisons in are follow-up commits.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two paired changes that flip the split from "decl phase populates a
sidecar" to "decl + impl emitted as separate .g.cs files":

1. Teach DefaultRazorCSharpLoweringPhase to fork on
   GetDeclCSharpDocument() being non-null. When the decl phase produced
   its half, this phase produces the matching impl half -- a minimal
   partial class containing just the render method body and any
   IsSynthesizedHelper plumbing lifted from the primary class /
   namespace, plus IsGenericTyped helper namespaces lifted as siblings.
   The impl synthetic spine is built brand-new (not by mutating the
   original IR) so callers that walk documentNode continue to see its
   pre-split shape. The fork is gated on the decl document specifically
   rather than any pre-existing csharpDocument, so a future test or
   caller that pre-populates only the impl half doesn'"'"'t accidentally
   suppress this phase. When no decl document is present (non-component,
   suppressed primary method body, malformed primary structure) the
   phase falls through to its original single-file lowering path.

2. Thread declCSharpDocument through RazorSourceGenerator'"'"'s post-codegen
   projection tuple, teach the cache comparer to short-circuit when
   either document carries diagnostics and to compare both texts, and
   update the RegisterImplementationSourceOutput callback to report
   decl diagnostics and emit the decl half as a separate generated
   source. When the decl phase produced no document only the single
   impl output is emitted, preserving prior behavior.

The decl half is named via GetDeclIdentifierFromHintName, which
substitutes ".decl.g.cs" for the trailing ".g.cs" of the impl hint
name (e.g. Component1_razor.g.cs -> Component1_razor.decl.g.cs).
Both halves keep the .g.cs suffix that the editor and MSBuild use to
identify generated files, without stacking it.

The CloneContainer helpers used to build synthetic spines move from
DefaultRazorDeclCSharpLoweringPhase to RazorCSharpDocumentWriter so both
consumers (the decl phase and the new impl-clone path) share one copy.

Includes the Inject SG test as a representative baseline: bumps its
GeneratedSources count from 1 to 2 and adds the new decl baseline; the
existing impl baseline is trimmed to just the BuildRenderTree wrapper.
The remaining ~290 component-test count assertions and decl baselines
are follow-up commits.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Razor source generator now emits two files per component (impl +
decl), but the mapping layer between Roslyn and the Razor source still
assumed one generated document per .razor file. That left the decl
half invisible to feature round-trips: any IDE feature that takes a
position in the decl generated document and tries to map it back to the
original .razor source (go-to-def, find-references, hover, diagnostics
translation, code excerpts) silently failed.

* RazorSourceGenerator now also registers the decl hint name in
  hintNameToFilePath so reverse lookups (decl hint -> Razor file path)
  succeed.

* Adds RazorCodeDocument.GetCSharpDocumentForHintName(hintName)
  extension that returns the decl half when the hint name has the
  .decl.g.cs suffix, otherwise the impl. Mapping callers receive a
  hint name from Roslyn and need to consult the matching half so
  source mappings line up with positions in that generated document.

* Updates the two mapping entry points --
  RemoteDocumentMappingService.TryMapToHostDocumentRangeAsync and
  RemoteSpanMappingService.TryExcerptAsync / MapSpansAsync -- to thread
  the hint name through and pick the correct generated document via
  the new helper.

Other consumers of GetRequiredCSharpDocument operate on impl-side
positions only (formatting, completion, hover invoked at user cursor
positions, etc.) and don't need the dispatch -- the impl is the right
answer for them.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The baseline assertion and compilation helpers in
RazorBaselineIntegrationTestBase / RazorIntegrationTestBase used to
treat csharpDocument as the canonical full generated source. With the
decl/impl split, csharpDocument is just the impl half and
declCSharpDocument carries the decl half; both halves need to be
visible to baseline assertions and to the C# compilations the test
harness builds.

Test base changes:

* RazorIntegrationTestBase.CompileToCSharp adds both halves to
  AdditionalSyntaxTrees with distinct paths so file-local types
  (e.g. __PrivateComponentRenderModeAttribute) stay unambiguous.
  CompileToAssembly does the same when consuming a CompileToCSharpResult.

* CompileToCSharpResult gets two new properties:
  * DeclCode -- the decl half text, null when the document wasn't split
  * AllCode -- impl + decl concatenation, for assertions that need to
    look across both halves (e.g. Assert.Contains for content that may
    have moved to the decl half).

* New AddGeneratedSyntaxTrees(result, primaryPath?) helper for
  multi-step tests that compose generated output into a downstream
  compilation.

* RazorBaselineIntegrationTestBase.AssertCSharpDocumentMatchesBaseline
  extracts per-half assertion logic into AssertCSharpHalfMatchesBaseline.
  Always asserts the impl half against the existing
  .codegen.cs / .diagnostics.txt / .mappings.txt baselines; when the
  document was split, also asserts the decl half against
  .decl.codegen.cs / .decl.diagnostics.txt / .decl.mappings.txt. The
  decl writer suppresses diagnostic reporting (the impl writer reports
  the full seeded set), so the .decl.diagnostics.txt baseline is never
  produced.

Caller updates in ComponentCodeGenerationTestBase:

* Four multi-step tests (GenericComponent_GenericChildContent_*,
  GenericInheritsTagHelper, GenericComponentInherits_TypeArguments)
  switch their AdditionalSyntaxTrees.Add(Parse(generated.Code)) calls to
  AddGeneratedSyntaxTrees(generated) so the decl half also makes it into
  the downstream compilation.

* AddComponentParameter and GenericInference_DynamicallyAccessedMembers
  _01/_02 switch from generated.Code to generated.AllCode -- the content
  they search for now lives in the decl half.

The two representative .decl.codegen.cs baselines added previously
(Component_WithDocType, RenderMode_Directive_FullyQualified) are now
actively validated by the split-aware assertion. Other ~290 component
baselines still need regenerating; that is a follow-up commit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Updates the SG and integration test suites to recognize that
splittable components now produce two generated sources (impl + decl)
instead of one. Where a test asserts on count, content, diagnostics,
or generated-source ordering, it learns the new shape.

* RazorSourceGeneratorComponentTests:
  * Adds three regression tests -- DeclImplSplit_ProducesTwoFilesPerComponent,
    DeclImplSplit_DoesNotApplyToCshtml,
    DeclImplSplit_DocumentLevelDiagnosticReportedOnce -- pinning the
    behavior the rest of the changes assume.
  * Bumps Assert.Single / Assert.Equal(N, ...) count assertions on
    component tests to account for the extra decl source.

* RazorSourceGeneratorTests / RazorSourceGeneratorTestsBase:
  * Adds ImplGeneratedSources() / DeclGeneratedSources() extension
    helpers so tests can grab the half they care about.
  * SourceGenerator_UppercaseRazor_GeneratesComponent and
    IncrementalCompilation_RazorFiles_WhenChildComponentsAreAdded
    are updated as representative examples of the inline expected-text
    shape: each VerifyPageOutput call now passes one expected string
    per generated source (impl + decl), and VerifyOutputsMatch diff
    indices are updated for the new source positions. The remaining
    inline-expected SG tests get the same treatment as part of the
    bulk baseline regen commit.
  * WhenProjectReferencesChange expects the duplicate compile
    diagnostic now produced because the offending base type appears
    in both partial halves of the declaration -- marked PROTOTYPE
    because the duplication is a temporary issue to fix before main.

* RazorSourceGeneratorCshtmlTests / RazorSourceGeneratorTagHelperTests:
  Apply the new assertion helpers where mixed-document tests need the
  impl half specifically.

* ComponentCodeGenerationTestBase / ComponentRenderModeDirectiveIntegrationTests:
  Update a handful of tests where the generated diagnostic location
  shifts because the partial class header (or the synthesized
  __PrivateComponentRenderModeAttribute helper) lives in a different
  file path under the split. PROTOTYPE markers note the line-mapping
  and duplicate-diagnostic gaps that need to be fixed before main.

The bulk component baselines (~290 .codegen.cs / .mappings.txt
regenerations and matching .decl.codegen.cs / .decl.mappings.txt
additions) plus the matching SG decl baselines and the inline
expected-text updates for the remaining 21+ SG tests land in a
follow-up commit -- so most affected tests will fail at this commit
until those baselines arrive.

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

The bulk regen step that finishes the decl/impl split work:

* Regenerate ~290 component .codegen.cs and .mappings.txt baselines
  to reflect the impl half (now without @code blocks, properties,
  etc., which moved to the decl half).
* Add ~290 matching .decl.codegen.cs and .decl.mappings.txt baselines
  carrying the user-API-surface content the impl half no longer holds.
  Remove ~14 .mappings.txt files whose impl halves no longer have
  source-mapped content.
* Add the matching SG decl baselines (.razor.decl.g.cs) for the
  component SG tests, and trim the existing .razor.g.cs impl baselines
  for tests where the impl half is now smaller.
* Update the remaining inline-expected SG tests in
  RazorSourceGeneratorTests.cs to pass the full impl + decl expected
  text per generated source. VerifyOutputsMatch callers that previously
  diff'd a single position now supply diffs at the post-split positions
  for both halves of the changed component.

The test helpers in RazorSourceGeneratorTestsBase.cs are unchanged from
the previous commit -- the simple shape (strict positional comparison
in VerifyPageOutput, strict positional pair-by-position with optional
diff overrides in VerifyOutputsMatch) is what the test author works
against.

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

chsienki commented May 14, 2026 •

Copy link
Copy Markdown
Member Author

Replaced by #83688

@chsienki chsienki closed this May 14, 2026
chsienki added a commit that referenced this pull request May 14, 2026
This is a smaller subset of #83681
that stops at the point where we are generating the decl file, but not
yet generating the impl file, or replacing the regular C# file with it.

It has a couple of new tests with baselines that show the state of the
decl doc.

Once we have this in, we can add the commits that switch over to
generating the impl docs, and flesh out how the tooling works with
multiple files.
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.

2 participants