Instructions for adding new public APIs (properties, methods, events, delegates) to existing WinForms controls or components. Covers API issue tracking, PublicAPI file maintenance, property/event conventions, CodeDOM serialization, design-time attributes, and XML documentation.
66
80%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Fix and improve this skill with Tessl
tessl review fix ./.github/skills/new-control-api/SKILL.mdThese rules apply when adding new public or protected members — properties,
methods, events, delegates, enums, or interfaces — to an existing WinForms
control or component. For creating entirely new controls, or for general coding
standards, see the coding-standards skill instead.
Golden rule: Every new public API must be tracked, reviewed, documented, and serialization-safe before it ships.
Every new public API surface change requires a corresponding API proposal
issue in the api-suggestion format, which will be reviewed by the .NET API
review board.
Before creating anything, search the upstream repository
(dotnet/winforms) for an existing issue with the api-suggestion label that
covers the planned API change.
⚠️ NEVER create a new issue in the upstream repo (
dotnet/winforms).
The API issue must contain all of the following sections. If any information is missing, stop and ask the user before proceeding with implementation.
Section 1 — Background and motivation: Why is this API needed? What scenario does it enable?
Section 2 — API Proposal (C# code block with full public signature, no method bodies):
namespace System.Windows.Forms;
public partial class ExistingControl
{
public Color NewProperty { get; set; }
public event EventHandler? NewPropertyChanged;
protected virtual void OnNewPropertyChanged(EventArgs e);
}Section 3 — API Usage (C# code block showing consumption):
var control = new ExistingControl();
control.NewProperty = Color.Red;
control.NewPropertyChanged += (s, e) => Console.WriteLine("Changed!");Section 4 — Alternative Designs: Other approaches considered.
Section 5 — Risks: Breaking changes, perf regressions, etc.
Section 6 — Will this feature affect UI controls? Designer support, accessibility impact, localization needs.
All new public and protected members must be recorded in the PublicAPI text files so the Roslyn analyzer can enforce API compatibility.
PublicAPI.Unshipped.txtAdd entries for every new API surface to the Unshipped file of the
project that contains the new code. For System.Windows.Forms controls this
is:
src\System.Windows.Forms\PublicAPI.Unshipped.txtPublicAPI.Shipped.txtBefore snapping for a release, entries are moved from Unshipped to
Shipped. During development, only touch Unshipped.
Entries use the Roslyn PublicAPI format — one line per accessor, fully qualified, sorted alphabetically. Key patterns:
# Property (getter + setter on separate lines)
System.Windows.Forms.Control.DataContext.get -> object?
System.Windows.Forms.Control.DataContext.set -> void
# Virtual / override / abstract modifiers prefix the line
virtual System.Windows.Forms.Control.DataContext.get -> object?
virtual System.Windows.Forms.Control.DataContext.set -> void
# Events — handler type as return
System.Windows.Forms.Control.DataContextChanged -> System.EventHandler?
# Protected virtual On-methods
virtual System.Windows.Forms.Control.OnDataContextChanged(System.EventArgs! e) -> void
# Methods with parameters
System.Windows.Forms.Control.SomeMethod(int count, string! name) -> bool
# Constructors
System.Windows.Forms.MyComponent.MyComponent() -> void
# Enum members
System.Windows.Forms.MyEnum.Value1 = 0 -> System.Windows.Forms.MyEnum
System.Windows.Forms.MyEnum.Value2 = 1 -> System.Windows.Forms.MyEnumNullable annotations: ? = nullable reference, ! = non-nullable
reference. Value types do not carry these markers unless Nullable<T>.
override members must be tracked tooThe PublicAPI analyzer (RS0016) treats a newly introduced override of a public or
protected member as new API surface — even though the base member is already public. Whenever
you add an override that did not previously exist on that type, add a line for it to
PublicAPI.Unshipped.txt with the override prefix. This is easy to miss for paint/lifecycle
overrides added to support a feature. Examples:
override System.Windows.Forms.CheckBox.OnPaint(System.Windows.Forms.PaintEventArgs! pevent) -> void
override System.Windows.Forms.CheckBox.Dispose(bool disposing) -> void
override System.Windows.Forms.ButtonBase.OnVisualStylesModeChanged(System.EventArgs! e) -> voidCI catches this, a plain
dotnet buildmay not. RS0016 is enforced as an error under the CI/Arcade build (build.cmd); a single-projectdotnet buildcan report it as 0 warnings. Always re-verify API tracking withbuild.cmd(see thebuilding-codeskill's build tenet).
private new bool ShouldSerializePadding() shadowing Control.ShouldSerializePadding()),
you must use the new keyword, or the CI build fails.cref to internal types in another assembly (CS1574): XML-doc <see cref="..."/> cannot
resolve a type that is internal in a different assembly (even via InternalsVisibleTo). Use
<c>TypeName</c> (plain code font) instead of a cref for such references.If a new public or protected interface is introduced (or an existing one gains new members), every member that is publicly accessible must also appear in the PublicAPI file and be part of the API review scope.
WinForms controls do not use regular backing fields for public properties.
Instead, all property values are stored in the control's Properties
collection (a PropertyStore instance). This is critical for two reasons:
CreateParams are called
before the derived class's constructor body executes. If a property
getter relied on a backing field initialized in the derived constructor,
it would read an uninitialized value. The PropertyStore avoids this because
GetValueOrDefault safely returns a default when no value has been stored.Declare a static key for each new property (one per property, shared across all instances):
private static readonly int s_myPropertyProperty = PropertyStore.CreateKey();Value-type property (int, bool, Color, enum, struct):
public Color MyProperty
{
get => Properties.GetValueOrDefault(s_myPropertyProperty, Color.Empty);
set
{
if (Properties.GetValueOrDefault(s_myPropertyProperty, Color.Empty) != value)
{
Properties.AddOrRemoveValue(s_myPropertyProperty, value, defaultValue: Color.Empty);
OnMyPropertyChanged(EventArgs.Empty);
}
}
}AddOrRemoveValue automatically removes the entry when the value equals the
default, keeping the store lean.
Reference-type property (object, string, Image):
public Image? MyImage
{
get => Properties.GetValueOrDefault<Image>(s_myImageProperty);
set
{
if (Properties.GetValueOrDefault<Image>(s_myImageProperty) != value)
{
Properties.AddOrRemoveValue(s_myImageProperty, value);
OnMyImageChanged(EventArgs.Empty);
}
}
}Every new public property must have a serialization strategy so the WinForms Designer can persist it correctly. Use one of these approaches:
| Approach | When to use |
|---|---|
[DefaultValue(...)] | Simple value-type properties with a constant default |
[DesignerSerializationVisibility(Hidden)] | Properties that must not be serialized (e.g., runtime-only, bound) |
ShouldSerialize + Reset methods | Complex defaults, reference-type defaults, or ambient properties |
ShouldSerialize / Reset pattern (uses PropertyStore):
// These methods MUST be private — the Designer finds them by convention.
private bool ShouldSerializeMyProperty()
=> Properties.ContainsKey(s_myPropertyProperty);
private void ResetMyProperty()
=> Properties.RemoveValue(s_myPropertyProperty);On[Property]Changed + eventUnless explicitly stated otherwise, every new public property requires:
protected virtual void On[Property]Changed(EventArgs e) method.[Property]Changed event of type EventHandler?.Event delegate storage also uses a static key / centralized collection — see Section 4.3 below.
private static readonly object s_myPropertyChangedEvent = new();
/// <summary>
/// Occurs when the value of <see cref="MyProperty"/> changes.
/// </summary>
[SRCategory(nameof(SR.CatPropertyChanged))]
[SRDescription(nameof(SR.ControlOnMyPropertyChangedDescr))]
public event EventHandler? MyPropertyChanged
{
add => Events.AddHandler(s_myPropertyChangedEvent, value);
remove => Events.RemoveHandler(s_myPropertyChangedEvent, value);
}
/// <summary>
/// Raises the <see cref="MyPropertyChanged"/> event.
/// </summary>
/// <param name="e">An <see cref="EventArgs"/> that contains the event data.</param>
[EditorBrowsable(EditorBrowsableState.Advanced)]
protected virtual void OnMyPropertyChanged(EventArgs e)
{
if (Events[s_myPropertyChangedEvent] is EventHandler handler)
{
handler(this, e);
}
}Decorate new properties with attributes to support the Properties window and IntelliSense. Check existing properties on the same control for precedent:
| Attribute | Purpose |
|---|---|
[SRCategory(nameof(SR.CatXxx))] | Groups the property in the Properties window |
[SRDescription(nameof(SR.XxxDescr))] | Tooltip in the Properties window |
[Browsable(true/false)] | Show/hide in Properties window |
[Bindable(true)] | Marks the property as data-bindable |
[EditorBrowsable(...)] | Controls IntelliSense visibility |
[Localizable(true)] | Marks the property value as localizable |
Resource strings: Category and description strings go in SR.resx with
localization-ready keys — follow the naming conventions already present (e.g.,
CatAppearance, CatBehavior, CatData, ControlOnXxxDescr).
DataContext), match the established name where it makes sense.Use the standard EventHandler delegate and EventArgs.Empty when no
additional data is needed:
public event EventHandler? SomethingHappened;When the event carries data beyond what EventArgs provides, do not use
generics (EventHandler<T>). Instead, create:
EventArgs subclass (e.g., MyActionEventArgs).MyActionEventHandler)./// <summary>
/// Provides data for the <see cref="Control.MyAction"/> event.
/// </summary>
public class MyActionEventArgs : EventArgs
{
public MyActionEventArgs(string detail) => Detail = detail;
/// <summary>
/// Gets the detail information associated with this event.
/// </summary>
public string Detail { get; }
}
/// <summary>
/// Represents the method that will handle the <see cref="Control.MyAction"/> event.
/// </summary>
public delegate void MyActionEventHandler(object? sender, MyActionEventArgs e);Just as properties use PropertyStore, event delegates use the inherited
Events collection (EventHandlerList from Component) with static key
objects — one object per event, shared across all instances. This avoids
allocating a delegate field per instance for events that are rarely subscribed.
Declare a static key object for each event:
private static readonly object s_myActionEvent = new();Declare the event using custom add/remove accessors:
public event MyActionEventHandler? MyAction
{
add => Events.AddHandler(s_myActionEvent, value);
remove => Events.RemoveHandler(s_myActionEvent, value);
}Raise the event in the On method by retrieving the delegate from the
collection:
protected virtual void OnMyAction(MyActionEventArgs e)
{
if (Events[s_myActionEvent] is MyActionEventHandler handler)
{
handler(this, e);
}
}Exception: Components with only a single event (e.g.,
Timer.Tick) may use a regular field-backed delegate instead. For controls — which inherit dozens of events fromControl— always use theEventscollection.
Use throw helpers — never hand-roll null or range checks:
ArgumentNullException.ThrowIfNull(parameter);
ArgumentOutOfRangeException.ThrowIfNegative(value);When adding a method that derived controls should be able to override, make
it protected virtual. Follow the naming conventions already in use on the
control's class hierarchy.
Every new public or protected member must have XML documentation. This is the basis for the official docs.
<summary> — always present. Concise statement of what the member does.<param> — for every parameter, with a meaningful description.<returns> — for non-void methods.<exception> — for every exception the method can throw.<value> — for properties, when the summary alone is not sufficient.For APIs that are not self-explanatory, also include:
<remarks> with <para> blocks — design rationale, usage patterns,
threading considerations, inheritance notes.<example> — code samples using <code> blocks./// <summary>
/// Gets or sets the data context for data binding purposes.
/// This is an ambient property.
/// </summary>
/// <remarks>
/// <para>
/// The data context is inherited by child controls that do not have
/// their own <see cref="DataContext"/> set. When a parent's data
/// context changes, <see cref="OnParentDataContextChanged"/> is
/// called on each child.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// var form = new MyForm();
/// form.DataContext = new MyViewModel();
/// </code>
/// </example>
public virtual object? DataContext { get; set; }/// <summary>
/// Raises the <see cref="MyPropertyChanged"/> event.
/// </summary>
/// <param name="e">An <see cref="EventArgs"/> that contains the event data.</param>
protected virtual void OnMyPropertyChanged(EventArgs e)<see cref="..."/> for cross-references.<inheritdoc/> on overrides when the base documentation is sufficient.[Experimental]New public APIs ship as normal, stable APIs by default. Do not add the
[Experimental(...)] attribute, a WFO5xxx diagnostic ID, or [WFO5xxx]
PublicAPI prefixes unless the work item explicitly asks for an experimental
API.
Never make an API experimental implicitly. Experimental status is a deliberate, requested decision (it changes the customer contract and requires a diagnostic ID + suppression to consume). If the context does not explicitly call for it, the API is stable.
Only when the task explicitly requests an experimental API:
WFO500x group in
src\System.Windows.Forms.Analyzers\src\System\Windows\Forms\Analyzers\Diagnostics\DiagnosticIDs.cs
(e.g. ExperimentalDarkMode = "WFO5001", ExperimentalAsync = "WFO5002",
ExperimentalAsyncDropTarget = "WFO5003"). New IDs continue the sequence.[Experimental(DiagnosticIDs.ExperimentalXxx, UrlFormat = DiagnosticIDs.UrlFormat)][WFO5001]System.Windows.Forms.SomeNewApi.get -> ....docs\analyzers\Experimental.Help.md and
docs\list-of-diagnostics.md.#pragma warning disable WFOxxxx / #Disable Warning WFOxxxx in VB).When the API later graduates to stable (typically the next release), reverse
all five steps: remove the attribute, the [WFOxxxx] PublicAPI prefixes, the
suppressions, the docs rows, and the unused diagnostic ID.
This repository single-targets the current in-development .NET (see
TargetFramework / NetCurrent), so source is not wrapped in
#if NETxx_0_OR_GREATER guards — there are none in System.Windows.Forms. Do
not add #if NET11_0_OR_GREATER blocks around new APIs. Add the member
directly:
/// <summary>
/// Gets or sets the corner radius for the control's border.
/// </summary>
public int CornerRadius
{
get => Properties.GetValueOrDefault(s_cornerRadiusProperty, 0);
set
{
ArgumentOutOfRangeException.ThrowIfNegative(value);
if (Properties.GetValueOrDefault(s_cornerRadiusProperty, 0) != value)
{
Properties.AddOrRemoveValue(s_cornerRadiusProperty, value, defaultValue: 0);
OnCornerRadiusChanged(EventArgs.Empty);
}
}
}Tests do not need a version guard either.
Before considering the implementation complete, verify:
PublicAPI.Unshipped.txt[Experimental]/WFO5xxx) unless experimental was
explicitly requested; no #if NETxx_0_OR_GREATER guardsPropertyStore (not backing fields)On[Property]Changed + [Property]Changed event
(unless explicitly exempted)Events collection (not field-backed)SRCategory, SRDescription, etc.)SR.resx as XML entriesEventArgs + Delegate (no generics)The API proposal issue itself should contain the following checklist at the bottom (to be maintained as part of the issue, not just at PR time):
### Status Checklist
- [ ] API proposal has `api-suggestion` label
- [ ] Background, API Proposal, API Usage, and Risks sections are complete
- [ ] API shape has been discussed with the team
- [ ] Review the issue for compatibility with what the API review board expects
- [ ] Change label to `api-ready-for-review`
- [ ] If late in the release cycle, also add the `blocking` label to expedite
the review appointment
- [ ] API review completed — label changed to `api-approved`All user-facing strings — categories, descriptions, exception messages — must be added as resource entries, never hard-coded.
SR.resxAdd new entries as XML to the SR.resx file in the project. Follow the
existing naming conventions:
| Purpose | Key pattern | Example |
|---|---|---|
| Property category | Cat[CategoryName] | CatAppearance, CatData |
| Property description | [Control]On[Property]Descr | ControlOnMyPropertyChangedDescr |
| Event description | [Event]Descr | CommandChangedEventDescr |
| Exception message | [Context]_[Error] | InvalidArgument_OutOfRange |
After adding English entries to SR.resx, build the solution once. The
build automatically generates .xlf translation files for all supported
languages from the English originals. In a subsequent pass, localize the
generated .xlf entries into their respective languages.
DataContext that cascade
from parent to children require a dedicated OnParent[Property]Changed
propagation pattern. This is out of scope for this Skill — a separate
Ambient Properties Skill is being tracked (see upstream issue).4ba3b30
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.