Skip to content

Commit 6cb5752

Browse files
Cache JS->.NET string decoding results within a single renderbatch (#23773)
1 parent c202344 commit 6cb5752

File tree

14 files changed

+237
-15
lines changed

14 files changed

+237
-15
lines changed

src/Components/Web.JS/dist/Release/blazor.server.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/dist/Release/blazor.webassembly.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/src/Boot.Server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ async function initializeConnection(options: CircuitStartOptions, logger: Logger
8989
const connection = connectionBuilder.build();
9090

9191
setEventDispatcher((descriptor, args) => {
92-
return connection.send('DispatchBrowserEvent', JSON.stringify(descriptor), JSON.stringify(args));
92+
connection.send('DispatchBrowserEvent', JSON.stringify(descriptor), JSON.stringify(args));
9393
});
9494

9595
// Configure navigation via SignalR

src/Components/Web.JS/src/Boot.WebAssembly.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,32 @@ async function boot(options?: Partial<WebAssemblyStartOptions>): Promise<void> {
2222
}
2323
started = true;
2424

25-
setEventDispatcher((eventDescriptor, eventArgs) => DotNet.invokeMethodAsync('Microsoft.AspNetCore.Components.WebAssembly', 'DispatchEvent', eventDescriptor, JSON.stringify(eventArgs)));
25+
setEventDispatcher((eventDescriptor, eventArgs) => {
26+
// It's extremely unusual, but an event can be raised while we're in the middle of synchronously applying a
27+
// renderbatch. For example, a renderbatch might mutate the DOM in such a way as to cause an <input> to lose
28+
// focus, in turn triggering a 'change' event. It may also be possible to listen to other DOM mutation events
29+
// that are themselves triggered by the application of a renderbatch.
30+
monoPlatform.invokeWhenHeapUnlocked(() => DotNet.invokeMethodAsync('Microsoft.AspNetCore.Components.WebAssembly', 'DispatchEvent', eventDescriptor, JSON.stringify(eventArgs)));
31+
});
2632

2733
// Configure environment for execution under Mono WebAssembly with shared-memory rendering
2834
const platform = Environment.setPlatform(monoPlatform);
2935
window['Blazor'].platform = platform;
3036
window['Blazor']._internal.renderBatch = (browserRendererId: number, batchAddress: Pointer) => {
3137
profileStart('renderBatch');
32-
renderBatch(browserRendererId, new SharedMemoryRenderBatch(batchAddress));
38+
39+
// We're going to read directly from the .NET memory heap, so indicate to the platform
40+
// that we don't want anything to modify the memory contents during this time. Currently this
41+
// is only guaranteed by the fact that .NET code doesn't run during this time, but in the
42+
// future (when multithreading is implemented) we might need the .NET runtime to understand
43+
// that GC compaction isn't allowed during this critical section.
44+
const heapLock = monoPlatform.beginHeapLock();
45+
try {
46+
renderBatch(browserRendererId, new SharedMemoryRenderBatch(batchAddress));
47+
} finally {
48+
heapLock.release();
49+
}
50+
3351
profileEnd('renderBatch');
3452
};
3553

src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,18 @@ import { DotNet } from '@microsoft/dotnet-js-interop';
22
import { attachDebuggerHotkey, hasDebuggingEnabled } from './MonoDebugger';
33
import { showErrorNotification } from '../../BootErrors';
44
import { WebAssemblyResourceLoader, LoadingResource } from '../WebAssemblyResourceLoader';
5-
import { Platform, System_Array, Pointer, System_Object, System_String } from '../Platform';
5+
import { Platform, System_Array, Pointer, System_Object, System_String, HeapLock } from '../Platform';
66
import { loadTimezoneData } from './TimezoneDataFile';
77
import { WebAssemblyBootResourceType } from '../WebAssemblyStartOptions';
88
import { initializeProfiling } from '../Profiling';
99

10-
let mono_string_get_utf8: (managedString: System_String) => Pointer;
1110
let mono_wasm_add_assembly: (name: string, heapAddress: number, length: number) => void;
1211
const appBinDirName = 'appBinDir';
1312
const uint64HighOrderShift = Math.pow(2, 32);
1413
const maxSafeNumberHighPart = Math.pow(2, 21) - 1; // The high-order int32 from Number.MAX_SAFE_INTEGER
1514

15+
let currentHeapLock: MonoHeapLock | null = null;
16+
1617
// Memory access helpers
1718
// The implementations are exactly equivalent to what the global getValue(addr, type) function does,
1819
// except without having to parse the 'type' parameter, and with less risk of mistakes at the call site
@@ -124,12 +125,38 @@ export const monoPlatform: Platform = {
124125
return unboxedValue;
125126
}
126127

127-
return BINDING.conv_string(fieldValue as any as System_String);
128+
let decodedString: string | null | undefined;
129+
if (currentHeapLock) {
130+
decodedString = currentHeapLock.stringCache.get(fieldValue);
131+
if (decodedString === undefined) {
132+
decodedString = BINDING.conv_string(fieldValue as any as System_String);
133+
currentHeapLock.stringCache.set(fieldValue, decodedString);
134+
}
135+
} else {
136+
decodedString = BINDING.conv_string(fieldValue as any as System_String);
137+
}
138+
139+
return decodedString;
128140
},
129141

130142
readStructField: function readStructField<T extends Pointer>(baseAddress: Pointer, fieldOffset?: number): T {
131143
return ((baseAddress as any as number) + (fieldOffset || 0)) as any as T;
132144
},
145+
146+
beginHeapLock: function() {
147+
assertHeapIsNotLocked();
148+
currentHeapLock = new MonoHeapLock();
149+
return currentHeapLock;
150+
},
151+
152+
invokeWhenHeapUnlocked: function(callback) {
153+
// This is somewhat like a sync context. If we're not locked, just pass through the call directly.
154+
if (!currentHeapLock) {
155+
callback();
156+
} else {
157+
currentHeapLock.enqueuePostReleaseAction(callback);
158+
}
159+
}
133160
};
134161

135162
function addScriptTagsToDocument(resourceLoader: WebAssemblyResourceLoader) {
@@ -246,7 +273,6 @@ function createEmscriptenModuleInstance(resourceLoader: WebAssemblyResourceLoade
246273
module.preRun.push(() => {
247274
// By now, emscripten should be initialised enough that we can capture these methods for later use
248275
mono_wasm_add_assembly = cwrap('mono_wasm_add_assembly', null, ['string', 'number', 'number']);
249-
mono_string_get_utf8 = cwrap('mono_wasm_string_get_utf8', 'number', ['number']);
250276
MONO.loaded_files = [];
251277

252278
if (timeZoneResource) {
@@ -387,6 +413,7 @@ function attachInteropInvoker(): void {
387413

388414
DotNet.attachDispatcher({
389415
beginInvokeDotNetFromJS: (callId: number, assemblyName: string | null, methodIdentifier: string, dotNetObjectId: any | null, argsJson: string): void => {
416+
assertHeapIsNotLocked();
390417
if (!dotNetObjectId && !assemblyName) {
391418
throw new Error('Either assemblyName or dotNetObjectId must have a non null value.');
392419
}
@@ -409,6 +436,7 @@ function attachInteropInvoker(): void {
409436
);
410437
},
411438
invokeDotNetFromJS: (assemblyName, methodIdentifier, dotNetObjectId, argsJson) => {
439+
assertHeapIsNotLocked();
412440
return dotNetDispatcherInvokeMethodHandle(
413441
assemblyName ? assemblyName : null,
414442
methodIdentifier,
@@ -460,3 +488,42 @@ function changeExtension(filename: string, newExtensionWithLeadingDot: string) {
460488

461489
return filename.substr(0, lastDotIndex) + newExtensionWithLeadingDot;
462490
}
491+
492+
function assertHeapIsNotLocked() {
493+
if (currentHeapLock) {
494+
throw new Error('Assertion failed - heap is currently locked');
495+
}
496+
}
497+
498+
class MonoHeapLock implements HeapLock {
499+
// Within a given heap lock, it's safe to cache decoded strings since the memory can't change
500+
stringCache = new Map<number, string | null>();
501+
502+
private postReleaseActions?: Function[];
503+
504+
enqueuePostReleaseAction(callback: Function) {
505+
if (!this.postReleaseActions) {
506+
this.postReleaseActions = [];
507+
}
508+
509+
this.postReleaseActions.push(callback);
510+
}
511+
512+
release() {
513+
if (currentHeapLock !== this) {
514+
throw new Error('Trying to release a lock which isn\'t current');
515+
}
516+
517+
currentHeapLock = null;
518+
519+
while (this.postReleaseActions?.length) {
520+
const nextQueuedAction = this.postReleaseActions.shift()!;
521+
522+
// It's possible that the action we invoke here might itself take a succession of heap locks,
523+
// but since heap locks must be released synchronously, by the time we get back to this stack
524+
// frame, we know the heap should no longer be locked.
525+
nextQueuedAction();
526+
assertHeapIsNotLocked();
527+
}
528+
}
529+
}

src/Components/Web.JS/src/Platform/Platform.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ export interface Platform {
1818
readObjectField<T extends System_Object>(baseAddress: Pointer, fieldOffset?: number): T;
1919
readStringField(baseAddress: Pointer, fieldOffset?: number, readBoolValueAsString?: boolean): string | null;
2020
readStructField<T extends Pointer>(baseAddress: Pointer, fieldOffset?: number): T;
21+
22+
beginHeapLock(): HeapLock;
23+
invokeWhenHeapUnlocked(callback: Function): void;
24+
}
25+
26+
export interface HeapLock {
27+
release();
2128
}
2229

2330
// We don't actually instantiate any of these at runtime. For perf it's preferable to

src/Components/Web.JS/src/Rendering/RendererEventDispatcher.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ export function dispatchEvent(eventDescriptor: EventDescriptor, eventArgs: UIEve
1010
throw new Error('eventDispatcher not initialized. Call \'setEventDispatcher\' to configure it.');
1111
}
1212

13-
return eventDispatcherInstance(eventDescriptor, eventArgs);
13+
eventDispatcherInstance(eventDescriptor, eventArgs);
1414
}
1515

16-
export function setEventDispatcher(newDispatcher: (eventDescriptor: EventDescriptor, eventArgs: UIEventArgs) => Promise<void>): void {
16+
export function setEventDispatcher(newDispatcher: (eventDescriptor: EventDescriptor, eventArgs: UIEventArgs) => void): void {
1717
eventDispatcherInstance = newDispatcher;
1818
}

src/Components/test/E2ETest/Tests/EventTest.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,37 @@ public void InteractiveElementWithDisabledAttributeDoesNotRespondToMouseEvents(s
239239
Browser.Equal("Got event on enabled button", () => eventLog.GetAttribute("value"));
240240
}
241241

242+
[Fact]
243+
public void EventDuringBatchRendering_CanTriggerDOMEvents()
244+
{
245+
Browser.MountTestComponent<EventDuringBatchRendering>();
246+
247+
var input = Browser.FindElements(By.CssSelector("#reversible-list input"))[0];
248+
var eventLog = Browser.FindElement(By.Id("event-log"));
249+
250+
SendKeysSequentially(input, "abc");
251+
Browser.Equal("abc", () => input.GetAttribute("value"));
252+
Browser.Equal(
253+
"Change event on item First with value a\n" +
254+
"Change event on item First with value ab\n" +
255+
"Change event on item First with value abc",
256+
() => eventLog.Text.Trim().Replace("\r\n", "\n"));
257+
}
258+
259+
[Fact]
260+
public void EventDuringBatchRendering_CannotTriggerJSInterop()
261+
{
262+
Browser.MountTestComponent<EventDuringBatchRendering>();
263+
var errorLog = Browser.FindElement(By.Id("web-component-error-log"));
264+
265+
Browser.FindElement(By.Id("add-web-component")).Click();
266+
var expectedMessage = _serverFixture.ExecutionMode == ExecutionMode.Client
267+
? "Assertion failed - heap is currently locked"
268+
: "There was an exception invoking 'SomeMethodThatDoesntNeedToExistForThisTest' on assembly 'SomeAssembly'";
269+
270+
Browser.Contains(expectedMessage, () => errorLog.Text);
271+
}
272+
242273
void SendKeysSequentially(IWebElement target, string text)
243274
{
244275
// Calling it for each character works around some chars being skipped
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<h1>Event during batch rendering</h1>
2+
3+
<p>
4+
While Blazor WebAssembly is rendering a batch, the JavaScript code reads data from the .NET heap directly.
5+
So, it's essential that .NET code doesn't run during this time (either to modify the state of the
6+
render tree or to perform garbage collection which may relocate objects in the heap).
7+
</p>
8+
<p>
9+
To ensure this is safe, batch rendering is a fully synchronous process during which the JS code doesn't
10+
yield control to user code. However, there are possible cases where user code may be triggered unavoidably
11+
including (1) JavaScript DOM mutation observers, (2) Web Component lifecycle events, and (3) edge cases
12+
where Blazor performing a DOM mutation can itself trigger a .NET-bound event such as "change".
13+
</p>
14+
<p>
15+
Cases (1) and (2) result in developer-supplied JS code executing, which may try to perform .NET interop.
16+
The intended behavior is that .NET interop calls should be blocked while a batch is being rendered.
17+
Developers need to wrap such calls in <code>requestAnimationFrame</code> or <code>setTimeout(..., 0)</code>
18+
or similar, so that it runs after the current batch has finished rendering.
19+
</p>
20+
<p>
21+
Case (3) more directly results in developer-supplied .NET code executing. The intended behavior in this case
22+
is that Blazor takes care of deferring the event dispatch until the current batch finishes rendering. This
23+
shouldn't be regarded as problematic, because Blazor has never guaranteed synchronous dispatch of DOM event
24+
handlers (in the Blazor Server case, all DOM event handlers run asynchronously).
25+
</p>
26+
27+
<h2>WebComponent attempting JS interop during batch rendering (cases 1 & 2 above)</h2>
28+
29+
@for (var i = 0; i < numWebComponents; i++)
30+
{
31+
<custom-web-component-performing-js-interop>Instance @i</custom-web-component-performing-js-interop>
32+
}
33+
34+
<button id="add-web-component" @onclick="@(() => numWebComponents++)">Add a web component</button>
35+
36+
<pre id="web-component-error-log"></pre>
37+
38+
<h2>DOM mutation triggering a .NET event handler (case 3 above)</h2>
39+
40+
<p>
41+
Type into either text box. Each keystroke will swap the list order, causing a change event during batch rendering.
42+
</p>
43+
44+
<div id="reversible-list">
45+
@foreach (var item in itemsList)
46+
{
47+
<div @key="item">
48+
<input @oninput="@(() => itemsList.Reverse())"
49+
@onchange="@(evt => eventLog += $"Change event on item {item.Name} with value {evt.Value}\n")" />
50+
</div>
51+
}
52+
</div>
53+
54+
<pre id="event-log">@eventLog</pre>
55+
56+
@code {
57+
string eventLog = "";
58+
int numWebComponents = 0;
59+
60+
class ListItem
61+
{
62+
public string Name { get; set; }
63+
}
64+
65+
List<ListItem> itemsList = new List<ListItem>
66+
{
67+
new ListItem { Name = "First" },
68+
new ListItem { Name = "Second" },
69+
};
70+
}

src/Components/test/testassets/BasicTestApp/Index.razor

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
<option value="BasicTestApp.EventCallbackTest.EventCallbackCases">EventCallback</option>
2828
<option value="BasicTestApp.EventCasesComponent">Event cases</option>
2929
<option value="BasicTestApp.EventDisablingComponent">Event disabling</option>
30+
<option value="BasicTestApp.EventDuringBatchRendering">Event during batch rendering</option>
3031
<option value="BasicTestApp.EventPreventDefaultComponent">Event preventDefault</option>
3132
<option value="BasicTestApp.ExternalContentPackage">External content package</option>
3233
<option value="BasicTestApp.FocusEventComponent">Focus events</option>

src/Components/test/testassets/BasicTestApp/wwwroot/index.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@
2121
<a class='dismiss' style="cursor: pointer;">🗙</a>
2222
</div>
2323

24-
<!-- Used for testing interop scenarios between JS and .NET -->
24+
<!-- Used for specific test cases -->
2525
<script src="js/jsinteroptests.js"></script>
26+
<script src="js/webComponentPerformingJsInterop.js"></script>
2627

2728
<script>
2829
// Used by ElementRefComponent
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// This web component is used from the EventDuringBatchRendering test case
2+
3+
window.customElements.define('custom-web-component-performing-js-interop', class extends HTMLElement {
4+
connectedCallback() {
5+
this.attachShadow({ mode: 'open' });
6+
this.shadowRoot.innerHTML = `
7+
<div style='border: 2px dashed red; margin: 10px 0; padding: 5px; background: #dddddd;'>
8+
<slot></slot>
9+
</div>
10+
`;
11+
12+
// Since this happens during batch rendering, it will be blocked.
13+
// In the future we could allow async calls, but this is enough of an edge case
14+
// that it doesn't need to be implemented currently. Developers who need to do this
15+
// can wrap their interop call in requestAnimationFrame or setTimeout(..., 0).
16+
(async function () {
17+
try {
18+
await DotNet.invokeMethodAsync('SomeAssembly', 'SomeMethodThatDoesntNeedToExistForThisTest');
19+
} catch (ex) {
20+
document.getElementById('web-component-error-log').innerText += ex.toString() + '\n';
21+
}
22+
})();
23+
}
24+
});

src/Components/test/testassets/TestServer/Components.TestServer.csproj

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,17 @@
3434

3535
<Target Name="CopyClientAssetsForTest" BeforeTargets="Build"
3636
Inputs="..\BasicTestApp\wwwroot\js\jsinteroptests.js;
37+
..\BasicTestApp\wwwroot\js\webComponentPerformingJsInterop.js;
3738
..\BasicTestApp\wwwroot\NotAComponent.html;
3839
..\BasicTestApp\wwwroot\style.css"
3940
Outputs="wwwroot\js\jsinteroptests.js;
41+
wwwroot\js\webComponentPerformingJsInterop.js;
4042
wwwroot\NotAComponent.html;
4143
wwwroot\style.css">
4244

4345
<MakeDir Directories="wwwroot" />
4446

45-
<Copy SourceFiles="..\BasicTestApp\wwwroot\js\jsinteroptests.js;..\BasicTestApp\wwwroot\NotAComponent.html;..\BasicTestApp\wwwroot\style.css"
46-
DestinationFiles="wwwroot\js\jsinteroptests.js;wwwroot\NotAComponent.html;wwwroot\style.css" />
47+
<Copy SourceFiles="..\BasicTestApp\wwwroot\js\jsinteroptests.js;..\BasicTestApp\wwwroot\js\webComponentPerformingJsInterop.js;..\BasicTestApp\wwwroot\NotAComponent.html;..\BasicTestApp\wwwroot\style.css"
48+
DestinationFiles="wwwroot\js\jsinteroptests.js;wwwroot\js\webComponentPerformingJsInterop.js;wwwroot\NotAComponent.html;wwwroot\style.css" />
4749
</Target>
4850
</Project>

src/Components/test/testassets/TestServer/Pages/_ServerHost.cshtml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
<body>
1515
<root><component type="typeof(BasicTestApp.Index)" render-mode="Server" /></root>
1616

17-
<!-- Used for testing interop scenarios between JS and .NET -->
17+
<!-- Used for specific test cases -->
1818
<script src="js/jsinteroptests.js"></script>
19+
<script src="js/webComponentPerformingJsInterop.js"></script>
1920

2021
<div id="blazor-error-ui">
2122
An unhandled error has occurred.

0 commit comments

Comments
 (0)