Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/Components/Web.JS/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,20 @@ const ROOT_DIR = path.resolve(__dirname, '..', '..', '..');

module.exports = {
testEnvironment: 'node',
roots: ['<rootDir>/src','<rootDir>/test'],
roots: ['<rootDir>/src', '<rootDir>/test'],
testMatch: ['**/*.test.ts'],
moduleFileExtensions: ['js', 'ts'],
transform: {
'^.+\\.(js|ts)$': 'babel-jest',
},
moduleDirectories: ['node_modules', 'src'],
moduleNameMapper: {
'^@microsoft/dotnet-js-interop$': '<rootDir>/test/__mocks__/@microsoft/dotnet-js-interop.js',
'^@microsoft/dotnet-runtime$': '<rootDir>/test/__mocks__/@microsoft/dotnet-runtime.js',
},
testEnvironment: "jsdom",
reporters: [
"default",
[path.resolve(ROOT_DIR, "node_modules", "jest-junit", "index.js"), { "outputDirectory": path.resolve(ROOT_DIR, "artifacts", "log"), "outputName": `${process.platform}` + ".components-webjs.junit.xml" }]
"default",
[path.resolve(ROOT_DIR, "node_modules", "jest-junit", "index.js"), { "outputDirectory": path.resolve(ROOT_DIR, "artifacts", "log"), "outputName": `${process.platform}` + ".components-webjs.junit.xml" }]
],
}
42 changes: 23 additions & 19 deletions src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { showErrorNotification } from '../../BootErrors';
import { Platform, System_Array, Pointer, System_Object, HeapLock, PlatformApi } from '../Platform';
import { WebAssemblyBootResourceType, WebAssemblyStartOptions } from '../WebAssemblyStartOptions';
import { Blazor } from '../../GlobalExports';
import { DotnetModuleConfig, MonoConfig, ModuleAPI, RuntimeAPI, GlobalizationMode } from '@microsoft/dotnet-runtime';
import { DotnetModuleConfig, DotnetHostBuilder, MonoConfig, ModuleAPI, RuntimeAPI, GlobalizationMode } from '@microsoft/dotnet-runtime';
import { fetchAndInvokeInitializers } from '../../JSInitializers/JSInitializers.WebAssembly';
import { JSInitializer } from '../../JSInitializers/JSInitializers';

Expand Down Expand Up @@ -141,7 +141,8 @@ async function importDotnetJs(startOptions: Partial<WebAssemblyStartOptions>): P
return await import(/* webpackIgnore: true */ './dotnet.js');
}

function prepareRuntimeConfig(options: Partial<WebAssemblyStartOptions>, onConfigLoadedCallback?: (loadedConfig: MonoConfig) => void): DotnetModuleConfig {
/** @internal Exported for unit testing only. */
export function prepareRuntimeConfig(options: Partial<WebAssemblyStartOptions>, dotnet: DotnetHostBuilder, onConfigLoadedCallback?: (loadedConfig: MonoConfig) => void): DotnetModuleConfig {
const config: MonoConfig = {
maxParallelDownloads: 1000000, // disable throttling parallel downloads
enableDownloadRetry: false, // disable retry downloads
Expand All @@ -166,6 +167,25 @@ function prepareRuntimeConfig(options: Partial<WebAssemblyStartOptions>, onConfi
onConfigLoadedCallback?.(loadedConfig);

jsInitializer = await fetchAndInvokeInitializers(options, loadedConfig);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maraf why we are doing fetchAndInvokeInitializers here and not only in runtime ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initializers is a blazor feature, not a wasm feature

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know wasm has it too, it got added there too when we did a refactor a couple years later. The blazor feature predates the wasm feature #34798

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, why we didn't finish the refactoring and unify it ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we can't. This is used by libraries and we shouldn't break them, and I believe there were some things that could be done in the Blazor version that couldn't be done in the wasm version.

To be very clear, we can't just remove this feature for blazor. it's used on server, ssr, webview and (wasm) where wasm also added its own hooks. And it's the pattern libraries have to bring their JS. This should keep working for backwards compatibility.

// Apply options AFTER beforeStart initializers have had a chance to mutate them.
// This ensures custom loadBootResource, environment, etc. set by JS initializers
// are respected when the runtime downloads resources.
if (options.applicationCulture) {
dotnet.withApplicationCulture(options.applicationCulture);
}

if (options.environment) {
dotnet.withApplicationEnvironment(options.environment);
}

if (options.loadBootResource) {
dotnet.withResourceLoader(options.loadBootResource);
}

if (options.configureRuntime) {
options.configureRuntime(dotnet);
}
};

const moduleConfig = (window['Module'] || {}) as any;
Expand All @@ -183,27 +203,11 @@ function prepareRuntimeConfig(options: Partial<WebAssemblyStartOptions>, onConfi

async function createRuntimeInstance(options: Partial<WebAssemblyStartOptions>, onConfigLoaded?: (loadedConfig: MonoConfig) => void): Promise<void> {
const { dotnet } = await importDotnetJs(options);
const moduleConfig = prepareRuntimeConfig(options, onConfigLoaded);

if (options.applicationCulture) {
dotnet.withApplicationCulture(options.applicationCulture);
}

if (options.environment) {
dotnet.withApplicationEnvironment(options.environment);
}

if (options.loadBootResource) {
dotnet.withResourceLoader(options.loadBootResource);
}
const moduleConfig = prepareRuntimeConfig(options, dotnet, onConfigLoaded);

const anyDotnet = (dotnet as any);
anyDotnet.withModuleConfig(moduleConfig);

if (options.configureRuntime) {
options.configureRuntime(dotnet);
}

runtime = await dotnet.create();
}

Expand Down
196 changes: 196 additions & 0 deletions src/Components/Web.JS/test/MonoPlatform.InitializerTiming.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/**
* Test: JS Initializers beforeStart timing fix (Issue #54358)
*
* These tests validate the prepareRuntimeConfig function from MonoPlatform.ts
* to ensure that dotnet.with*() calls happen AFTER onConfigLoaded callback invokes
* fetchAndInvokeInitializers (which runs beforeStart JS initializer callbacks).
*
* Evidence from dotnet/runtime source (src/mono/browser/runtime/loader/config.ts):
* - onConfigLoaded is explicitly `await`ed by the runtime
* - Comment in source: "scripts need to be loaded before onConfigLoaded because
* Blazor calls `beforeStart` export in onConfigLoaded"
* - After onConfigLoaded returns, normalizeConfig() runs again
* - Resource downloads begin AFTER this point
* - Type signature: onConfigLoaded?: (config: MonoConfig) => void | Promise<void>
*/
import { describe, test, expect, jest, beforeEach } from '@jest/globals';

// ---------- Mocks ----------
// jest.mock() calls are HOISTED before const declarations, so we must NOT reference
// any const from outer scope inside the mock factory. Instead, we get a reference
// to the mock function via require() after mocking.

jest.mock('../src/JSInitializers/JSInitializers.WebAssembly', () => ({
fetchAndInvokeInitializers: jest.fn(),
}));

jest.mock('../src/GlobalExports', () => ({
Blazor: { _internal: {} },
}));

jest.mock('../src/BootErrors', () => ({
showErrorNotification: jest.fn(),
}));

jest.mock('../src/Platform/Mono/MonoDebugger', () => ({
attachDebuggerHotkey: jest.fn(),
}));

// Import modules after mocks
import { prepareRuntimeConfig } from '../src/Platform/Mono/MonoPlatform';
import { fetchAndInvokeInitializers } from '../src/JSInitializers/JSInitializers.WebAssembly';

// Cast to jest mock for type safety
const mockFetchAndInvokeInitializers = fetchAndInvokeInitializers as any;

// ---------- Helpers ----------

function createMockDotnetBuilder() {
const callOrder: string[] = [];
return {
callOrder,
builder: {
withApplicationCulture: jest.fn(() => { callOrder.push('withApplicationCulture'); }),
withApplicationEnvironment: jest.fn(() => { callOrder.push('withApplicationEnvironment'); }),
withResourceLoader: jest.fn(() => { callOrder.push('withResourceLoader'); }),
},
};
}

function createMockMonoConfig(overrides: Record<string, any> = {}) {
return {
environmentVariables: {},
applicationEnvironment: 'Production',
applicationCulture: 'en-US',
resources: {},
...overrides,
};
}

// ---------- Tests ----------

describe('prepareRuntimeConfig — onConfigLoaded timing (Issue #54358)', () => {
beforeEach(() => {
mockFetchAndInvokeInitializers.mockReset();
});

test('dotnet.withResourceLoader is called AFTER fetchAndInvokeInitializers (beforeStart)', async () => {
const callOrder: string[] = [];
const customLoader = jest.fn();
const options: Record<string, any> = {};

const { builder } = createMockDotnetBuilder();
builder.withResourceLoader = jest.fn((loader: any) => {
callOrder.push('withResourceLoader');
expect(loader).toBe(customLoader);
}) as any;

// Simulate beforeStart setting loadBootResource on the options object
mockFetchAndInvokeInitializers.mockImplementation(async (opts: any) => {
callOrder.push('fetchAndInvokeInitializers');
opts.loadBootResource = customLoader;
opts.environment = 'Development123';
return {} as any;
});

const moduleConfig = prepareRuntimeConfig(options as any, builder as any);
await moduleConfig.onConfigLoaded!(createMockMonoConfig() as any);

// Core invariant: beforeStart runs BEFORE dotnet.with*()
expect(callOrder.indexOf('fetchAndInvokeInitializers'))
.toBeLessThan(callOrder.indexOf('withResourceLoader'));
expect(builder.withResourceLoader).toHaveBeenCalledWith(customLoader);
});

test('dotnet.withApplicationEnvironment is called AFTER beforeStart sets environment', async () => {
const options: Record<string, any> = {};
const { builder, callOrder } = createMockDotnetBuilder();

mockFetchAndInvokeInitializers.mockImplementation(async (opts: any) => {
callOrder.push('fetchAndInvokeInitializers');
opts.environment = 'Staging';
return {} as any;
});

const moduleConfig = prepareRuntimeConfig(options as any, builder as any);
await moduleConfig.onConfigLoaded!(createMockMonoConfig() as any);

expect(callOrder.indexOf('fetchAndInvokeInitializers'))
.toBeLessThan(callOrder.indexOf('withApplicationEnvironment'));
expect(builder.withApplicationEnvironment).toHaveBeenCalledWith('Staging');
});

test('dotnet.with*() is NOT called when beforeStart does not set those options', async () => {
const options: Record<string, any> = {};
const { builder } = createMockDotnetBuilder();

mockFetchAndInvokeInitializers.mockResolvedValue({} as any);

const moduleConfig = prepareRuntimeConfig(options as any, builder as any);
await moduleConfig.onConfigLoaded!(createMockMonoConfig() as any);

expect(builder.withResourceLoader).not.toHaveBeenCalled();
expect(builder.withApplicationEnvironment).not.toHaveBeenCalled();
expect(builder.withApplicationCulture).not.toHaveBeenCalled();
});

test('configureRuntime is called AFTER fetchAndInvokeInitializers', async () => {
const callOrder: string[] = [];
const customConfigureRuntime = jest.fn(() => { callOrder.push('configureRuntime'); });
const options: Record<string, any> = {};
const { builder } = createMockDotnetBuilder();

mockFetchAndInvokeInitializers.mockImplementation(async (opts: any) => {
callOrder.push('fetchAndInvokeInitializers');
opts.configureRuntime = customConfigureRuntime;
return {} as any;
});

const moduleConfig = prepareRuntimeConfig(options as any, builder as any);
await moduleConfig.onConfigLoaded!(createMockMonoConfig() as any);

expect(callOrder.indexOf('fetchAndInvokeInitializers'))
.toBeLessThan(callOrder.indexOf('configureRuntime'));
expect(customConfigureRuntime).toHaveBeenCalledWith(builder);
});

test('onConfigLoadedCallback fires before fetchAndInvokeInitializers', async () => {
const callOrder: string[] = [];
const options: Record<string, any> = {};
const { builder } = createMockDotnetBuilder();

mockFetchAndInvokeInitializers.mockImplementation(async () => {
callOrder.push('fetchAndInvokeInitializers');
return {} as any;
});

const onConfigLoadedCallback = jest.fn(() => {
callOrder.push('onConfigLoadedCallback');
});

const moduleConfig = prepareRuntimeConfig(options as any, builder as any, onConfigLoadedCallback as any);
await moduleConfig.onConfigLoaded!(createMockMonoConfig() as any);

expect(callOrder.indexOf('onConfigLoadedCallback'))
.toBeLessThan(callOrder.indexOf('fetchAndInvokeInitializers'));
});

test('pre-configured options (via Blazor.start) are also applied in onConfigLoaded', async () => {
const customLoader = jest.fn();
const options: Record<string, any> = {
loadBootResource: customLoader,
environment: 'Production',
applicationCulture: 'tr-TR',
};
const { builder } = createMockDotnetBuilder();

mockFetchAndInvokeInitializers.mockResolvedValue({} as any);

const moduleConfig = prepareRuntimeConfig(options as any, builder as any);
await moduleConfig.onConfigLoaded!(createMockMonoConfig() as any);

expect(builder.withResourceLoader).toHaveBeenCalledWith(customLoader);
expect(builder.withApplicationEnvironment).toHaveBeenCalledWith('Production');
expect(builder.withApplicationCulture).toHaveBeenCalledWith('tr-TR');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Stub module for @microsoft/dotnet-js-interop (not a real npm package in this repo)
module.exports = {
DotNet: {
attachDispatcher: function () { return {}; },
attachReviver: function () { },
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Stub module for @microsoft/dotnet-runtime (not a real npm package in this repo)
module.exports = {
GlobalizationMode: Object.freeze({
Sharded: 'sharded',
All: 'all',
Invariant: 'invariant',
Custom: 'custom',
}),
};
8 changes: 8 additions & 0 deletions src/Components/test/E2ETest/Tests/JsInitializersTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ public void InitializersWork()
{
Browser.Exists(By.Id(callback));
}

// Blazor WebAssembly JS Initializers bug fix verification (Issue #54358):
// Ensure loadBootResource set in beforeStart was actually used to download resources
if (_serverFixture.ExecutionMode == ExecutionMode.Client)
{
Browser.Exists(By.Id("total-requests"));
Browser.True(() => Browser.FindElements(By.CssSelector("#total-requests tr")).Count > 1);
}
}

protected virtual string[] GetExpectedCallbacks()
Expand Down
Loading