diff --git a/CHANGELOG.md b/CHANGELOG.md index f4bf49bf..cd227008 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- **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) ## 1.0.0 - 2026-10-12 📢 The first stable release: from here on, a breaking change to the public API comes with a major version. diff --git a/src/componentsBase/IIgniteUIBlazorRuntime.cs b/src/componentsBase/IIgniteUIBlazorRuntime.cs index f6d57cab..d7ba38f4 100644 --- a/src/componentsBase/IIgniteUIBlazorRuntime.cs +++ b/src/componentsBase/IIgniteUIBlazorRuntime.cs @@ -16,6 +16,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/IgbComponentBase.cs b/src/componentsBase/IgbComponentBase.cs index a41c3f90..43022058 100644 --- a/src/componentsBase/IgbComponentBase.cs +++ b/src/componentsBase/IgbComponentBase.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() { @@ -728,11 +733,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; } @@ -1629,14 +1634,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() }); } @@ -1669,18 +1673,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(BaseJsonSerializable child) @@ -1745,16 +1741,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() }); @@ -3502,7 +3500,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) @@ -3511,7 +3509,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 }); } private void SetPropertyValue(object item, System.Reflection.PropertyInfo property, JsonElement jsonElement) @@ -3767,7 +3765,7 @@ public interface IIgniteUIBlazor IJSRuntime JsRuntime { get; } } - internal class IgniteUIBlazor : IIgniteUIBlazor, IIgniteUIBlazorRuntime + internal class IgniteUIBlazor : IIgniteUIBlazor, IIgniteUIBlazorRuntime, IAsyncDisposable, IDisposable { private bool _isRuntimeValid = false; private bool _isRuntimeChecked = false; @@ -3823,7 +3821,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) { @@ -3981,7 +4005,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/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 c9a19d9e..e8e63560 100644 --- a/src/src/index.ts +++ b/src/src/index.ts @@ -35,7 +35,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 { @@ -113,7 +113,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 { @@ -121,16 +121,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); @@ -283,8 +278,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; @@ -490,31 +483,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, + }); } } @@ -533,19 +520,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) { @@ -695,7 +675,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); @@ -1473,7 +1458,6 @@ function updateAngularElement(element: any) { }; declare var Blazor: any; -declare var BINDING: any; declare var getValue: any; declare var Module: any; @@ -2217,44 +2201,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); @@ -2309,11 +2308,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); @@ -2327,16 +2325,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); @@ -2363,10 +2362,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); @@ -2376,7 +2376,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); @@ -2403,12 +2403,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); @@ -2437,16 +2437,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 67885a41..dd163738 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 IgbComponentBaseDisposalTests : 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 10c6f4c2..81bfd045 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 0cbb88dc..09e7d7ab 100644 --- a/tests/js/static-web-assets.test.mjs +++ b/tests/js/static-web-assets.test.mjs @@ -23,7 +23,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); @@ -36,6 +36,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', () => { @@ -71,6 +72,34 @@ test('no Ignite UI window state', () => { } }); +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('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 f401a7a0..5553ec21 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -6,12 +6,14 @@ 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' }; -const entries = { app: 'src/src/index.ts', api: 'src/src/api.ts' }; +const fixedNames: Record = { api: 'api.js', interop: 'interop.js', 'lit-html': 'lit-html.js' }; +const entries = { app: 'src/src/index.ts', api: 'src/src/api.ts', interop: 'src/src/interop.ts' }; /** * Emits the Blazor JS initializer as a flat list of static imports. * Blazor awaits the import, so no startup hooks required for that. + * 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 { @@ -30,6 +32,10 @@ function emitInitializer(): Plugin { }; visit(app.fileName); 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,