[Wasm] Support @JsExport.Default in Kotlin/Wasm
The @JsExport.Default annotation was already available in Kotlin/Wasm
(same stdlib class as Kotlin/JS), but the Wasm backend ignored it and
treated the declaration as a regular named export instead of generating
an ES module default export.
Two things were missing:
1. TypeScript declarations (.d.mts): The Wasm ExportModelGenerator's
withAttributesFor() was not setting ExportedAttribute.DefaultExport
on declarations annotated with @JsExport.Default. Fix: check
isJsExportDefault() and add the attribute, the same way the JS
backend does in its ExportModelGenerator.
2. JS wrapper (.mjs): generateExports() was treating all @JsExport
declarations as named exports. Fix: collect the set of
default-exported function names from the export model (which is now
always computed for wasm-js targets, not only when generateDts is
on), partition Wasm IR exports into named vs. default, emit
'export const { ... }' only for named exports, and append
'export default exports.name;' for each default export.
The defaultExportNames set flows from compileToLoweredIr() through
LoweredIrWithExtraArtifacts and WasmIrModuleConfiguration to
generateExports(). IC and WASI builds default to emptySet() since
default-export semantics are not applicable there.
Test: updated typescript-export/wasm/default to import produceUByte
as a default export and to expect the correct .d.mts output.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
^KT-81801 Fixed
diff --git a/compiler/cli/cli-js/src/org/jetbrains/kotlin/cli/pipeline/web/wasm/KotlinIr2WasmIrCompiler.kt b/compiler/cli/cli-js/src/org/jetbrains/kotlin/cli/pipeline/web/wasm/KotlinIr2WasmIrCompiler.kt
index ac4e384..acfeedb 100644
--- a/compiler/cli/cli-js/src/org/jetbrains/kotlin/cli/pipeline/web/wasm/KotlinIr2WasmIrCompiler.kt
+++ b/compiler/cli/cli-js/src/org/jetbrains/kotlin/cli/pipeline/web/wasm/KotlinIr2WasmIrCompiler.kt
@@ -168,6 +168,7 @@
typeScriptFragment = typeScriptFragment,
baseFileName = configuration.outputName!!,
multimoduleOptions = null,
+ defaultExportNames = loweredIr.defaultExportNames,
)
}
@@ -278,6 +279,7 @@
typeScriptFragment = loweredIr.typeScriptFragment,
baseFileName = mainModuleFragment.outputFileName,
multimoduleOptions = multimoduleOptions,
+ defaultExportNames = loweredIr.defaultExportNames,
)
}
diff --git a/compiler/ir/backend.wasm/src/org/jetbrains/kotlin/backend/wasm/export/ExportModelGenerator.kt b/compiler/ir/backend.wasm/src/org/jetbrains/kotlin/backend/wasm/export/ExportModelGenerator.kt
index 29c73cd..ce9df90 100644
--- a/compiler/ir/backend.wasm/src/org/jetbrains/kotlin/backend/wasm/export/ExportModelGenerator.kt
+++ b/compiler/ir/backend.wasm/src/org/jetbrains/kotlin/backend/wasm/export/ExportModelGenerator.kt
@@ -17,6 +17,7 @@
import org.jetbrains.kotlin.ir.backend.js.utils.getDeprecated
import org.jetbrains.kotlin.ir.backend.js.utils.getFqNameWithJsNameWhenAvailable
import org.jetbrains.kotlin.ir.backend.js.utils.isExplicitlyExported
+import org.jetbrains.kotlin.ir.backend.js.utils.isJsExportDefault
import org.jetbrains.kotlin.ir.backend.js.utils.realOverrideTarget
import org.jetbrains.kotlin.ir.declarations.*
import org.jetbrains.kotlin.ir.symbols.IrClassSymbol
@@ -390,5 +391,6 @@
private fun <T : ExportedDeclaration> T.withAttributesFor(declaration: IrDeclaration): T {
declaration.getDeprecated()?.let { attributes.add(ExportedAttribute.DeprecatedAttribute(it)) }
+ if (declaration.isJsExportDefault()) attributes.add(ExportedAttribute.DefaultExport)
return this
}
diff --git a/compiler/ir/backend.wasm/src/org/jetbrains/kotlin/backend/wasm/wasmCompiler.kt b/compiler/ir/backend.wasm/src/org/jetbrains/kotlin/backend/wasm/wasmCompiler.kt
index 03bf30b..2b33653 100644
--- a/compiler/ir/backend.wasm/src/org/jetbrains/kotlin/backend/wasm/wasmCompiler.kt
+++ b/compiler/ir/backend.wasm/src/org/jetbrains/kotlin/backend/wasm/wasmCompiler.kt
@@ -25,6 +25,9 @@
import org.jetbrains.kotlin.ir.backend.js.MainModule
import org.jetbrains.kotlin.ir.backend.js.WholeWorldStageController
import org.jetbrains.kotlin.ir.backend.js.tsexport.ExportModelToTsDeclarations
+import org.jetbrains.kotlin.ir.backend.js.tsexport.ExportedAttribute
+import org.jetbrains.kotlin.ir.backend.js.tsexport.ExportedFunction
+import org.jetbrains.kotlin.ir.backend.js.tsexport.ExportedMemberName
import org.jetbrains.kotlin.ir.backend.js.tsexport.TypeScriptFragment
import org.jetbrains.kotlin.ir.declarations.IrModuleFragment
import org.jetbrains.kotlin.ir.types.isString
@@ -80,6 +83,7 @@
val backendContext: WasmBackendContext,
val typeScriptFragment: TypeScriptFragment?,
val moduleDependencies: (IrModuleFragment) -> Set<IrModuleFragment>,
+ val defaultExportNames: Set<String> = emptySet(),
)
fun linkIr(
@@ -137,13 +141,22 @@
}
}
- val typeScriptFragment = runIf(configuration.generateDts) {
- val exportModel = ExportModelGenerator(context).generateExport(allModules)
+ val exportModel = runIf(context.isWasmJsTarget) {
+ ExportModelGenerator(context).generateExport(allModules)
+ }
+
+ val typeScriptFragment = runIf(configuration.generateDts && exportModel != null) {
val exportModelToDtsTranslator = ExportModelToTsDeclarations(ModuleKind.ES)
- val fragment = exportModelToDtsTranslator.generateTypeScriptFragment(exportModel.declarations)
+ val fragment = exportModelToDtsTranslator.generateTypeScriptFragment(exportModel!!.declarations)
TypeScriptFragment(exportModelToDtsTranslator.generateTypeScript("", listOf(fragment)))
}
+ val defaultExportNames = exportModel?.declarations
+ ?.filter { ExportedAttribute.DefaultExport in it.attributes }
+ ?.filterIsInstance<ExportedFunction>()
+ ?.mapNotNull { (it.name as? ExportedMemberName.Identifier)?.value }
+ ?.toSet() ?: emptySet()
+
lowerPreservingTags(
allModules,
context,
@@ -162,7 +175,8 @@
loweredIr = allModules,
backendContext = context,
typeScriptFragment = typeScriptFragment,
- moduleDependencies = dependencyTracker::getAllDependencies
+ moduleDependencies = dependencyTracker::getAllDependencies,
+ defaultExportNames = defaultExportNames,
)
}
@@ -211,6 +225,7 @@
val baseFileName: String,
val typeScriptFragment: TypeScriptFragment?,
val multimoduleOptions: MultimoduleCompileOptions?,
+ val defaultExportNames: Set<String> = emptySet(),
)
fun linkWasmIr(moduleConfiguration: WasmIrModuleConfiguration): WasmModule {
@@ -361,7 +376,8 @@
isStdlibModule = isStdlibModule,
wholeProgramMode = wholeProgramMode,
wasmStartFunctionDefined = wasmStartFunctionDefined,
- wasmInitializeFunctionDefined = wasmInitializeFunctionDefined
+ wasmInitializeFunctionDefined = wasmInitializeFunctionDefined,
+ defaultExportNames = moduleConfiguration.defaultExportNames,
)
} else {
@@ -655,7 +671,8 @@
isStdlibModule: Boolean,
wholeProgramMode: Boolean,
wasmStartFunctionDefined: Boolean,
- wasmInitializeFunctionDefined: Boolean
+ wasmInitializeFunctionDefined: Boolean,
+ defaultExportNames: Set<String> = emptySet(),
): String {
val commonStdlibExports = if (isStdlibModule) ", getCachedJsObject, __TAG as wasmTag" else ""
@@ -731,7 +748,7 @@
setWasmExports(exports);
$mainFunctionCall
-${generateExports(exports, wholeProgramMode, isStdlibModule)}
+${generateExports(exports, wholeProgramMode, isStdlibModule, defaultExportNames)}
"""
}
@@ -784,12 +801,15 @@
exports: List<WasmExport<*>>,
wholeProgramMode: Boolean,
isStdlibModule: Boolean,
+ defaultExportNames: Set<String> = emptySet(),
): String {
// TODO: necessary to move export check onto common place
val exportNames = exports
.filterNot { it.isWasmInternalUsageExport }
- val [validIdentifiers, notValidIdentifiers] = exportNames.partition { it.name.isValidES5Identifier() }
+ val (defaultExports, namedExports) = exportNames.partition { it.name in defaultExportNames }
+
+ val [validIdentifiers, notValidIdentifiers] = namedExports.partition { it.name.isValidES5Identifier() }
val regularlyExportedVariables = validIdentifiers
.ifNotEmpty {
"""
@@ -818,6 +838,14 @@
}
.orEmpty()
+ val defaultExportStatements = defaultExports
+ .joinToString("\n") { export ->
+ if (export.name.isValidES5Identifier())
+ "export default exports.${export.name};"
+ else
+ "export default exports['${export.name.replace("'", "\\'")}'];"
+ }
+
val commonStdlibExports =
if (isStdlibModule)
"""
@@ -840,6 +868,7 @@
$regularlyExportedVariables
$escapedExportedVariables
+$defaultExportStatements
"""
}
diff --git a/js/js.translator/testData/typescript-export/wasm/default/default__main.ts b/js/js.translator/testData/typescript-export/wasm/default/default__main.ts
index 75fa126..4ac9042 100644
--- a/js/js.translator/testData/typescript-export/wasm/default/default__main.ts
+++ b/js/js.translator/testData/typescript-export/wasm/default/default__main.ts
@@ -1,5 +1,4 @@
-import {
- produceUByte,
+import produceUByte, {
produceUInt,
produceUShort
} from "./index.mjs"
diff --git a/js/js.translator/testData/typescript-export/wasm/default/index.d.mts b/js/js.translator/testData/typescript-export/wasm/default/index.d.mts
index bd2f496..365f6e3 100644
--- a/js/js.translator/testData/typescript-export/wasm/default/index.d.mts
+++ b/js/js.translator/testData/typescript-export/wasm/default/index.d.mts
@@ -2,4 +2,5 @@
declare function KtSingleton<T>(): T & (abstract new() => any);
export declare function produceUShort(): number;
export declare function produceUInt(): number;
-export declare function produceUByte(): number;
+declare function produceUByte(): number;
+export default produceUByte;