dotnet / razor

Compiler and tooling experience for Razor ASP.NET Core apps in Visual Studio, Visual Studio for Mac, and VS Code.
https://asp.net
MIT License
499 stars 191 forks source link

Proposal: Razor preprocessor directives support #8468

Open SteveSandersonMS opened 1 year ago

SteveSandersonMS commented 1 year ago

Summary

Proposal for a simplified syntax for C# preprocessor directives in .razor markup along with improved editor handling.

Motivation

Currently, Razor does not have any specific support for C# preprocessor directives. It is possible to use them inside code blocks, e.g.:

<h1>Hello</h1>
@{ #if DEBUG }
<p>You're running in debug mode.</p>
@{ #endif }

This works because the contents of @{ ... } are simply emitted into the resulting .cs source code, whereafter it is handled by the C# compiler in the normal way. So all preprocessor directives automatically work, and the IDE even offers correct completions and highlighting for them.

However, there are some significant issues and limitations:

Until now this has been good enough since people didn't often have reasons to use preprocessor directives inside rendering logic.

What's changing

A major element of the Blazor United work for .NET 8 (the flagship web UI feature for this .NET release) is building both WebAssembly and Server applications from the same project via multitargeting. This is also why we're introducing a new TFM, net8.0-browser, so that typical web UI projects can target both net8.0 (for server) and net8.0-browser (for WebAssembly).

The key bit of experience we have showing that this is a desirable pattern for UI apps targeting different runtime environments is MAUI and its use of multitargeting with #if ANDROID etc.

When using this pattern it's commonly desirable to vary the server/WebAssembly compilations based on a compiler symbol such as SERVER or BROWER. The examples below are based on this scenario. In many cases the reason to use #if SERVER...#endif rather than a runtime if/else is that the code in question relies on dependencies that are only available in one of the two compilations. For example, the server compilation may reference Entity Framework, whereas the WebAssembly compilation likely must not. That's why preprocessor directives are sometimes the only legal choice, besides heavier solutions like defining service interfaces and varying the behavior based on DI.

Detailed design

1. Syntax

We propose that preprocessor directives should have a simplified syntax starting @# as follows:

<h1>Hello</h1>
@#if SERVER
<p>This component is running on the server.</p>
@#endif

Just like C# preprocessor directives, we can keep this simple by requiring that:

See below for details about where this should be supported, and how it behaves.

Open question: Trailing comments

Should we also support a C#-like comment syntax that can follow a preprocessor directive? Example:

This would have the benefit of consistency with .cs syntax and being generally useful, but the disbenefit of inconsistency with two other .razor comment syntaxes (@* comment *@ and <!-- comment -->).

I propose that if this causes any controversy or uncertainty then we simply don't support it. It's not a primary requirement and could be added in the future.

2. Usage locations

It makes sense to support preprocessor directives in four different locations, some of which are already handled by the existing compiler and @{ #... } syntax.

2.1. Inside @code/@functions blocks

Example:

@code {
    #nullable enable

    async Task SaveChangesAsync()
    {
#if WEBASSEMBLY
        // Some logic to call an API endpoint
#else
        // Just write to the DB directly using EF
#endif
    }
}

This already works perfectly and completely. No new syntax or IDE support is required - it already greys out deactivated code, provides completions, etc.

2.2. Inside markup, but outside tags

Example:

<h1>Hello</h1>

@#if SERVER || WEBASSEMBLY
<SomeComponentThatOnlyMakesSenseInteractively />
@#else
<span>Please wait...</span>
@#endif

Behaviorally, this can be exactly equivalent to the existing @{ #... } syntax. That is, the preprocessor directives can simply be emitted into the generated code. All we're looking for here is a syntactical shorthand.

2.3. Among directives

This is not currently supported by @{ #... }, but is necessary if you are trying to @inject services that only existing in certain runtime environments, or if you want to have @using but the namespace only exists in certain compilations. Example:

@# if SERVER
@using Microsoft.EntityFramework
@inject MyApplicationDbContext
@# endif

This is less trivial to implement since each of the directives has to be considered individually to make sure the corresponding preprocessor directive code is emitted around each place where that directive causes code to be output (and that this is valid). Directives can cause code to be output in multiple places, as they have arbitrary behavior. In the case of @inject and @using it's quite simple, but other directives in the future could do anything.

2.4. Inside tags (lowest priority)

This would make it possible to vary the attributes on HTML elements or which set of parameters are passed to a child component. Example:

<button
@# if USE_JAVASCRIPT
    onclick="alert('You clicked')"
@# else
    @onclick="HandleClickAsync"
@# endif
>
    Click me
</button>

It's not actually clear this is essential for the known scenarios, and the code looks pretty horrible. If it were implemented, the behavior would be to emit the preprocessor directives around the corresponding AddAttribute/AddComponentParameter calls.

Open question: Can we deprioritize this until we have definite scenarios where it's essential, based on external feedback?

3. IDE support

We would like VS / VS Code to grey out any regions of Razor syntax that are are excluded by an @# if SYMBOL preprocessor directive, equivalently to how this works for lines inside @code { ... } blocks or .cs files generally.

We would also like VS to show its TFM picker for .razor files, just like it does for .cs files:

image

Perhaps this is a separate requirement to preprocessor directives, but is needed for the whole multitargeting-based UI project to be maximally useful.

Drawbacks

For cases 2.1 and 2.2 I don't think there are any drawbacks as this is either already supported, or is a very slight syntactical simplication on top of what we already have.

For case 2.3 the drawback is additional work in the compiler to keep track of the association between a directive and any preprocessor directives that may apply to it. It may also force the Razor compiler to know which preprocessor directives have corresponding start/end markers instead of just passing them through opaquely, however I'm not sure and perhaps that won't be required. I don't see any obvious drawbacks as far as the developer is concerned.

For case 2.4 the key drawback is that it would encourage a particuarly unpleasant syntax with HTML tags being broken across multiple lines and interleaved with preprocessor directives. Since this isn't required for any current scenarios, we could simply not do it.

Alternatives

We considered a much more specialized and involved syntax involving new conventions like:

However, we concluded that this was much too specific to a particular set of .NET 8 library features, forces people to learn a range of novel syntaxes that don't relate to anything existing in C#/Razor, might limit future syntactical choices, still require people to use preprocessor directives in some cases, and are unclear how to make extensible.

Instead we think it's more desirable to take the existing C# syntax - preprocessor directives - and give Razor a more ergonomic way to use it. It's easier to understand how it relates to compiler symbols, automatically extensible to arbitrary developer-defined symbols, and doesn't require multiple new syntaxes.

Open questions

What do we want to do in a case like this?

@#if SOMETHING
<div>
@#endif
</div>

This is similar to:

@if (something)
{
<div>
}
</div>

... which today produces the error RZ1006: The block is missing a closing '}' character.

Perhaps it would be ideal if the compiler didn't have to understand the preprocessor directives internally, and hence didn't need to know which of them entailed "end" markers versus, say, @#nullable enable. As such if malformed nesting simply produces a malformed result that may be acceptable, though ideally we'd understand the kind of behavior that may occur.

SteveSandersonMS commented 1 year ago

cc @dotnet/aspnet-blazor-eng, @chsienki @danroth27

davidwengier commented 1 year ago

I might be speaking out of turn, but FWIW I agree that 2.4 (inside tags) is horrible looking and best avoided. I does ocur to me though that this syntax is very much as you've said, a "a simplified syntax for C# preprocessor directives" rather than a true "Razor preprocessor directives". I wonder what the latter would look like, and whether it would allow for easier implmenentation - eg, it could be more like a true "pre-processor" for the .razor file, rather than a pre-process for the generated C#, so dealing with the complexities of 2.3 might be easier?

Were any alternatives considered that look more like Razor directives? Like @ifdef SOMETHING and @endif?

javiercn commented 1 year ago

I wonder what the latter would look like, and whether it would allow for easier implmenentation - eg, it could be more like a true "pre-processor" for the .razor file, rather than a pre-process for the generated C#, so dealing with the complexities of 2.3 might be easier?

Are you asking what would happen if you pre-processed the directives before even before the C# compiler sees them? I think you would end up with separate documents (one per TFM) and that might mean more work?

davidwengier commented 1 year ago

Are you asking what would happen if you pre-processed the directives before even before the C# compiler sees them? I think you would end up with separate documents (one per TFM) and that might mean more work?

I guess I would frame it more as "does the C# compiler need to even see them?". Rather than try to generate a single C# document that will differ at compile time based on preprocessor directives, why can't the source generator, which still runs at compile time, produce different generated documents?

I might be way off base in my assumptions about how the source generator works, but doesn't each TFM mean a separate build, which therefore means a separate run of the generator? In that case the same Razor document is already being processed multiple times, and with different defines, and could produce slightly different generated C#.

javiercn commented 1 year ago

@davidwengier both ways are valid I think, I assume that a Razor preprocessor directive will be more involved in the sense that it needs to do another layer of source mapping and I am not sure how expensive that is. I am also not sure if it would mean that we still need to do anything for #if directives (is there a reason to leave them there for the compiler) or if we could just process #if directives as part of generating the source document and not flow them to the c# compiler.

SteveSandersonMS commented 1 year ago

[Javier] both ways are valid I think

Agreed!

@davidwengier If you think that it would be either cheaper to evaluate the preprocessor instructions/branches inside the Razor compiler, or have significant technical advantages, then I think we'd be perfectly fine with that too. My guess is the advantage you're looking for is not having to carry forwards any info about #if etc later into the compilation process.

My guess is that this only really helps if the set of preprocessor directives is reduced to a smaller set than what's possible in .cs files, perhaps restricting it to just variants of #if (and related syntaxes) and excluding others like #pragma. I think that would be fine for our .NET 8 requirements, though we would definitely hope for all of the #if syntactical possibilities to be allowed (e.g., chains of boolean conditions with &&/||, nesting, and so on). But would evaluating it inside Razor complicate the tooling goals? How would VS know about this?

@javiercn Can you think of any preprocessor directives we definitely would need besides #if and its friends?

javiercn commented 1 year ago

@SteveSandersonMS not really.

This is the entire list https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/preprocessor-directives

davidwengier commented 1 year ago

My guess is that this only really helps if the set of preprocessor directives is reduced to a smaller set than what's possible in .cs files

I admit I was only thinking about #if, but I don't think we should try to blindly add support for all C# pre-processor directives without defining what they mean in a Razor context. eg, users will expect @#pragma warning disable followed by a Html diagnostic error code to remove that error from the error list, and just generating that pragma in to the C# is not going to help with that.

My guess is the advantage you're looking for is not having to carry forwards any info about #if etc later into the compilation process.

The main thing I was thinking about was that it seems like the original proposal is thinking about this feature in terms of how the directives would translate to the generated C# (eg, complexity around Razor directives) and so I was trying to think about how someone without any knowledge of that implementation detail would approach the root problem of "What syntax should be used for preprocessor directives in Razor?" From a tooling perspective I don't know that syntax matters at all, and the Razor compiler will have to understand the directives fully at a syntax level, for the scenario in 2.2 to be correctly greyed out in VS, so my point of view was mainly around removing the assumption that these would have to flow through to the generated file, and then what, if any, other possibilities that might bring up.

Though having said that, I think the difference between @#if DEBUG and @ifdef DEBUG is pretty trivial from a code generation point of view, even if we ignore everything I've said about potential implementation details. I mainly just wonder if the latter looks more "idiomatic Razor". If the @ifdef bit was entirely yellow, it might even stand out enough in the IDE that 2.4 isn't too bad to consider. IntelliSense around the possible defines to show would work exactly the same way, and be the same effort from the tooling side.

Adding multi-targetting support for Razor, and including the navigation bar drop downs, and greying out content that won't be compiled etc. is still a bit of an open question on the tooling side of things (tracked by #7629 and #8203 and possibly others), but probably shouldn't be considered as part of designing the language feature. If I could snap my fingers now and add the nav bars, and it made C# pre-processor directives in C# code in Razor files work as they do in C# files, it still wouldn't solve the problem of graying out the Razor/Html parts of a Razor file, but we also wouldn't need any new syntax for it either. At the end of the day the complexity here is having some kind of pre-processor directive apply to Razor code (not just C#), and so we don't necessarily have to pick a syntax that looks like C#.

danroth27 commented 1 year ago

@DamianEdwards

danroth27 commented 1 year ago

we don't necessarily have to pick a syntax that looks like C#.

True, but I think having some similarity to the C# syntax actually helps here. C# devs can then reuse their C# syntax knowledge instead of having to learn a totally new and different Razor one.

gbthakkar commented 1 year ago

Hi Dear Microsoft. I personally disagree with 2.4. And any other things which inject bug while trying to make a super language.

10% developer are like superman, and 90% (80% for your satisfaction) are adopting new things while working on the project and trying to meet the deadlines. (like me). Having Blazor Server and Blazor WASM as seperate project is fine for their specific purpose and giving compition to Angular. (We want a nice car which we can drive and not a figher jet)

I am always scared to update VS 2022 because it then break working things. e.g. latest update on VS 2022 stopped generating CS files from EDMX, and had to installed VS 2019 for some old projects. And if I try to report any such things to Dev team, I never understood what kind of information they need, and then the tickets were eventually closed. (sad,.)

peterdrier commented 6 months ago

peanut gallery here, fwiw I'm in the middle of an unplanned component migration between vendors due to VendorA not playing nice in the mixed mode Blazor 8 world. It's not a small app, so this is going to take a bit, and it's challenging overcoming the lack of 2.3 and there being no viable workarounds. I really want to be able to

if VendorA

#endif #if VendorB #endif app wide, in using statements, razor files, ... Not the most ideal design pattern, but a means of transition with a quick way to switch back and forth for the undersized dev team. Future me will appreciate any motion on this issue. Cheers.
RobAinscough commented 1 week ago

I need a simple approach to disabling Authorization on policy providers. The compiler directives don't work on Blazor pages so the only way for me to disable is using comments which is NOT ideal as I need to remember to undo the comments before pushing to a Git repo.

Example on a .razor page:

@attribute [Authorize(Policy = ... )]

if !DEBUG

...

endif

Because I frequently work "at home" or "remote" where I don't have connectivity to a companies domain but want to still work on a project, I have to comment out the attribute:

@ @attribute [Authorize(Policy = ... )] @

There are more "worky" alternative that involve a lot more code to generate a necessary claims for a user (me) while working on the project, but again not ideal.