From 0af6b293995b6c4bb37e401ece875a02a610cbfb Mon Sep 17 00:00:00 2001 From: Deyan Kamburov Date: Wed, 30 Sep 2026 19:10:51 +0300 Subject: [PATCH 1/9] feat: open the runtime to the full IgniteUI.Blazor package The licensed/Trial IgniteUI.Blazor package is moving onto this package for its small components and builds its charts, grids and Dock Manager on the same base classes. An IL scan of its components against 0.4.0 found what it cannot reach; this change provides it: - InternalsVisibleTo the Infragistics-signed IgniteUI.Blazor assembly (keyed to eng/IG.publickey.hex, unconditional so release builds keep it). - BaseCollection, JsonSerializable, SerializationContext, SerializationFilter and IDataIntentAttribute are public again but [EditorBrowsable(Never)]: public full-package types derive from them, which friend access cannot allow (CS0060). DataIntentAttribute is public. - SetPropertyValue is internal instead of private (used by the grids). - RendererSerializer exposes its Context and ShouldWrite internally so the full package can serialize its drawing types as extension methods. - Content-child host hook: components implementing the internal IContentChildHost cascade themselves to their child content, and IgbFormatSpecifier attaches to / detaches from the nearest host. Lets an IgbNumberFormatSpecifier declared inside a chart axis keep working. Non-host components render exactly as before. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 + src/IgniteUI.Blazor.Lite.csproj | 7 ++ src/componentsBase/BaseCollection.cs | 23 +++- src/componentsBase/BaseRendererControl.cs | 10 +- src/componentsBase/BaseRendererElement.cs | 4 +- src/componentsBase/ContentChildHost.cs | 73 +++++++++++++ src/componentsBase/DataIntents.cs | 19 +++- src/componentsBase/JsonSerializable.cs | 35 +++++- src/componentsBase/RendererSerializer.cs | 9 ++ .../ContentChildHostTests.cs | 100 ++++++++++++++++++ 10 files changed, 270 insertions(+), 14 deletions(-) create mode 100644 src/componentsBase/ContentChildHost.cs create mode 100644 tests/IgniteUI.Blazor.Tests/ContentChildHostTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e6896c75..1c7ecf94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- **Infrastructure:** the package can now be extended by the full `IgniteUI.Blazor` package, which takes a dependency on it for the components listed here and builds its charts, grids and Dock Manager on the same runtime. Its infrastructure access is granted only to the Infragistics-signed `IgniteUI.Blazor` assembly. `BaseCollection`, `JsonSerializable`, `SerializationContext`, `SerializationFilter` and `IDataIntentAttribute` are public again but hidden from IntelliSense, since the full package's public collection and value types derive from them; they are not intended for application code. `DataIntentAttribute` is public. Format specifiers (`IgbNumberFormatSpecifier`) declared inside a component of the full package attach to it as content children. + ## 0.4.0 - 2026-09-30 This release updates Ignite UI for Blazor to the latest [igniteui-webcomponents@7.4.1 release](https://github.com/IgniteUI/igniteui-webcomponents/releases/tag/7.4.1) with highlights noted below: diff --git a/src/IgniteUI.Blazor.Lite.csproj b/src/IgniteUI.Blazor.Lite.csproj index 044a8b6e..0f997cf9 100644 --- a/src/IgniteUI.Blazor.Lite.csproj +++ b/src/IgniteUI.Blazor.Lite.csproj @@ -72,6 +72,13 @@ + + + + + diff --git a/src/componentsBase/BaseCollection.cs b/src/componentsBase/BaseCollection.cs index 359d748a..02b64cdf 100644 --- a/src/componentsBase/BaseCollection.cs +++ b/src/componentsBase/BaseCollection.cs @@ -1,9 +1,16 @@ using System.Collections.ObjectModel; +using System.ComponentModel; namespace IgniteUI.Blazor.Controls { - internal class BaseCollection : ObservableCollection + /// + /// Base of the collections that hold a component's child objects and report changes to the component that owns them. + /// Infrastructure for Ignite UI component libraries built on this package; not intended for application code. + /// + /// The type of the items. + [EditorBrowsable(EditorBrowsableState.Never)] + public class BaseCollection : ObservableCollection { private bool _suppressNotify = false; @@ -34,6 +41,8 @@ internal BaseCollection Fill(T[] items) return this; } + /// Copies the items to a new array. + /// An array with the items, in order. public T[] ToArray() { var array = new T[Count]; @@ -101,6 +110,9 @@ internal string? PropertyName } } + /// Creates a collection owned by , reported to it as the property. + /// The component or element that owns the collection, or . + /// The name of the owner's property that holds the collection. public BaseCollection(object? parent, string? propertyName) { _parent = parent; @@ -142,6 +154,9 @@ protected override void ClearItems() NotifyParent(); } + /// Writes the items as a JSON array into the description sent to the client renderer. + /// The serialization context to write to. + /// The property name to write the array under, or to write a bare array. public void Serialize(SerializationContext context, string? propertyName = null) { //var vals = new List(); @@ -212,6 +227,9 @@ public void Serialize(SerializationContext context, string? propertyName = null) //return "[" + string.Join(", \n", vals) + "]"; } + /// Finds the item, or a descendant of an item, with the given renderer name. + /// The renderer name to look for. + /// The matching object, or . public object? FindByName(string name) { //TODO: hash map @@ -241,6 +259,9 @@ public void Serialize(SerializationContext context, string? propertyName = null) return null; } + /// Determines whether an item, or a descendant of an item, has the given renderer name. + /// The renderer name to look for. + /// when a match exists; otherwise . public bool HasName(string name) { //TODO: hash map diff --git a/src/componentsBase/BaseRendererControl.cs b/src/componentsBase/BaseRendererControl.cs index 14fba7e5..87bfd51d 100644 --- a/src/componentsBase/BaseRendererControl.cs +++ b/src/componentsBase/BaseRendererControl.cs @@ -549,7 +549,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddAttribute(9 + 15 + Sequence.MaxSequence, "ChildContent", (RenderFragment)delegate (RenderTreeBuilder builder2) { //builder2.AddMarkupContent(10 + 15 + Sequence.MaxSequence, "\r\n "); - builder2.AddContent(11 + 15 + Sequence.MaxSequence, ChildContent); + ContentChildHost.AddChildContent(builder2, 11 + 15 + Sequence.MaxSequence, this, ChildContent); //builder2.AddMarkupContent(12 + 15 + Sequence.MaxSequence, "\r\n "); }); builder.CloseComponent(); @@ -626,7 +626,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddAttribute(17, "ChildContent", (RenderFragment)delegate (RenderTreeBuilder builder2) { builder2.AddMarkupContent(18, "\r\n "); - builder2.AddContent(19, ChildContent); + ContentChildHost.AddChildContent(builder2, 19, this, ChildContent); builder2.AddMarkupContent(20, "\r\n "); }); builder.CloseComponent(); @@ -645,7 +645,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder2.OpenElement(26, "igc-portal-entrance"); builder2.AddAttribute(27, "portal-id", "portal-" + _containerId); builder2.AddAttribute(28, "move-once-mode", "true"); - builder2.AddContent(29, ChildContent); + ContentChildHost.AddChildContent(builder2, 29, this, ChildContent); builder2.CloseElement(); builder2.AddMarkupContent(30, "\r\n "); }); @@ -3404,7 +3404,7 @@ private async Task TrySendCleanupAsync() return await JsRuntime.InvokeAsync("igSetResourceString", new object[] { "register", grouping, "", json }); } - private void SetPropertyValue(object item, System.Reflection.PropertyInfo property, JsonElement jsonElement) + internal void SetPropertyValue(object item, System.Reflection.PropertyInfo property, JsonElement jsonElement) { System.Type? type = Nullable.GetUnderlyingType(property.PropertyType); if (type == null) @@ -3458,7 +3458,7 @@ private void SetPropertyValue(object item, System.Reflection.PropertyInfo proper } } [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Creates the property's own array PropertyType, which is present in metadata whenever the property exists.")] - private void SetPropertyValue(object item, System.Reflection.PropertyInfo property, object value) + internal void SetPropertyValue(object item, System.Reflection.PropertyInfo property, object value) { System.Type? type = Nullable.GetUnderlyingType(property.PropertyType); if (type == null) diff --git a/src/componentsBase/BaseRendererElement.cs b/src/componentsBase/BaseRendererElement.cs index fadbda3b..1010b71f 100644 --- a/src/componentsBase/BaseRendererElement.cs +++ b/src/componentsBase/BaseRendererElement.cs @@ -126,7 +126,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddAttribute(3, "ChildContent", (RenderFragment)delegate (RenderTreeBuilder builder2) { builder2.AddMarkupContent(4, "\r\n "); - builder2.AddContent(5, ChildContent); + ContentChildHost.AddChildContent(builder2, 5, this, ChildContent); builder2.AddMarkupContent(6, "\r\n "); }); builder.CloseComponent(); @@ -151,7 +151,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder2.OpenElement(5, "igc-portal-entrance"); builder2.AddAttribute(6, "portal-id", "portal-" + parentId + "/" + Name); builder2.AddAttribute(7, "move-once-mode", "true"); - builder2.AddContent(8, ChildContent); + ContentChildHost.AddChildContent(builder2, 8, this, ChildContent); builder2.CloseElement(); builder2.AddMarkupContent(9, "\r\n "); }); diff --git a/src/componentsBase/ContentChildHost.cs b/src/componentsBase/ContentChildHost.cs new file mode 100644 index 00000000..94701073 --- /dev/null +++ b/src/componentsBase/ContentChildHost.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +namespace IgniteUI.Blazor.Controls +{ + /// + /// Implemented by components of other Ignite UI packages (the full IgniteUI.Blazor package) that accept elements + /// of this package as content children, e.g. an declared inside a chart axis. + /// The host cascades itself to its child content; supported elements attach on initialization and detach on disposal. + /// + internal interface IContentChildHost + { + /// Adds to the matching content collection of the host. + /// when the host accepted the child. + bool AttachContentChild(BaseRendererElement child); + + /// Removes from the content collection it was added to. + void DetachContentChild(BaseRendererElement child); + } + + internal static class ContentChildHost + { + /// The name the host is cascaded under. + internal const string CascadingName = "IgbContentChildHost"; + + /// + /// Adds to , wrapped in a cascade of + /// when it is a . Components that are not hosts render exactly as before. + /// + internal static void AddChildContent(RenderTreeBuilder builder, int sequence, object owner, RenderFragment? content) + { + if (owner is not IContentChildHost host) + { + builder.AddContent(sequence, content); + return; + } + + builder.OpenRegion(sequence); + builder.OpenComponent>(0); + builder.AddComponentParameter(1, nameof(CascadingValue.Value), host); + builder.AddComponentParameter(2, nameof(CascadingValue.Name), CascadingName); + builder.AddComponentParameter(3, nameof(CascadingValue.IsFixed), true); + builder.AddComponentParameter(4, nameof(CascadingValue.ChildContent), content); + builder.CloseComponent(); + builder.CloseRegion(); + } + } + + public partial class IgbFormatSpecifier : IDisposable + { + private IContentChildHost? _attachedHost; + + [CascadingParameter(Name = ContentChildHost.CascadingName)] + private IContentChildHost? ContentChildHostParent { get; set; } + + /// + protected override void OnInitialized() + { + base.OnInitialized(); + if (ContentChildHostParent != null && ContentChildHostParent.AttachContentChild(this)) + { + _attachedHost = ContentChildHostParent; + } + } + + void IDisposable.Dispose() + { + _attachedHost?.DetachContentChild(this); + _attachedHost = null; + GC.SuppressFinalize(this); + } + } +} diff --git a/src/componentsBase/DataIntents.cs b/src/componentsBase/DataIntents.cs index 1276b8ec..22908e93 100644 --- a/src/componentsBase/DataIntents.cs +++ b/src/componentsBase/DataIntents.cs @@ -1,20 +1,33 @@ +using System.ComponentModel; + namespace IgniteUI.Blazor.Controls { - // Currently not used in Lite, possibly in the future (if lite components self-configure) - internal interface IDataIntentAttribute + /// + /// Implemented by attributes that describe the intent of a data member for components that infer + /// their configuration from bound data. Infrastructure for Ignite UI component libraries built on this package. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public interface IDataIntentAttribute { + /// The intent of the annotated data member. string Intent { get; } } + /// + /// Describes the intent of a data member for components that infer their configuration from bound data. + /// [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = true)] - internal class DataIntentAttribute + public class DataIntentAttribute : Attribute, IDataIntentAttribute { + /// Marks the member with . + /// The intent of the annotated data member. public DataIntentAttribute(string intent) { Intent = intent; } + /// public string Intent { get; private set; } } } diff --git a/src/componentsBase/JsonSerializable.cs b/src/componentsBase/JsonSerializable.cs index 48d7d2d7..d48474c1 100644 --- a/src/componentsBase/JsonSerializable.cs +++ b/src/componentsBase/JsonSerializable.cs @@ -1,12 +1,33 @@ +using System.ComponentModel; + namespace IgniteUI.Blazor.Controls { - internal delegate bool SerializationFilter(string? name, string? property); + /// + /// Decides whether a property is written while a description is serialized for the client renderer. + /// Infrastructure for Ignite UI component libraries built on this package; not intended for application code. + /// + /// The renderer name of the object being serialized. + /// The name of the property about to be written. + /// to write the property; otherwise . + [EditorBrowsable(EditorBrowsableState.Never)] + public delegate bool SerializationFilter(string? name, string? property); - internal class SerializationContext + /// + /// The writer and filter used while a description is serialized for the client renderer. + /// Infrastructure for Ignite UI component libraries built on this package; not intended for application code. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public class SerializationContext { + /// The JSON writer the description is written to. public System.Text.Json.Utf8JsonWriter Writer { get; set; } + + /// An optional filter that decides which properties are written. public SerializationFilter? Filter { get; set; } + /// Creates a context over . + /// The JSON writer the description is written to. + /// An optional filter that decides which properties are written. public SerializationContext(System.Text.Json.Utf8JsonWriter writer, SerializationFilter? filter) { Writer = writer; @@ -14,8 +35,16 @@ public SerializationContext(System.Text.Json.Utf8JsonWriter writer, Serializatio } } - internal interface JsonSerializable + /// + /// An object that writes itself into the description sent to the client renderer. + /// Infrastructure for Ignite UI component libraries built on this package; not intended for application code. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public interface JsonSerializable { + /// Writes this object to . + /// The serialization context to write to. + /// The property name to write the object under, or to write a bare value. void Serialize(SerializationContext writer, string? propertyName = null); } diff --git a/src/componentsBase/RendererSerializer.cs b/src/componentsBase/RendererSerializer.cs index 40018510..ec5f4aa0 100644 --- a/src/componentsBase/RendererSerializer.cs +++ b/src/componentsBase/RendererSerializer.cs @@ -20,6 +20,15 @@ public RendererSerializer(SerializationContext context, ComponentBase component, private SerializationContext _context; + /// + /// The context being written to. Lets the full IgniteUI.Blazor package add serializers for its own value types + /// (points, rects, brushes) as extension methods. + /// + internal SerializationContext Context => _context; + + /// Whether the serialization filter, if any, lets be written. + internal bool ShouldWrite(string propertyName) => _context.Filter == null || _context.Filter(_name, propertyName); + //private List _properties = new List(); private string? _type = null; diff --git a/tests/IgniteUI.Blazor.Tests/ContentChildHostTests.cs b/tests/IgniteUI.Blazor.Tests/ContentChildHostTests.cs new file mode 100644 index 00000000..b411e599 --- /dev/null +++ b/tests/IgniteUI.Blazor.Tests/ContentChildHostTests.cs @@ -0,0 +1,100 @@ +using Bunit; +using IgniteUI.Blazor.Controls; +using Microsoft.AspNetCore.Components; + +namespace IgniteUI.Blazor.Tests; + +/// +/// Covers the content-child host hook the full IgniteUI.Blazor package uses to collect elements of this package +/// (format specifiers) declared inside its components, e.g. an inside a chart axis. +/// +public class ContentChildHostTests : BlazorComponentTestBase +{ + [Fact] + public void FormatSpecifier_InsideHost_AttachesOnInitializeAndDetachesOnDispose() + { + var cut = Render(p => p + .AddChildContent(s => s.Add(x => x.MinimumFractionDigits, 2))); + + var host = cut.Instance; + var specifier = Assert.Single(host.Attached); + Assert.IsType(specifier); + Assert.Same(cut.FindComponent().Instance, specifier); + + cut.Render(p => p.AddChildContent(builder => { })); + + Assert.Empty(host.Attached); + Assert.Equal(1, host.DetachCount); + } + + [Fact] + public void FormatSpecifier_InsideHostThatDeclinesChild_IsNotDetachedFromIt() + { + var cut = Render(p => p + .Add(x => x.Accept, false) + .AddChildContent()); + + var host = cut.Instance; + Assert.Empty(host.Attached); + + cut.Render(p => p.Add(x => x.Accept, false).AddChildContent(builder => { })); + + Assert.Equal(0, host.DetachCount); + } + + [Fact] + public void FormatSpecifier_InsideNonHostElement_RendersWithoutAttaching() + { + var cut = Render(p => p.AddChildContent()); + + Assert.NotNull(cut.FindComponent().Instance); + cut.Render(p => p.AddChildContent(builder => { })); + Assert.Empty(cut.FindComponents()); + } + + [Fact] + public void NestedHosts_ChildAttachesToNearestHost() + { + var cut = Render(p => p + .AddChildContent(inner => inner + .AddChildContent())); + + var outer = cut.Instance; + var inner = cut.FindComponents().Single(h => !ReferenceEquals(h.Instance, outer)).Instance; + + Assert.Empty(outer.Attached); + Assert.Single(inner.Attached); + } + + private sealed class TestHost : BaseRendererElement, IContentChildHost + { + [Parameter] public bool Accept { get; set; } = true; + + public List Attached { get; } = new(); + + public int DetachCount { get; private set; } + + private protected override string? ParentTypeName => "TestHostParent"; + + bool IContentChildHost.AttachContentChild(BaseRendererElement child) + { + if (!Accept) + { + return false; + } + Attached.Add(child); + return true; + } + + void IContentChildHost.DetachContentChild(BaseRendererElement child) + { + DetachCount++; + Attached.Remove(child); + } + } + + private sealed class NonHost : BaseRendererElement + { + private protected override string? ParentTypeName => "NonHostParent"; + } +} From 3f4db36623d3ae9c4b2edfe20bf3e25dcc408646 Mon Sep 17 00:00:00 2001 From: Deyan Kamburov Date: Wed, 30 Sep 2026 22:07:08 +0300 Subject: [PATCH 2/9] refactor!: move the client interop off window into interop.js (#255) The components called igSendMessage, igCheckReady, igWaitForLoaded, igRequestLoad, igSetResourceString and igUnmarshalledDataSource* as functions on window. Two Ignite UI packages on one page (Lite plus the full IgniteUI.Blazor) would overwrite each other's, so the interop now follows Blazor JS isolation: - index.ts exports the functions instead of assigning them to window; a new fixed-name entry, interop.js, re-exports them, so importing it reuses the instance the initializer loaded. igSendMessages (dead: it called an undefined window.sendMessage), igConvertReturnValue and the raisingEvent flag are gone. - InteropModule imports the module once per runtime and path. Calls made while it is still importing are queued and dispatched in call order, so fire-and-forget messages keep their order; synchronous calls (WASM) use the imported IJSInProcessObjectReference, available once EnsureReady has completed (a precondition of sync invokes already). The runtime service disposes its modules. - Unmarshalled data (WASM) goes to the module on every TFM: a synchronous in-process call with the address of the local holding the columns. The net8-only InvokeUnmarshalled fast path needed a window global and is dropped; the client now dereferences the address with whichever heap reader the runtime has (getValue on net8, Blazor.runtime on net9+). - Tests: the bUnit harness records the module interop; the unmarshalled channel records through an IUnmarshalledColumnSink module double. static-web-assets tests pin interop.js's exports and assert no interop function is put on window. Verified: unit suite (net8/9/10, only the pre-existing locale-dependent RatingTests.Methods_FollowContract failure), JS tests, and the Playwright integration suite (89/89, Server + WASM). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + src/componentsBase/BaseRendererControl.cs | 73 ++++--- src/componentsBase/IIgniteUIBlazorRuntime.cs | 3 + src/componentsBase/InteropModule.cs | 163 +++++++++++++++ src/componentsBase/RuntimeHelper.cs | 147 ++++--------- src/componentsBase/UnmarshalledDataSource.cs | 12 +- src/src/index.ts | 197 +++++++++--------- src/src/interop.ts | 23 ++ .../BaseRendererControlDisposalTests.cs | 32 +-- .../Interop/RendererMessageInteropHarness.cs | 95 ++++++--- .../UnmarshalledDataChannelTests.cs | 10 +- tests/js/static-web-assets.test.mjs | 28 +++ vite.config.mts | 14 +- 13 files changed, 507 insertions(+), 291 deletions(-) create mode 100644 src/componentsBase/InteropModule.cs create mode 100644 src/src/interop.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c7ecf94..bae5fd92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **Infrastructure:** the package can now be extended by the full `IgniteUI.Blazor` package, which takes a dependency on it for the components listed here and builds its charts, grids and Dock Manager on the same runtime. Its infrastructure access is granted only to the Infragistics-signed `IgniteUI.Blazor` assembly. `BaseCollection`, `JsonSerializable`, `SerializationContext`, `SerializationFilter` and `IDataIntentAttribute` are public again but hidden from IntelliSense, since the full package's public collection and value types derive from them; they are not intended for application code. `DataIntentAttribute` is public. Format specifiers (`IgbNumberFormatSpecifier`) declared inside a component of the full package attach to it as content children. +- **Interop:** the components reach their client code through the `_content/IgniteUI.Blazor/interop.js` module (JS isolation) instead of functions on `window`. The internal `window.igSendMessage`, `igCheckReady`, `igWaitForLoaded`, `igRequestLoad`, `igSetResourceString`, `igConvertReturnValue` and `igUnmarshalledDataSource*` functions, the unused `igSendMessages` and the `raisingEvent` flag no longer exist. They were never a supported API. On .NET 8 WebAssembly, unmarshalled data now travels on the same path as on .NET 9+. [#255](https://github.com/IgniteUI/igniteui-blazor/issues/255) ## 0.4.0 - 2026-09-30 diff --git a/src/componentsBase/BaseRendererControl.cs b/src/componentsBase/BaseRendererControl.cs index 87bfd51d..7649fe3b 100644 --- a/src/componentsBase/BaseRendererControl.cs +++ b/src/componentsBase/BaseRendererControl.cs @@ -58,13 +58,18 @@ protected IIgniteUIBlazor IgBlazor // } _runtime.WebCallback.Register(this); - _dataSourceManager = new DataSourceManager(this, new RuntimeHelper(JsRuntime, _runtime)); + _dataSourceManager = new DataSourceManager(this, new RuntimeHelper(JsRuntime, _runtime, () => Interop)); EnsureModulesLoaded(); } } internal IIgniteUIBlazorRuntime Runtime => _runtime ?? throw new InvalidOperationException("IgBlazor accessed before dependency injection completed."); + private InteropModule? _interop; + + /// The client interop module this component talks to. + internal InteropModule Interop => _interop ??= Runtime.GetInteropModule(InteropModule.LitePath); + /// Requests the client modules this component needs; override to register more through . protected virtual void EnsureModulesLoaded() { @@ -848,11 +853,11 @@ public async Task EnsureReady() //Console.WriteLine("ensuring ready: " + this.GetType().Name); while (!this._ready) { - bool ready = await jsRuntime.InvokeAsync("igCheckReady", new object[] { _containerId }); + bool ready = await Interop.InvokeAsync("checkReady", new object[] { _containerId }); //Console.WriteLine(ready + " -> " + this.GetType().Name); if (ready) { - await jsRuntime.InvokeVoidAsync("igWaitForLoaded"); + await Interop.InvokeVoidAsync("waitForLoaded"); OnReady(); break; } @@ -1751,14 +1756,13 @@ private void ProcessMessageSync(RendererMessage m) if (m.NativeElements != null) { - return await JsRuntime.InvokeAsync("igSendMessage", + return await Interop.InvokeAsync("sendMessage", new object[] { this._containerId, json, GetObjectRef(), m.NativeElements }); } else { - //json = "window.sendMessage(`" + this._id + "`, `" + json + "`)"; - return await JsRuntime.InvokeAsync("igSendMessage", + return await Interop.InvokeAsync("sendMessage", new object[] { this._containerId, json, GetObjectRef() }); } @@ -1791,18 +1795,10 @@ private void SendJson(string json, ElementReference[]? nativeElements) return; } - if (nativeElements != null) - { - JsRuntime.InvokeAsync("igSendMessage", - new object[] { this._containerId, json, - GetObjectRef(), nativeElements }); - } - else - { - JsRuntime.InvokeAsync("igSendMessage", - new object[] { this._containerId, json, - GetObjectRef() }); - } + var args = nativeElements != null + ? new object[] { this._containerId, json, GetObjectRef(), nativeElements } + : new object[] { this._containerId, json, GetObjectRef() }; + Interop.Post(module => _ = module.InvokeAsync("sendMessage", args)); } internal void AttachChild(BaseRendererElement child) @@ -1867,16 +1863,18 @@ private object SendJsonSync(string json, ElementReference[]? nativeElements) { throw new InvalidOperationException("JsInProcessRuntime is not available."); } + var module = Interop.LoadedInProcess + ?? throw new InvalidOperationException("The Ignite UI client interop module is not loaded yet; await EnsureReady() or use the async method."); if (nativeElements != null) { - return JsInProcessRuntime.Invoke("igSendMessage", + return module.Invoke("sendMessage", new object[] { this._containerId, json, GetObjectRef(), nativeElements }); } else { - return JsInProcessRuntime.Invoke("igSendMessage", + return module.Invoke("sendMessage", new object[] { this._containerId, json, GetObjectRef() }); @@ -3392,7 +3390,7 @@ private async Task TrySendCleanupAsync() { return null; } - return await JsRuntime.InvokeAsync("igSetResourceString", new object[] { "set", grouping, id, value }); + return await Interop.InvokeAsync("setResourceString", new object[] { "set", grouping, id, value }); } internal async Task SetResourceStringAsync(string grouping, string json) @@ -3401,7 +3399,7 @@ private async Task TrySendCleanupAsync() { return null; } - return await JsRuntime.InvokeAsync("igSetResourceString", new object[] { "register", grouping, "", json }); + return await Interop.InvokeAsync("setResourceString", new object[] { "register", grouping, "", json }); } internal void SetPropertyValue(object item, System.Reflection.PropertyInfo property, JsonElement jsonElement) @@ -3651,7 +3649,7 @@ public interface IIgniteUIBlazor { } - internal class IgniteUIBlazor : IIgniteUIBlazor, IIgniteUIBlazorRuntime + internal class IgniteUIBlazor : IIgniteUIBlazor, IIgniteUIBlazorRuntime, IAsyncDisposable, IDisposable { private bool _isRuntimeValid = false; private bool _isRuntimeChecked = false; @@ -3707,7 +3705,33 @@ public void RequestLoad(string moduleName) return; } _loadedCache.AddOrUpdate(moduleName, true, (name, oldValue) => true); - JsRuntime.InvokeAsync("igRequestLoad", moduleName); + GetInteropModule(InteropModule.LitePath).Post(module => _ = module.InvokeVoidAsync("requestLoad", moduleName)); + } + + private readonly ConcurrentDictionary _interopModules = new ConcurrentDictionary(); + + public InteropModule GetInteropModule(string path) + { + return _interopModules.GetOrAdd(path, p => new InteropModule(JsRuntime, p)); + } + + public async ValueTask DisposeAsync() + { + foreach (var module in _interopModules.Values) + { + await module.DisposeAsync().ConfigureAwait(false); + } + _interopModules.Clear(); + } + + // For containers disposed synchronously: releasing a module reference is a JS call, so it is started and not awaited. + public void Dispose() + { + foreach (var module in _interopModules.Values) + { + _ = module.DisposeAsync(); + } + _interopModules.Clear(); } public bool IsLoadRequested(string moduleName) { @@ -3865,7 +3889,6 @@ internal class ModuleLoader public static void Load(IIgniteUIBlazor runtime, string moduleName) { runtime.AsRuntime().RequestLoad(moduleName); - //runtime.JsRuntime.InvokeAsync("igRequestLoad", moduleName); } internal static void MarkIsLoadRequested(IIgniteUIBlazor runtime, string moduleName) diff --git a/src/componentsBase/IIgniteUIBlazorRuntime.cs b/src/componentsBase/IIgniteUIBlazorRuntime.cs index fb991f99..af7942f2 100644 --- a/src/componentsBase/IIgniteUIBlazorRuntime.cs +++ b/src/componentsBase/IIgniteUIBlazorRuntime.cs @@ -15,6 +15,9 @@ internal interface IIgniteUIBlazorRuntime bool IsLoadRequested(string moduleName); void MarkIsLoadRequested(string moduleName); bool IsRuntimeValid(bool reevaluate = false); + + /// The client interop module at , shared by the components of the runtime. + InteropModule GetInteropModule(string path); } internal static class IgniteUIBlazorRuntimeExtensions diff --git a/src/componentsBase/InteropModule.cs b/src/componentsBase/InteropModule.cs new file mode 100644 index 00000000..7d251e2c --- /dev/null +++ b/src/componentsBase/InteropModule.cs @@ -0,0 +1,163 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.JSInterop; + +namespace IgniteUI.Blazor.Controls +{ + /// + /// The client interop module of one Ignite UI package, imported through JS isolation (import()) + /// instead of calling functions on window. One instance per module path and runtime (circuit or app). + /// + /// + /// Calls made while the module is still being imported are queued and dispatched in call order once it + /// arrives, so fire-and-forget messages keep their relative order, as they did with the window functions. + /// + internal sealed class InteropModule : IAsyncDisposable + { + /// The interop module of this package. + internal const string LitePath = "./_content/IgniteUI.Blazor/interop.js"; + + private readonly IJSRuntime _jsRuntime; + private readonly object _lock = new object(); + private readonly List> _pending = new List>(); + private Task? _loading; + private volatile IJSObjectReference? _module; + private bool _disposed; + + internal InteropModule(IJSRuntime jsRuntime, string path) + { + _jsRuntime = jsRuntime; + Path = path; + } + + internal string Path { get; } + + /// The module, once imported; while it is still loading. + internal IJSObjectReference? Loaded => _module; + + /// The module for synchronous calls, where .NET runs in the browser and the module is imported. + internal IJSInProcessObjectReference? LoadedInProcess => _module as IJSInProcessObjectReference; + + /// Imports the module, once. + internal Task GetAsync() + { + lock (_lock) + { + return _loading ??= LoadAsync(); + } + } + + /// Runs with the module: now if it is imported, otherwise after the calls queued before it. + internal void Post(Action call) + { + IJSObjectReference? module; + lock (_lock) + { + if (_disposed) + { + return; + } + module = _module; + if (module == null) + { + _pending.Add(call); + _loading ??= LoadAsync(); + return; + } + } + call(module); + } + + internal async ValueTask InvokeAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.PublicProperties)] TValue>(string identifier, params object?[]? args) + { + var module = _module ?? await GetAsync().ConfigureAwait(false); + return await module.InvokeAsync(identifier, args).ConfigureAwait(false); + } + + internal async ValueTask InvokeVoidAsync(string identifier, params object?[]? args) + { + var module = _module ?? await GetAsync().ConfigureAwait(false); + await module.InvokeVoidAsync(identifier, args).ConfigureAwait(false); + } + + private async Task LoadAsync() + { + IJSObjectReference module; + try + { + module = await _jsRuntime.InvokeAsync("import", Path).ConfigureAwait(false); + } + catch + { + lock (_lock) + { + // Allow a later call to retry (e.g. the circuit was not connected yet); queued calls are dropped, + // like calls made on a runtime that could not reach the client. + _loading = null; + _pending.Clear(); + } + throw; + } + + // Drain in order; publish the module only when nothing is left queued, so a call arriving meanwhile + // cannot overtake the queued ones. + while (true) + { + Action[] batch; + lock (_lock) + { + if (_pending.Count == 0) + { + _module = module; + break; + } + batch = _pending.ToArray(); + _pending.Clear(); + } + foreach (var call in batch) + { + try + { + call(module); + } + catch (Exception) + { + // Fire-and-forget calls: one failing must not keep the rest from being dispatched. + } + } + } + return module; + } + + public async ValueTask DisposeAsync() + { + IJSObjectReference? module; + lock (_lock) + { + _disposed = true; + _pending.Clear(); + module = _module; + _module = null; + } + if (module == null) + { + return; + } + try + { + await module.DisposeAsync().ConfigureAwait(false); + } + catch (JSDisconnectedException) + { + } + catch (JSException) + { + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + } + } +} diff --git a/src/componentsBase/RuntimeHelper.cs b/src/componentsBase/RuntimeHelper.cs index 74834fec..7ddcb507 100644 --- a/src/componentsBase/RuntimeHelper.cs +++ b/src/componentsBase/RuntimeHelper.cs @@ -1,137 +1,68 @@ -#if NET8_0 -using System.Diagnostics.CodeAnalysis; -using System.Linq.Expressions; -#endif using System.Runtime.CompilerServices; using Microsoft.JSInterop; namespace IgniteUI.Blazor.Controls { - // The InvokeUnmarshalled fast path exists only on net8 (API removed in net9+), so the probe and its - // trim/AOT surface compile only there; net9+ always uses the raw-pointer InvokeVoid path. + // Sends unmarshalled data (WebAssembly) to the component's client interop module: a synchronous in-process call + // that passes the address of the local holding the columns array, which the client reads off the heap for the + // duration of the call. The same path on every target framework (the net8-only InvokeUnmarshalled fast path + // needed a window global). internal class RuntimeHelper { -#if NET8_0 - private Func? _callSendUnmarshalledColumnMessage; - private Func? _callSendUnmarshalledColumnDataIntentMessage; -#endif - private IJSInProcessRuntime? _inprocRuntime; private readonly IIgniteUIBlazorRuntime? _igBlazor; + private readonly Func? _interop; -#if NET8_0 - [DynamicDependency( - DynamicallyAccessedMemberTypes.PublicMethods, - "Microsoft.AspNetCore.Components.WebAssembly.Services.DefaultWebAssemblyJSRuntime", - "Microsoft.AspNetCore.Components.WebAssembly")] - [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Probes the net8-only InvokeUnmarshalled methods, preserved via the DynamicDependency above; absence falls back to the raw-pointer InvokeVoid path.")] - [UnconditionalSuppressMessage("Trimming", "IL2060", Justification = "The generic arguments are statically referenced framework/library types, and the InvokeUnmarshalled generic parameters carry no DynamicallyAccessedMembers requirements.")] - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "The DynamicDependency above marks the runtime's RequiresUnreferencedCode members (Invoke, GetValue, SetValue, ...); the probe filters by name and never invokes them.")] - [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "net8 Blazor WASM-only probe (IJSInProcessRuntime + InvokeUnmarshalled); no NativeAOT target exists for net8 wasm, and under Mono AOT the interpreter executes this.")] -#endif - public RuntimeHelper(IJSRuntime? runtime, IIgniteUIBlazorRuntime igBlazor) + public RuntimeHelper(IJSRuntime? runtime, IIgniteUIBlazorRuntime igBlazor, Func? interop) { _igBlazor = igBlazor; - var inprocRuntime = runtime as IJSInProcessRuntime; - _inprocRuntime = inprocRuntime; - if (inprocRuntime != null) - { - IsInproc = true; - } -#if NET8_0 - if (IsInproc && inprocRuntime != null) - { - var unmarshalled = inprocRuntime.GetType().GetMethods().Where(m => m.Name == "InvokeUnmarshalled").ToList(); - - if (unmarshalled.Count > 0) - { - var target = unmarshalled.Where(m => m.GetGenericArguments() != null && - m.GetGenericArguments().Length == 4).FirstOrDefault(); - if (target != null) - { - var meth = target.MakeGenericMethod(new Type[] { - typeof(string), - typeof(int), - typeof(UnmarshalledColumn[]), - typeof(string) - }); - - var jsRuntimeParam = Expression.Parameter(typeof(IJSInProcessRuntime), "jsRuntime"); - var methodNameParam = Expression.Parameter(typeof(string), "methodName"); - var refNameParam = Expression.Parameter(typeof(string), "refName"); - var indexParam = Expression.Parameter(typeof(int), "index"); - var columnsParam = Expression.Parameter(typeof(UnmarshalledColumn[]), "columns"); - - var wsRuntime = Expression.Convert(jsRuntimeParam, inprocRuntime.GetType()); - var call = Expression.Call(wsRuntime, meth, methodNameParam, refNameParam, - indexParam, columnsParam); - - _callSendUnmarshalledColumnMessage = - Expression.Lambda>( - call, jsRuntimeParam, methodNameParam, refNameParam, indexParam, columnsParam).Compile(); - } - - target = unmarshalled.Where(m => m.GetGenericArguments() != null && - m.GetGenericArguments().Length == 3).FirstOrDefault(); - if (target != null) - { - var meth = target.MakeGenericMethod(new Type[] { - typeof(string), - typeof(string), - typeof(string) - }); - - var jsRuntimeParam = Expression.Parameter(typeof(IJSInProcessRuntime), "jsRuntime"); - var methodNameParam = Expression.Parameter(typeof(string), "methodName"); - var refNameParam = Expression.Parameter(typeof(string), "refName"); - var dataIntentParam = Expression.Parameter(typeof(string), "index"); - - var wsRuntime = Expression.Convert(jsRuntimeParam, inprocRuntime.GetType()); - var call = Expression.Call(wsRuntime, meth, methodNameParam, refNameParam, - dataIntentParam); + _interop = interop; + IsInproc = runtime is IJSInProcessRuntime; + } - _callSendUnmarshalledColumnDataIntentMessage = - Expression.Lambda>( - call, jsRuntimeParam, methodNameParam, refNameParam, dataIntentParam).Compile(); - } - } - } -#endif + public void SendUnmarshalledColumnMessage(string methodName, string refName, int index, UnmarshalledColumn[]? columns) + { + _interop?.Invoke().Post(module => SendUnmarshalledColumnMessageNow(module, methodName, refName, index, columns)); } - public unsafe string? SendUnmarshalledColumnMessage(string methodName, string refName, int index, UnmarshalledColumn[]? columns) + public void SendUnmarshalledColumnDataIntentsMessage(string methodName, string refName, string dataIntents) { -#if NET8_0 - if (_callSendUnmarshalledColumnMessage != null && _inprocRuntime != null) - { - return _callSendUnmarshalledColumnMessage(_inprocRuntime, methodName, refName, index, columns); - } -#endif - var intptr = Unsafe.AsPointer(ref columns); - if (_inprocRuntime != null) + _interop?.Invoke().Post(module => { - _inprocRuntime.InvokeVoid(methodName, new object[] { refName, index, (int)intptr }); - } - - return null; + if (module is IUnmarshalledColumnSink sink) + { + sink.SendDataIntents(methodName, refName, dataIntents); + } + else + { + (module as IJSInProcessObjectReference)?.InvokeVoid(methodName, refName, dataIntents); + } + }); } - public string? SendUnmarshalledColumnDataIntentsMessage(string methodName, string refName, string dataIntents) + private static unsafe void SendUnmarshalledColumnMessageNow(IJSObjectReference module, string methodName, string refName, int index, UnmarshalledColumn[]? columns) { -#if NET8_0 - if (_callSendUnmarshalledColumnDataIntentMessage != null && _inprocRuntime != null) + if (module is IUnmarshalledColumnSink sink) { - return _callSendUnmarshalledColumnDataIntentMessage(_inprocRuntime, methodName, refName, dataIntents); + sink.SendColumns(methodName, refName, index, columns); } -#endif - if (_inprocRuntime != null) + else if (module is IJSInProcessObjectReference inproc) { - _inprocRuntime.InvokeVoid(methodName, new object[] { refName, dataIntents }); + var intptr = Unsafe.AsPointer(ref columns); + inproc.InvokeVoid(methodName, refName, index, (int)intptr); } - - return null; } public bool IsInproc { get; private set; } public bool IsForcedJsonDataMarshalling { get { return _igBlazor?.Settings?.ForceJsonDataMarshalling ?? false; } } } + + /// + /// A module that takes the column arrays themselves instead of their address, which only a WebAssembly heap + /// can resolve. Implemented by test doubles of the interop module. + /// + internal interface IUnmarshalledColumnSink + { + void SendColumns(string methodName, string refName, int index, UnmarshalledColumn[]? columns); + void SendDataIntents(string methodName, string refName, string dataIntents); + } } diff --git a/src/componentsBase/UnmarshalledDataSource.cs b/src/componentsBase/UnmarshalledDataSource.cs index f4c70312..d46929db 100644 --- a/src/componentsBase/UnmarshalledDataSource.cs +++ b/src/componentsBase/UnmarshalledDataSource.cs @@ -1794,7 +1794,7 @@ public void SendClear(string containerId, string refName) { return; } - _helper.SendUnmarshalledColumnMessage("igUnmarshalledDataSourceClear", containerId + ":" + refName, -1, GetColumns(refName)); + _helper.SendUnmarshalledColumnMessage("unmarshalledDataSourceClear", containerId + ":" + refName, -1, GetColumns(refName)); } public void SendRemove(string containerId, string refName, int index) @@ -1803,7 +1803,7 @@ public void SendRemove(string containerId, string refName, int index) { return; } - _helper.SendUnmarshalledColumnMessage("igUnmarshalledDataSourceRemove", containerId + ":" + refName, index, GetColumns(refName)); + _helper.SendUnmarshalledColumnMessage("unmarshalledDataSourceRemove", containerId + ":" + refName, index, GetColumns(refName)); } public void SendInsert(string containerId, string refName, int index) @@ -1812,7 +1812,7 @@ public void SendInsert(string containerId, string refName, int index) { return; } - _helper.SendUnmarshalledColumnMessage("igUnmarshalledDataSourceInsert", containerId + ":" + refName, index, GetColumns(refName)); + _helper.SendUnmarshalledColumnMessage("unmarshalledDataSourceInsert", containerId + ":" + refName, index, GetColumns(refName)); } public void SendUpdate(string containerId, string refName, int index, bool syncDataOnly) @@ -1821,7 +1821,7 @@ public void SendUpdate(string containerId, string refName, int index, bool syncD { return; } - _helper.SendUnmarshalledColumnMessage("igUnmarshalledDataSourceUpdate", containerId + ":" + refName + ":" + (syncDataOnly ? "true" : "false"), index, GetColumns(refName)); + _helper.SendUnmarshalledColumnMessage("unmarshalledDataSourceUpdate", containerId + ":" + refName + ":" + (syncDataOnly ? "true" : "false"), index, GetColumns(refName)); } public void SendCreate(string containerId, string refName, string? dataIntents) @@ -1833,9 +1833,9 @@ public void SendCreate(string containerId, string refName, string? dataIntents) if (dataIntents != null) { //Console.WriteLine("sending create data intents"); - _helper.SendUnmarshalledColumnDataIntentsMessage("igUnmarshalledDataSourceCreateDataIntents", containerId + ":" + refName, dataIntents); + _helper.SendUnmarshalledColumnDataIntentsMessage("unmarshalledDataSourceCreateDataIntents", containerId + ":" + refName, dataIntents); } - _helper.SendUnmarshalledColumnMessage("igUnmarshalledDataSourceCreate", containerId + ":" + refName, -1, GetColumns(refName)); + _helper.SendUnmarshalledColumnMessage("unmarshalledDataSourceCreate", containerId + ":" + refName, -1, GetColumns(refName)); } private void OnRemoveId(Guid oldId) diff --git a/src/src/index.ts b/src/src/index.ts index 535055bb..29845954 100644 --- a/src/src/index.ts +++ b/src/src/index.ts @@ -97,7 +97,7 @@ async function callDotNet(webCallback, methodName: string, ...args: any[]): Prom } } -(window as any).igWaitForLoaded = async function waitForLoaded() { +export const waitForLoaded = async function waitForLoaded() { if (Loader.instance.isLoading) { await Loader.instance.loadingPromise; } else { @@ -105,16 +105,11 @@ async function callDotNet(webCallback, methodName: string, ...args: any[]): Prom } }; -(window as any).igRequestLoad = function requestLoad(module: string) { +export const requestLoad = function requestLoad(module: string) { Loader.instance.request(module, cr); }; -(window as any).igSetResourceString = function setResourceString( - type: string, - grouping: string, - id: string, - value: string, -) { +export const setResourceString = function setResourceString(type: string, grouping: string, id: string, value: string) { switch (type) { case 'set': Loader.instance.setResourceString(grouping, id, value); @@ -273,8 +268,6 @@ let toReturn = function (retVal: any): any { return JSON.parse(Loader.stringify(retVal, getMainTarget())); }; -(window as any).igConvertReturnValue = convertReturnValue; - function toSimpleArgs(args: CustomEvent) { let orig = args; @@ -480,31 +473,25 @@ function raiseEventImpl(propertyName: string, sender: any, args: any, containerN Loader.clearMarshalIdByValueOnceByEvent(propertyName); - try { - (window as any).raisingEvent = true; - - callDotNet(webCallback, 'OnRaiseEvent', name, propertyName, JSON.stringify({ sender: sender, args: outerArgs })); - if ((window as any).webViewCallback) { - (window as any).webViewCallback.onRaiseEvent( - name, - propertyName, - JSON.stringify({ sender: sender, args: outerArgs }), - ); - } - if ( - (window as any).webkit && - (window as any).webkit.messageHandlers && - (window as any).webkit.messageHandlers.raiseEvent - ) { - (window as any).webkit.messageHandlers.raiseEvent.postMessage({ - name: name, - propertyName: propertyName, - sender: sender, - args: outerArgs, - }); - } - } finally { - (window as any).raisingEvent = false; + callDotNet(webCallback, 'OnRaiseEvent', name, propertyName, JSON.stringify({ sender: sender, args: outerArgs })); + if ((window as any).webViewCallback) { + (window as any).webViewCallback.onRaiseEvent( + name, + propertyName, + JSON.stringify({ sender: sender, args: outerArgs }), + ); + } + if ( + (window as any).webkit && + (window as any).webkit.messageHandlers && + (window as any).webkit.messageHandlers.raiseEvent + ) { + (window as any).webkit.messageHandlers.raiseEvent.postMessage({ + name: name, + propertyName: propertyName, + sender: sender, + args: outerArgs, + }); } } @@ -523,19 +510,12 @@ var raiseEvent = function (propertyName: string, sender: any, args: any, contain } }; -(window as any).igCheckReady = function (containerId: string) { +export const checkReady = function checkReady(containerId: string) { // var cont = document.getElementById(containerId); var cont = getContainerByIgIdAttribute(containerId); return cont !== null && cont !== undefined; }; -(window as any).igSendMessages = function (json: string) { - let m = JSON.parse(json); - for (let i = 0; i < m.length; i++) { - (window as any).sendMessage(JSON.stringify(m[i])); - } -}; - var propMaps = new WeakMap>(); function getPropMap(target: any) { if (!target) { @@ -685,7 +665,12 @@ function updateAngularElement(element: any) { } } -(window as any).igSendMessage = function (containerId: string, json: string, webCallback: any, nativeElements: any[]) { +export const sendMessage = function sendMessage( + containerId: string, + json: string, + webCallback: any, + nativeElements: any[], +) { updateContainer(containerId); let m = JSON.parse(json); @@ -1461,7 +1446,6 @@ function updateAngularElement(element: any) { }; declare var Blazor: any; -declare var BINDING: any; declare var getValue: any; declare var Module: any; @@ -2205,44 +2189,59 @@ function getValueNinePlus(ptr, type) { } } -let isDotnetNinePlus = false; - function getArrayDataPtr(value: any): any { return value + 12; } -(window as any).igUnmarshalledDataSourceCreate = function (refName: string, index: number, columns: any) { - if ((window as any).getValue) { - getValueActual = (window as any).getValue; - } - if ((globalThis as any).getValue) { - getValueActual = (globalThis as any).getValue; - } - if ((self as any).getValue) { - getValueActual = (self as any).getValue; - } - if (!getValueActual && (window as any).Module !== undefined) { - getValueActual = Module.getValue; - } +// Picks the heap readers of the running .NET WebAssembly runtime (getValue on .NET 8, the Blazor.runtime +// heap views on .NET 9+). +function ensureHeapAccess() { if (!getValueActual) { - getValueActual = getValueNinePlus; - isDotnetNinePlus = true; + if ((window as any).getValue) { + getValueActual = (window as any).getValue; + } + if ((globalThis as any).getValue) { + getValueActual = (globalThis as any).getValue; + } + if ((self as any).getValue) { + getValueActual = (self as any).getValue; + } + if (!getValueActual && (window as any).Module !== undefined) { + getValueActual = Module.getValue; + } + if (!getValueActual) { + getValueActual = getValueNinePlus; + } } - if (Blazor.platform.getArrayLength) { - getArrayLengthActual = Blazor.platform.getArrayLength; - } if (!getArrayLengthActual) { - getArrayLengthActual = (arr: any) => { - return getValueActual(getArrayDataPtr(arr), 'i32'); - }; + if (Blazor.platform.getArrayLength) { + getArrayLengthActual = Blazor.platform.getArrayLength; + } else { + getArrayLengthActual = (arr: any) => { + return getValueActual(getArrayDataPtr(arr), 'i32'); + }; + } } +} - if (isDotnetNinePlus) columns = Blazor.runtime.getHeapU32(columns); - - if (!isDotnetNinePlus) { - refName = BINDING.conv_string(refName); +// .NET passes the address of the local that holds the columns array (a synchronous in-process call, so the +// address is valid for its duration); read the array's address out of it. +function derefColumns(ptr: any): any { + ensureHeapAccess(); + if (Blazor.runtime && Blazor.runtime.getHeapU32) { + return Blazor.runtime.getHeapU32(ptr); } + return getValueActual(ptr, 'i32'); +} + +export const unmarshalledDataSourceCreate = function unmarshalledDataSourceCreate( + refName: string, + index: number, + columns: any, +) { + columns = derefColumns(columns); + var ind = refName.indexOf(':'); var containerId = refName.substr(0, ind); updateContainer(containerId); @@ -2297,11 +2296,10 @@ function getArrayDataPtr(value: any): any { refValues.set(refName, data); }; -(window as any).igUnmarshalledDataSourceCreateDataIntents = function (refName: string, intents: string) { - if (!isDotnetNinePlus) { - refName = BINDING.conv_string(refName); - intents = BINDING.conv_string(intents); - } +export const unmarshalledDataSourceCreateDataIntents = function unmarshalledDataSourceCreateDataIntents( + refName: string, + intents: string, +) { var ind = refName.indexOf(':'); var containerId = refName.substr(0, ind); updateContainer(containerId); @@ -2315,16 +2313,17 @@ function getArrayDataPtr(value: any): any { refDataIntents.set(refName, dataIntents); }; -(window as any).igUnmarshalledDataSourceInsert = function (refName: string, index: number, columns: any) { - if (!isDotnetNinePlus) { - refName = BINDING.conv_string(refName); - } +export const unmarshalledDataSourceInsert = function unmarshalledDataSourceInsert( + refName: string, + index: number, + columns: any, +) { var ind = refName.indexOf(':'); var containerId = refName.substr(0, ind); updateContainer(containerId); var refName = refName.substr(ind + 1); - if (isDotnetNinePlus) columns = Blazor.runtime.getHeapU32(columns); + columns = derefColumns(columns); var colItems = getUnmarshalledColumnItems(columns, index); var item = createOrUpdateUnmarshalledItem(refName, null, colItems); @@ -2351,10 +2350,11 @@ function getArrayDataPtr(value: any): any { } } }; -(window as any).igUnmarshalledDataSourceUpdate = function (refName: string, index: number, columns: any) { - if (!isDotnetNinePlus) { - refName = BINDING.conv_string(refName); - } +export const unmarshalledDataSourceUpdate = function unmarshalledDataSourceUpdate( + refName: string, + index: number, + columns: any, +) { var ind = refName.indexOf(':'); var containerId = refName.substr(0, ind); updateContainer(containerId); @@ -2364,7 +2364,7 @@ function getArrayDataPtr(value: any): any { var syncDataOnly = refName.substr(ind + 1) == 'true'; refName = n; - if (isDotnetNinePlus) columns = Blazor.runtime.getHeapU32(columns); + columns = derefColumns(columns); var colItems = getUnmarshalledColumnItems(columns, index); @@ -2391,12 +2391,12 @@ function getArrayDataPtr(value: any): any { } } }; -(window as any).igUnmarshalledDataSourceRemove = function (refName: string, index: number, columns: any) { - if (!isDotnetNinePlus) { - refName = BINDING.conv_string(refName); - } - - if (isDotnetNinePlus) columns = Blazor.runtime.getHeapU32(columns); +export const unmarshalledDataSourceRemove = function unmarshalledDataSourceRemove( + refName: string, + index: number, + columns: any, +) { + columns = derefColumns(columns); var ind = refName.indexOf(':'); var containerId = refName.substr(0, ind); @@ -2425,16 +2425,17 @@ function getArrayDataPtr(value: any): any { } } }; -(window as any).igUnmarshalledDataSourceClear = function (refName: string, index: number, columns: any) { - if (!isDotnetNinePlus) { - refName = BINDING.conv_string(refName); - } +export const unmarshalledDataSourceClear = function unmarshalledDataSourceClear( + refName: string, + index: number, + columns: any, +) { var ind = refName.indexOf(':'); var containerId = refName.substr(0, ind); updateContainer(containerId); var refName = refName.substr(ind + 1); - if (isDotnetNinePlus) columns = Blazor.runtime.getHeapU32(columns); + columns = derefColumns(columns); var cols = getUnmarshalledColumns(columns); diff --git a/src/src/interop.ts b/src/src/interop.ts new file mode 100644 index 00000000..fe9868a1 --- /dev/null +++ b/src/src/interop.ts @@ -0,0 +1,23 @@ +/** + * The client side of the .NET interop, served at `_content/IgniteUI.Blazor/interop.js`. + * + * The components import this module through `IJSRuntime` (JS isolation) instead of calling functions on `window`, + * so the library defines no globals and a second Ignite UI package can bring its own interop module alongside. + * The functions live in the interop bundle (index.ts); this entry only gives them a fixed, importable file name, so + * importing it reuses the instance the JS initializer already loaded. + * + * Internal to the Ignite UI packages; not an application API. + */ +export { + checkReady, + requestLoad, + sendMessage, + setResourceString, + unmarshalledDataSourceClear, + unmarshalledDataSourceCreate, + unmarshalledDataSourceCreateDataIntents, + unmarshalledDataSourceInsert, + unmarshalledDataSourceRemove, + unmarshalledDataSourceUpdate, + waitForLoaded, +} from './index'; diff --git a/tests/IgniteUI.Blazor.Tests/BaseRendererControlDisposalTests.cs b/tests/IgniteUI.Blazor.Tests/BaseRendererControlDisposalTests.cs index cf22eeb9..af243005 100644 --- a/tests/IgniteUI.Blazor.Tests/BaseRendererControlDisposalTests.cs +++ b/tests/IgniteUI.Blazor.Tests/BaseRendererControlDisposalTests.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Bunit; using IgniteUI.Blazor.Controls; +using IgniteUI.Blazor.Tests.Interop; using Microsoft.JSInterop; namespace IgniteUI.Blazor.Tests; @@ -31,6 +32,9 @@ public sealed class DisposalCollection [Collection(DisposalCollection.Name)] public class BaseRendererControlDisposalTests : BlazorComponentTestBase { + /// The client interop module the components send their messages to. + private BunitJSInterop Module => ((RendererMessageInteropHarness)Interop).Module; + [Fact] public async Task DisposeAsync_WhenInteropThrowsJSException_DoesNotThrow() { @@ -39,8 +43,8 @@ public async Task DisposeAsync_WhenInteropThrowsJSException_DoesNotThrow() // Make the cleanup interop call fail the way a mis-behaving JS side would. // The most recently registered matching handler wins in bUnit, so this - // overrides the default "return undefined" answer for igSendMessage. - JSInterop.Setup("igSendMessage", _ => true) + // overrides the default "return undefined" answer for sendMessage. + Module.Setup("sendMessage", _ => true) .SetException(new JSException("simulated JS failure during cleanup")); var ex = await Record.ExceptionAsync(async () => @@ -55,7 +59,7 @@ public async Task DisposeAsync_WhenCircuitDisconnected_DoesNotThrow() var cut = Render(); var instance = cut.Instance; - JSInterop.Setup("igSendMessage", _ => true) + Module.Setup("sendMessage", _ => true) .SetException(new JSDisconnectedException("circuit gone")); var ex = await Record.ExceptionAsync(async () => @@ -70,7 +74,7 @@ public async Task DisposeAsync_WhenInteropCanceled_DoesNotThrow() var cut = Render(); var instance = cut.Instance; - JSInterop.Setup("igSendMessage", _ => true) + Module.Setup("sendMessage", _ => true) .SetException(new TaskCanceledException("host shutting down")); var ex = await Record.ExceptionAsync(async () => @@ -85,7 +89,7 @@ public async Task DisposeAsync_WhenObjectAlreadyDisposed_DoesNotThrow() var cut = Render(); var instance = cut.Instance; - JSInterop.Setup("igSendMessage", _ => true) + Module.Setup("sendMessage", _ => true) .SetException(new ObjectDisposedException("JSRuntime")); var ex = await Record.ExceptionAsync(async () => @@ -102,7 +106,7 @@ public async Task DisposeAsync_WhenUnexpectedExceptionFromInterop_IsSwallowed() var cut = Render(); var instance = cut.Instance; - JSInterop.Setup("igSendMessage", _ => true) + Module.Setup("sendMessage", _ => true) .SetException(new InvalidOperationException("unexpected interop failure")); var ex = await Record.ExceptionAsync(async () => @@ -116,7 +120,7 @@ public async Task DisposeAsync_SendsCleanupMessage() { Interop.PrimeReady(); var cut = Render(); - var invocationCount = JSInterop.Invocations.Count; + var invocationCount = Module.Invocations.Count; await cut.Instance.DisposeAsync(); @@ -129,7 +133,7 @@ public async Task DisposeAsync_CalledTwice_IsIdempotent() Interop.PrimeReady(); var cut = Render(); var instance = (IAsyncDisposable)cut.Instance; - var invocationCount = JSInterop.Invocations.Count; + var invocationCount = Module.Invocations.Count; await instance.DisposeAsync(); @@ -141,8 +145,8 @@ public async Task DisposeAsync_CalledTwice_IsIdempotent() private void AssertCleanupSentOnce(int invocationCount) { var cleanup = Assert.Single( - JSInterop.Invocations.Skip(invocationCount), - invocation => invocation.Identifier == "igSendMessage"); + Module.Invocations.Skip(invocationCount), + invocation => invocation.Identifier == "sendMessage"); using var message = JsonDocument.Parse((string)cleanup.Arguments[1]!); Assert.Equal("cleanup", message.RootElement.GetProperty("type").GetString()); } @@ -152,8 +156,8 @@ private sealed record Row(string Name); private IReadOnlyList MessageTypesFor(string containerId) { var types = new List(); - foreach (var invocation in JSInterop.Invocations.Where(v => - v.Identifier == "igSendMessage" && v.Arguments.Count > 1 && v.Arguments[0] as string == containerId)) + foreach (var invocation in Module.Invocations.Where(v => + v.Identifier == "sendMessage" && v.Arguments.Count > 1 && v.Arguments[0] as string == containerId)) { using var message = JsonDocument.Parse((string)invocation.Arguments[1]!); types.Add(message.RootElement.GetProperty("type").GetString()); @@ -218,8 +222,8 @@ public async Task DisposeAsync_SendsNothingAfterCleanup() stop.Cancel(); await producer; - var sent = JSInterop.Invocations - .Where(v => v.Identifier == "igSendMessage" && v.Arguments.Count > 1 && v.Arguments[0] as string == id) + var sent = Module.Invocations + .Where(v => v.Identifier == "sendMessage" && v.Arguments.Count > 1 && v.Arguments[0] as string == id) .Select(v => (string)v.Arguments[1]!) .ToList(); var cleanup = sent.FindIndex(json => json.Contains("\"type\": \"cleanup\"")); diff --git a/tests/IgniteUI.Blazor.Tests/Interop/RendererMessageInteropHarness.cs b/tests/IgniteUI.Blazor.Tests/Interop/RendererMessageInteropHarness.cs index b5907c67..60f1d4a6 100644 --- a/tests/IgniteUI.Blazor.Tests/Interop/RendererMessageInteropHarness.cs +++ b/tests/IgniteUI.Blazor.Tests/Interop/RendererMessageInteropHarness.cs @@ -9,16 +9,20 @@ namespace IgniteUI.Blazor.Tests.Interop; /// /// adapter for the current interop implementation: -/// global JS functions (igSendMessage, igCheckReady, igWaitForLoaded) +/// exports of the client interop module (sendMessage, checkReady, waitForLoaded) /// carrying RendererMessage JSON envelopes, with JS→.NET traffic entering /// through the JSInvokable surface. /// All knowledge of that wire format is intentionally concentrated here. /// public sealed class RendererMessageInteropHarness : InteropHarness { - private const string SendMessage = "igSendMessage"; + private const string SendMessage = "sendMessage"; - private readonly BunitJSInterop _js; + // The component traffic goes to the interop module (JS isolation), recorded on its own interop. + private readonly BunitJSInterop _module; + + /// The recorded interop of the client module, for tests that assert on the wire itself. + internal BunitJSInterop Module => _module; private readonly IgniteUIBlazor _service; // Read by invocation matchers on background flush threads while tests add // stubs on the test thread — must be thread-safe. @@ -36,15 +40,14 @@ public RendererMessageInteropHarness(BunitJSInterop js, Func dispatc /// /// With false, drives the in-process - /// (unmarshalled) data channel instead: the service's runtime carries an - /// InvokeUnmarshalled method that RuntimeHelper discovers by reflection, so - /// DataSourceManager picks UnmarshalledDataSource and the column messages are - /// recorded in instead of crossing to JS. + /// (unmarshalled) data channel instead: DataSourceManager picks UnmarshalledDataSource and + /// the column messages reach a recording double of the interop module, recorded in + /// instead of crossing to JS. /// public RendererMessageInteropHarness(BunitJSInterop js, Func dispatcher, bool forceJsonDataMarshalling) : base(dispatcher) { - _js = js; + _module = js.SetupModule(InteropModule.LitePath); var runtime = forceJsonDataMarshalling ? js.JSRuntime : new UnmarshalledRecordingRuntime(js.JSRuntime, RecordUnmarshalledMessage); @@ -54,7 +57,7 @@ public RendererMessageInteropHarness(BunitJSInterop js, Func dispatc // an unanswered invokeMethod would otherwise await its return forever. // Method-specific stubs are excluded here so their handlers always win, // regardless of bUnit's handler-resolution order. - _js.Setup(SendMessage, inv => !IsStubbedInvokeMethod(inv)) + _module.Setup(SendMessage, inv => !IsStubbedInvokeMethod(inv)) .SetResult(ToResultPayload(InteropReturn.Undefined)); } @@ -93,9 +96,9 @@ private void RecordUnmarshalledMessage(string methodName, string refName, int in } /// - /// In-process runtime whose InvokeUnmarshalled methods RuntimeHelper discovers by - /// name-based reflection — the seam replacing the API modern runtimes removed. - /// Everything else delegates to bUnit's runtime. + /// In-process runtime that hands the components a recording double of the interop module, which takes + /// the column arrays directly () — their raw heap address, what the + /// real module receives, only resolves on a WebAssembly heap. Everything else delegates to bUnit. /// private sealed class UnmarshalledRecordingRuntime : Microsoft.JSInterop.IJSInProcessRuntime { @@ -108,29 +111,55 @@ public UnmarshalledRecordingRuntime(Microsoft.JSInterop.IJSRuntime inner, Action _record = record; } - public TResult InvokeUnmarshalled(string identifier, T0 arg0, T1 arg1, T2 arg2) + public TResult Invoke(string identifier, params object?[]? args) + => _inner.Invoke(identifier, args); + + public ValueTask InvokeAsync(string identifier, object?[]? args) + => Wrap(identifier, _inner.InvokeAsync(identifier, args)); + + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) + => Wrap(identifier, _inner.InvokeAsync(identifier, cancellationToken, args)); + + private async ValueTask Wrap(string identifier, ValueTask result) { - _record(identifier, (string)(object)arg0!, (int)(object)arg1!, (UnmarshalledColumn[]?)(object?)arg2); - return default!; + var value = await result; + if (identifier == "import" && value is Microsoft.JSInterop.IJSInProcessObjectReference module) + { + return (TValue)(object)new RecordingModule(module, _record); + } + return value; } + } + + private sealed class RecordingModule : Microsoft.JSInterop.IJSInProcessObjectReference, IUnmarshalledColumnSink + { + private readonly Microsoft.JSInterop.IJSInProcessObjectReference _inner; + private readonly Action _record; - public TResult InvokeUnmarshalled(string identifier, T0 arg0, T1 arg1) + public RecordingModule(Microsoft.JSInterop.IJSInProcessObjectReference inner, Action record) { - // Data-intents variant; recorded with no columns. - _record(identifier, (string)(object)arg0!, -1, null); - return default!; + _inner = inner; + _record = record; } - public TResult Invoke(string identifier, params object?[]? args) - => _inner.Invoke(identifier, args); + public void SendColumns(string methodName, string refName, int index, UnmarshalledColumn[]? columns) => + _record(methodName, refName, index, columns); - public ValueTask InvokeAsync(string identifier, object?[]? args) - => _inner.InvokeAsync(identifier, args); + // Data-intents variant; recorded with no columns. + public void SendDataIntents(string methodName, string refName, string dataIntents) => + _record(methodName, refName, -1, null); - public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) - => _inner.InvokeAsync(identifier, cancellationToken, args); - } + public TValue Invoke(string identifier, params object?[]? args) => _inner.Invoke(identifier, args); + + public ValueTask InvokeAsync(string identifier, object?[]? args) => _inner.InvokeAsync(identifier, args); + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) => + _inner.InvokeAsync(identifier, cancellationToken, args); + + public void Dispose() => _inner.Dispose(); + + public ValueTask DisposeAsync() => _inner.DisposeAsync(); + } public override IIgniteUIBlazor Service => _service; public override void ConfigureServices(IServiceCollection services) => @@ -147,8 +176,8 @@ public override void PrimeReady() return; } _primed = true; - _js.Setup("igCheckReady", _ => true).SetResult(true); - _js.SetupVoid("igWaitForLoaded", _ => true).SetVoidResult(); + _module.Setup("checkReady", _ => true).SetResult(true); + _module.SetupVoid("waitForLoaded", _ => true).SetVoidResult(); } // The JS-to-.NET entries below run on the dispatcher, where Blazor delivers the real ones. @@ -216,7 +245,7 @@ public override void SetupMethodResult(string methodName, InteropReturn result) _stubbedMethods.TryAdd(methodName, true); if (!_methodHandlers.TryGetValue(methodName, out var handler)) { - handler = _js.Setup(SendMessage, inv => MethodNameOf(inv) == methodName); + handler = _module.Setup(SendMessage, inv => MethodNameOf(inv) == methodName); _methodHandlers[methodName] = handler; } handler.SetResult(ToResultPayload(result)); @@ -225,7 +254,7 @@ public override void SetupMethodResult(string methodName, InteropReturn result) public override Action WithholdMethodReply(string methodName) { _stubbedMethods.TryAdd(methodName, true); - var handler = _js.Setup(SendMessage, inv => MethodNameOf(inv) == methodName); + var handler = _module.Setup(SendMessage, inv => MethodNameOf(inv) == methodName); _methodHandlers[methodName] = handler; return result => handler.SetResult(ToResultPayload(result)); } @@ -445,7 +474,7 @@ public override void ClearObserved() => _observedFrom = SnapshotInvocations().Count(i => i.Identifier == SendMessage); /// - /// Enumerates every recorded igSendMessage as (containerId, parsed message, element handles). + /// Enumerates every recorded sendMessage as (containerId, parsed message, element handles). /// Element handles are not part of the JSON envelope on this stack — they ride as a /// trailing marshalled argument of the call, alongside the component's object reference. /// @@ -499,7 +528,7 @@ private IReadOnlyList SnapshotInvocations() { try { - return [.. _js.Invocations]; + return [.. _module.Invocations]; } catch (InvalidOperationException) when (attempt < 200) { @@ -545,7 +574,7 @@ private bool IsStubbedInvokeMethod(JSRuntimeInvocation invocation) } /// - /// The value igSendMessage resolves with: a JSON *string* element containing the + /// The value sendMessage resolves with: a JSON *string* element containing the /// return envelope ({"retType": ..., "value": ...}), matching what the JS /// side produces for method invocations. /// diff --git a/tests/IgniteUI.Blazor.Tests/UnmarshalledDataChannelTests.cs b/tests/IgniteUI.Blazor.Tests/UnmarshalledDataChannelTests.cs index ea7b7f7f..9c6e7ffd 100644 --- a/tests/IgniteUI.Blazor.Tests/UnmarshalledDataChannelTests.cs +++ b/tests/IgniteUI.Blazor.Tests/UnmarshalledDataChannelTests.cs @@ -109,26 +109,26 @@ public void ObservableCollectionMutations_EmitChannelMessages() RenderScenario(scenario); items.Add(ComboDataScenarios.AddedProduct()); - var insert = WaitFor("igUnmarshalledDataSourceInsert"); + var insert = WaitFor("unmarshalledDataSourceInsert"); Assert.Equal(3, insert.Index); Assert.Equal(4, Column(insert, "Name").ActualCount); Assert.Equal("Added", Column(insert, "Name").StringValues[3]); items.RemoveAt(0); - var remove = WaitFor("igUnmarshalledDataSourceRemove"); + var remove = WaitFor("unmarshalledDataSourceRemove"); Assert.Equal(0, remove.Index); Assert.Equal(3, Column(remove, "Name").ActualCount); Assert.Equal("Chang", Column(remove, "Name").StringValues[0]); // A replace crosses as remove + insert at the same index, not as an update message. items[0] = ComboDataScenarios.ReplacementProduct(); - var replaceInsert = WaitFor("igUnmarshalledDataSourceInsert", + var replaceInsert = WaitFor("unmarshalledDataSourceInsert", m => m.Index == 0 && Column(m, "Name").StringValues[0] == "Replaced"); Assert.Equal(3, Column(replaceInsert, "Name").ActualCount); // Clear resets the source's columns; the message itself carries none. items.Clear(); - var clear = WaitFor("igUnmarshalledDataSourceClear"); + var clear = WaitFor("unmarshalledDataSourceClear"); Assert.Empty(clear.Columns!); } @@ -143,7 +143,7 @@ private UnmarshalledColumnMessage RenderScenario(ComboDataScenarios.Scenario sce .Add(c => c.ValueKey, scenario.ValueKey) .Add(c => c.DisplayKey, scenario.DisplayKey)); _interop.MakeReady(); - return WaitFor("igUnmarshalledDataSourceCreate"); + return WaitFor("unmarshalledDataSourceCreate"); } private UnmarshalledColumnMessage WaitFor(string methodName, Func? match = null) => diff --git a/tests/js/static-web-assets.test.mjs b/tests/js/static-web-assets.test.mjs index 76090c18..721eb041 100644 --- a/tests/js/static-web-assets.test.mjs +++ b/tests/js/static-web-assets.test.mjs @@ -73,6 +73,34 @@ test('no window state beyond the legacy queue', () => { } }); +test('interop.js exports the client interop the components import', () => { + // The .NET side imports this module by path and calls these names (BaseRendererControl, RuntimeHelper, IgniteUIBlazor). + assert.deepEqual(exportsOf(read('interop.js')), [ + 'checkReady', + 'requestLoad', + 'sendMessage', + 'setResourceString', + 'unmarshalledDataSourceClear', + 'unmarshalledDataSourceCreate', + 'unmarshalledDataSourceCreateDataIntents', + 'unmarshalledDataSourceInsert', + 'unmarshalledDataSourceRemove', + 'unmarshalledDataSourceUpdate', + 'waitForLoaded', + ]); +}); + +test('no interop functions on window', () => { + // The interop is reached through interop.js (JS isolation); a second Ignite UI package brings its own module. + const interopGlobal = + /\big(?:SendMessages?|CheckReady|WaitForLoaded|RequestLoad|SetResourceString|ConvertReturnValue|UnmarshalledDataSource\w*)\b|\braisingEvent\b/; + assert.match('window.igSendMessage = f', interopGlobal); + assert.doesNotMatch('window.igRegisterScript = f', interopGlobal); + for (const f of jsFiles) { + assert.doesNotMatch(read(f), interopGlobal, f); + } +}); + test('no free `global` references (webpack polyfilled it; ESM does not)', () => { const nodeGlobal = /[^\w$.]global\./; assert.match('x=global.getValue', nodeGlobal); // the pattern is live diff --git a/vite.config.mts b/vite.config.mts index 4461bc7b..102f7687 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -6,8 +6,18 @@ const outDir = 'src/wwwroot'; const initializer = 'IgniteUI.Blazor.Lite.lib.module.js'; const licenseManifest = 'THIRD-PARTY-LICENSES.md'; /** Public modules keep fixed names so apps can import them and/or import maps can override them. */ -const fixedNames: Record = { api: 'api.js', 'lit-html': 'lit-html.js', legacyStub: 'app.bundle.js' }; -const entries = { app: 'src/src/index.ts', api: 'src/src/api.ts', legacyStub: 'src/src/app.bundle.ts' }; +const fixedNames: Record = { + api: 'api.js', + interop: 'interop.js', + 'lit-html': 'lit-html.js', + legacyStub: 'app.bundle.js', +}; +const entries = { + app: 'src/src/index.ts', + api: 'src/src/api.ts', + interop: 'src/src/interop.ts', + legacyStub: 'src/src/app.bundle.ts', +}; /** * Emits the Blazor JS initializer as a flat list of static imports. From 89191670c21a45a7cf3f8aaa8fc6faffedfb6ca2 Mon Sep 17 00:00:00 2001 From: Deyan Kamburov Date: Wed, 30 Sep 2026 22:11:19 +0300 Subject: [PATCH 3/9] feat: per-package client interop module for the base classes Builds on the interop module: a component asks for its module through the overridable (private protected) InteropModulePath, so the full IgniteUI.Blazor package's components can talk to their own client runtime while this package's components keep using interop.js. Nothing in either runtime is shared through window. - BaseRendererControl.InteropModulePath (defaults to Lite's interop.js) selects the module for messages, readiness, resource strings and the unmarshalled data channel (RuntimeHelper resolves it lazily). - The runtime tracks client module load requests per interop module, so the same module name requested by two packages loads in both; internal ModuleLoader overloads take the interop module path. - Tests: a component overriding the path sends only to its module, and load requests are routed and tracked per module. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- src/componentsBase/BaseRendererControl.cs | 52 +++++++++++----- src/componentsBase/IIgniteUIBlazorRuntime.cs | 7 ++- .../PerPackageInteropTests.cs | 61 +++++++++++++++++++ 4 files changed, 104 insertions(+), 18 deletions(-) create mode 100644 tests/IgniteUI.Blazor.Tests/PerPackageInteropTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index bae5fd92..69d52333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Infrastructure:** the package can now be extended by the full `IgniteUI.Blazor` package, which takes a dependency on it for the components listed here and builds its charts, grids and Dock Manager on the same runtime. Its infrastructure access is granted only to the Infragistics-signed `IgniteUI.Blazor` assembly. `BaseCollection`, `JsonSerializable`, `SerializationContext`, `SerializationFilter` and `IDataIntentAttribute` are public again but hidden from IntelliSense, since the full package's public collection and value types derive from them; they are not intended for application code. `DataIntentAttribute` is public. Format specifiers (`IgbNumberFormatSpecifier`) declared inside a component of the full package attach to it as content children. +- **Infrastructure:** the package can now be extended by the full `IgniteUI.Blazor` package, which takes a dependency on it for the components listed here and builds its charts, grids and Dock Manager on the same runtime. Its infrastructure access is granted only to the Infragistics-signed `IgniteUI.Blazor` assembly. `BaseCollection`, `JsonSerializable`, `SerializationContext`, `SerializationFilter` and `IDataIntentAttribute` are public again but hidden from IntelliSense, since the full package's public collection and value types derive from them; they are not intended for application code. `DataIntentAttribute` is public. Format specifiers (`IgbNumberFormatSpecifier`) declared inside a component of the full package attach to it as content children. The full package's components use their own client interop module next to this package's, and load their client modules through it. - **Interop:** the components reach their client code through the `_content/IgniteUI.Blazor/interop.js` module (JS isolation) instead of functions on `window`. The internal `window.igSendMessage`, `igCheckReady`, `igWaitForLoaded`, `igRequestLoad`, `igSetResourceString`, `igConvertReturnValue` and `igUnmarshalledDataSource*` functions, the unused `igSendMessages` and the `raisingEvent` flag no longer exist. They were never a supported API. On .NET 8 WebAssembly, unmarshalled data now travels on the same path as on .NET 9+. [#255](https://github.com/IgniteUI/igniteui-blazor/issues/255) ## 0.4.0 - 2026-09-30 diff --git a/src/componentsBase/BaseRendererControl.cs b/src/componentsBase/BaseRendererControl.cs index 7649fe3b..fa006502 100644 --- a/src/componentsBase/BaseRendererControl.cs +++ b/src/componentsBase/BaseRendererControl.cs @@ -68,7 +68,14 @@ protected IIgniteUIBlazor IgBlazor private InteropModule? _interop; /// The client interop module this component talks to. - internal InteropModule Interop => _interop ??= Runtime.GetInteropModule(InteropModule.LitePath); + internal InteropModule Interop => _interop ??= Runtime.GetInteropModule(InteropModulePath); + + /// + /// The path of the client interop module that renders this component. Components of another Ignite UI package + /// (the full IgniteUI.Blazor) override it with their own module, so each package's client runtime handles its + /// own components and the two never share window state. + /// + private protected virtual string InteropModulePath => InteropModule.LitePath; /// Requests the client modules this component needs; override to register more through . protected virtual void EnsureModulesLoaded() @@ -3698,16 +3705,20 @@ public IgniteUIBlazor(IJSRuntime runtime) public WebCallback WebCallback { get; private set; } private ConcurrentDictionary _loadedCache = new ConcurrentDictionary(); - public void RequestLoad(string moduleName) + public void RequestLoad(string moduleName, string interopModulePath = InteropModule.LitePath) { - if (!IsRuntimeValid() || _loadedCache.ContainsKey(moduleName)) + var key = LoadKey(moduleName, interopModulePath); + if (!IsRuntimeValid() || _loadedCache.ContainsKey(key)) { return; } - _loadedCache.AddOrUpdate(moduleName, true, (name, oldValue) => true); - GetInteropModule(InteropModule.LitePath).Post(module => _ = module.InvokeVoidAsync("requestLoad", moduleName)); + _loadedCache.AddOrUpdate(key, true, (name, oldValue) => true); + GetInteropModule(interopModulePath).Post(module => _ = module.InvokeVoidAsync("requestLoad", moduleName)); } + private static string LoadKey(string moduleName, string interopModulePath) => + interopModulePath == InteropModule.LitePath ? moduleName : interopModulePath + "|" + moduleName; + private readonly ConcurrentDictionary _interopModules = new ConcurrentDictionary(); public InteropModule GetInteropModule(string path) @@ -3733,22 +3744,19 @@ public void Dispose() } _interopModules.Clear(); } - public bool IsLoadRequested(string moduleName) + public bool IsLoadRequested(string moduleName, string interopModulePath = InteropModule.LitePath) { - if (_loadedCache.ContainsKey(moduleName)) - { - return true; - } - return false; + return _loadedCache.ContainsKey(LoadKey(moduleName, interopModulePath)); } - public void MarkIsLoadRequested(string moduleName) + public void MarkIsLoadRequested(string moduleName, string interopModulePath = InteropModule.LitePath) { - if (!IsRuntimeValid() || _loadedCache.ContainsKey(moduleName)) + var key = LoadKey(moduleName, interopModulePath); + if (!IsRuntimeValid() || _loadedCache.ContainsKey(key)) { return; } - _loadedCache.AddOrUpdate(moduleName, true, (name, oldValue) => true); + _loadedCache.AddOrUpdate(key, true, (name, oldValue) => true); } [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Probes the optional RemoteJSRuntime.IsInitialized, which does not exist in supported trim targets (WASM, WebView); a string DynamicDependency is not an option — it fails with IL2035 where the Server assembly is absent.")] @@ -3891,11 +3899,27 @@ public static void Load(IIgniteUIBlazor runtime, string moduleName) runtime.AsRuntime().RequestLoad(moduleName); } + /// Requests from the interop module of another Ignite UI package. + internal static void Load(IIgniteUIBlazor runtime, string moduleName, string interopModulePath) + { + runtime.AsRuntime().RequestLoad(moduleName, interopModulePath); + } + internal static void MarkIsLoadRequested(IIgniteUIBlazor runtime, string moduleName) { runtime.AsRuntime().MarkIsLoadRequested(moduleName); } + internal static void MarkIsLoadRequested(IIgniteUIBlazor runtime, string moduleName, string interopModulePath) + { + runtime.AsRuntime().MarkIsLoadRequested(moduleName, interopModulePath); + } + + internal static bool IsLoadRequested(IIgniteUIBlazor runtime, string moduleName, string interopModulePath) + { + return runtime.AsRuntime().IsLoadRequested(moduleName, interopModulePath); + } + internal static bool IsLoadRequested(IIgniteUIBlazor runtime, string moduleName) { return runtime.AsRuntime().IsLoadRequested(moduleName); diff --git a/src/componentsBase/IIgniteUIBlazorRuntime.cs b/src/componentsBase/IIgniteUIBlazorRuntime.cs index af7942f2..fc18f5d3 100644 --- a/src/componentsBase/IIgniteUIBlazorRuntime.cs +++ b/src/componentsBase/IIgniteUIBlazorRuntime.cs @@ -11,9 +11,10 @@ internal interface IIgniteUIBlazorRuntime IJSRuntime JsRuntime { get; } IIgniteUIBlazorSettings? Settings { get; } WebCallback WebCallback { get; } - void RequestLoad(string moduleName); - bool IsLoadRequested(string moduleName); - void MarkIsLoadRequested(string moduleName); + // Load requests are tracked per interop module: each Ignite UI package loads its client modules through its own. + void RequestLoad(string moduleName, string interopModulePath = InteropModule.LitePath); + bool IsLoadRequested(string moduleName, string interopModulePath = InteropModule.LitePath); + void MarkIsLoadRequested(string moduleName, string interopModulePath = InteropModule.LitePath); bool IsRuntimeValid(bool reevaluate = false); /// The client interop module at , shared by the components of the runtime. diff --git a/tests/IgniteUI.Blazor.Tests/PerPackageInteropTests.cs b/tests/IgniteUI.Blazor.Tests/PerPackageInteropTests.cs new file mode 100644 index 00000000..9126e2b2 --- /dev/null +++ b/tests/IgniteUI.Blazor.Tests/PerPackageInteropTests.cs @@ -0,0 +1,61 @@ +using Bunit; +using IgniteUI.Blazor.Controls; +using IgniteUI.Blazor.Tests.Interop; + +namespace IgniteUI.Blazor.Tests; + +/// +/// Each Ignite UI package talks to its own client interop module: the components of another package (the full +/// IgniteUI.Blazor) override InteropModulePath, and their traffic and load requests go to that module only. +/// +public class PerPackageInteropTests : BlazorComponentTestBase +{ + private const string OtherPath = "./_content/IgniteUI.Blazor/other/interop.js"; + + private BunitJSInterop LiteModule => ((RendererMessageInteropHarness)Interop).Module; + + [Fact] + public void Component_WithOwnInteropModule_SendsItsMessagesThere() + { + var other = JSInterop.SetupModule(OtherPath); + other.Setup("checkReady", _ => true).SetResult(true); + other.SetupVoid("waitForLoaded", _ => true).SetVoidResult(); + Interop.PrimeReady(); + + var otherCut = Render(); + var liteCut = Render(); + var otherId = Interop.ContainerIdOf(otherCut); + var liteId = Interop.ContainerIdOf(liteCut); + Interop.MakeReady(); + + otherCut.WaitForAssertion(() => Assert.Contains(other.Invocations, i => i.Identifier == "sendMessage" && (string?)i.Arguments[0] == otherId)); + liteCut.WaitForAssertion(() => Assert.Contains(LiteModule.Invocations, i => i.Identifier == "sendMessage" && (string?)i.Arguments[0] == liteId)); + + Assert.DoesNotContain(LiteModule.Invocations, i => i.Arguments.Count > 0 && (string?)i.Arguments[0] == otherId); + Assert.DoesNotContain(other.Invocations, i => i.Arguments.Count > 0 && (string?)i.Arguments[0] == liteId); + } + + [Fact] + public void ModuleLoader_TracksLoadRequestsPerInteropModule() + { + var other = JSInterop.SetupModule(OtherPath); + + ModuleLoader.Load(IgniteUIBlazor, "SharedNameModule", OtherPath); + + Assert.True(ModuleLoader.IsLoadRequested(IgniteUIBlazor, "SharedNameModule", OtherPath)); + Assert.False(ModuleLoader.IsLoadRequested(IgniteUIBlazor, "SharedNameModule")); + Assert.Contains(other.Invocations, i => i.Identifier == "requestLoad" && (string?)i.Arguments[0] == "SharedNameModule"); + Assert.DoesNotContain(LiteModule.Invocations, i => i.Identifier == "requestLoad" && (string?)i.Arguments[0] == "SharedNameModule"); + + ModuleLoader.Load(IgniteUIBlazor, "SharedNameModule"); + + Assert.True(ModuleLoader.IsLoadRequested(IgniteUIBlazor, "SharedNameModule")); + Assert.Contains(LiteModule.Invocations, i => i.Identifier == "requestLoad" && (string?)i.Arguments[0] == "SharedNameModule"); + Assert.Single(other.Invocations, i => i.Identifier == "requestLoad"); + } + + private sealed class OtherPackageCalendar : IgbCalendar + { + private protected override string InteropModulePath => OtherPath; + } +} From 31e1910d33c941ff3ffb8214843b2f2f7900306f Mon Sep 17 00:00:00 2001 From: Deyan Kamburov Date: Wed, 30 Sep 2026 22:15:27 +0300 Subject: [PATCH 4/9] build: initializer preloads interop.js (#256) The JS initializer is the whole bootstrap: a flat list of static imports that Blazor awaits before it starts. It now includes interop.js next to the app entry and api.js, so the module the components import through JS isolation is already evaluated when the first component asks for it - its import() resolves to that instance without another fetch, and no startup step depends on window state (the former document.write / window-guard bootstrap of the full package is not needed on this path). static-web-assets tests assert the initializer covers interop.js. Verified with the Playwright integration suite (89/89). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/js/static-web-assets.test.mjs | 3 ++- vite.config.mts | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/js/static-web-assets.test.mjs b/tests/js/static-web-assets.test.mjs index 721eb041..f0b3c6fc 100644 --- a/tests/js/static-web-assets.test.mjs +++ b/tests/js/static-web-assets.test.mjs @@ -24,7 +24,7 @@ test('no emitted file inspects its own URL', () => { } }); -test('initializer is a flat import list covering the app entry and api.js', () => { +test('initializer is a flat import list covering the app entry, api.js and interop.js', () => { const lines = read('IgniteUI.Blazor.Lite.lib.module.js').trim().split('\n'); const imported = lines.map((l) => { const m = /^import '\.\/(.+)';$/.exec(l); @@ -37,6 +37,7 @@ test('initializer is a flat import list covering the app entry and api.js', () = 'app entry missing', ); assert.ok(imported.includes('api.js'), 'api.js missing'); + assert.ok(imported.includes('interop.js'), 'interop.js missing'); }); test('api.js exports everything api.d.ts declares', () => { diff --git a/vite.config.mts b/vite.config.mts index 102f7687..7821410c 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -23,6 +23,8 @@ const entries = { * Emits the Blazor JS initializer as a flat list of static imports. * Blazor awaits the import, so no startup hooks required for that. * api.js included for the deprecated window globals before Blazor starts. + * interop.js included so the module the components import is already evaluated: their import() of it resolves to + * this instance without another fetch, and nothing about startup depends on window. */ function emitInitializer(): Plugin { return { @@ -44,6 +46,10 @@ function emitInitializer(): Plugin { throw this.error(`no ${fixedNames.api} entry chunk — cannot emit ${initializer}`); } visit(fixedNames.api); + if (bundle[fixedNames.interop]?.type !== 'chunk') { + throw this.error(`no ${fixedNames.interop} entry chunk — cannot emit ${initializer}`); + } + visit(fixedNames.interop); this.emitFile({ type: 'asset', fileName: initializer, From 778c6a7e570d51a46200d3ab598e00a71eda4074 Mon Sep 17 00:00:00 2001 From: Deyan Kamburov Date: Wed, 30 Sep 2026 22:16:31 +0300 Subject: [PATCH 5/9] docs: pin the contract with the full IgniteUI.Blazor package docs/PACKAGE-CONTRACT.md lists what the full package relies on: friend access pinned to the Infragistics key, the public-hidden base types, the content-child host, the per-package interop module, the fixed asset names under _content/IgniteUI.Blazor (interop.js exports, api.js, lit-html.js, themes) and the absence of window interop state. api.js's getRegisteredScript stays @internal (out of api.d.ts) but is now documented as a stable cross-package export: the full package's runtime resolves *Script parameters through it, so one registry serves both. static-web-assets tests pin the fixed file names and that export. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- docs/PACKAGE-CONTRACT.md | 38 +++++++++++++++++++++++++++++ src/src/api.ts | 8 +++++- tests/js/static-web-assets.test.mjs | 7 ++++++ 4 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 docs/PACKAGE-CONTRACT.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 69d52333..85ec5c31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Infrastructure:** the package can now be extended by the full `IgniteUI.Blazor` package, which takes a dependency on it for the components listed here and builds its charts, grids and Dock Manager on the same runtime. Its infrastructure access is granted only to the Infragistics-signed `IgniteUI.Blazor` assembly. `BaseCollection`, `JsonSerializable`, `SerializationContext`, `SerializationFilter` and `IDataIntentAttribute` are public again but hidden from IntelliSense, since the full package's public collection and value types derive from them; they are not intended for application code. `DataIntentAttribute` is public. Format specifiers (`IgbNumberFormatSpecifier`) declared inside a component of the full package attach to it as content children. The full package's components use their own client interop module next to this package's, and load their client modules through it. +- **Infrastructure:** the package can now be extended by the full `IgniteUI.Blazor` package, which takes a dependency on it for the components listed here and builds its charts, grids and Dock Manager on the same runtime. Its infrastructure access is granted only to the Infragistics-signed `IgniteUI.Blazor` assembly. `BaseCollection`, `JsonSerializable`, `SerializationContext`, `SerializationFilter` and `IDataIntentAttribute` are public again but hidden from IntelliSense, since the full package's public collection and value types derive from them; they are not intended for application code. `DataIntentAttribute` is public. Format specifiers (`IgbNumberFormatSpecifier`) declared inside a component of the full package attach to it as content children. The full package's components use their own client interop module next to this package's, and load their client modules through it. What the full package relies on is listed in [docs/PACKAGE-CONTRACT.md](docs/PACKAGE-CONTRACT.md). - **Interop:** the components reach their client code through the `_content/IgniteUI.Blazor/interop.js` module (JS isolation) instead of functions on `window`. The internal `window.igSendMessage`, `igCheckReady`, `igWaitForLoaded`, `igRequestLoad`, `igSetResourceString`, `igConvertReturnValue` and `igUnmarshalledDataSource*` functions, the unused `igSendMessages` and the `raisingEvent` flag no longer exist. They were never a supported API. On .NET 8 WebAssembly, unmarshalled data now travels on the same path as on .NET 9+. [#255](https://github.com/IgniteUI/igniteui-blazor/issues/255) ## 0.4.0 - 2026-09-30 diff --git a/docs/PACKAGE-CONTRACT.md b/docs/PACKAGE-CONTRACT.md new file mode 100644 index 00000000..b2320bfe --- /dev/null +++ b/docs/PACKAGE-CONTRACT.md @@ -0,0 +1,38 @@ +# Contract with the full IgniteUI.Blazor package + +The licensed/Trial `IgniteUI.Blazor` package depends on this package: it gets the components listed in the README +from here, and builds its charts, grids and Dock Manager on the same base classes and next to the same client code. +Everything below is what it relies on. None of it is application API. Changing any item is a breaking change for +that package, so coordinate it with the package's maintainers. + +## Assemblies + +- `InternalsVisibleTo("IgniteUI.Blazor")`, pinned to the Infragistics public key (`eng/IG.publickey.hex`). The full + package overrides and calls the base classes' `internal` / `private protected` infrastructure members. +- Public, but `[EditorBrowsable(Never)]`, because public types of the full package derive from them: + `BaseCollection`, `JsonSerializable`, `SerializationContext`, `SerializationFilter`, `IDataIntentAttribute`. +- `IContentChildHost`: components that implement it cascade themselves to their child content, and elements of + this package that the full package's components hold as content children (`IgbFormatSpecifier` and derived) + attach to the nearest host. +- `BaseRendererControl.InteropModulePath`: each package's components use their own client interop module. Client + module load requests are tracked per interop module (`ModuleLoader.Load(runtime, name, interopModulePath)`). +- `RendererSerializer.Context` / `ShouldWrite`: used to serialize the full package's drawing types (points, rects, + brushes) as extension methods. + +## Static web assets (`_content/IgniteUI.Blazor/`) + +Both packages serve from the same base path, so the file routes must not overlap. + +| File | Contract | +|---|---| +| `interop.js` | The client interop module the components import (JS isolation). Exports: `sendMessage`, `checkReady`, `waitForLoaded`, `requestLoad`, `setResourceString`, `unmarshalledDataSource{Create,CreateDataIntents,Insert,Update,Remove,Clear}`. The full package ships its own module under another path. | +| `api.js` | Public script API (`registerScript`, `removeScript`, `html`). Its `getRegisteredScript` export is internal but stable: the full package's runtime reads the same registry through it, so scripts registered once serve both packages. | +| `lit-html.js` | The lit-html instance both runtimes render templates with. | +| `themes/**` | The web components themes. The full package ships only `themes/grid/**`. | +| `IgniteUI.Blazor.Lite.lib.module.js` | This package's JS initializer. The full package has its own (`IgniteUI.Blazor[.Trial].lib.module.js`). | + +This package defines no interop functions or state on `window`. The legacy script globals +(`igRegisterScript` / `igRemoveScript` / `igTemplating`) and `app.bundle.js`, where still present, are being +removed. The full package takes them over for its own users. + +`tests/js/static-web-assets.test.mjs` pins the file names and the `interop.js` / `api.js` exports. diff --git a/src/src/api.ts b/src/src/api.ts index c2afc9b3..8f805be5 100644 --- a/src/src/api.ts +++ b/src/src/api.ts @@ -35,7 +35,13 @@ export function removeScript(name: string): void { scripts.delete(name); } -/** Gets a previously registered script, if any. @internal */ +/** + * Gets a previously registered script, if any. + * + * Contract with the other Ignite UI packages (the full IgniteUI.Blazor): their client runtime resolves `*Script` + * parameters through this function, so there is one registry for both packages. Kept under this name and + * signature; see docs/PACKAGE-CONTRACT.md. Not an application API. @internal + */ export function getRegisteredScript(name: string): RegisteredScript | undefined { return scripts.get(name); } diff --git a/tests/js/static-web-assets.test.mjs b/tests/js/static-web-assets.test.mjs index f0b3c6fc..f5366985 100644 --- a/tests/js/static-web-assets.test.mjs +++ b/tests/js/static-web-assets.test.mjs @@ -91,6 +91,13 @@ test('interop.js exports the client interop the components import', () => { ]); }); +test('package contract: fixed file names and the registry export the full package imports', () => { + // docs/PACKAGE-CONTRACT.md - the full IgniteUI.Blazor package imports these by name. + for (const f of ['api.js', 'interop.js', 'lit-html.js', 'IgniteUI.Blazor.Lite.lib.module.js']) { + assert.ok(jsFiles.includes(f), `missing ${f}`); + } + assert.ok(exportsOf(read('api.js')).includes('getRegisteredScript'), 'api.js no longer exports getRegisteredScript'); +}); test('no interop functions on window', () => { // The interop is reached through interop.js (JS isolation); a second Ignite UI package brings its own module. const interopGlobal = From 4db3da2f82c2b07b3af56d2def353c4ce92b7c46 Mon Sep 17 00:00:00 2001 From: Deyan Kamburov Date: Wed, 30 Sep 2026 22:43:07 +0300 Subject: [PATCH 6/9] feat: remaining extension points for the full package build Found by compiling the full IgniteUI.Blazor package against this package: - MarshalByValueFactory.AddProvider: the full package registers its own by-value types (chart/grid value objects), consulted after this package's. - BaseRendererControl.FindByName is internal virtual, like the element's, so generated types override both the same way. - The shared client custom elements (igc-portal-*, igc-template-container, igc-template-content, and those registered through RegisterElementHelper) are defined only when not defined yet: with both packages' client runtimes on a page, the second definition threw and stopped that runtime from loading. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- docs/PACKAGE-CONTRACT.md | 5 ++- src/components/Blazor/Accordion.cs | 2 +- src/components/Blazor/Dropdown.cs | 2 +- src/components/Blazor/Select.cs | 2 +- src/components/Blazor/Tabs.cs | 2 +- src/components/Blazor/TileManager.cs | 2 +- src/components/Blazor/Tree.cs | 2 +- src/componentsBase/BaseRendererControl.cs | 2 +- src/componentsBase/MarshalByValueFactory.cs | 42 +++++++++++++++++++ src/src/ig/igniteui-core/igc-portal.ts | 6 +-- .../igc-template-container-component.ts | 2 +- .../igc-template-content-component.ts | 2 +- src/src/ig/igniteui-core/module-manager.ts | 2 +- .../MarshalByValueProviderTests.cs | 22 ++++++++++ 15 files changed, 82 insertions(+), 15 deletions(-) create mode 100644 tests/IgniteUI.Blazor.Tests/MarshalByValueProviderTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ec5c31..840dab1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Infrastructure:** the package can now be extended by the full `IgniteUI.Blazor` package, which takes a dependency on it for the components listed here and builds its charts, grids and Dock Manager on the same runtime. Its infrastructure access is granted only to the Infragistics-signed `IgniteUI.Blazor` assembly. `BaseCollection`, `JsonSerializable`, `SerializationContext`, `SerializationFilter` and `IDataIntentAttribute` are public again but hidden from IntelliSense, since the full package's public collection and value types derive from them; they are not intended for application code. `DataIntentAttribute` is public. Format specifiers (`IgbNumberFormatSpecifier`) declared inside a component of the full package attach to it as content children. The full package's components use their own client interop module next to this package's, and load their client modules through it. What the full package relies on is listed in [docs/PACKAGE-CONTRACT.md](docs/PACKAGE-CONTRACT.md). +- **Infrastructure:** the package can now be extended by the full `IgniteUI.Blazor` package, which takes a dependency on it for the components listed here and builds its charts, grids and Dock Manager on the same runtime. Its infrastructure access is granted only to the Infragistics-signed `IgniteUI.Blazor` assembly. `BaseCollection`, `JsonSerializable`, `SerializationContext`, `SerializationFilter` and `IDataIntentAttribute` are public again but hidden from IntelliSense, since the full package's public collection and value types derive from them; they are not intended for application code. `DataIntentAttribute` is public. Format specifiers (`IgbNumberFormatSpecifier`) declared inside a component of the full package attach to it as content children. The full package's components use their own client interop module next to this package's, and load their client modules through it. What the full package relies on is listed in [docs/PACKAGE-CONTRACT.md](docs/PACKAGE-CONTRACT.md). The shared client elements (portals, template containers) are defined only once when both packages' client runtimes load on a page. - **Interop:** the components reach their client code through the `_content/IgniteUI.Blazor/interop.js` module (JS isolation) instead of functions on `window`. The internal `window.igSendMessage`, `igCheckReady`, `igWaitForLoaded`, `igRequestLoad`, `igSetResourceString`, `igConvertReturnValue` and `igUnmarshalledDataSource*` functions, the unused `igSendMessages` and the `raisingEvent` flag no longer exist. They were never a supported API. On .NET 8 WebAssembly, unmarshalled data now travels on the same path as on .NET 9+. [#255](https://github.com/IgniteUI/igniteui-blazor/issues/255) ## 0.4.0 - 2026-09-30 diff --git a/docs/PACKAGE-CONTRACT.md b/docs/PACKAGE-CONTRACT.md index b2320bfe..426fa399 100644 --- a/docs/PACKAGE-CONTRACT.md +++ b/docs/PACKAGE-CONTRACT.md @@ -18,6 +18,8 @@ that package, so coordinate it with the package's maintainers. module load requests are tracked per interop module (`ModuleLoader.Load(runtime, name, interopModulePath)`). - `RendererSerializer.Context` / `ShouldWrite`: used to serialize the full package's drawing types (points, rects, brushes) as extension methods. +- `MarshalByValueFactory.AddProvider`: the full package registers its by-value types (chart and grid value objects). +- `FindByName` is `internal virtual` on both base classes, so the full package's generated types override it the same way. ## Static web assets (`_content/IgniteUI.Blazor/`) @@ -31,7 +33,8 @@ Both packages serve from the same base path, so the file routes must not overlap | `themes/**` | The web components themes. The full package ships only `themes/grid/**`. | | `IgniteUI.Blazor.Lite.lib.module.js` | This package's JS initializer. The full package has its own (`IgniteUI.Blazor[.Trial].lib.module.js`). | -This package defines no interop functions or state on `window`. The legacy script globals +This package defines no interop functions or state on `window`. Custom elements shared by both client runtimes (`igc-portal-*`, +`igc-template-container`, `igc-template-content`) are defined only if not defined yet, whichever runtime loads first. The legacy script globals (`igRegisterScript` / `igRemoveScript` / `igTemplating`) and `app.bundle.js`, where still present, are being removed. The full package takes them over for its own users. diff --git a/src/components/Blazor/Accordion.cs b/src/components/Blazor/Accordion.cs index 72a53a5a..f754474e 100644 --- a/src/components/Blazor/Accordion.cs +++ b/src/components/Blazor/Accordion.cs @@ -80,7 +80,7 @@ public bool SingleExpand } /// - private protected override object? FindByName(string name) + internal override object? FindByName(string name) { var baseResult = base.FindByName(name); if (baseResult != null) diff --git a/src/components/Blazor/Dropdown.cs b/src/components/Blazor/Dropdown.cs index 939584df..e6b9f35c 100644 --- a/src/components/Blazor/Dropdown.cs +++ b/src/components/Blazor/Dropdown.cs @@ -231,7 +231,7 @@ public IgbDropdownGroup[] GetGroups() } /// - private protected override object? FindByName(string name) + internal override object? FindByName(string name) { var baseResult = base.FindByName(name); if (baseResult != null) diff --git a/src/components/Blazor/Select.cs b/src/components/Blazor/Select.cs index ef578d7a..b6b4d7ca 100644 --- a/src/components/Blazor/Select.cs +++ b/src/components/Blazor/Select.cs @@ -362,7 +362,7 @@ public bool Invalid } /// - private protected override object? FindByName(string name) + internal override object? FindByName(string name) { var baseResult = base.FindByName(name); if (baseResult != null) diff --git a/src/components/Blazor/Tabs.cs b/src/components/Blazor/Tabs.cs index e0e0364a..54d95aa9 100644 --- a/src/components/Blazor/Tabs.cs +++ b/src/components/Blazor/Tabs.cs @@ -252,7 +252,7 @@ public string GetSelected() } /// - private protected override object? FindByName(string name) + internal override object? FindByName(string name) { var baseResult = base.FindByName(name); if (baseResult != null) diff --git a/src/components/Blazor/TileManager.cs b/src/components/Blazor/TileManager.cs index d545d0fc..70358d7c 100644 --- a/src/components/Blazor/TileManager.cs +++ b/src/components/Blazor/TileManager.cs @@ -205,7 +205,7 @@ public IgbTile[] GetTiles() } /// - private protected override object? FindByName(string name) + internal override object? FindByName(string name) { var baseResult = base.FindByName(name); if (baseResult != null) diff --git a/src/components/Blazor/Tree.cs b/src/components/Blazor/Tree.cs index 52f766a0..01249f84 100644 --- a/src/components/Blazor/Tree.cs +++ b/src/components/Blazor/Tree.cs @@ -119,7 +119,7 @@ public TreeSelection Selection } /// - private protected override object? FindByName(string name) + internal override object? FindByName(string name) { var baseResult = base.FindByName(name); if (baseResult != null) diff --git a/src/componentsBase/BaseRendererControl.cs b/src/componentsBase/BaseRendererControl.cs index fa006502..46cf3511 100644 --- a/src/componentsBase/BaseRendererControl.cs +++ b/src/componentsBase/BaseRendererControl.cs @@ -2192,7 +2192,7 @@ internal void OnInvokeReturn(long invokeId, Object returnValue) } /// Resolves to the child element it identifies, or null. - private protected virtual object? FindByName(string name) + internal virtual object? FindByName(string name) { if ("mainControl".Equals(name)) { diff --git a/src/componentsBase/MarshalByValueFactory.cs b/src/componentsBase/MarshalByValueFactory.cs index fea6266e..32f77abb 100644 --- a/src/componentsBase/MarshalByValueFactory.cs +++ b/src/componentsBase/MarshalByValueFactory.cs @@ -2,6 +2,30 @@ namespace IgniteUI.Blazor.Controls { internal class MarshalByValueFactory { + private static readonly List<(Func MustMarshal, Func Create)> _providers = new(); + + /// + /// Adds the by-value types of another Ignite UI package (the full IgniteUI.Blazor), consulted after this package's own. + /// + internal static void AddProvider(Func mustMarshalByValue, Func createInstance) + { + lock (_providers) + { + _providers.Add((mustMarshalByValue, createInstance)); + } + } + + private static (Func MustMarshal, Func Create)[] Providers + { + get + { + lock (_providers) + { + return _providers.ToArray(); + } + } + } + internal static bool MustMarshalByValue(string? typeName) { switch (typeName) @@ -144,6 +168,16 @@ internal static bool MustMarshalByValue(string? typeName) //@@MustMarshalByValueEnd } + if (typeName != null) + { + foreach (var provider in Providers) + { + if (provider.MustMarshal(typeName)) + { + return true; + } + } + } return false; } @@ -289,6 +323,14 @@ internal static bool MustMarshalByValue(string? typeName) //@@MarshalByValueEnd } + foreach (var provider in Providers) + { + var instance = provider.Create(typeName); + if (instance != null) + { + return instance; + } + } return null; } diff --git a/src/src/ig/igniteui-core/igc-portal.ts b/src/src/ig/igniteui-core/igc-portal.ts index 4ebe5b6a..96ab6a75 100644 --- a/src/src/ig/igniteui-core/igc-portal.ts +++ b/src/src/ig/igniteui-core/igc-portal.ts @@ -345,8 +345,8 @@ class PortalMessage { export class IgcPortalModule { public static register() { - window.customElements.define("igc-portal-entrance", IgcPortalEntranceComponent); - window.customElements.define("igc-portal-exit", IgcPortalExitComponent); - window.customElements.define("igc-portal-item", IgcPortalItemComponent); + if (!window.customElements.get("igc-portal-entrance")) { window.customElements.define("igc-portal-entrance", IgcPortalEntranceComponent); } + if (!window.customElements.get("igc-portal-exit")) { window.customElements.define("igc-portal-exit", IgcPortalExitComponent); } + if (!window.customElements.get("igc-portal-item")) { window.customElements.define("igc-portal-item", IgcPortalItemComponent); } } } \ No newline at end of file diff --git a/src/src/ig/igniteui-core/igc-template-container-component.ts b/src/src/ig/igniteui-core/igc-template-container-component.ts index 65b2b2d3..748fdfd5 100644 --- a/src/src/ig/igniteui-core/igc-template-container-component.ts +++ b/src/src/ig/igniteui-core/igc-template-container-component.ts @@ -13,7 +13,7 @@ export class IgcTemplateContainerComponent extends IgcTemplateContentComponent { if (!IgcTemplateContainerComponent._registered) { IgcTemplateContainerComponent._registered = true; if (window.customElements) { - window.customElements.define(IgcTemplateContainerComponent.htmlTagName, IgcTemplateContainerComponent); + if (!window.customElements.get(IgcTemplateContainerComponent.htmlTagName)) { window.customElements.define(IgcTemplateContainerComponent.htmlTagName, IgcTemplateContainerComponent); } } else { (document).registerElement(IgcTemplateContainerComponent.htmlTagName, IgcTemplateContainerComponent); } diff --git a/src/src/ig/igniteui-core/igc-template-content-component.ts b/src/src/ig/igniteui-core/igc-template-content-component.ts index 29de6187..470e06fb 100644 --- a/src/src/ig/igniteui-core/igc-template-content-component.ts +++ b/src/src/ig/igniteui-core/igc-template-content-component.ts @@ -119,7 +119,7 @@ export class IgcTemplateContentComponent extends IgcHTMLElement { if (!IgcTemplateContentComponent._registered) { IgcTemplateContentComponent._registered = true; if (window.customElements) { - window.customElements.define(IgcTemplateContentComponent.htmlTagName, IgcTemplateContentComponent); + if (!window.customElements.get(IgcTemplateContentComponent.htmlTagName)) { window.customElements.define(IgcTemplateContentComponent.htmlTagName, IgcTemplateContentComponent); } } else { (document).registerElement(IgcTemplateContentComponent.htmlTagName, IgcTemplateContentComponent); } diff --git a/src/src/ig/igniteui-core/module-manager.ts b/src/src/ig/igniteui-core/module-manager.ts index 98d38f3c..2a723e26 100644 --- a/src/src/ig/igniteui-core/module-manager.ts +++ b/src/src/ig/igniteui-core/module-manager.ts @@ -19,7 +19,7 @@ export class RegisterElementHelper { } RegisterElementHelper._registeredSet.add(tagName); if (window.customElements) { - window.customElements.define(tagName, cons as any); + if (!window.customElements.get(tagName)) { window.customElements.define(tagName, cons as any); } } else { (document).registerElement(tagName, cons as any); } diff --git a/tests/IgniteUI.Blazor.Tests/MarshalByValueProviderTests.cs b/tests/IgniteUI.Blazor.Tests/MarshalByValueProviderTests.cs new file mode 100644 index 00000000..e13ea807 --- /dev/null +++ b/tests/IgniteUI.Blazor.Tests/MarshalByValueProviderTests.cs @@ -0,0 +1,22 @@ +using IgniteUI.Blazor.Controls; + +namespace IgniteUI.Blazor.Tests; + +/// The full IgniteUI.Blazor package registers its by-value types next to this package's own. +public class MarshalByValueProviderTests +{ + [Fact] + public void AddProvider_ExtendsMustMarshalAndCreate_AfterOwnTypes() + { + var created = new object(); + MarshalByValueFactory.AddProvider( + name => name == "ProviderTestOnlyType", + name => name == "ProviderTestOnlyType" ? created : null); + + Assert.True(MarshalByValueFactory.MustMarshalByValue("ProviderTestOnlyType")); + Assert.Same(created, MarshalByValueFactory.CreateInstance("ProviderTestOnlyType")); + Assert.False(MarshalByValueFactory.MustMarshalByValue("SomeUnknownType")); + Assert.Null(MarshalByValueFactory.CreateInstance("SomeUnknownType")); + Assert.True(MarshalByValueFactory.MustMarshalByValue("CalendarFormatOptions")); + } +} From f4679f7d6a551d2fe11162b688503b77c19c03a5 Mon Sep 17 00:00:00 2001 From: Deyan Kamburov Date: Mon, 5 Oct 2026 11:24:30 +0300 Subject: [PATCH 7/9] docs: note why BaseRendererElement.Name keeps no [Parameter] Documents, for the next person touching this, why Name intentionally has no [Parameter] (per #436) and where the full package gets a Name markup attribute instead: its generator shadows Name with 'new' + [Parameter] on its own root, non-MarshalByValue BaseRendererElement-derived types (chart axes, series, gauges), rather than this package reintroducing [Parameter] on the shared base - which would retrigger analyzer BL0005 on this package's own MarshalByValue types (e.g. IgbIconMeta) that still use plain object-initializer syntax in tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/PACKAGE-CONTRACT.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/PACKAGE-CONTRACT.md b/docs/PACKAGE-CONTRACT.md index 426fa399..df97006c 100644 --- a/docs/PACKAGE-CONTRACT.md +++ b/docs/PACKAGE-CONTRACT.md @@ -20,6 +20,11 @@ that package, so coordinate it with the package's maintainers. brushes) as extension methods. - `MarshalByValueFactory.AddProvider`: the full package registers its by-value types (chart and grid value objects). - `FindByName` is `internal virtual` on both base classes, so the full package's generated types override it the same way. +- `BaseRendererElement.Name` has no `[Parameter]` (see `#436`): it also backs non-rendered `MarshalByValueAttribute` + types (e.g. `IgbIconMeta`), where `[Parameter]` trips analyzer BL0005 on ordinary object-initializer usage. The + full package renders several `BaseRendererElement`-derived types (chart axes, series, gauges) as components with + a `Name` markup attribute (e.g. ``); its generator shadows `Name` with `new` and + `[Parameter]` on exactly those root, non-MarshalByValue types instead of asking for it here. ## Static web assets (`_content/IgniteUI.Blazor/`) From e72e9094c3a51653a83922a92f233d038ccb88ff Mon Sep 17 00:00:00 2001 From: Deyan Kamburov Date: Tue, 6 Oct 2026 09:52:45 +0300 Subject: [PATCH 8/9] refactor: update infrastructure and internal access for extensibility in IgniteUI.Blazor package --- CHANGELOG.md | 2 +- docs/PACKAGE-CONTRACT.md | 3 +-- src/componentsBase/BaseCollection.cs | 23 +---------------- src/componentsBase/DataIntents.cs | 19 +++----------- src/componentsBase/JsonSerializable.cs | 35 +++----------------------- 5 files changed, 9 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 862cf3d1..77c19801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Infrastructure:** the package can now be extended by the full `IgniteUI.Blazor` package, which takes a dependency on it for the components listed here and builds its charts, grids and Dock Manager on the same runtime. Its infrastructure access is granted only to the Infragistics-signed `IgniteUI.Blazor` assembly. `BaseCollection`, `JsonSerializable`, `SerializationContext`, `SerializationFilter` and `IDataIntentAttribute` are public again but hidden from IntelliSense, since the full package's public collection and value types derive from them; they are not intended for application code. `DataIntentAttribute` is public. Format specifiers (`IgbNumberFormatSpecifier`) declared inside a component of the full package attach to it as content children. The full package's components use their own client interop module next to this package's, and load their client modules through it. What the full package relies on is listed in [docs/PACKAGE-CONTRACT.md](docs/PACKAGE-CONTRACT.md). The shared client elements (portals, template containers) are defined only once when both packages' client runtimes load on a page. +- **Infrastructure:** the package can now be extended by the full `IgniteUI.Blazor` package, which takes a dependency on it for the components listed here and builds its charts, grids and Dock Manager on the same runtime. Its infrastructure access is granted only to the Infragistics-signed `IgniteUI.Blazor` assembly. Format specifiers (`IgbNumberFormatSpecifier`) declared inside a component of the full package attach to it as content children. The full package's components use their own client interop module next to this package's, and load their client modules through it. What the full package relies on is listed in [docs/PACKAGE-CONTRACT.md](docs/PACKAGE-CONTRACT.md). The shared client elements (portals, template containers) are defined only once when both packages' client runtimes load on a page. - **Interop:** the components reach their client code through the `_content/IgniteUI.Blazor/interop.js` module (JS isolation) instead of functions on `window`. The internal `window.igSendMessage`, `igCheckReady`, `igWaitForLoaded`, `igRequestLoad`, `igSetResourceString`, `igConvertReturnValue` and `igUnmarshalledDataSource*` functions, the unused `igSendMessages` and the `raisingEvent` flag no longer exist. They were never a supported API. On .NET 8 WebAssembly, unmarshalled data now travels on the same path as on .NET 9+. [#255](https://github.com/IgniteUI/igniteui-blazor/issues/255) ### Added diff --git a/docs/PACKAGE-CONTRACT.md b/docs/PACKAGE-CONTRACT.md index df97006c..91a3c428 100644 --- a/docs/PACKAGE-CONTRACT.md +++ b/docs/PACKAGE-CONTRACT.md @@ -9,8 +9,7 @@ that package, so coordinate it with the package's maintainers. - `InternalsVisibleTo("IgniteUI.Blazor")`, pinned to the Infragistics public key (`eng/IG.publickey.hex`). The full package overrides and calls the base classes' `internal` / `private protected` infrastructure members. -- Public, but `[EditorBrowsable(Never)]`, because public types of the full package derive from them: - `BaseCollection`, `JsonSerializable`, `SerializationContext`, `SerializationFilter`, `IDataIntentAttribute`. +- `BaseCollection`, `JsonSerializable`, `SerializationContext`, `SerializationFilter`, `IDataIntentAttribute` and `DataIntentAttribute` stay `internal`; the full package reaches them through the friend assembly. - `IContentChildHost`: components that implement it cascade themselves to their child content, and elements of this package that the full package's components hold as content children (`IgbFormatSpecifier` and derived) attach to the nearest host. diff --git a/src/componentsBase/BaseCollection.cs b/src/componentsBase/BaseCollection.cs index 98c9987a..a441dc08 100644 --- a/src/componentsBase/BaseCollection.cs +++ b/src/componentsBase/BaseCollection.cs @@ -1,16 +1,9 @@ using System.Collections.ObjectModel; -using System.ComponentModel; namespace IgniteUI.Blazor.Controls { - /// - /// Base of the collections that hold a component's child objects and report changes to the component that owns them. - /// Infrastructure for Ignite UI component libraries built on this package; not intended for application code. - /// - /// The type of the items. - [EditorBrowsable(EditorBrowsableState.Never)] - public class BaseCollection : ObservableCollection + internal class BaseCollection : ObservableCollection { private bool _suppressNotify = false; @@ -41,8 +34,6 @@ internal BaseCollection Fill(T[] items) return this; } - /// Copies the items to a new array. - /// An array with the items, in order. public T[] ToArray() { var array = new T[Count]; @@ -110,9 +101,6 @@ internal string? PropertyName } } - /// Creates a collection owned by , reported to it as the property. - /// The component or element that owns the collection, or . - /// The name of the owner's property that holds the collection. public BaseCollection(object? parent, string? propertyName) { _parent = parent; @@ -154,9 +142,6 @@ protected override void ClearItems() NotifyParent(); } - /// Writes the items as a JSON array into the description sent to the client renderer. - /// The serialization context to write to. - /// The property name to write the array under, or to write a bare array. public void Serialize(SerializationContext context, string? propertyName = null) { //var vals = new List(); @@ -227,9 +212,6 @@ public void Serialize(SerializationContext context, string? propertyName = null) //return "[" + string.Join(", \n", vals) + "]"; } - /// Finds the item, or a descendant of an item, with the given renderer name. - /// The renderer name to look for. - /// The matching object, or . public object? FindByName(string name) { //TODO: hash map @@ -259,9 +241,6 @@ public void Serialize(SerializationContext context, string? propertyName = null) return null; } - /// Determines whether an item, or a descendant of an item, has the given renderer name. - /// The renderer name to look for. - /// when a match exists; otherwise . public bool HasName(string name) { //TODO: hash map diff --git a/src/componentsBase/DataIntents.cs b/src/componentsBase/DataIntents.cs index 22908e93..1276b8ec 100644 --- a/src/componentsBase/DataIntents.cs +++ b/src/componentsBase/DataIntents.cs @@ -1,33 +1,20 @@ -using System.ComponentModel; - namespace IgniteUI.Blazor.Controls { - /// - /// Implemented by attributes that describe the intent of a data member for components that infer - /// their configuration from bound data. Infrastructure for Ignite UI component libraries built on this package. - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public interface IDataIntentAttribute + // Currently not used in Lite, possibly in the future (if lite components self-configure) + internal interface IDataIntentAttribute { - /// The intent of the annotated data member. string Intent { get; } } - /// - /// Describes the intent of a data member for components that infer their configuration from bound data. - /// [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = true)] - public class DataIntentAttribute + internal class DataIntentAttribute : Attribute, IDataIntentAttribute { - /// Marks the member with . - /// The intent of the annotated data member. public DataIntentAttribute(string intent) { Intent = intent; } - /// public string Intent { get; private set; } } } diff --git a/src/componentsBase/JsonSerializable.cs b/src/componentsBase/JsonSerializable.cs index d48474c1..48d7d2d7 100644 --- a/src/componentsBase/JsonSerializable.cs +++ b/src/componentsBase/JsonSerializable.cs @@ -1,33 +1,12 @@ -using System.ComponentModel; - namespace IgniteUI.Blazor.Controls { - /// - /// Decides whether a property is written while a description is serialized for the client renderer. - /// Infrastructure for Ignite UI component libraries built on this package; not intended for application code. - /// - /// The renderer name of the object being serialized. - /// The name of the property about to be written. - /// to write the property; otherwise . - [EditorBrowsable(EditorBrowsableState.Never)] - public delegate bool SerializationFilter(string? name, string? property); + internal delegate bool SerializationFilter(string? name, string? property); - /// - /// The writer and filter used while a description is serialized for the client renderer. - /// Infrastructure for Ignite UI component libraries built on this package; not intended for application code. - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public class SerializationContext + internal class SerializationContext { - /// The JSON writer the description is written to. public System.Text.Json.Utf8JsonWriter Writer { get; set; } - - /// An optional filter that decides which properties are written. public SerializationFilter? Filter { get; set; } - /// Creates a context over . - /// The JSON writer the description is written to. - /// An optional filter that decides which properties are written. public SerializationContext(System.Text.Json.Utf8JsonWriter writer, SerializationFilter? filter) { Writer = writer; @@ -35,16 +14,8 @@ public SerializationContext(System.Text.Json.Utf8JsonWriter writer, Serializatio } } - /// - /// An object that writes itself into the description sent to the client renderer. - /// Infrastructure for Ignite UI component libraries built on this package; not intended for application code. - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public interface JsonSerializable + internal interface JsonSerializable { - /// Writes this object to . - /// The serialization context to write to. - /// The property name to write the object under, or to write a bare value. void Serialize(SerializationContext writer, string? propertyName = null); } From 37c598d170e2a2deabf5ef0098addce009df8ffb Mon Sep 17 00:00:00 2001 From: Deyan Kamburov Date: Tue, 6 Oct 2026 14:10:12 +0300 Subject: [PATCH 9/9] refactor: enhance extensibility and remove legacy components in IgniteUI.Blazor package --- CHANGELOG.md | 1 - docs/PACKAGE-CONTRACT.md | 45 -------- src/IgniteUI.Blazor.Lite.csproj | 7 -- src/components/Blazor/Accordion.cs | 2 +- src/components/Blazor/Dropdown.cs | 2 +- src/components/Blazor/Select.cs | 2 +- src/components/Blazor/Tabs.cs | 2 +- src/components/Blazor/TileManager.cs | 2 +- src/components/Blazor/Tree.cs | 2 +- src/componentsBase/BaseRendererControl.cs | 64 ++++------- src/componentsBase/BaseRendererElement.cs | 4 +- src/componentsBase/ContentChildHost.cs | 73 ------------- src/componentsBase/IIgniteUIBlazorRuntime.cs | 7 +- src/componentsBase/MarshalByValueFactory.cs | 42 -------- src/componentsBase/RendererSerializer.cs | 9 -- src/src/ig/igniteui-core/igc-portal.ts | 6 +- .../igc-template-container-component.ts | 2 +- .../igc-template-content-component.ts | 2 +- src/src/ig/igniteui-core/module-manager.ts | 2 +- src/src/index.ts | 2 +- .../ContentChildHostTests.cs | 100 ------------------ .../MarshalByValueProviderTests.cs | 22 ---- .../PerPackageInteropTests.cs | 61 ----------- tests/js/static-web-assets.test.mjs | 9 +- vite.config.mts | 1 - 25 files changed, 40 insertions(+), 431 deletions(-) delete mode 100644 docs/PACKAGE-CONTRACT.md delete mode 100644 src/componentsBase/ContentChildHost.cs delete mode 100644 tests/IgniteUI.Blazor.Tests/ContentChildHostTests.cs delete mode 100644 tests/IgniteUI.Blazor.Tests/MarshalByValueProviderTests.cs delete mode 100644 tests/IgniteUI.Blazor.Tests/PerPackageInteropTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 77c19801..2315b5cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Infrastructure:** the package can now be extended by the full `IgniteUI.Blazor` package, which takes a dependency on it for the components listed here and builds its charts, grids and Dock Manager on the same runtime. Its infrastructure access is granted only to the Infragistics-signed `IgniteUI.Blazor` assembly. Format specifiers (`IgbNumberFormatSpecifier`) declared inside a component of the full package attach to it as content children. The full package's components use their own client interop module next to this package's, and load their client modules through it. What the full package relies on is listed in [docs/PACKAGE-CONTRACT.md](docs/PACKAGE-CONTRACT.md). The shared client elements (portals, template containers) are defined only once when both packages' client runtimes load on a page. - **Interop:** the components reach their client code through the `_content/IgniteUI.Blazor/interop.js` module (JS isolation) instead of functions on `window`. The internal `window.igSendMessage`, `igCheckReady`, `igWaitForLoaded`, `igRequestLoad`, `igSetResourceString`, `igConvertReturnValue` and `igUnmarshalledDataSource*` functions, the unused `igSendMessages` and the `raisingEvent` flag no longer exist. They were never a supported API. On .NET 8 WebAssembly, unmarshalled data now travels on the same path as on .NET 9+. [#255](https://github.com/IgniteUI/igniteui-blazor/issues/255) ### Added diff --git a/docs/PACKAGE-CONTRACT.md b/docs/PACKAGE-CONTRACT.md deleted file mode 100644 index 91a3c428..00000000 --- a/docs/PACKAGE-CONTRACT.md +++ /dev/null @@ -1,45 +0,0 @@ -# Contract with the full IgniteUI.Blazor package - -The licensed/Trial `IgniteUI.Blazor` package depends on this package: it gets the components listed in the README -from here, and builds its charts, grids and Dock Manager on the same base classes and next to the same client code. -Everything below is what it relies on. None of it is application API. Changing any item is a breaking change for -that package, so coordinate it with the package's maintainers. - -## Assemblies - -- `InternalsVisibleTo("IgniteUI.Blazor")`, pinned to the Infragistics public key (`eng/IG.publickey.hex`). The full - package overrides and calls the base classes' `internal` / `private protected` infrastructure members. -- `BaseCollection`, `JsonSerializable`, `SerializationContext`, `SerializationFilter`, `IDataIntentAttribute` and `DataIntentAttribute` stay `internal`; the full package reaches them through the friend assembly. -- `IContentChildHost`: components that implement it cascade themselves to their child content, and elements of - this package that the full package's components hold as content children (`IgbFormatSpecifier` and derived) - attach to the nearest host. -- `BaseRendererControl.InteropModulePath`: each package's components use their own client interop module. Client - module load requests are tracked per interop module (`ModuleLoader.Load(runtime, name, interopModulePath)`). -- `RendererSerializer.Context` / `ShouldWrite`: used to serialize the full package's drawing types (points, rects, - brushes) as extension methods. -- `MarshalByValueFactory.AddProvider`: the full package registers its by-value types (chart and grid value objects). -- `FindByName` is `internal virtual` on both base classes, so the full package's generated types override it the same way. -- `BaseRendererElement.Name` has no `[Parameter]` (see `#436`): it also backs non-rendered `MarshalByValueAttribute` - types (e.g. `IgbIconMeta`), where `[Parameter]` trips analyzer BL0005 on ordinary object-initializer usage. The - full package renders several `BaseRendererElement`-derived types (chart axes, series, gauges) as components with - a `Name` markup attribute (e.g. ``); its generator shadows `Name` with `new` and - `[Parameter]` on exactly those root, non-MarshalByValue types instead of asking for it here. - -## Static web assets (`_content/IgniteUI.Blazor/`) - -Both packages serve from the same base path, so the file routes must not overlap. - -| File | Contract | -|---|---| -| `interop.js` | The client interop module the components import (JS isolation). Exports: `sendMessage`, `checkReady`, `waitForLoaded`, `requestLoad`, `setResourceString`, `unmarshalledDataSource{Create,CreateDataIntents,Insert,Update,Remove,Clear}`. The full package ships its own module under another path. | -| `api.js` | Public script API (`registerScript`, `removeScript`, `html`). Its `getRegisteredScript` export is internal but stable: the full package's runtime reads the same registry through it, so scripts registered once serve both packages. | -| `lit-html.js` | The lit-html instance both runtimes render templates with. | -| `themes/**` | The web components themes. The full package ships only `themes/grid/**`. | -| `IgniteUI.Blazor.Lite.lib.module.js` | This package's JS initializer. The full package has its own (`IgniteUI.Blazor[.Trial].lib.module.js`). | - -This package defines no interop functions or state on `window`. Custom elements shared by both client runtimes (`igc-portal-*`, -`igc-template-container`, `igc-template-content`) are defined only if not defined yet, whichever runtime loads first. The legacy script globals -(`igRegisterScript` / `igRemoveScript` / `igTemplating`) and `app.bundle.js`, where still present, are being -removed. The full package takes them over for its own users. - -`tests/js/static-web-assets.test.mjs` pins the file names and the `interop.js` / `api.js` exports. diff --git a/src/IgniteUI.Blazor.Lite.csproj b/src/IgniteUI.Blazor.Lite.csproj index 0f997cf9..044a8b6e 100644 --- a/src/IgniteUI.Blazor.Lite.csproj +++ b/src/IgniteUI.Blazor.Lite.csproj @@ -72,13 +72,6 @@ - - - - - diff --git a/src/components/Blazor/Accordion.cs b/src/components/Blazor/Accordion.cs index e510fead..df340e2f 100644 --- a/src/components/Blazor/Accordion.cs +++ b/src/components/Blazor/Accordion.cs @@ -80,7 +80,7 @@ public bool SingleExpand } /// - internal override object? FindByName(string name) + private protected override object? FindByName(string name) { var baseResult = base.FindByName(name); if (baseResult != null) diff --git a/src/components/Blazor/Dropdown.cs b/src/components/Blazor/Dropdown.cs index 8e6d3226..26735d39 100644 --- a/src/components/Blazor/Dropdown.cs +++ b/src/components/Blazor/Dropdown.cs @@ -231,7 +231,7 @@ public IgbDropdownGroup[] GetGroups() } /// - internal override object? FindByName(string name) + private protected override object? FindByName(string name) { var baseResult = base.FindByName(name); if (baseResult != null) diff --git a/src/components/Blazor/Select.cs b/src/components/Blazor/Select.cs index e8fada66..bec5440e 100644 --- a/src/components/Blazor/Select.cs +++ b/src/components/Blazor/Select.cs @@ -381,7 +381,7 @@ public bool Invalid } /// - internal override object? FindByName(string name) + private protected override object? FindByName(string name) { var baseResult = base.FindByName(name); if (baseResult != null) diff --git a/src/components/Blazor/Tabs.cs b/src/components/Blazor/Tabs.cs index 8c62df74..bf62832e 100644 --- a/src/components/Blazor/Tabs.cs +++ b/src/components/Blazor/Tabs.cs @@ -252,7 +252,7 @@ public string GetSelected() } /// - internal override object? FindByName(string name) + private protected override object? FindByName(string name) { var baseResult = base.FindByName(name); if (baseResult != null) diff --git a/src/components/Blazor/TileManager.cs b/src/components/Blazor/TileManager.cs index 77c197f0..7cdf814f 100644 --- a/src/components/Blazor/TileManager.cs +++ b/src/components/Blazor/TileManager.cs @@ -205,7 +205,7 @@ public IgbTile[] GetTiles() } /// - internal override object? FindByName(string name) + private protected override object? FindByName(string name) { var baseResult = base.FindByName(name); if (baseResult != null) diff --git a/src/components/Blazor/Tree.cs b/src/components/Blazor/Tree.cs index 4855cc28..349ed757 100644 --- a/src/components/Blazor/Tree.cs +++ b/src/components/Blazor/Tree.cs @@ -119,7 +119,7 @@ public TreeSelection Selection } /// - internal override object? FindByName(string name) + private protected override object? FindByName(string name) { var baseResult = base.FindByName(name); if (baseResult != null) diff --git a/src/componentsBase/BaseRendererControl.cs b/src/componentsBase/BaseRendererControl.cs index f82b9ad6..9462759b 100644 --- a/src/componentsBase/BaseRendererControl.cs +++ b/src/componentsBase/BaseRendererControl.cs @@ -68,14 +68,7 @@ protected IIgniteUIBlazor IgBlazor private InteropModule? _interop; /// The client interop module this component talks to. - internal InteropModule Interop => _interop ??= Runtime.GetInteropModule(InteropModulePath); - - /// - /// The path of the client interop module that renders this component. Components of another Ignite UI package - /// (the full IgniteUI.Blazor) override it with their own module, so each package's client runtime handles its - /// own components and the two never share window state. - /// - private protected virtual string InteropModulePath => InteropModule.LitePath; + internal InteropModule Interop => _interop ??= Runtime.GetInteropModule(InteropModule.LitePath); /// Requests the client modules this component needs; override to register more through . protected virtual void EnsureModulesLoaded() @@ -531,7 +524,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddAttribute(9 + 15 + Sequence.MaxSequence, "ChildContent", (RenderFragment)delegate (RenderTreeBuilder builder2) { //builder2.AddMarkupContent(10 + 15 + Sequence.MaxSequence, "\r\n "); - ContentChildHost.AddChildContent(builder2, 11 + 15 + Sequence.MaxSequence, this, ChildContent); + builder2.AddContent(11 + 15 + Sequence.MaxSequence, ChildContent); //builder2.AddMarkupContent(12 + 15 + Sequence.MaxSequence, "\r\n "); }); builder.CloseComponent(); @@ -622,7 +615,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddAttribute(17, "ChildContent", (RenderFragment)delegate (RenderTreeBuilder builder2) { builder2.AddMarkupContent(18, "\r\n "); - ContentChildHost.AddChildContent(builder2, 19, this, ChildContent); + builder2.AddContent(19, ChildContent); builder2.AddMarkupContent(20, "\r\n "); }); builder.CloseComponent(); @@ -641,7 +634,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder2.OpenElement(26, "igc-portal-entrance"); builder2.AddAttribute(27, "portal-id", "portal-" + _containerId); builder2.AddAttribute(28, "move-once-mode", "true"); - ContentChildHost.AddChildContent(builder2, 29, this, ChildContent); + builder2.AddContent(29, ChildContent); builder2.CloseElement(); builder2.AddMarkupContent(30, "\r\n "); }); @@ -2176,7 +2169,7 @@ internal void OnInvokeReturn(long invokeId, Object returnValue) } /// Resolves to the child element it identifies, or null. - internal virtual object? FindByName(string name) + private protected virtual object? FindByName(string name) { if ("mainControl".Equals(name)) { @@ -3394,7 +3387,7 @@ private async Task TrySendCleanupAsync() return await Interop.InvokeAsync("setResourceString", new object[] { "register", grouping, "", json }); } - internal void SetPropertyValue(object item, System.Reflection.PropertyInfo property, JsonElement jsonElement) + private void SetPropertyValue(object item, System.Reflection.PropertyInfo property, JsonElement jsonElement) { System.Type? type = Nullable.GetUnderlyingType(property.PropertyType); if (type == null) @@ -3448,7 +3441,7 @@ internal void SetPropertyValue(object item, System.Reflection.PropertyInfo prope } } [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Creates the property's own array PropertyType, which is present in metadata whenever the property exists.")] - internal void SetPropertyValue(object item, System.Reflection.PropertyInfo property, object value) + private void SetPropertyValue(object item, System.Reflection.PropertyInfo property, object value) { System.Type? type = Nullable.GetUnderlyingType(property.PropertyType); if (type == null) @@ -3690,20 +3683,16 @@ public IgniteUIBlazor(IJSRuntime runtime) public WebCallback WebCallback { get; private set; } private ConcurrentDictionary _loadedCache = new ConcurrentDictionary(); - public void RequestLoad(string moduleName, string interopModulePath = InteropModule.LitePath) + public void RequestLoad(string moduleName) { - var key = LoadKey(moduleName, interopModulePath); - if (!IsRuntimeValid() || _loadedCache.ContainsKey(key)) + if (!IsRuntimeValid() || _loadedCache.ContainsKey(moduleName)) { return; } - _loadedCache.AddOrUpdate(key, true, (name, oldValue) => true); - GetInteropModule(interopModulePath).Post(module => _ = module.InvokeVoidAsync("requestLoad", moduleName)); + _loadedCache.AddOrUpdate(moduleName, true, (name, oldValue) => true); + GetInteropModule(InteropModule.LitePath).Post(module => _ = module.InvokeVoidAsync("requestLoad", moduleName)); } - private static string LoadKey(string moduleName, string interopModulePath) => - interopModulePath == InteropModule.LitePath ? moduleName : interopModulePath + "|" + moduleName; - private readonly ConcurrentDictionary _interopModules = new ConcurrentDictionary(); public InteropModule GetInteropModule(string path) @@ -3729,19 +3718,22 @@ public void Dispose() } _interopModules.Clear(); } - public bool IsLoadRequested(string moduleName, string interopModulePath = InteropModule.LitePath) + public bool IsLoadRequested(string moduleName) { - return _loadedCache.ContainsKey(LoadKey(moduleName, interopModulePath)); + if (_loadedCache.ContainsKey(moduleName)) + { + return true; + } + return false; } - public void MarkIsLoadRequested(string moduleName, string interopModulePath = InteropModule.LitePath) + public void MarkIsLoadRequested(string moduleName) { - var key = LoadKey(moduleName, interopModulePath); - if (!IsRuntimeValid() || _loadedCache.ContainsKey(key)) + if (!IsRuntimeValid() || _loadedCache.ContainsKey(moduleName)) { return; } - _loadedCache.AddOrUpdate(key, true, (name, oldValue) => true); + _loadedCache.AddOrUpdate(moduleName, true, (name, oldValue) => true); } [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Probes the optional RemoteJSRuntime.IsInitialized, which does not exist in supported trim targets (WASM, WebView); a string DynamicDependency is not an option — it fails with IL2035 where the Server assembly is absent.")] @@ -3884,27 +3876,11 @@ public static void Load(IIgniteUIBlazor runtime, string moduleName) runtime.AsRuntime().RequestLoad(moduleName); } - /// Requests from the interop module of another Ignite UI package. - internal static void Load(IIgniteUIBlazor runtime, string moduleName, string interopModulePath) - { - runtime.AsRuntime().RequestLoad(moduleName, interopModulePath); - } - internal static void MarkIsLoadRequested(IIgniteUIBlazor runtime, string moduleName) { runtime.AsRuntime().MarkIsLoadRequested(moduleName); } - internal static void MarkIsLoadRequested(IIgniteUIBlazor runtime, string moduleName, string interopModulePath) - { - runtime.AsRuntime().MarkIsLoadRequested(moduleName, interopModulePath); - } - - internal static bool IsLoadRequested(IIgniteUIBlazor runtime, string moduleName, string interopModulePath) - { - return runtime.AsRuntime().IsLoadRequested(moduleName, interopModulePath); - } - internal static bool IsLoadRequested(IIgniteUIBlazor runtime, string moduleName) { return runtime.AsRuntime().IsLoadRequested(moduleName); diff --git a/src/componentsBase/BaseRendererElement.cs b/src/componentsBase/BaseRendererElement.cs index 8d18223c..11ec1f7d 100644 --- a/src/componentsBase/BaseRendererElement.cs +++ b/src/componentsBase/BaseRendererElement.cs @@ -126,7 +126,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddAttribute(3, "ChildContent", (RenderFragment)delegate (RenderTreeBuilder builder2) { builder2.AddMarkupContent(4, "\r\n "); - ContentChildHost.AddChildContent(builder2, 5, this, ChildContent); + builder2.AddContent(5, ChildContent); builder2.AddMarkupContent(6, "\r\n "); }); builder.CloseComponent(); @@ -151,7 +151,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder2.OpenElement(5, "igc-portal-entrance"); builder2.AddAttribute(6, "portal-id", "portal-" + parentId + "/" + RendererName); builder2.AddAttribute(7, "move-once-mode", "true"); - ContentChildHost.AddChildContent(builder2, 8, this, ChildContent); + builder2.AddContent(8, ChildContent); builder2.CloseElement(); builder2.AddMarkupContent(9, "\r\n "); }); diff --git a/src/componentsBase/ContentChildHost.cs b/src/componentsBase/ContentChildHost.cs deleted file mode 100644 index 94701073..00000000 --- a/src/componentsBase/ContentChildHost.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Rendering; - -namespace IgniteUI.Blazor.Controls -{ - /// - /// Implemented by components of other Ignite UI packages (the full IgniteUI.Blazor package) that accept elements - /// of this package as content children, e.g. an declared inside a chart axis. - /// The host cascades itself to its child content; supported elements attach on initialization and detach on disposal. - /// - internal interface IContentChildHost - { - /// Adds to the matching content collection of the host. - /// when the host accepted the child. - bool AttachContentChild(BaseRendererElement child); - - /// Removes from the content collection it was added to. - void DetachContentChild(BaseRendererElement child); - } - - internal static class ContentChildHost - { - /// The name the host is cascaded under. - internal const string CascadingName = "IgbContentChildHost"; - - /// - /// Adds to , wrapped in a cascade of - /// when it is a . Components that are not hosts render exactly as before. - /// - internal static void AddChildContent(RenderTreeBuilder builder, int sequence, object owner, RenderFragment? content) - { - if (owner is not IContentChildHost host) - { - builder.AddContent(sequence, content); - return; - } - - builder.OpenRegion(sequence); - builder.OpenComponent>(0); - builder.AddComponentParameter(1, nameof(CascadingValue.Value), host); - builder.AddComponentParameter(2, nameof(CascadingValue.Name), CascadingName); - builder.AddComponentParameter(3, nameof(CascadingValue.IsFixed), true); - builder.AddComponentParameter(4, nameof(CascadingValue.ChildContent), content); - builder.CloseComponent(); - builder.CloseRegion(); - } - } - - public partial class IgbFormatSpecifier : IDisposable - { - private IContentChildHost? _attachedHost; - - [CascadingParameter(Name = ContentChildHost.CascadingName)] - private IContentChildHost? ContentChildHostParent { get; set; } - - /// - protected override void OnInitialized() - { - base.OnInitialized(); - if (ContentChildHostParent != null && ContentChildHostParent.AttachContentChild(this)) - { - _attachedHost = ContentChildHostParent; - } - } - - void IDisposable.Dispose() - { - _attachedHost?.DetachContentChild(this); - _attachedHost = null; - GC.SuppressFinalize(this); - } - } -} diff --git a/src/componentsBase/IIgniteUIBlazorRuntime.cs b/src/componentsBase/IIgniteUIBlazorRuntime.cs index fc18f5d3..af7942f2 100644 --- a/src/componentsBase/IIgniteUIBlazorRuntime.cs +++ b/src/componentsBase/IIgniteUIBlazorRuntime.cs @@ -11,10 +11,9 @@ internal interface IIgniteUIBlazorRuntime IJSRuntime JsRuntime { get; } IIgniteUIBlazorSettings? Settings { get; } WebCallback WebCallback { get; } - // Load requests are tracked per interop module: each Ignite UI package loads its client modules through its own. - void RequestLoad(string moduleName, string interopModulePath = InteropModule.LitePath); - bool IsLoadRequested(string moduleName, string interopModulePath = InteropModule.LitePath); - void MarkIsLoadRequested(string moduleName, string interopModulePath = InteropModule.LitePath); + void RequestLoad(string moduleName); + bool IsLoadRequested(string moduleName); + void MarkIsLoadRequested(string moduleName); bool IsRuntimeValid(bool reevaluate = false); /// The client interop module at , shared by the components of the runtime. diff --git a/src/componentsBase/MarshalByValueFactory.cs b/src/componentsBase/MarshalByValueFactory.cs index 32f77abb..fea6266e 100644 --- a/src/componentsBase/MarshalByValueFactory.cs +++ b/src/componentsBase/MarshalByValueFactory.cs @@ -2,30 +2,6 @@ namespace IgniteUI.Blazor.Controls { internal class MarshalByValueFactory { - private static readonly List<(Func MustMarshal, Func Create)> _providers = new(); - - /// - /// Adds the by-value types of another Ignite UI package (the full IgniteUI.Blazor), consulted after this package's own. - /// - internal static void AddProvider(Func mustMarshalByValue, Func createInstance) - { - lock (_providers) - { - _providers.Add((mustMarshalByValue, createInstance)); - } - } - - private static (Func MustMarshal, Func Create)[] Providers - { - get - { - lock (_providers) - { - return _providers.ToArray(); - } - } - } - internal static bool MustMarshalByValue(string? typeName) { switch (typeName) @@ -168,16 +144,6 @@ internal static bool MustMarshalByValue(string? typeName) //@@MustMarshalByValueEnd } - if (typeName != null) - { - foreach (var provider in Providers) - { - if (provider.MustMarshal(typeName)) - { - return true; - } - } - } return false; } @@ -323,14 +289,6 @@ internal static bool MustMarshalByValue(string? typeName) //@@MarshalByValueEnd } - foreach (var provider in Providers) - { - var instance = provider.Create(typeName); - if (instance != null) - { - return instance; - } - } return null; } diff --git a/src/componentsBase/RendererSerializer.cs b/src/componentsBase/RendererSerializer.cs index ec5f4aa0..40018510 100644 --- a/src/componentsBase/RendererSerializer.cs +++ b/src/componentsBase/RendererSerializer.cs @@ -20,15 +20,6 @@ public RendererSerializer(SerializationContext context, ComponentBase component, private SerializationContext _context; - /// - /// The context being written to. Lets the full IgniteUI.Blazor package add serializers for its own value types - /// (points, rects, brushes) as extension methods. - /// - internal SerializationContext Context => _context; - - /// Whether the serialization filter, if any, lets be written. - internal bool ShouldWrite(string propertyName) => _context.Filter == null || _context.Filter(_name, propertyName); - //private List _properties = new List(); private string? _type = null; diff --git a/src/src/ig/igniteui-core/igc-portal.ts b/src/src/ig/igniteui-core/igc-portal.ts index 96ab6a75..4ebe5b6a 100644 --- a/src/src/ig/igniteui-core/igc-portal.ts +++ b/src/src/ig/igniteui-core/igc-portal.ts @@ -345,8 +345,8 @@ class PortalMessage { export class IgcPortalModule { public static register() { - if (!window.customElements.get("igc-portal-entrance")) { window.customElements.define("igc-portal-entrance", IgcPortalEntranceComponent); } - if (!window.customElements.get("igc-portal-exit")) { window.customElements.define("igc-portal-exit", IgcPortalExitComponent); } - if (!window.customElements.get("igc-portal-item")) { window.customElements.define("igc-portal-item", IgcPortalItemComponent); } + window.customElements.define("igc-portal-entrance", IgcPortalEntranceComponent); + window.customElements.define("igc-portal-exit", IgcPortalExitComponent); + window.customElements.define("igc-portal-item", IgcPortalItemComponent); } } \ No newline at end of file diff --git a/src/src/ig/igniteui-core/igc-template-container-component.ts b/src/src/ig/igniteui-core/igc-template-container-component.ts index 748fdfd5..65b2b2d3 100644 --- a/src/src/ig/igniteui-core/igc-template-container-component.ts +++ b/src/src/ig/igniteui-core/igc-template-container-component.ts @@ -13,7 +13,7 @@ export class IgcTemplateContainerComponent extends IgcTemplateContentComponent { if (!IgcTemplateContainerComponent._registered) { IgcTemplateContainerComponent._registered = true; if (window.customElements) { - if (!window.customElements.get(IgcTemplateContainerComponent.htmlTagName)) { window.customElements.define(IgcTemplateContainerComponent.htmlTagName, IgcTemplateContainerComponent); } + window.customElements.define(IgcTemplateContainerComponent.htmlTagName, IgcTemplateContainerComponent); } else { (document).registerElement(IgcTemplateContainerComponent.htmlTagName, IgcTemplateContainerComponent); } diff --git a/src/src/ig/igniteui-core/igc-template-content-component.ts b/src/src/ig/igniteui-core/igc-template-content-component.ts index 470e06fb..29de6187 100644 --- a/src/src/ig/igniteui-core/igc-template-content-component.ts +++ b/src/src/ig/igniteui-core/igc-template-content-component.ts @@ -119,7 +119,7 @@ export class IgcTemplateContentComponent extends IgcHTMLElement { if (!IgcTemplateContentComponent._registered) { IgcTemplateContentComponent._registered = true; if (window.customElements) { - if (!window.customElements.get(IgcTemplateContentComponent.htmlTagName)) { window.customElements.define(IgcTemplateContentComponent.htmlTagName, IgcTemplateContentComponent); } + window.customElements.define(IgcTemplateContentComponent.htmlTagName, IgcTemplateContentComponent); } else { (document).registerElement(IgcTemplateContentComponent.htmlTagName, IgcTemplateContentComponent); } diff --git a/src/src/ig/igniteui-core/module-manager.ts b/src/src/ig/igniteui-core/module-manager.ts index 2a723e26..98d38f3c 100644 --- a/src/src/ig/igniteui-core/module-manager.ts +++ b/src/src/ig/igniteui-core/module-manager.ts @@ -19,7 +19,7 @@ export class RegisterElementHelper { } RegisterElementHelper._registeredSet.add(tagName); if (window.customElements) { - if (!window.customElements.get(tagName)) { window.customElements.define(tagName, cons as any); } + window.customElements.define(tagName, cons as any); } else { (document).registerElement(tagName, cons as any); } diff --git a/src/src/index.ts b/src/src/index.ts index b7f4315f..462968e7 100644 --- a/src/src/index.ts +++ b/src/src/index.ts @@ -38,7 +38,7 @@ _scriptRegistryEvents.addEventListener('registered', (e) => { if (refs.size === 0) containersPendingScriptRefs.delete(containerId); } for (const [containerId, json] of ready) { - (window as any).igSendMessage(containerId, json, null /* webCallback not used */, [] /* nativeElements not used */); + sendMessage(containerId, json, null /* webCallback not used */, [] /* nativeElements not used */); } }); function getContainer(id: string): HTMLElement { diff --git a/tests/IgniteUI.Blazor.Tests/ContentChildHostTests.cs b/tests/IgniteUI.Blazor.Tests/ContentChildHostTests.cs deleted file mode 100644 index b411e599..00000000 --- a/tests/IgniteUI.Blazor.Tests/ContentChildHostTests.cs +++ /dev/null @@ -1,100 +0,0 @@ -using Bunit; -using IgniteUI.Blazor.Controls; -using Microsoft.AspNetCore.Components; - -namespace IgniteUI.Blazor.Tests; - -/// -/// Covers the content-child host hook the full IgniteUI.Blazor package uses to collect elements of this package -/// (format specifiers) declared inside its components, e.g. an inside a chart axis. -/// -public class ContentChildHostTests : BlazorComponentTestBase -{ - [Fact] - public void FormatSpecifier_InsideHost_AttachesOnInitializeAndDetachesOnDispose() - { - var cut = Render(p => p - .AddChildContent(s => s.Add(x => x.MinimumFractionDigits, 2))); - - var host = cut.Instance; - var specifier = Assert.Single(host.Attached); - Assert.IsType(specifier); - Assert.Same(cut.FindComponent().Instance, specifier); - - cut.Render(p => p.AddChildContent(builder => { })); - - Assert.Empty(host.Attached); - Assert.Equal(1, host.DetachCount); - } - - [Fact] - public void FormatSpecifier_InsideHostThatDeclinesChild_IsNotDetachedFromIt() - { - var cut = Render(p => p - .Add(x => x.Accept, false) - .AddChildContent()); - - var host = cut.Instance; - Assert.Empty(host.Attached); - - cut.Render(p => p.Add(x => x.Accept, false).AddChildContent(builder => { })); - - Assert.Equal(0, host.DetachCount); - } - - [Fact] - public void FormatSpecifier_InsideNonHostElement_RendersWithoutAttaching() - { - var cut = Render(p => p.AddChildContent()); - - Assert.NotNull(cut.FindComponent().Instance); - cut.Render(p => p.AddChildContent(builder => { })); - Assert.Empty(cut.FindComponents()); - } - - [Fact] - public void NestedHosts_ChildAttachesToNearestHost() - { - var cut = Render(p => p - .AddChildContent(inner => inner - .AddChildContent())); - - var outer = cut.Instance; - var inner = cut.FindComponents().Single(h => !ReferenceEquals(h.Instance, outer)).Instance; - - Assert.Empty(outer.Attached); - Assert.Single(inner.Attached); - } - - private sealed class TestHost : BaseRendererElement, IContentChildHost - { - [Parameter] public bool Accept { get; set; } = true; - - public List Attached { get; } = new(); - - public int DetachCount { get; private set; } - - private protected override string? ParentTypeName => "TestHostParent"; - - bool IContentChildHost.AttachContentChild(BaseRendererElement child) - { - if (!Accept) - { - return false; - } - Attached.Add(child); - return true; - } - - void IContentChildHost.DetachContentChild(BaseRendererElement child) - { - DetachCount++; - Attached.Remove(child); - } - } - - private sealed class NonHost : BaseRendererElement - { - private protected override string? ParentTypeName => "NonHostParent"; - } -} diff --git a/tests/IgniteUI.Blazor.Tests/MarshalByValueProviderTests.cs b/tests/IgniteUI.Blazor.Tests/MarshalByValueProviderTests.cs deleted file mode 100644 index e13ea807..00000000 --- a/tests/IgniteUI.Blazor.Tests/MarshalByValueProviderTests.cs +++ /dev/null @@ -1,22 +0,0 @@ -using IgniteUI.Blazor.Controls; - -namespace IgniteUI.Blazor.Tests; - -/// The full IgniteUI.Blazor package registers its by-value types next to this package's own. -public class MarshalByValueProviderTests -{ - [Fact] - public void AddProvider_ExtendsMustMarshalAndCreate_AfterOwnTypes() - { - var created = new object(); - MarshalByValueFactory.AddProvider( - name => name == "ProviderTestOnlyType", - name => name == "ProviderTestOnlyType" ? created : null); - - Assert.True(MarshalByValueFactory.MustMarshalByValue("ProviderTestOnlyType")); - Assert.Same(created, MarshalByValueFactory.CreateInstance("ProviderTestOnlyType")); - Assert.False(MarshalByValueFactory.MustMarshalByValue("SomeUnknownType")); - Assert.Null(MarshalByValueFactory.CreateInstance("SomeUnknownType")); - Assert.True(MarshalByValueFactory.MustMarshalByValue("CalendarFormatOptions")); - } -} diff --git a/tests/IgniteUI.Blazor.Tests/PerPackageInteropTests.cs b/tests/IgniteUI.Blazor.Tests/PerPackageInteropTests.cs deleted file mode 100644 index 9126e2b2..00000000 --- a/tests/IgniteUI.Blazor.Tests/PerPackageInteropTests.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Bunit; -using IgniteUI.Blazor.Controls; -using IgniteUI.Blazor.Tests.Interop; - -namespace IgniteUI.Blazor.Tests; - -/// -/// Each Ignite UI package talks to its own client interop module: the components of another package (the full -/// IgniteUI.Blazor) override InteropModulePath, and their traffic and load requests go to that module only. -/// -public class PerPackageInteropTests : BlazorComponentTestBase -{ - private const string OtherPath = "./_content/IgniteUI.Blazor/other/interop.js"; - - private BunitJSInterop LiteModule => ((RendererMessageInteropHarness)Interop).Module; - - [Fact] - public void Component_WithOwnInteropModule_SendsItsMessagesThere() - { - var other = JSInterop.SetupModule(OtherPath); - other.Setup("checkReady", _ => true).SetResult(true); - other.SetupVoid("waitForLoaded", _ => true).SetVoidResult(); - Interop.PrimeReady(); - - var otherCut = Render(); - var liteCut = Render(); - var otherId = Interop.ContainerIdOf(otherCut); - var liteId = Interop.ContainerIdOf(liteCut); - Interop.MakeReady(); - - otherCut.WaitForAssertion(() => Assert.Contains(other.Invocations, i => i.Identifier == "sendMessage" && (string?)i.Arguments[0] == otherId)); - liteCut.WaitForAssertion(() => Assert.Contains(LiteModule.Invocations, i => i.Identifier == "sendMessage" && (string?)i.Arguments[0] == liteId)); - - Assert.DoesNotContain(LiteModule.Invocations, i => i.Arguments.Count > 0 && (string?)i.Arguments[0] == otherId); - Assert.DoesNotContain(other.Invocations, i => i.Arguments.Count > 0 && (string?)i.Arguments[0] == liteId); - } - - [Fact] - public void ModuleLoader_TracksLoadRequestsPerInteropModule() - { - var other = JSInterop.SetupModule(OtherPath); - - ModuleLoader.Load(IgniteUIBlazor, "SharedNameModule", OtherPath); - - Assert.True(ModuleLoader.IsLoadRequested(IgniteUIBlazor, "SharedNameModule", OtherPath)); - Assert.False(ModuleLoader.IsLoadRequested(IgniteUIBlazor, "SharedNameModule")); - Assert.Contains(other.Invocations, i => i.Identifier == "requestLoad" && (string?)i.Arguments[0] == "SharedNameModule"); - Assert.DoesNotContain(LiteModule.Invocations, i => i.Identifier == "requestLoad" && (string?)i.Arguments[0] == "SharedNameModule"); - - ModuleLoader.Load(IgniteUIBlazor, "SharedNameModule"); - - Assert.True(ModuleLoader.IsLoadRequested(IgniteUIBlazor, "SharedNameModule")); - Assert.Contains(LiteModule.Invocations, i => i.Identifier == "requestLoad" && (string?)i.Arguments[0] == "SharedNameModule"); - Assert.Single(other.Invocations, i => i.Identifier == "requestLoad"); - } - - private sealed class OtherPackageCalendar : IgbCalendar - { - private protected override string InteropModulePath => OtherPath; - } -} diff --git a/tests/js/static-web-assets.test.mjs b/tests/js/static-web-assets.test.mjs index 047f3b53..09e7d7ab 100644 --- a/tests/js/static-web-assets.test.mjs +++ b/tests/js/static-web-assets.test.mjs @@ -89,13 +89,6 @@ test('interop.js exports the client interop the components import', () => { ]); }); -test('package contract: fixed file names and the registry export the full package imports', () => { - // docs/PACKAGE-CONTRACT.md - the full IgniteUI.Blazor package imports these by name. - for (const f of ['api.js', 'interop.js', 'lit-html.js', 'IgniteUI.Blazor.Lite.lib.module.js']) { - assert.ok(jsFiles.includes(f), `missing ${f}`); - } - assert.ok(exportsOf(read('api.js')).includes('getRegisteredScript'), 'api.js no longer exports getRegisteredScript'); -}); test('no interop functions on window', () => { // The interop is reached through interop.js (JS isolation); a second Ignite UI package brings its own module. const interopGlobal = @@ -105,6 +98,8 @@ test('no interop functions on window', () => { for (const f of jsFiles) { assert.doesNotMatch(read(f), interopGlobal, f); } +}); + test('legacy script globals and compatibility bundle are not emitted', () => { assert.equal(existsSync(join(wwwroot, 'app.bundle.js')), false); const emitted = jsFiles.map(read).join('\n'); diff --git a/vite.config.mts b/vite.config.mts index b6556362..5553ec21 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -12,7 +12,6 @@ const entries = { app: 'src/src/index.ts', api: 'src/src/api.ts', interop: 'src/ /** * Emits the Blazor JS initializer as a flat list of static imports. * Blazor awaits the import, so no startup hooks required for that. - * api.js included for the deprecated window globals before Blazor starts. * interop.js included so the module the components import is already evaluated: their import() of it resolves to * this instance without another fetch, and nothing about startup depends on window. */