From 94f3bc9c9c220ace4c2366e51fc50424acbf2944 Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Fri, 24 Apr 2026 14:15:10 +0200 Subject: [PATCH 1/4] download only --- .../BuildWasmAppsJobsListCoreCLR.txt | 1 + .../Wasm.Build.Tests/DownloadThenInitTests.cs | 43 +++++++ .../WasmBasicTestApp/App/wwwroot/main.js | 42 +++++++ .../libs/Common/JavaScript/loader/assets.ts | 119 +++++++++++++++++- .../libs/Common/JavaScript/loader/dotnet.d.ts | 6 +- .../Common/JavaScript/loader/host-builder.ts | 4 +- .../libs/Common/JavaScript/loader/run.ts | 105 +++++++++++----- .../Common/JavaScript/types/public-api.ts | 6 +- 8 files changed, 291 insertions(+), 35 deletions(-) diff --git a/eng/testing/scenarios/BuildWasmAppsJobsListCoreCLR.txt b/eng/testing/scenarios/BuildWasmAppsJobsListCoreCLR.txt index 4f567f5cba1082..8cf543448b73fe 100644 --- a/eng/testing/scenarios/BuildWasmAppsJobsListCoreCLR.txt +++ b/eng/testing/scenarios/BuildWasmAppsJobsListCoreCLR.txt @@ -12,3 +12,4 @@ Wasm.Build.Tests.WasmRunOutOfAppBundleTests Wasm.Build.Tests.WasmTemplateTests Wasm.Build.Tests.MaxParallelDownloadsTests Wasm.Build.Tests.LibraryInitializerTests +Wasm.Build.Tests.DownloadThenInitTests diff --git a/src/mono/wasm/Wasm.Build.Tests/DownloadThenInitTests.cs b/src/mono/wasm/Wasm.Build.Tests/DownloadThenInitTests.cs index 6d8f0a6167602b..6d4e3c87d17bc6 100644 --- a/src/mono/wasm/Wasm.Build.Tests/DownloadThenInitTests.cs +++ b/src/mono/wasm/Wasm.Build.Tests/DownloadThenInitTests.cs @@ -31,6 +31,15 @@ public async Task NoResourcesReFetchedAfterDownloadFinished(Configuration config var resultTestOutput = result.TestOutput.ToList(); int index = resultTestOutput.FindIndex(s => s.Contains("download finished")); Assert.True(index > 0); // number of fetched resources cannot be 0 + + // Verify onConfigLoaded was called during download() + Assert.Contains(resultTestOutput, s => s.Contains("onConfigLoaded was called during download")); + + // Verify resources were actually fetched during download + var fetchesDuringDownload = resultTestOutput.Take(index + 1).Where(s => s.StartsWith("fetching")).ToList(); + Assert.True(fetchesDuringDownload.Count > 0, "Expected resources to be fetched during download()"); + + // Verify no resources were re-fetched during create() var afterDownload = resultTestOutput.Skip(index + 1).Where(s => s.StartsWith("fetching")).ToList(); if (afterDownload.Count > 0) { @@ -39,5 +48,39 @@ public async Task NoResourcesReFetchedAfterDownloadFinished(Configuration config if (reFetchedResources.Any()) Assert.Fail($"Resources should not be fetched twice. Re-fetched on init: {string.Join(", ", reFetchedResources)}"); } + + // Verify create() completed successfully + Assert.Contains(resultTestOutput, s => s.Contains("create finished")); + } + + [Theory] + [InlineData(Configuration.Debug)] + [InlineData(Configuration.Release)] + public async Task HttpCacheOnlyThenCreateWorks(Configuration config) + { + ProjectInfo info = CopyTestAsset(config, aot: false, TestAsset.WasmBasicTestApp, "DownloadThenInitHttpCacheOnly"); + BuildProject(info, config); + BrowserRunOptions options = new(config, TestScenario: "DownloadThenInitHttpCacheOnly"); + RunResult result = await RunForBuildWithDotnetRun(options); + var resultTestOutput = result.TestOutput.ToList(); + int index = resultTestOutput.FindIndex(s => s.Contains("download finished")); + Assert.True(index > 0); + + // Verify loadBootResource was called during download(true) + Assert.Contains(resultTestOutput, s => s.Contains("loadBootResource was called")); + + // Verify onConfigLoaded was called during download(true) — config init runs before prefetch + Assert.Contains(resultTestOutput, s => s.Contains("onConfigLoaded was called during download")); + + // Verify prefetch requests happened during download + var fetchesDuringDownload = resultTestOutput.Take(index + 1).Where(s => s.StartsWith("fetching")).ToList(); + Assert.True(fetchesDuringDownload.Count > 0, "Expected prefetch requests during download(true)"); + + // Verify resource fetches happened during create() (httpCacheOnly doesn't load into memory) + var fetchesAfterDownload = resultTestOutput.Skip(index + 1).Where(s => s.StartsWith("fetching")).ToList(); + Assert.True(fetchesAfterDownload.Count > 0, "Expected resource fetches during create() after httpCacheOnly download"); + + // Verify create() completed successfully + Assert.Contains(resultTestOutput, s => s.Contains("create finished")); } } diff --git a/src/mono/wasm/testassets/WasmBasicTestApp/App/wwwroot/main.js b/src/mono/wasm/testassets/WasmBasicTestApp/App/wwwroot/main.js index 84c77206bcf6fe..b7349232ba97f6 100644 --- a/src/mono/wasm/testassets/WasmBasicTestApp/App/wwwroot/main.js +++ b/src/mono/wasm/testassets/WasmBasicTestApp/App/wwwroot/main.js @@ -117,12 +117,50 @@ switch (testCase) { .withInterpreterPgo(true); break; case "DownloadThenInit": + let dtConfigLoadedCalled = false; + dotnet.withModuleConfig({ + onConfigLoaded: () => { + dtConfigLoadedCalled = true; + testOutput("onConfigLoaded called"); + } + }); const originalFetch = globalThis.fetch; globalThis.fetch = (url, fetchArgs) => { testOutput("fetching " + url); return originalFetch(url, fetchArgs); }; await dotnet.download(); + if (dtConfigLoadedCalled) { + testOutput("onConfigLoaded was called during download"); + } + testOutput("download finished"); + break; + case "DownloadThenInitHttpCacheOnly": + let loadBootResourceCalled = false; + let hcConfigLoadedCalled = false; + dotnet.withResourceLoader((type, name, defaultUri, integrity, behavior) => { + testOutput("loadBootResource " + type + " " + name); + loadBootResourceCalled = true; + return defaultUri; + }); + dotnet.withModuleConfig({ + onConfigLoaded: () => { + hcConfigLoadedCalled = true; + testOutput("onConfigLoaded called"); + } + }); + const originalFetch3 = globalThis.fetch; + globalThis.fetch = (url, fetchArgs) => { + testOutput("fetching " + url); + return originalFetch3(url, fetchArgs); + }; + await dotnet.download(true); + if (loadBootResourceCalled) { + testOutput("loadBootResource was called"); + } + if (hcConfigLoadedCalled) { + testOutput("onConfigLoaded was called during download"); + } testOutput("download finished"); break; case "MaxParallelDownloads": @@ -266,6 +304,10 @@ try { exit(0); break; case "DownloadThenInit": + case "DownloadThenInitHttpCacheOnly": + testOutput("create finished"); + exit(0); + break; case "MaxParallelDownloads": exit(0); break; diff --git a/src/native/libs/Common/JavaScript/loader/assets.ts b/src/native/libs/Common/JavaScript/loader/assets.ts index aeb5c8d7244759..6c13196dd6f255 100644 --- a/src/native/libs/Common/JavaScript/loader/assets.ts +++ b/src/native/libs/Common/JavaScript/loader/assets.ts @@ -1,10 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -import type { JsModuleExports, JsAsset, AssemblyAsset, WasmAsset, IcuAsset, EmscriptenModuleInternal, WebAssemblyBootResourceType, AssetEntryInternal, PromiseCompletionSource, LoadBootResourceCallback, InstantiateWasmSuccessCallback, SymbolsAsset } from "./types"; +import type { JsModuleExports, JsAsset, AssemblyAsset, WasmAsset, IcuAsset, EmscriptenModuleInternal, WebAssemblyBootResourceType, AssetEntryInternal, PromiseCompletionSource, LoadBootResourceCallback, InstantiateWasmSuccessCallback, SymbolsAsset, AssetBehaviors } from "./types"; import { dotnetAssert, dotnetLogger, dotnetInternals, dotnetBrowserHostExports, dotnetUpdateInternals, Module, dotnetDiagnosticsExports, dotnetNativeBrowserExports, dotnetApi } from "./cross-module"; -import { ENVIRONMENT_IS_SHELL, ENVIRONMENT_IS_NODE, browserVirtualAppBase } from "./per-module"; +import { ENVIRONMENT_IS_SHELL, ENVIRONMENT_IS_NODE, ENVIRONMENT_IS_WEB, browserVirtualAppBase } from "./per-module"; import { createPromiseCompletionSource, delay } from "./promise-completion-source"; import { locateFile, makeURLAbsoluteWithApplicationBase } from "./bootstrap"; import { fetchLike, responseLike } from "./polyfills"; @@ -585,3 +585,118 @@ const leaveAfterInstantiation: { [key: string]: number | undefined } = { "dotnetwasm": 1, "webcil": 1, }; + +// Fetches all data resources into the browser HTTP cache without loading them into memory. +// JS modules get hints instead of fetch() since they use import(). +export async function prefetchAllResources(): Promise { + const resources = loaderConfig.resources; + if (!resources) return; + + const maxParallel = loaderConfig.maxParallelDownloads || 1000000; + const pending: Promise[] = []; + const queue: { url: string; hash?: string | null | ""; name: string; behavior: string }[] = []; + + function enqueueAsset(asset: { name?: string; resolvedUrl?: string; hash?: string | null | "" }, behavior: string): void { + if (!asset.resolvedUrl && asset.name) { + asset.resolvedUrl = locateFile(asset.name); + } + if (asset.resolvedUrl && asset.name) { + queue.push({ url: asset.resolvedUrl, hash: asset.hash, name: asset.name, behavior }); + } + } + + // Data assets: fetch and discard + if (resources.coreAssembly) resources.coreAssembly.forEach(a => enqueueAsset(a, "assembly")); + if (resources.assembly) resources.assembly.forEach(a => enqueueAsset(a, "assembly")); + if (resources.coreVfs) resources.coreVfs.forEach(a => enqueueAsset(a, "vfs")); + if (resources.vfs) resources.vfs.forEach(a => enqueueAsset(a, "vfs")); + if (resources.icu) resources.icu.forEach(a => enqueueAsset(a, "icu")); + if (resources.wasmNative) resources.wasmNative.forEach(a => enqueueAsset(a, "dotnetwasm")); + if (resources.corePdb) resources.corePdb.forEach(a => enqueueAsset(a, "pdb")); + if (resources.pdb) resources.pdb.forEach(a => enqueueAsset(a, "pdb")); + if (resources.wasmSymbols) resources.wasmSymbols.forEach(a => enqueueAsset(a, "symbols")); + // Satellite resources + if (loaderConfig.loadAllSatelliteResources && resources.satelliteResources) { + for (const culture of Object.keys(resources.satelliteResources)) { + for (const asset of resources.satelliteResources[culture]) { + enqueueAsset(asset, "assembly"); + } + } + } + + // JS modules: add in web environments + prefetchJSModuleLinks([ + ...(resources.jsModuleNative || []), + ...(resources.jsModuleRuntime || []), + ...(resources.jsModuleDiagnostics || []), + ...(resources.jsModuleWorker || []), + ...(resources.modulesAfterConfigLoaded || []), + ...(resources.modulesAfterRuntimeReady || []), + ]); + + // Fetch data assets with throttling + let index = 0; + async function worker(): Promise { + while (index < queue.length) { + const item = queue[index++]; + try { + await prefetchUrl(item.url, item.hash, item.name, item.behavior); + } catch { + // Best-effort cache warming — individual failures are non-fatal. + // The subsequent create() call will handle retries and error reporting. + } + } + } + const workerCount = Math.min(maxParallel, queue.length); + for (let i = 0; i < workerCount; i++) { + pending.push(worker()); + } + await Promise.all(pending); +} + +export function prefetchJSModuleLinks(modules: JsAsset[]): void { + if (!ENVIRONMENT_IS_WEB) return; + for (const mod of modules) { + if (!mod.resolvedUrl && mod.name) { + mod.resolvedUrl = locateFile(mod.name, true); + } + if (mod.resolvedUrl) { + const link = globalThis.document.createElement("link"); + link.rel = "prefetch"; + link.href = mod.resolvedUrl; + link.as = "script"; + globalThis.document.head.appendChild(link); + } + } +} + +async function prefetchUrl(url: string, hash?: string | null | "", name?: string, behavior?: string): Promise { + // Respect loadBootResourceCallback so prefetch goes through the same custom loader as create() + if (typeof loadBootResourceCallback === "function" && behavior && name) { + const blazorType = behaviorToBlazorAssetTypeMap[behavior]; + if (blazorType) { + const customLoadResult = loadBootResourceCallback(blazorType, name, url, hash ?? "", behavior as AssetBehaviors); + if (typeof customLoadResult === "string") { + url = makeURLAbsoluteWithApplicationBase(customLoadResult); + } else if (typeof customLoadResult === "object") { + // Custom loader returned a Response promise — await and discard + const response = await (customLoadResult as Promise); + await response.arrayBuffer(); + return; + } + } + } + const fetchOptions: RequestInit = {}; + if (!loaderConfig.disableNoCacheFetch) { + fetchOptions.cache = "no-cache"; + } + if (!loaderConfig.disableIntegrityCheck && hash) { + fetchOptions.integrity = hash; + } + // Use globalThis.fetch directly because this runs before initPolyfills() + const response = await globalThis.fetch(url, fetchOptions); + if (response.ok) { + // Read the body to ensure the response is fully received into cache + await response.arrayBuffer(); + } +} diff --git a/src/native/libs/Common/JavaScript/loader/dotnet.d.ts b/src/native/libs/Common/JavaScript/loader/dotnet.d.ts index 0f7e61e51c8715..60946334f7e9f4 100644 --- a/src/native/libs/Common/JavaScript/loader/dotnet.d.ts +++ b/src/native/libs/Common/JavaScript/loader/dotnet.d.ts @@ -106,8 +106,12 @@ interface DotnetHostBuilder { withResourceLoader(loadBootResource?: LoadBootResourceCallback): DotnetHostBuilder; /** * Downloads all the assets but doesn't create the runtime instance. + * @param httpCacheOnly If true, resources are only fetched into the browser HTTP cache + * and discarded. A subsequent create() call will re-fetch from cache and do full init. + * If false (default), resources are downloaded and loaded into WASM memory, so that + * a subsequent create() call only needs to initialize the managed runtime. */ - download(): Promise; + download(httpCacheOnly?: boolean): Promise; /** * Starts the runtime and returns promise of the API object. */ diff --git a/src/native/libs/Common/JavaScript/loader/host-builder.ts b/src/native/libs/Common/JavaScript/loader/host-builder.ts index eebdaebdbb4732..8000f1282c0454 100644 --- a/src/native/libs/Common/JavaScript/loader/host-builder.ts +++ b/src/native/libs/Common/JavaScript/loader/host-builder.ts @@ -102,10 +102,10 @@ export class HostBuilder implements DotnetHostBuilder { return this; } - async download(): Promise { + async download(httpCacheOnly?: boolean): Promise { try { validateLoaderConfig(); - return createRuntime(true); + return createRuntime(true, httpCacheOnly ?? false); } catch (err) { exit(1, err); throw err; diff --git a/src/native/libs/Common/JavaScript/loader/run.ts b/src/native/libs/Common/JavaScript/loader/run.ts index 25347cedbc061e..5b11e7aad1f533 100644 --- a/src/native/libs/Common/JavaScript/loader/run.ts +++ b/src/native/libs/Common/JavaScript/loader/run.ts @@ -8,52 +8,90 @@ import { exit, runtimeState } from "./exit"; import { createPromiseCompletionSource } from "./promise-completion-source"; import { getIcuResourceName } from "./icu"; import { loaderConfig, validateLoaderConfig } from "./config"; -import { fetchAssembly, fetchIcu, fetchNativeSymbols, fetchPdb, fetchSatelliteAssemblies, fetchVfs, fetchMainWasm, loadDotnetModule, loadJSModule, nativeModulePromiseController, verifyAllAssetsDownloaded, callLibraryInitializerOnRuntimeReady, callLibraryInitializerOnRuntimeConfigLoaded } from "./assets"; +import { fetchAssembly, fetchIcu, fetchNativeSymbols, fetchPdb, fetchSatelliteAssemblies, fetchVfs, fetchMainWasm, loadDotnetModule, loadJSModule, nativeModulePromiseController, verifyAllAssetsDownloaded, callLibraryInitializerOnRuntimeReady, callLibraryInitializerOnRuntimeConfigLoaded, prefetchAllResources, prefetchJSModuleLinks } from "./assets"; import { initPolyfills } from "./polyfills"; import { validateEngineFeatures } from "./bootstrap"; const runMainPromiseController = createPromiseCompletionSource(); -// WASM-TODO: downloadOnly https://github.com/dotnet/runtime/issues/124896 -// WASM-TODO: debugLevel +let downloadStarted = false; +let downloadedIntoMemory = false; +let configInitialized = false; // many things happen in parallel here, but order matters for performance! // ideally we want to utilize network and CPU at the same time -export async function createRuntime(downloadOnly: boolean): Promise { +export async function createRuntime(downloadOnly: boolean, httpCacheOnly: boolean = false): Promise { if (!loaderConfig.resources || !loaderConfig.resources.coreAssembly || !loaderConfig.resources.coreAssembly.length) throw new Error("Invalid config, resources is not set"); try { runtimeState.creatingRuntime = true; - const resources = loaderConfig.resources; - - await validateEngineFeatures(); - if (typeof Module.onConfigLoaded === "function") { - await Module.onConfigLoaded(loaderConfig); + // Calling download() again is a noop + if (downloadOnly && downloadStarted) { + return; } - validateLoaderConfig(); - const modulesAfterConfigLoadedPromises: [JsAsset, Promise][] = normalizeCollection(resources.modulesAfterConfigLoaded).map((a) => [a, callLibraryInitializerOnRuntimeConfigLoaded(a)]); - await Promise.all(modulesAfterConfigLoadedPromises.map(([, p]) => p)); + // Fast path: download() already loaded everything into memory, create() just needs to init + if (downloadedIntoMemory && !downloadOnly) { + Module.runtimeKeepalivePush(); + await initializeCoreCLR(); - // Wire user-provided out/err overrides to Emscripten's print/printErr. - // This must happen before the native module loads so Emscripten picks them up. - if (!Module.out) { - // eslint-disable-next-line no-console - Module.out = console.log.bind(console); - } - if (!Module.err) { - // eslint-disable-next-line no-console - Module.err = console.error.bind(console); - } - if (!Module.print) { - Module.print = Module.out; + if (typeof Module.onDotnetReady === "function") { + await Module.onDotnetReady(); + } + + const resources = loaderConfig.resources; + // modulesAfterConfigLoaded were already loaded and had onRuntimeConfigLoaded called during download(). They don't need onRuntimeReady. + // modulesAfterRuntimeReady were only prefetched during download(), now load and call onRuntimeReady. + const modulesAfterRuntimeReadyPromises: [JsAsset, Promise][] = normalizeCollection(resources.modulesAfterRuntimeReady).map((a) => [a, loadJSModule(a)]); + await Promise.all(modulesAfterRuntimeReadyPromises.map(callLibraryInitializerOnRuntimeReady)); + return; } - if (!Module.printErr) { - Module.printErr = Module.err; + + const resources = loaderConfig.resources; + + // Run config initialization once: onConfigLoaded, modulesAfterConfigLoaded, polyfills. + // This must happen before any asset fetches so that URL overrides take effect. + let modulesAfterConfigLoadedPromises: [JsAsset, Promise][] = []; + if (!configInitialized) { + configInitialized = true; + + await validateEngineFeatures(); + + if (typeof Module.onConfigLoaded === "function") { + await Module.onConfigLoaded(loaderConfig); + } + validateLoaderConfig(); + + modulesAfterConfigLoadedPromises = normalizeCollection(resources.modulesAfterConfigLoaded).map((a) => [a, callLibraryInitializerOnRuntimeConfigLoaded(a)]); + await Promise.all(modulesAfterConfigLoadedPromises.map(([, p]) => p)); + + // Wire user-provided out/err overrides to Emscripten's print/printErr. + // This must happen before the native module loads so Emscripten picks them up. + if (!Module.out) { + // eslint-disable-next-line no-console + Module.out = console.log.bind(console); + } + if (!Module.err) { + // eslint-disable-next-line no-console + Module.err = console.error.bind(console); + } + if (!Module.print) { + Module.print = Module.out; + } + if (!Module.printErr) { + Module.printErr = Module.err; + } + + // after onConfigLoaded hooks that could install polyfills, our polyfills can be initialized + await initPolyfills(); } - // after onConfigLoaded hooks that could install polyfills, our polyfills can be initialized - await initPolyfills(); + // HTTP cache only path: just fetch all resources into browser cache and discard + if (downloadOnly && httpCacheOnly) { + downloadStarted = true; + await prefetchAllResources(); + return; + } if (resources.jsModuleDiagnostics && resources.jsModuleDiagnostics.length > 0) { const diagnosticsModule = await loadDotnetModule(resources.jsModuleDiagnostics[0]); @@ -82,7 +120,14 @@ export async function createRuntime(downloadOnly: boolean): Promise { const isDebuggingSupported = loaderConfig.debugLevel != 0; const corePDBsPromise = forEachResource(resources.corePdb, fetchPdb, () => isDebuggingSupported); const pdbsPromise = forEachResource(resources.pdb, fetchPdb, () => isDebuggingSupported); - const modulesAfterRuntimeReadyPromises: [JsAsset, Promise][] = normalizeCollection(resources.modulesAfterRuntimeReady).map((a) => [a, loadJSModule(a)]); + // In download-only mode, just add prefetch hints for runtime-ready modules so create() loads them from cache. + // In create mode, load them now so onRuntimeReady can be called later. + let modulesAfterRuntimeReadyPromises: [JsAsset, Promise][] = []; + if (downloadOnly) { + prefetchJSModuleLinks(normalizeCollection(resources.modulesAfterRuntimeReady)); + } else { + modulesAfterRuntimeReadyPromises = normalizeCollection(resources.modulesAfterRuntimeReady).map((a) => [a, loadJSModule(a)]); + } const nativeModule = await nativeModulePromise; const modulePromise = nativeModule.dotnetInitializeModule(dotnetInternals); @@ -113,6 +158,8 @@ export async function createRuntime(downloadOnly: boolean): Promise { verifyAllAssetsDownloaded(); if (downloadOnly) { + downloadStarted = true; + downloadedIntoMemory = true; return; } diff --git a/src/native/libs/Common/JavaScript/types/public-api.ts b/src/native/libs/Common/JavaScript/types/public-api.ts index eca35c36b9fa06..a187a04f1639a0 100644 --- a/src/native/libs/Common/JavaScript/types/public-api.ts +++ b/src/native/libs/Common/JavaScript/types/public-api.ts @@ -70,8 +70,12 @@ export interface DotnetHostBuilder { withResourceLoader(loadBootResource?: LoadBootResourceCallback): DotnetHostBuilder; /** * Downloads all the assets but doesn't create the runtime instance. + * @param httpCacheOnly If true, resources are only fetched into the browser HTTP cache + * and discarded. A subsequent create() call will re-fetch from cache and do full init. + * If false (default), resources are downloaded and loaded into WASM memory, so that + * a subsequent create() call only needs to initialize the managed runtime. */ - download(): Promise; + download(httpCacheOnly?: boolean): Promise; /** * Starts the runtime and returns promise of the API object. */ From 84d755ccce6d8c7f9bbc66ed8441577999d2302e Mon Sep 17 00:00:00 2001 From: Pavel Savara Date: Mon, 27 Apr 2026 11:09:40 +0200 Subject: [PATCH 2/4] Update src/native/libs/Common/JavaScript/loader/assets.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/native/libs/Common/JavaScript/loader/assets.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/native/libs/Common/JavaScript/loader/assets.ts b/src/native/libs/Common/JavaScript/loader/assets.ts index 6c13196dd6f255..b25f497101da06 100644 --- a/src/native/libs/Common/JavaScript/loader/assets.ts +++ b/src/native/libs/Common/JavaScript/loader/assets.ts @@ -656,16 +656,19 @@ export async function prefetchAllResources(): Promise { export function prefetchJSModuleLinks(modules: JsAsset[]): void { if (!ENVIRONMENT_IS_WEB) return; + const document = globalThis.document; + const documentHead = document?.head; + if (!document || !documentHead) return; for (const mod of modules) { if (!mod.resolvedUrl && mod.name) { mod.resolvedUrl = locateFile(mod.name, true); } if (mod.resolvedUrl) { - const link = globalThis.document.createElement("link"); + const link = document.createElement("link"); link.rel = "prefetch"; link.href = mod.resolvedUrl; link.as = "script"; - globalThis.document.head.appendChild(link); + documentHead.appendChild(link); } } } From a01540ea5eaa3d41da6ecf75166cb1c641d85de8 Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Mon, 27 Apr 2026 11:18:13 +0200 Subject: [PATCH 3/4] feedback --- .../libs/Common/JavaScript/loader/assets.ts | 14 ++++++----- .../libs/Common/JavaScript/loader/run.ts | 25 +++++++++++++------ 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/native/libs/Common/JavaScript/loader/assets.ts b/src/native/libs/Common/JavaScript/loader/assets.ts index b25f497101da06..b77e2382944546 100644 --- a/src/native/libs/Common/JavaScript/loader/assets.ts +++ b/src/native/libs/Common/JavaScript/loader/assets.ts @@ -594,9 +594,9 @@ export async function prefetchAllResources(): Promise { const maxParallel = loaderConfig.maxParallelDownloads || 1000000; const pending: Promise[] = []; - const queue: { url: string; hash?: string | null | ""; name: string; behavior: string }[] = []; + const queue: { url: string; hash?: string | null | ""; name: string; behavior: AssetBehaviors }[] = []; - function enqueueAsset(asset: { name?: string; resolvedUrl?: string; hash?: string | null | "" }, behavior: string): void { + function enqueueAsset(asset: { name?: string; resolvedUrl?: string; hash?: string | null | "" }, behavior: AssetBehaviors): void { if (!asset.resolvedUrl && asset.name) { asset.resolvedUrl = locateFile(asset.name); } @@ -673,18 +673,20 @@ export function prefetchJSModuleLinks(modules: JsAsset[]): void { } } -async function prefetchUrl(url: string, hash?: string | null | "", name?: string, behavior?: string): Promise { +async function prefetchUrl(url: string, hash?: string | null | "", name?: string, behavior?: AssetBehaviors): Promise { // Respect loadBootResourceCallback so prefetch goes through the same custom loader as create() if (typeof loadBootResourceCallback === "function" && behavior && name) { const blazorType = behaviorToBlazorAssetTypeMap[behavior]; if (blazorType) { - const customLoadResult = loadBootResourceCallback(blazorType, name, url, hash ?? "", behavior as AssetBehaviors); + const customLoadResult = loadBootResourceCallback(blazorType, name, url, hash ?? "", behavior); if (typeof customLoadResult === "string") { url = makeURLAbsoluteWithApplicationBase(customLoadResult); - } else if (typeof customLoadResult === "object") { + } else if (customLoadResult != null && typeof customLoadResult === "object") { // Custom loader returned a Response promise — await and discard const response = await (customLoadResult as Promise); - await response.arrayBuffer(); + if (typeof response?.arrayBuffer === "function") { + await response.arrayBuffer(); + } return; } } diff --git a/src/native/libs/Common/JavaScript/loader/run.ts b/src/native/libs/Common/JavaScript/loader/run.ts index 5b11e7aad1f533..ddf77ec3dd7159 100644 --- a/src/native/libs/Common/JavaScript/loader/run.ts +++ b/src/native/libs/Common/JavaScript/loader/run.ts @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -import type { JsModuleExports, EmscriptenModuleInternal, JsAsset } from "./types"; +import type { JsModuleExports, EmscriptenModuleInternal, JsAsset, PromiseCompletionSource } from "./types"; import { dotnetAssert, dotnetInternals, dotnetBrowserHostExports, Module } from "./cross-module"; import { exit, runtimeState } from "./exit"; @@ -14,7 +14,9 @@ import { validateEngineFeatures } from "./bootstrap"; const runMainPromiseController = createPromiseCompletionSource(); -let downloadStarted = false; +type DownloadMode = "none" | "cacheOnly" | "intoMemory"; +let downloadMode: DownloadMode = "none"; +let downloadDeferred: PromiseCompletionSource | undefined; let downloadedIntoMemory = false; let configInitialized = false; @@ -25,9 +27,15 @@ export async function createRuntime(downloadOnly: boolean, httpCacheOnly: boolea try { runtimeState.creatingRuntime = true; - // Calling download() again is a noop - if (downloadOnly && downloadStarted) { - return; + // Re-entrancy guard: await any in-flight download, skip if already at requested level + if (downloadOnly) { + if (downloadDeferred) { + await downloadDeferred.promise; + } + if (downloadMode === "intoMemory" || (httpCacheOnly && downloadMode === "cacheOnly")) { + return; + } + downloadDeferred = createPromiseCompletionSource(); } // Fast path: download() already loaded everything into memory, create() just needs to init @@ -88,8 +96,9 @@ export async function createRuntime(downloadOnly: boolean, httpCacheOnly: boolea // HTTP cache only path: just fetch all resources into browser cache and discard if (downloadOnly && httpCacheOnly) { - downloadStarted = true; await prefetchAllResources(); + downloadMode = "cacheOnly"; + downloadDeferred?.resolve(undefined as unknown as void); return; } @@ -158,8 +167,9 @@ export async function createRuntime(downloadOnly: boolean, httpCacheOnly: boolea verifyAllAssetsDownloaded(); if (downloadOnly) { - downloadStarted = true; + downloadMode = "intoMemory"; downloadedIntoMemory = true; + downloadDeferred?.resolve(undefined as unknown as void); return; } @@ -170,6 +180,7 @@ export async function createRuntime(downloadOnly: boolean, httpCacheOnly: boolea await Promise.all([...modulesAfterConfigLoadedPromises, ...modulesAfterRuntimeReadyPromises].map(callLibraryInitializerOnRuntimeReady)); } catch (err) { + downloadDeferred?.reject(err); exit(1, err); } finally { runtimeState.creatingRuntime = false; From f623be8e6843014b64a943d3023025df1ee91341 Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Mon, 27 Apr 2026 12:23:34 +0200 Subject: [PATCH 4/4] feedback --- src/native/libs/Common/JavaScript/loader/assets.ts | 8 +++++--- src/native/libs/Common/JavaScript/loader/run.ts | 10 ++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/native/libs/Common/JavaScript/loader/assets.ts b/src/native/libs/Common/JavaScript/loader/assets.ts index b77e2382944546..5fa921c55be5da 100644 --- a/src/native/libs/Common/JavaScript/loader/assets.ts +++ b/src/native/libs/Common/JavaScript/loader/assets.ts @@ -592,7 +592,7 @@ export async function prefetchAllResources(): Promise { const resources = loaderConfig.resources; if (!resources) return; - const maxParallel = loaderConfig.maxParallelDownloads || 1000000; + const maxParallel = loaderConfig.maxParallelDownloads ?? 16; const pending: Promise[] = []; const queue: { url: string; hash?: string | null | ""; name: string; behavior: AssetBehaviors }[] = []; @@ -619,6 +619,9 @@ export async function prefetchAllResources(): Promise { if (loaderConfig.loadAllSatelliteResources && resources.satelliteResources) { for (const culture of Object.keys(resources.satelliteResources)) { for (const asset of resources.satelliteResources[culture]) { + if (!asset.resolvedUrl && asset.name) { + asset.resolvedUrl = locateFile(`${culture}/${asset.name}`); + } enqueueAsset(asset, "assembly"); } } @@ -698,8 +701,7 @@ async function prefetchUrl(url: string, hash?: string | null | "", name?: string if (!loaderConfig.disableIntegrityCheck && hash) { fetchOptions.integrity = hash; } - // Use globalThis.fetch directly because this runs before initPolyfills() - const response = await globalThis.fetch(url, fetchOptions); + const response = await fetchLike(url, fetchOptions); if (response.ok) { // Read the body to ensure the response is fully received into cache await response.arrayBuffer(); diff --git a/src/native/libs/Common/JavaScript/loader/run.ts b/src/native/libs/Common/JavaScript/loader/run.ts index ddf77ec3dd7159..230f54fca8e150 100644 --- a/src/native/libs/Common/JavaScript/loader/run.ts +++ b/src/native/libs/Common/JavaScript/loader/run.ts @@ -19,6 +19,7 @@ let downloadMode: DownloadMode = "none"; let downloadDeferred: PromiseCompletionSource | undefined; let downloadedIntoMemory = false; let configInitialized = false; +let modulesAfterConfigLoadedCache: [JsAsset, Promise][] = []; // many things happen in parallel here, but order matters for performance! // ideally we want to utilize network and CPU at the same time @@ -48,10 +49,10 @@ export async function createRuntime(downloadOnly: boolean, httpCacheOnly: boolea } const resources = loaderConfig.resources; - // modulesAfterConfigLoaded were already loaded and had onRuntimeConfigLoaded called during download(). They don't need onRuntimeReady. // modulesAfterRuntimeReady were only prefetched during download(), now load and call onRuntimeReady. const modulesAfterRuntimeReadyPromises: [JsAsset, Promise][] = normalizeCollection(resources.modulesAfterRuntimeReady).map((a) => [a, loadJSModule(a)]); - await Promise.all(modulesAfterRuntimeReadyPromises.map(callLibraryInitializerOnRuntimeReady)); + // modulesAfterConfigLoaded were loaded during download() — call onRuntimeReady for them too. + await Promise.all([...modulesAfterConfigLoadedCache, ...modulesAfterRuntimeReadyPromises].map(callLibraryInitializerOnRuntimeReady)); return; } @@ -61,8 +62,6 @@ export async function createRuntime(downloadOnly: boolean, httpCacheOnly: boolea // This must happen before any asset fetches so that URL overrides take effect. let modulesAfterConfigLoadedPromises: [JsAsset, Promise][] = []; if (!configInitialized) { - configInitialized = true; - await validateEngineFeatures(); if (typeof Module.onConfigLoaded === "function") { @@ -92,6 +91,9 @@ export async function createRuntime(downloadOnly: boolean, httpCacheOnly: boolea // after onConfigLoaded hooks that could install polyfills, our polyfills can be initialized await initPolyfills(); + + configInitialized = true; + modulesAfterConfigLoadedCache = modulesAfterConfigLoadedPromises; } // HTTP cache only path: just fetch all resources into browser cache and discard