Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,64 @@ describe('JIT transform nested class support', () => {
expect(result).toContain('TopComponent.decorators');
expect(result).toContain('InnerComponent.decorators');
});

// Regression for #2360: a component declared inside a function/callback
// scope (the common "host component to test a directive" pattern, where the
// class lives inside a `describe(...)`/`it(...)` block) must have its
// metadata statements emitted IN SCOPE — emitting them at the end of the
// file would reference an undefined name and throw `ReferenceError` when the
// spec module is evaluated.
it('emits nested-class metadata in scope (no ReferenceError on eval)', () => {
const emitted = jitTransform(
`
import { Component } from '@angular/core';

export function makeHost() {
@Component({ selector: 'app-host', template: '' })
class TestComponent {}
return TestComponent;
}
`,
'example.component.spec.ts',
).code;

// Stub @angular/core so the emitted JS can run under \`new Function\`.
const stubs = {
Component: () => () => undefined,
ɵcompileComponent: () => undefined,
ɵcompileDirective: () => undefined,
ɵcompilePipe: () => undefined,
ɵcompileNgModule: () => undefined,
};
const runnable = emitted
.replace(
/import\s+\{([^}]+)\}\s+from\s+['"]@angular\/core['"];?/g,
(_m, specs: string) => {
const destructured = specs
.split(',')
.map((s) => {
const parts = s.trim().match(/^(\S+)(?:\s+as\s+(\S+))?$/);
if (!parts) return '';
const [, imported, local] = parts;
return local ? `${imported}: ${local}` : imported;
})
.filter(Boolean)
.join(', ');
return `const { ${destructured} } = __stubs__;`;
},
)
.replace(/^\s*export\s+(?=function)/gm, '');

// eslint-disable-next-line @typescript-eslint/no-implied-eval
const fn = new Function('__stubs__', `${runnable}\nreturn makeHost();`);
const Cls = fn(stubs);

// Evaluating the factory must not throw, and the in-scope statements must
// have attached the metadata to the nested class.
expect(typeof Cls).toBe('function');
expect((Cls as any).decorators).toBeDefined();
expect((Cls as any).decorators[0].args[0].selector).toBe('app-host');
});
});

describe('JIT transform auto-imports decorator classes for signal API downleveling', () => {
Expand Down
48 changes: 32 additions & 16 deletions packages/vite-plugin-angular/src/lib/compiler/jit-transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,15 @@ export function jitTransform(

const allClasses = findAllClasses(program.body);

const postClassStatements: string[] = [];
// Accumulates every class's emitted statements so the post-loop import
// detection (missing field-decorator imports) can scan all of them.
// The statements themselves are inserted in-scope after each class via
// `ms.appendLeft(node.end, ...)` rather than dumped at the end of the
// file — a class declared inside a function/callback scope (e.g. a host
// `@Component` defined inside a `describe(...)`/`it(...)` block in a test)
// is not in scope at module end, so a file-end emit would reference an
// undefined name (#2360).
const allEmittedStatements: string[] = [];
let importCounter = 0;
const resourceImports: string[] = [];
let needsJitImport = false;
Expand All @@ -93,6 +101,10 @@ export function jitTransform(
if (!className) continue;
hasAngularClass = true;

// Statements emitted for THIS class. Inserted directly after the class
// body (see end of loop) so they run in the class's own lexical scope.
const classStatements: string[] = [];

// 1. Remove Angular decorators from source
for (const dec of angularDecs) {
const start: number = dec.start;
Expand Down Expand Up @@ -182,7 +194,7 @@ export function jitTransform(
decoratorMeta.push({ name: decName, argsText: '{}' });
return `{ type: ${decName} }`;
});
postClassStatements.push(
classStatements.push(
`${className}.decorators = [${decoratorEntries.join(', ')}];`,
);

Expand All @@ -191,22 +203,22 @@ export function jitTransform(
needsJitImport = true;
switch (dm.name) {
case 'Component':
postClassStatements.push(
classStatements.push(
`_jitCompileComponent(${className}, ${dm.argsText});`,
);
break;
case 'Directive':
postClassStatements.push(
classStatements.push(
`_jitCompileDirective(${className}, ${dm.argsText});`,
);
break;
case 'Pipe':
postClassStatements.push(
classStatements.push(
`_jitCompilePipe(${className}, ${dm.argsText});`,
);
break;
case 'NgModule':
postClassStatements.push(
classStatements.push(
`_jitCompileNgModule(${className}, ${dm.argsText});`,
);
break;
Expand All @@ -216,17 +228,15 @@ export function jitTransform(
// 3. Emit Class.ctorParameters for constructor DI
const ctorParams = buildCtorParameters(node, sourceCode, typeOnlyImports);
if (ctorParams) {
postClassStatements.push(
classStatements.push(
`${className}.ctorParameters = () => [${ctorParams}];`,
);
}

// 4. Emit Class.propDecorators for field decorators + signal APIs
const propDecorators = buildPropDecorators(node, sourceCode);
if (propDecorators) {
postClassStatements.push(
`${className}.propDecorators = ${propDecorators};`,
);
classStatements.push(`${className}.propDecorators = ${propDecorators};`);
}

// 5. Remove member and parameter decorators from source now that
Expand Down Expand Up @@ -267,6 +277,17 @@ export function jitTransform(
}
}
}

// Insert this class's metadata statements directly after the class body
// so they execute in the class's own lexical scope. `node.end` is the
// position just past the closing brace (decorators were stripped before
// `node.start`), so for a top-level class this is module scope and for a
// class nested in a function/callback it is that enclosing scope —
// keeping the class identifier in scope either way (#2360).
if (classStatements.length > 0) {
allEmittedStatements.push(...classStatements);
ms.appendLeft(node.end, '\n' + classStatements.join('\n') + '\n');
}
}

if (!hasAngularClass) {
Expand All @@ -293,7 +314,7 @@ export function jitTransform(
}
}
}
const allPostCode = postClassStatements.join('\n');
const allPostCode = allEmittedStatements.join('\n');
const missingDecorators: string[] = [];
for (const dec of FIELD_DECORATORS) {
if (allPostCode.includes(`type: ${dec}`) && !existingImports.has(dec)) {
Expand All @@ -313,11 +334,6 @@ export function jitTransform(
);
}

// Append all post-class statements at the end
if (postClassStatements.length > 0) {
ms.append('\n' + postClassStatements.join('\n') + '\n');
}

const map = ms.generateMap({
source: fileName,
file: fileName + '.js',
Expand Down
Loading