Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
@@ -0,0 +1,4 @@
/* should not generate diagnostics */
import { useState } from "./export-equals-react";

export const state = useState;
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
source: crates/biome_js_analyze/tests/spec_tests.rs
assertion_line: 149
expression: export-equals-namespace.ts
---
# Input
```ts
/* should not generate diagnostics */
import { useState } from "./export-equals-react";

export const state = useState;

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
declare namespace ReactExportEquals {
function useState(): void;
}

export = ReactExportEquals;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
source: crates/biome_js_analyze/tests/spec_tests.rs
assertion_line: 149
expression: export-equals-react.d.ts
---
# Input
```ts
declare namespace ReactExportEquals {
function useState(): void;
}

export = ReactExportEquals;

```
51 changes: 48 additions & 3 deletions crates/biome_module_graph/src/js_module_info/collector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ pub(super) enum JsCollectedExport {
ExportDefaultAssignment {
/// Reference to the type assigned to the export.
ty: TypeReference,
/// Local identifier assigned to the export, if the expression is a plain identifier.
local_name: Option<TokenText>,
},
Reexport {
/// Name under which the import will be re-exported.
Expand Down Expand Up @@ -861,7 +863,7 @@ impl JsModuleInfoCollector {
for collected_export in &mut self.exports {
match collected_export {
JsCollectedExport::ExportDefault { ty }
| JsCollectedExport::ExportDefaultAssignment { ty } => {
| JsCollectedExport::ExportDefaultAssignment { ty, .. } => {
if let TypeReference::Resolved(resolved_id) = ty {
update_resolved_id(resolved_id);
}
Expand Down Expand Up @@ -903,9 +905,16 @@ impl JsModuleInfoCollector {
let export = JsExport::Own(JsOwnExport::Type(resolved));
finalised_exports.insert(Text::new_static("default"), export);
}
JsCollectedExport::ExportDefaultAssignment { ty } => {
JsCollectedExport::ExportDefaultAssignment { ty, local_name } => {
let resolved = self.resolve_reference(&ty).unwrap_or(GLOBAL_UNKNOWN_ID);

if let Some(local_name) = local_name.as_ref() {
self.collect_namespace_exports_for_local_name(
local_name,
&mut finalised_exports,
);
}

if let Some(data) = self.get_by_resolved_id(resolved) {
for member in data.as_raw_data().own_members() {
let Some(name) = member.name() else {
Expand All @@ -927,7 +936,13 @@ impl JsModuleInfoCollector {
}
}

let export = JsExport::Own(JsOwnExport::Type(resolved));
let export = local_name
.as_ref()
.and_then(|local_name| {
self.get_export_for_local_name(local_name.clone())
.map(JsExport::Own)
})
.unwrap_or_else(|| JsExport::Own(JsOwnExport::Type(resolved)));
finalised_exports.insert(Text::new_static("default"), export);
}
JsCollectedExport::Reexport {
Expand Down Expand Up @@ -1000,6 +1015,36 @@ impl JsModuleInfoCollector {

Some(export)
}

fn collect_namespace_exports_for_local_name(
&self,
local_name: &TokenText,
finalised_exports: &mut IndexMap<Text, JsExport>,
) {
let Some(binding_ref) = self.scopes[0].bindings_by_name.get(local_name).copied() else {
return;
};
let Some(namespace_binding_id) = binding_ref.namespace_ty_or_ty() else {
return;
};
let namespace_binding = &self.bindings[namespace_binding_id.index()];
let namespace_scope_id = namespace_binding.scope_id;

for binding in &self.bindings {
if binding.name.text().is_empty() {
continue;
}

let scope = &self.scopes[binding.scope_id.index()];
if scope.parent != Some(namespace_scope_id) {
continue;
}

finalised_exports
.entry(binding.name.clone())
.or_insert_with(|| JsExport::Own(JsOwnExport::Binding(binding.range)));
}
}
}

impl TypeResolver for JsModuleInfoCollector {
Expand Down
11 changes: 8 additions & 3 deletions crates/biome_module_graph/src/js_module_info/visitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,15 @@ impl<'a> JsModuleVisitor<'a> {
node: TsExportAssignmentClause,
collector: &mut JsModuleInfoCollector,
) -> Option<()> {
let type_data =
TypeData::from_any_js_expression(collector, ScopeId::GLOBAL, &node.expression().ok()?);
let expression = node.expression().ok()?;
let local_name = expression
.as_js_identifier_expression()
.and_then(|ident| ident.name().ok())
.and_then(|name| name.value_token().ok())
.map(|token| token.token_text_trimmed());
let type_data = TypeData::from_any_js_expression(collector, ScopeId::GLOBAL, &expression);
let ty = TypeReference::from(collector.register_and_resolve(type_data));
collector.register_export(JsCollectedExport::ExportDefaultAssignment { ty });
collector.register_export(JsCollectedExport::ExportDefaultAssignment { ty, local_name });

Some(())
}
Expand Down
57 changes: 57 additions & 0 deletions crates/biome_module_graph/tests/spec_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1598,6 +1598,16 @@ fn test_resolve_react_types() {
let module_graph = Arc::new(ModuleGraph::default());
module_graph.update_graph_for_js_paths(&fs, &project_layout, &added_paths, true);

let react_module = module_graph
.js_module_info_for_path(Utf8Path::new("/node_modules/@types/react/index.d.ts"))
.expect("react typings module must exist");
assert!(
react_module
.find_js_exported_symbol(module_graph.as_ref(), "useCallback")
.is_some(),
"`useCallback` should be visible as a named export from `export = React` typings"
);

let index_module = module_graph
.js_module_info_for_path(Utf8Path::new("/src/index.ts"))
.expect("module must exist");
Expand All @@ -1619,6 +1629,53 @@ fn test_resolve_react_types() {
assert!(promise_ty.is_promise_instance());
}

#[test]
fn test_react_named_exports_are_visible_without_type_inference() {
let fs = MemoryFileSystem::default();
fs.insert(
"/node_modules/@types/react/index.d.ts".into(),
include_bytes!("../../biome_resolver/tests/fixtures/resolver_cases_5/node_modules/@types/react/index.d.ts")
);
fs.insert(
"/src/index.ts".into(),
r#"import { useState } from "react";

export const x = useState;
"#,
);

let added_paths = [
BiomePath::new("/node_modules/@types/react/index.d.ts"),
BiomePath::new("/src/index.ts"),
];
let added_paths = get_added_js_paths(&fs, &added_paths);

let project_layout = ProjectLayout::default();
project_layout.insert_node_manifest(
"/".into(),
PackageJson::new("frontend")
.with_version("0.0.0")
.with_dependencies(Dependencies(Box::new([("react".into(), "19.0.0".into())]))),
);

let tsconfig_json = parse_json(r#"{}"#, JsonParserOptions::default());
project_layout
.insert_serialized_tsconfig("/".into(), &tsconfig_json.syntax().as_send().unwrap());

let module_graph = ModuleGraph::default();
module_graph.update_graph_for_js_paths(&fs, &project_layout, &added_paths, false);

let react_module = module_graph
.js_module_info_for_path(Utf8Path::new("/node_modules/@types/react/index.d.ts"))
.expect("react typings module must exist");
assert!(
react_module
.find_js_exported_symbol(&module_graph, "useState")
.is_some(),
"`useState` should stay visible to noUnresolvedImports during project scans without type inference"
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[test]
fn test_resolve_redis_commander_types() {
let fs = MemoryFileSystem::default();
Expand Down
Loading