diff --git a/.changeset/union-static-member-resolution.md b/.changeset/union-static-member-resolution.md new file mode 100644 index 000000000000..5d005b3df371 --- /dev/null +++ b/.changeset/union-static-member-resolution.md @@ -0,0 +1,5 @@ +--- +"@biomejs/biome": patch +--- + +Fixed [#8527](https://github.com/biomejs/biome/issues/8527): Improved type inference where analyzing code with repeated object property access and assignments (e.g. `node = node.parent`, a pattern common when traversing trees in a while loop) could hit an internal type limit. Biome now handles these cases without exceeding the type limit. diff --git a/crates/biome_js_type_info/src/flattening/expressions.rs b/crates/biome_js_type_info/src/flattening/expressions.rs index 9bbb301ae079..e753d7da2f02 100644 --- a/crates/biome_js_type_info/src/flattening/expressions.rs +++ b/crates/biome_js_type_info/src/flattening/expressions.rs @@ -8,7 +8,7 @@ use crate::{ CallArgumentType, DestructureField, Function, FunctionParameter, Literal, MAX_FLATTEN_DEPTH, Resolvable, ResolvedTypeData, ResolvedTypeMember, ResolverId, TypeData, TypeMember, TypeReference, TypeResolver, TypeofCallExpression, TypeofDestructureExpression, - TypeofExpression, TypeofStaticMemberExpression, + TypeofExpression, conditionals::{ ConditionalType, reference_to_falsy_subset_of, reference_to_non_nullish_subset_of, reference_to_truthy_subset_of, @@ -265,26 +265,31 @@ pub(super) fn flattened_expression( } TypeData::Union(_) => { - let types: Vec<_> = object + // Resolve the requested member across union variants directly and build a union of the resulting references. + let variants: Vec<_> = object .flattened_union_variants(resolver) .filter(|variant| *variant != GLOBAL_UNDEFINED_ID.into()) .collect(); - let types = types - .into_iter() - .map(|variant| { - // Resolve and flatten the type member for each variant. - let variant = TypeData::TypeofExpression(Box::new( - TypeofExpression::StaticMember(TypeofStaticMemberExpression { - object: variant, - member: expr.member.clone(), - }), - )); - resolver.reference_to_owned_data(variant) - }) - .collect(); + let mut types: Vec = Vec::new(); + for variant in variants { + if let Some(resolved) = resolver.resolve_and_get(&variant) { + let member_opt = resolved + .find_member(resolver, |member| member.has_name(&expr.member)) + .or_else(|| { + resolved + .find_index_signature_with_ty(resolver, |ty| ty.is_string()) + }); + if let Some(member) = member_opt { + let type_ref = resolver.reference_to_owned_data( + TypeData::Reference(member.deref_ty(resolver).into_owned()), + ); + types.push(type_ref); + } + } + } - Some(TypeData::union_of(resolver, types)) + Some(TypeData::union_of(resolver, types.into_boxed_slice())) } _ => { diff --git a/crates/biome_js_type_info/tests/flattening.rs b/crates/biome_js_type_info/tests/flattening.rs index 4159ab12db7d..4607a559d8a2 100644 --- a/crates/biome_js_type_info/tests/flattening.rs +++ b/crates/biome_js_type_info/tests/flattening.rs @@ -8,6 +8,46 @@ use utils::{ parse_ts, }; +#[test] +fn infer_flattened_type_of_static_member_on_union() { + // This test triggers flattening of a static member access on a union type. + // It mimics the original bug where `parentNode = parentNode.parentNode;` caused + // runaway type growth because accessing a property on a union created new + // TypeofExpression::StaticMember nodes for each variant. + const CODE: &str = r#"interface Node { + parentNode: Node | null; +} + +declare let parentNode: Node | null; + +parentNode.parentNode"#; + + let root = parse_ts(CODE); + let interface_decl = get_interface_declaration(&root); + let mut resolver = GlobalsResolver::default(); + let interface_ty = + TypeData::from_ts_interface_declaration(&mut resolver, ScopeId::GLOBAL, &interface_decl) + .expect("interface must be inferred"); + resolver.run_inference(); + + // Create the union type: Node | null + let interface_ref = resolver.reference_to_owned_data(interface_ty.clone()); + let null_ref = resolver.reference_to_owned_data(TypeData::Null); + let union_ty = TypeData::union_of(&resolver, [interface_ref, null_ref].into()); + + let expr = get_expression(&root); + let mut resolver = HardcodedSymbolResolver::new("parentNode", union_ty, resolver); + let expr_ty = TypeData::from_any_js_expression(&mut resolver, ScopeId::GLOBAL, &expr); + let expr_ty = expr_ty.inferred(&mut resolver); + + assert_type_data_snapshot( + CODE, + &expr_ty, + &resolver, + "infer_flattened_type_of_static_member_on_union", + ) +} + #[test] fn infer_flattened_type_of_typeof_expression() { const CODE: &str = r#"const foo = "foo"; diff --git a/crates/biome_js_type_info/tests/snapshots/infer_flattened_type_of_static_member_on_union.snap b/crates/biome_js_type_info/tests/snapshots/infer_flattened_type_of_static_member_on_union.snap new file mode 100644 index 000000000000..c36ffa30441f --- /dev/null +++ b/crates/biome_js_type_info/tests/snapshots/infer_flattened_type_of_static_member_on_union.snap @@ -0,0 +1,40 @@ +--- +source: crates/biome_js_type_info/tests/utils.rs +expression: content +--- +## Input + +```ts +interface Node { + parentNode: Node | null; +} + +declare let parentNode: Node | null; + +parentNode.parentNode; + +``` + +## Result + +``` +Global TypeId(0) | Global TypeId(1) | Global TypeId(2) +``` + +## Registered types + +``` +Thin TypeId(0) => Global TypeId(3) | Global TypeId(1) + +Global TypeId(0) => instanceof unresolved reference "Node" (scope ID: 0) + +Global TypeId(1) => null + +Global TypeId(2) => Global TypeId(0) | Global TypeId(1) + +Global TypeId(3) => interface "Node" { + extends: [] + type_args: [] + members: ["parentNode": Global TypeId(2)] +} +```