Skip to content

Commit 9a0a2d9

Browse files
serpentbladeclaude
andcommitted
fix(target-angular): alias <script> value-imports referenced in templates to a component field
Angular AOT resolves template-binding identifiers against the component INSTANCE, but <script> imports are hoisted to module scope (partitionUserImports → ShellParts.userImports) and are not class members. A <script> value-import referenced inside a template-context expression (e.g. :options="{ plugins: [listPlugin] }") therefore lowers to a bare instance lookup that does not exist → undefined at runtime, and because the import's only reference lives in the separate template compilation context, the bundler tree-shakes it away (FullCalendar then crashes in buildPluginHooks reading 'name'). The other five targets share one scope (React/Solid emit JSX in the import's module; Vue/Svelte single-file; Lit's html`` is a class method in module scope), so they are immune. Fix: the Angular emitter detects each VALUE-import whose local name is referenced as a bare, free identifier in any template-context expression and emits a `protected readonly <name> = <name>;` component field. The field name === the import local name so the UNCHANGED bare template reference resolves against `this` (Angular templates reference members bare); `protected readonly` because AOT template type-checking cannot see `private` members. Detection is AST-based over the IR template tree + <listeners> expressions (NOT a scan of the rendered template string, which false-positives on import names inside string literals such as a `rozie-<name>` CSS class). Type-only imports/specifiers and script-only imports are skipped; an import name colliding with an existing class member is left alone (pre-existing author clash). Angular-only — the other five targets emit nothing new (byte-identical output, zero snapshot drift). Root cause: Angular template≠module scope; module-hoisted <script> value-imports are not component members, so a bare template reference is undefined and the import is tree-shaken. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent adf2136 commit 9a0a2d9

4 files changed

Lines changed: 440 additions & 1 deletion

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/**
2+
* templateImportAlias.test.ts — Angular emitter regression for the
3+
* "<script> value-import referenced in a template expression" scope gap.
4+
*
5+
* Angular AOT evaluates template-binding identifiers against the COMPONENT
6+
* INSTANCE, but `<script>` imports are hoisted to module scope (partitionUserImports
7+
* → ShellParts.userImports) and are NOT class members. So a template binding
8+
* referencing an imported symbol (`:options="{ plugins: [listPlugin] }"`) lowers
9+
* to a bare instance lookup that does not exist → `undefined` at runtime, and
10+
* because the import's only reference lives in the separate template compilation
11+
* context, the bundler tree-shakes it away (FullCalendar then crashes in
12+
* buildPluginHooks reading 'name').
13+
*
14+
* FIX: the Angular emitter aliases each value-import whose local name appears in
15+
* a template-context expression to a `protected readonly <name> = <name>;`
16+
* component field — the field name === the import local name so the UNCHANGED
17+
* bare template reference resolves against `this`, and the initializer keeps the
18+
* module import live. `protected readonly` because AOT template type-checking
19+
* cannot see `private` members.
20+
*
21+
* The other five targets share one scope (React/Solid emit JSX in the import's
22+
* module; Vue/Svelte single-file; Lit's html`` is a class method in module
23+
* scope), so they emit NOTHING new — the negative cross-target case asserts a
24+
* React compile of the same source carries no alias.
25+
*/
26+
import { describe, expect, it } from 'vitest';
27+
import { parse } from '../../../../../core/src/parse.js';
28+
import { lowerToIR } from '../../../../../core/src/ir/lower.js';
29+
import { createDefaultRegistry } from '../../../../../core/src/modifiers/registerBuiltins.js';
30+
import { compile } from '../../../../../core/src/compile.js';
31+
import type { IRComponent } from '../../../../../core/src/ir/types.js';
32+
import { emitAngular } from '../../emitAngular.js';
33+
34+
function compileAngular(src: string, filename = 'Test.rozie'): string {
35+
const result = parse(src, { filename });
36+
if (!result.ast) {
37+
throw new Error(
38+
`parse() failed: ${result.diagnostics.map((d) => d.code).join(', ')}`,
39+
);
40+
}
41+
const lowered = lowerToIR(result.ast, {
42+
modifierRegistry: createDefaultRegistry(),
43+
});
44+
if (!lowered.ir) {
45+
throw new Error('lowerToIR() returned null IR');
46+
}
47+
const ir: IRComponent = lowered.ir;
48+
return emitAngular(ir, { filename, source: src }).code;
49+
}
50+
51+
describe('emitAngular — template-referenced <script> value-import alias', () => {
52+
it('(1) default value-import referenced ONLY in a template binding → protected readonly alias + retained import', () => {
53+
const code = compileAngular(`<rozie name="Test">
54+
<components>
55+
{ Child: './Child.rozie' }
56+
</components>
57+
<script>
58+
import listPlugin from '@fullcalendar/list'
59+
</script>
60+
<template>
61+
<Child :options="{ plugins: [listPlugin] }"></Child>
62+
</template>
63+
</rozie>`);
64+
65+
// The alias field — exactly `protected readonly listPlugin = listPlugin;`.
66+
expect(code).toContain('protected readonly listPlugin = listPlugin;');
67+
// The module import stays live (not tree-shaken / dropped from emit).
68+
expect(code).toContain("import listPlugin from '@fullcalendar/list';");
69+
// The bare template reference is UNCHANGED — no `this.` rewrite (Angular
70+
// templates reference members bare); the alias resolves it against `this`.
71+
expect(code).toContain('[options]="{ plugins: [listPlugin] }"');
72+
// protected/readonly are load-bearing: AOT cannot see `private` members.
73+
expect(code).not.toContain('private listPlugin');
74+
// Exactly ONE alias field (no duplicate).
75+
const aliasCount = (
76+
code.match(/protected readonly listPlugin = listPlugin;/g) ?? []
77+
).length;
78+
expect(aliasCount).toBe(1);
79+
});
80+
81+
it('(2) NAMED value-import referenced in a template binding → alias on the local name', () => {
82+
const code = compileAngular(`<rozie name="Test">
83+
<script>
84+
import { thePlugin } from 'thing'
85+
</script>
86+
<template>
87+
<div :data-x="thePlugin"></div>
88+
</template>
89+
</rozie>`);
90+
expect(code).toContain('protected readonly thePlugin = thePlugin;');
91+
expect(code).toContain("import { thePlugin } from 'thing';");
92+
});
93+
94+
it('(3) value-import referenced inside an @event handler expression → alias', () => {
95+
const code = compileAngular(`<rozie name="Test">
96+
<script>
97+
import doThing from 'thing'
98+
</script>
99+
<template>
100+
<button @click="doThing()">go</button>
101+
</template>
102+
</rozie>`);
103+
expect(code).toContain('protected readonly doThing = doThing;');
104+
expect(code).toContain("import doThing from 'thing';");
105+
});
106+
107+
it('(4-negative) value-import referenced ONLY in <script> (never in template) → NO alias', () => {
108+
const code = compileAngular(`<rozie name="Test">
109+
<script>
110+
import sideEffect from 'thing'
111+
$onMount(() => { sideEffect(); return undefined; })
112+
</script>
113+
<template>
114+
<div>hi</div>
115+
</template>
116+
</rozie>`);
117+
// sideEffect works via the module import inside the lifecycle body; no alias.
118+
expect(code).not.toContain('protected readonly sideEffect');
119+
// The import itself is still present (used in <script>).
120+
expect(code).toContain("import sideEffect from 'thing';");
121+
});
122+
123+
it('(5-negative) type-only import → NEVER aliased (TS erases it)', () => {
124+
const code = compileAngular(`<rozie name="Test">
125+
<script lang="ts">
126+
import type { Opts } from 'thing'
127+
const x: Opts = {}
128+
</script>
129+
<template>
130+
<div>{{ x }}</div>
131+
</template>
132+
</rozie>`);
133+
// `Opts` is a type — must not become a runtime field. `x` is the value,
134+
// declared in <script>, and is not a value-import.
135+
expect(code).not.toContain('protected readonly Opts');
136+
expect(code).not.toContain('readonly x = x');
137+
});
138+
139+
it('(6-collision) import name colliding with a declared prop → NO duplicate alias field', () => {
140+
// `value` is both a model prop AND an import local name (an author-level
141+
// clash). The import is referenced in the template, but `value` is already a
142+
// class member (`value = model<…>()`), so we must NOT emit a second
143+
// `protected readonly value = value;` field (it would shadow / duplicate).
144+
const code = compileAngular(`<rozie name="Test">
145+
<props>
146+
{ value: { type: String, model: true } }
147+
</props>
148+
<script>
149+
import value from 'thing'
150+
</script>
151+
<template>
152+
<div :data-x="value"></div>
153+
</template>
154+
</rozie>`);
155+
expect(code).not.toContain('protected readonly value = value;');
156+
});
157+
158+
it('(7-cross-target) the SAME source emits NO alias on a non-Angular target (react)', () => {
159+
const src = `<rozie name="Test">
160+
<components>
161+
{ Child: './Child.rozie' }
162+
</components>
163+
<script>
164+
import listPlugin from '@fullcalendar/list'
165+
</script>
166+
<template>
167+
<Child :options="{ plugins: [listPlugin] }"></Child>
168+
</template>
169+
</rozie>`;
170+
const react = compile(src, { target: 'react', filename: 'Test.rozie' });
171+
expect(react.code).not.toContain('protected readonly');
172+
expect(react.code).not.toContain('readonly listPlugin = listPlugin');
173+
// React shares one module scope — the import is referenced directly in the
174+
// emitted JSX, no aliasing needed.
175+
expect(react.code).toContain('listPlugin');
176+
});
177+
});

packages/targets/angular/src/emit/emitScript.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,17 @@ export interface EmitScriptResult {
653653
* would be emitted INSIDE the constructor body and produce TS1232.
654654
*/
655655
userImports: string;
656+
/**
657+
* Angular-only (template≠module scope alias) — local binding names of every
658+
* VALUE `<script>` import specifier (type-only imports/specifiers excluded).
659+
* emitAngular intersects this with the identifiers in the emitted template +
660+
* listener expressions and emits a `protected readonly <name> = <name>;` alias
661+
* field for each match, so a bare template reference to an imported symbol
662+
* (`:options="{ plugins: [listPlugin] }"`) resolves against the component
663+
* instance AND keeps the import live (Angular AOT tree-shakes a module-scope
664+
* import whose only use lives in the separate template compilation context).
665+
*/
666+
valueImportNames: Set<string>;
656667
/**
657668
* Phase 06.1 P2 (D-100/D-101): per-expression child sourcemap produced by
658669
* generating the user-authored constructor-expression statements as a single
@@ -760,6 +771,7 @@ export function emitScript(
760771
userImports: userImportNodes,
761772
hoistedTypeDecls,
762773
bodyStmts,
774+
valueImportNames,
763775
} = partitionUserImports(cloned);
764776
cloned.program.body = bodyStmts;
765777
const userImports =
@@ -1363,6 +1375,7 @@ export function emitScript(
13631375
imports,
13641376
interfaceDecls,
13651377
userImports,
1378+
valueImportNames,
13661379
scriptMap,
13671380
preambleSectionLines,
13681381
diagnostics,

0 commit comments

Comments
 (0)