From 9a216495709758c08b80338a9c69b0eb43de48dd Mon Sep 17 00:00:00 2001 From: Will Harney Date: Thu, 8 Jan 2026 11:50:55 -0500 Subject: [PATCH 1/4] chore(types): provide type for source maps --- packages/@lwc/compiler/src/transformers/shared.ts | 3 ++- packages/@lwc/compiler/src/transformers/style.ts | 3 ++- packages/@lwc/compiler/src/transformers/template.ts | 3 ++- packages/@lwc/ssr-compiler/src/index.ts | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/@lwc/compiler/src/transformers/shared.ts b/packages/@lwc/compiler/src/transformers/shared.ts index 25cf6e346d..ce679f9bdf 100644 --- a/packages/@lwc/compiler/src/transformers/shared.ts +++ b/packages/@lwc/compiler/src/transformers/shared.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: MIT * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ +import type { BabelFileResult } from '@babel/core'; import type { CompilerDiagnostic } from '@lwc/errors'; /** The object returned after transforming code. */ @@ -11,7 +12,7 @@ export interface TransformResult { /** The compiled source code. */ code: string; /** The generated source map. */ - map: unknown; + map: BabelFileResult['map']; /** Any diagnostic warnings that may have occurred. */ warnings?: CompilerDiagnostic[]; /** diff --git a/packages/@lwc/compiler/src/transformers/style.ts b/packages/@lwc/compiler/src/transformers/style.ts index e185223408..d6c5c7eaaa 100755 --- a/packages/@lwc/compiler/src/transformers/style.ts +++ b/packages/@lwc/compiler/src/transformers/style.ts @@ -7,6 +7,7 @@ import * as styleCompiler from '@lwc/style-compiler'; import { normalizeToCompilerError, TransformerErrors, CompilerAggregateError } from '@lwc/errors'; +import type { BabelFileResult } from '@babel/core'; import type { NormalizedTransformOptions } from '../options'; import type { TransformResult } from './shared'; @@ -63,6 +64,6 @@ export default function styleTransform( // the styles doesn't make sense, the transform returns an empty mappings. return { code: res.code, - map: { mappings: '' }, + map: { mappings: '' } as BabelFileResult['map'], }; } diff --git a/packages/@lwc/compiler/src/transformers/template.ts b/packages/@lwc/compiler/src/transformers/template.ts index ce23d5e692..213020fcd4 100755 --- a/packages/@lwc/compiler/src/transformers/template.ts +++ b/packages/@lwc/compiler/src/transformers/template.ts @@ -13,6 +13,7 @@ import { } from '@lwc/errors'; import { compile } from '@lwc/template-compiler'; +import type { BabelFileResult } from '@babel/core'; import type { NormalizedTransformOptions } from '../options'; import type { TransformResult } from './shared'; @@ -93,7 +94,7 @@ export default function templateTransform( // the template doesn't make sense, the transform returns an empty mappings. return { code: result.code, - map: { mappings: '' }, + map: { mappings: '' } as BabelFileResult['map'], warnings, cssScopeTokens: result.cssScopeTokens, }; diff --git a/packages/@lwc/ssr-compiler/src/index.ts b/packages/@lwc/ssr-compiler/src/index.ts index fdef53a9a9..f56cc86ae9 100644 --- a/packages/@lwc/ssr-compiler/src/index.ts +++ b/packages/@lwc/ssr-compiler/src/index.ts @@ -12,7 +12,7 @@ import type { ComponentTransformOptions, TemplateTransformOptions } from './shar export interface CompilationResult { code: string; - map: unknown; + map: undefined; } export function compileComponentForSSR( From 937d18434b0b2c4e21637cd712aa7630d91994ad Mon Sep 17 00:00:00 2001 From: Will Harney Date: Thu, 8 Jan 2026 11:21:30 -0500 Subject: [PATCH 2/4] chore: `path.join` is a thing --- packages/@lwc/rollup-plugin/src/index.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/@lwc/rollup-plugin/src/index.ts b/packages/@lwc/rollup-plugin/src/index.ts index 42e56eab41..dceb525a8c 100644 --- a/packages/@lwc/rollup-plugin/src/index.ts +++ b/packages/@lwc/rollup-plugin/src/index.ts @@ -79,9 +79,9 @@ export interface RollupLwcOptions { const PLUGIN_NAME = 'rollup-plugin-lwc-compiler'; -const IMPLICIT_DEFAULT_HTML_PATH = ['@lwc', 'resources', 'empty_html.js'].join(path.sep); +const IMPLICIT_DEFAULT_HTML_PATH = path.join('@lwc', 'resources', 'empty_html.js'); const EMPTY_IMPLICIT_HTML_CONTENT = 'export default void 0'; -const IMPLICIT_DEFAULT_CSS_PATH = ['@lwc', 'resources', 'empty_css.css'].join(path.sep); +const IMPLICIT_DEFAULT_CSS_PATH = path.join('@lwc', 'resources', 'empty_css.css'); const EMPTY_IMPLICIT_CSS_CONTENT = ''; const SCRIPT_FILE_EXTENSIONS = ['.js', '.mjs', '.jsx', '.ts', '.mts', '.tsx']; @@ -201,7 +201,7 @@ export default function lwc(pluginOptions: RollupLwcOptions = {}): Plugin { componentFeatureFlagModulePath, } = pluginOptions; - return { + const plugin: Plugin = { name: PLUGIN_NAME, // The version from the package.json is inlined by the build script version: process.env.LWC_VERSION, @@ -408,6 +408,18 @@ export default function lwc(pluginOptions: RollupLwcOptions = {}): Plugin { return { code, map: rollupMap }; }, }; + + Object.entries(plugin).forEach(([hook, val]) => { + if (typeof val === 'function') { + (plugin as any)[hook] = function (...args: any) { + // eslint-disable-next-line no-console + console.log(hook, args); + return val.apply(this, args); + }; + } + }); + + return plugin; } // For backward compatibility with commonjs format From 8354088928a78bebaab248879c256a5e47eea719 Mon Sep 17 00:00:00 2001 From: Will Harney Date: Thu, 8 Jan 2026 13:54:28 -0500 Subject: [PATCH 3/4] feat(rollup-plugin): add support for template-only components --- packages/@lwc/rollup-plugin/src/index.ts | 141 +++++++++++++---------- 1 file changed, 79 insertions(+), 62 deletions(-) diff --git a/packages/@lwc/rollup-plugin/src/index.ts b/packages/@lwc/rollup-plugin/src/index.ts index dceb525a8c..bc681fdd37 100644 --- a/packages/@lwc/rollup-plugin/src/index.ts +++ b/packages/@lwc/rollup-plugin/src/index.ts @@ -12,7 +12,7 @@ import pluginUtils from '@rollup/pluginutils'; import { transformSync } from '@lwc/compiler'; import { resolveModule, RegistryType } from '@lwc/module-resolver'; import { getAPIVersionFromNumber } from '@lwc/shared'; -import type { Plugin, SourceMapInput, RollupLog } from 'rollup'; +import type { Plugin, RollupLog, TransformResult } from 'rollup'; import type { FilterPattern } from '@rollup/pluginutils'; import type { StylesheetConfig, DynamicImportConfig } from '@lwc/compiler'; import type { ModuleRecord } from '@lwc/module-resolver'; @@ -83,6 +83,9 @@ const IMPLICIT_DEFAULT_HTML_PATH = path.join('@lwc', 'resources', 'empty_html.js const EMPTY_IMPLICIT_HTML_CONTENT = 'export default void 0'; const IMPLICIT_DEFAULT_CSS_PATH = path.join('@lwc', 'resources', 'empty_css.css'); const EMPTY_IMPLICIT_CSS_CONTENT = ''; +const IMPLICIT_DEFAULT_JS_PATH = path.join('@lwc', 'resources', 'empty_js.js'); +const EMPTY_IMPLICIT_JS_CONTENT = + 'import {LightningElement} from "lwc";export default class extends LightningElement{}'; const SCRIPT_FILE_EXTENSIONS = ['.js', '.mjs', '.jsx', '.ts', '.mts', '.tsx']; const DEFAULT_MODULES = [ @@ -110,27 +113,32 @@ function isImplicitCssImport(importee: string, importer: string): boolean { } interface Descriptor { + /** The filename for the component. May be a virtual `@lwc/resources` path. */ filename: string; + /** Whether the component uses scoped CSS. */ scoped: boolean; + /** The component's specifier, e.g. `x/cmp`. */ specifier: string | null; + /** For a template-only component, the real path to the template. */ + templateOnlyEntry: string | null; } function parseDescriptorFromFilePath(id: string): Descriptor { const [filename, query] = id.split('?', 2); const params = new URLSearchParams(query); - const scoped = params.has('scoped'); - const specifier = params.get('specifier'); return { filename, - specifier, - scoped, + specifier: params.get('specifier'), + scoped: params.has('scoped'), + templateOnlyEntry: params.get('template'), }; } -function appendAliasSpecifierQueryParam(id: string, specifier: string): string { +function appendQueryParams(id: string, specifier: string, template?: string): string { const [filename, query] = id.split('?', 2); const params = new URLSearchParams(query); params.set('specifier', specifier); + if (template) params.set('template', template); return `${filename}?${params.toString()}`; } @@ -201,7 +209,7 @@ export default function lwc(pluginOptions: RollupLwcOptions = {}): Plugin { componentFeatureFlagModulePath, } = pluginOptions; - const plugin: Plugin = { + return { name: PLUGIN_NAME, // The version from the package.json is inlined by the build script version: process.env.LWC_VERSION, @@ -232,7 +240,11 @@ export default function lwc(pluginOptions: RollupLwcOptions = {}): Plugin { resolveId(importee, importer) { if (importer) { // Importer has been resolved already and may contain an alias specifier - const { filename: importerFilename } = parseDescriptorFromFilePath(importer); + const importerDescriptor = parseDescriptorFromFilePath(importer); + const importerFilename = + // If `templateOnlyEntry` is set, then it points to the real filename and + // `filename` is just the virtual JS file + importerDescriptor.templateOnlyEntry ?? importerDescriptor.filename; // Normalize relative import to absolute import // Note that in @rollup/plugin-node-resolve v13, relative imports will sometimes @@ -285,6 +297,12 @@ export default function lwc(pluginOptions: RollupLwcOptions = {}): Plugin { rootDir, }); + if (entry.endsWith('.html')) { + // If the entrypoint is `.html`, then it's a template-only component. + // We need to inject a virtual JS file for the component to function. + return appendQueryParams(IMPLICIT_DEFAULT_JS_PATH, specifier, entry); + } + if (type === RegistryType.alias) { // specifier must be in in namespace/name format const [namespace, name, ...rest] = specifier.split('/'); @@ -293,11 +311,10 @@ export default function lwc(pluginOptions: RollupLwcOptions = {}): Plugin { // Verify 3 things about the alias specifier: // 1. The namespace is a non-empty string // 2. The name is an non-empty string - // 3. The specifier was in a namespace / name format, ie no extra '/' (this is what rest checks) - const hasValidSpecifier = - !!namespace?.length && !!name?.length && !rest?.length; + // 3. The specifier was in a `namespace/name` format, i.e. no extra '/' (this is what rest checks) + const hasValidSpecifier = namespace && name && rest.length === 0; if (hasValidSpecifier) { - return appendAliasSpecifierQueryParam(entry, specifier); + return appendQueryParams(entry, specifier); } } @@ -314,15 +331,18 @@ export default function lwc(pluginOptions: RollupLwcOptions = {}): Plugin { load(id) { if (id === IMPLICIT_DEFAULT_HTML_PATH) { return EMPTY_IMPLICIT_HTML_CONTENT; - } - - if (id === IMPLICIT_DEFAULT_CSS_PATH) { + } else if (id === IMPLICIT_DEFAULT_CSS_PATH) { return EMPTY_IMPLICIT_CSS_CONTENT; } // Have to parse the `?scoped=true` in `load`, because it's not guaranteed // that `resolveId` will always be called (e.g. if another plugin resolves it first) const { filename, specifier: hasAlias } = parseDescriptorFromFilePath(id); + // Not checking `id` directly because it's expected to have query params + if (filename === IMPLICIT_DEFAULT_JS_PATH) { + return EMPTY_IMPLICIT_JS_CONTENT; + } + const isCSS = path.extname(filename) === '.css'; if (isCSS || hasAlias) { @@ -340,11 +360,14 @@ export default function lwc(pluginOptions: RollupLwcOptions = {}): Plugin { } }, - transform(src, id) { - const { scoped, filename, specifier } = parseDescriptorFromFilePath(id); + transform(src, id): TransformResult { + const { scoped, filename, specifier, templateOnlyEntry } = + parseDescriptorFromFilePath(id); + // If `templateOnlyEntry` is set, then `filename` is the virtual JS file + const realFilename = templateOnlyEntry ?? filename; // Filter user-land config and lwc import - if (!filter(filename)) { + if (!filter(realFilename)) { return; } @@ -352,7 +375,7 @@ export default function lwc(pluginOptions: RollupLwcOptions = {}): Plugin { // Specifier will only exist for modules with alias paths. // Otherwise, use the file directory structure to resolve namespace and name. const [namespace, name] = - specifier?.split('/') ?? path.dirname(filename).split(path.sep).slice(-2); + specifier?.split('/') ?? path.dirname(realFilename).split(path.sep).slice(-2); /* v8 ignore next */ if (!namespace || !name) { @@ -364,62 +387,56 @@ export default function lwc(pluginOptions: RollupLwcOptions = {}): Plugin { name )}) could not be determined from the specifier ${JSON.stringify( specifier - )} or filename ${JSON.stringify(path.dirname(filename))}` + )} or filename ${JSON.stringify(path.dirname(realFilename))}` ); } const apiVersionToUse = getAPIVersionFromNumber(apiVersion); - const { code, map, warnings } = transformSync(src, filename, { - name, - namespace, - outputConfig: { sourcemap }, - stylesheetConfig, - experimentalDynamicComponent, - experimentalDynamicDirective, - enableDynamicComponents, - enableSyntheticElementInternals, - enableLwcOn, - enableLightningWebSecurityTransforms, - // TODO [#3370]: remove experimental template expression flag - experimentalComplexExpressions, - preserveHtmlComments, - scopedStyles: scoped, - disableSyntheticShadowSupport, - apiVersion: apiVersionToUse, - enableStaticContentOptimization: - // {enableStaticContentOptimization:undefined} behaves like `false` - // but {} (prop unspecified) behaves like `true` - 'enableStaticContentOptimization' in pluginOptions - ? pluginOptions.enableStaticContentOptimization - : true, - targetSSR, - ssrMode, - componentFeatureFlagModulePath, - }); + const { code, map, warnings } = transformSync( + src, + // If `templateOnlyEntry` is set, then the extension of `realFilename` is .html, + // but we're transforming the virtual JS file as if it is the component's JS file + // (which doesn't actually exist) + templateOnlyEntry ? `${realFilename.slice(0, -5)}.js` : realFilename, + { + name, + namespace, + outputConfig: { sourcemap }, + stylesheetConfig, + experimentalDynamicComponent, + experimentalDynamicDirective, + enableDynamicComponents, + enableSyntheticElementInternals, + enableLwcOn, + enableLightningWebSecurityTransforms, + // TODO [#3370]: remove experimental template expression flag + experimentalComplexExpressions, + preserveHtmlComments, + scopedStyles: scoped, + disableSyntheticShadowSupport, + apiVersion: apiVersionToUse, + enableStaticContentOptimization: + // {enableStaticContentOptimization:undefined} behaves like `false` + // but {} (prop unspecified) behaves like `true` + 'enableStaticContentOptimization' in pluginOptions + ? pluginOptions.enableStaticContentOptimization + : true, + targetSSR, + ssrMode, + componentFeatureFlagModulePath, + } + ); if (warnings) { for (const warning of warnings) { - this.warn(transformWarningToRollupLog(warning, src, filename)); + this.warn(transformWarningToRollupLog(warning, src, realFilename)); } } - const rollupMap = map as SourceMapInput; - return { code, map: rollupMap }; + return { code, map }; }, }; - - Object.entries(plugin).forEach(([hook, val]) => { - if (typeof val === 'function') { - (plugin as any)[hook] = function (...args: any) { - // eslint-disable-next-line no-console - console.log(hook, args); - return val.apply(this, args); - }; - } - }); - - return plugin; } // For backward compatibility with commonjs format From 660f1a244cc46c37b8f31e771cb2a72422a2b76b Mon Sep 17 00:00:00 2001 From: Will Harney Date: Thu, 8 Jan 2026 13:58:13 -0500 Subject: [PATCH 4/4] feat(playground): remove unnecessary app.js --- playground/src/modules/x/app/app.js | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 playground/src/modules/x/app/app.js diff --git a/playground/src/modules/x/app/app.js b/playground/src/modules/x/app/app.js deleted file mode 100644 index 0f4359d1d6..0000000000 --- a/playground/src/modules/x/app/app.js +++ /dev/null @@ -1,3 +0,0 @@ -import { LightningElement } from 'lwc'; - -export default class App extends LightningElement {}