diff --git a/compiler-backend/functional/src/convert.rs b/compiler-backend/functional/src/convert.rs index ae811e844..ef718a145 100644 --- a/compiler-backend/functional/src/convert.rs +++ b/compiler-backend/functional/src/convert.rs @@ -4,6 +4,7 @@ mod application; mod declaration; mod evidence; mod expression; +mod react; mod stylex; use std::cell::{OnceCell, RefCell}; @@ -30,6 +31,7 @@ use crate::tree::{ use self::declaration::{derive_declaration, instance_declaration, term_declaration}; use self::evidence::{EvidenceHoisting, EvidenceKeys, EvidenceScope}; +use self::react::ReactModule; use self::stylex::StyleXModules; type ConversionResult = Result; @@ -87,6 +89,7 @@ struct Context<'c, Q> { dependencies: FxHashMap, indexed_dependencies: RefCell>>, stylex_modules: OnceCell, + react_module: OnceCell, /// Whether a term of a virtual StyleX module was referenced, the only source of StyleX /// intrinsics and expressions. references_stylex_module: bool, @@ -153,6 +156,7 @@ where dependencies: FxHashMap::default(), indexed_dependencies: RefCell::default(), stylex_modules: OnceCell::new(), + react_module: OnceCell::new(), references_stylex_module: false, thunk_modules: OnceCell::new(), canonical_thunk_instances: RefCell::default(), @@ -192,6 +196,8 @@ fn convert(mut context: Context<'_, impl checking::ExternalQueries>) -> Conversi } validate_runtime_exports(&context, &declarations, &surface)?; context.validate_stylex_uses(&declarations)?; + context.validate_react_uses(&declarations)?; + context.materialize_react_functions(&declarations)?; context.hoist_closed_evidence(&mut declarations)?; let recursive_globals = declarations diff --git a/compiler-backend/functional/src/convert/application.rs b/compiler-backend/functional/src/convert/application.rs index 0d64a468a..ac3c8ae39 100644 --- a/compiler-backend/functional/src/convert/application.rs +++ b/compiler-backend/functional/src/convert/application.rs @@ -122,6 +122,9 @@ where { return Ok(expression); } + if let Some(expression) = self.react_intrinsic(known_function, &known_arguments)? { + return Ok(expression); + } if let Some(effect) = self.known_effect_application(known_function, &known_arguments)? { return Ok(self.expression(ExpressionKind::Effect { effect })); } diff --git a/compiler-backend/functional/src/convert/expression.rs b/compiler-backend/functional/src/convert/expression.rs index 5adf38de3..f24ca1b55 100644 --- a/compiler-backend/functional/src/convert/expression.rs +++ b/compiler-backend/functional/src/convert/expression.rs @@ -315,6 +315,9 @@ fn variable( if let Some(expression) = context.stylex_value_intrinsic(file_id, term_id)? { return Ok(expression); } + if let Some(expression) = context.react_value_intrinsic(file_id, term_id)? { + return Ok(expression); + } let global = context.term_global(file_id, term_id)?; Ok(context.expression(ExpressionKind::Global { global })) } diff --git a/compiler-backend/functional/src/convert/react.rs b/compiler-backend/functional/src/convert/react.rs new file mode 100644 index 000000000..f3f4ae9b4 --- /dev/null +++ b/compiler-backend/functional/src/convert/react.rs @@ -0,0 +1,222 @@ +//! Recognition and lowering for the virtual React module. + +use building_types::QueryResult; +use files::FileId; +use indexing::TermItemId; +use rustc_hash::FxHashSet; + +use crate::error::UnsupportedState; +use crate::optimize::for_each_expression_child; +use crate::react::{ReactElement, ReactExpression}; +use crate::tree::{Declaration, DeclarationKind, ExpressionId, ExpressionKind, GlobalId}; + +use super::{Context, ConversionResult}; + +#[derive(Clone, Copy)] +pub(super) struct ReactModule(Option); + +#[derive(Clone, Copy)] +enum ReactIntrinsic { + Component, + Element, + ElementKeyed, + Intrinsic, + IntrinsicKeyed, + Text, + Array, + Fragment, + Empty, +} + +impl<'c, Q: checking::ExternalQueries> Context<'c, Q> { + pub(super) fn react_intrinsic( + &mut self, + function: ExpressionId, + arguments: &[ExpressionId], + ) -> ConversionResult> { + let ExpressionKind::Global { global } = self.storage[function].kind.clone() else { + return Ok(None); + }; + let GlobalId::Term(file_id, term_id) = global.id else { return Ok(None) }; + let Some(intrinsic) = self.react_intrinsic_identity(file_id, term_id)? else { + return Ok(None); + }; + let expression = match (intrinsic, arguments) { + (ReactIntrinsic::Component, [render]) => ReactExpression::Component { render: *render }, + (ReactIntrinsic::Element, [_, component, props]) => { + self.react_element(ReactElement::Component(*component), *props, None) + } + (ReactIntrinsic::ElementKeyed, [_, key, component, props]) => { + self.react_element(ReactElement::Component(*component), *props, Some(*key)) + } + (ReactIntrinsic::Intrinsic, [_, tag, props]) => { + self.react_element(ReactElement::Intrinsic(*tag), *props, None) + } + (ReactIntrinsic::IntrinsicKeyed, [_, key, tag, props]) => { + self.react_element(ReactElement::Intrinsic(*tag), *props, Some(*key)) + } + (ReactIntrinsic::Text | ReactIntrinsic::Array, [value]) => return Ok(Some(*value)), + (ReactIntrinsic::Fragment, [children]) => { + ReactExpression::Fragment { children: *children } + } + (ReactIntrinsic::Empty, []) => ReactExpression::Empty, + _ => return Ok(None), + }; + Ok(Some(self.expression(ExpressionKind::React(expression)))) + } + + fn react_element( + &self, + component: ReactElement, + props: ExpressionId, + key: Option, + ) -> ReactExpression { + let static_multiple_children = match &self.storage[props].kind { + ExpressionKind::Record { fields } => fields.iter().any(|field| { + if field.field.name != "children" { + return false; + } + match &self.storage[field.expression].kind { + ExpressionKind::Array { elements } => elements.len() > 1, + _ => false, + } + }), + _ => false, + }; + ReactExpression::Element { component, props, key, static_multiple_children } + } + + pub(super) fn react_value_intrinsic( + &mut self, + file_id: FileId, + term_id: TermItemId, + ) -> ConversionResult> { + let Some(intrinsic) = self.react_intrinsic_identity(file_id, term_id)? else { + return Ok(None); + }; + if matches!(intrinsic, ReactIntrinsic::Empty) { + return Ok(Some(self.expression(ExpressionKind::React(ReactExpression::Empty)))); + } + Ok(None) + } + + fn react_intrinsic_identity( + &self, + file_id: FileId, + term_id: TermItemId, + ) -> QueryResult> { + if self.react_module().0 != Some(file_id) { + return Ok(None); + } + let indexed = self.indexed_module(file_id)?; + let intrinsic = match indexed.items[term_id].name.as_deref() { + Some("component") => ReactIntrinsic::Component, + Some("element") => ReactIntrinsic::Element, + Some("elementKeyed") => ReactIntrinsic::ElementKeyed, + Some("intrinsic") => ReactIntrinsic::Intrinsic, + Some("intrinsicKeyed") => ReactIntrinsic::IntrinsicKeyed, + Some("text") => ReactIntrinsic::Text, + Some("array") => ReactIntrinsic::Array, + Some("fragment") => ReactIntrinsic::Fragment, + Some("empty") => ReactIntrinsic::Empty, + _ => return Ok(None), + }; + Ok(Some(intrinsic)) + } + + pub(super) fn validate_react_uses(&self, declarations: &[Declaration]) -> ConversionResult<()> { + for declaration in declarations { + let DeclarationKind::Value(root) = declaration.kind else { continue }; + self.validate_react_expression(root, root, declaration)?; + } + Ok(()) + } + + pub(super) fn materialize_react_functions( + &mut self, + declarations: &[Declaration], + ) -> ConversionResult<()> { + let roots = declarations.iter().filter_map(|declaration| match declaration.kind { + DeclarationKind::Value(root) => Some((root, declaration.global.id)), + _ => None, + }); + let mut pending = roots.collect::>(); + let mut visited = FxHashSet::default(); + while let Some((expression, declaration)) = pending.pop() { + if !visited.insert(expression) { + continue; + } + if let ExpressionKind::Global { global } = &self.storage[expression].kind + && let GlobalId::Term(file_id, term_id) = global.id + && let Some(intrinsic) = self.react_intrinsic_identity(file_id, term_id)? + { + let names: &[&str] = match intrinsic { + ReactIntrinsic::Component => { + return Err(self + .unsupported(UnsupportedState::InvalidReactComponent { declaration })); + } + ReactIntrinsic::Element | ReactIntrinsic::Intrinsic => { + &["dictionary", "target", "props"] + } + ReactIntrinsic::ElementKeyed | ReactIntrinsic::IntrinsicKeyed => { + &["dictionary", "key", "target", "props"] + } + ReactIntrinsic::Text | ReactIntrinsic::Array => &["value"], + ReactIntrinsic::Fragment => &["children"], + ReactIntrinsic::Empty => &[], + }; + let mut parameters = Vec::new(); + let mut arguments = Vec::new(); + for name in names { + let parameter = self.fresh_parameter((*name).into())?; + arguments.push( + self.expression(ExpressionKind::Local { parameter: parameter.clone() }), + ); + parameters.push(parameter); + } + let body = self + .react_intrinsic(expression, &arguments)? + .expect("React intrinsic arity mismatch"); + let function = self.parameter_abstraction(parameters, body); + let kind = self.storage[function].kind.clone(); + self.storage.replace_expression_kind(expression, kind); + } else { + for_each_expression_child(&self.storage[expression].kind, |child| { + pending.push((child, declaration)) + }); + } + } + Ok(()) + } + + fn validate_react_expression( + &self, + expression: ExpressionId, + root: ExpressionId, + declaration: &Declaration, + ) -> ConversionResult<()> { + if let ExpressionKind::React(ReactExpression::Component { .. }) = + &self.storage[expression].kind + && (expression != root || declaration.recursive_group.is_some()) + { + return Err(self.unsupported(UnsupportedState::InvalidReactComponent { + declaration: declaration.global.id, + })); + } + let mut result = Ok(()); + for_each_expression_child(&self.storage[expression].kind, |child| { + if result.is_ok() { + result = self.validate_react_expression(child, root, declaration); + } + }); + result + } + + fn react_module(&self) -> ReactModule { + *self.react_module.get_or_init(|| ReactModule(self.queries.module_file("Iris.React"))) + } + + pub(super) fn react_module_is_virtual(&self, file_id: FileId) -> bool { + self.react_module().0 == Some(file_id) + } +} diff --git a/compiler-backend/functional/src/convert/stylex.rs b/compiler-backend/functional/src/convert/stylex.rs index 6fa0222e2..d118530f5 100644 --- a/compiler-backend/functional/src/convert/stylex.rs +++ b/compiler-backend/functional/src/convert/stylex.rs @@ -447,7 +447,7 @@ where } pub(super) fn module_is_virtual(&self, file_id: FileId) -> bool { - self.stylex_modules().module(file_id).is_some() + self.stylex_modules().module(file_id).is_some() || self.react_module_is_virtual(file_id) } pub(super) fn validate_runtime_reference( diff --git a/compiler-backend/functional/src/error.rs b/compiler-backend/functional/src/error.rs index aeac01efb..b21f1e513 100644 --- a/compiler-backend/functional/src/error.rs +++ b/compiler-backend/functional/src/error.rs @@ -50,6 +50,8 @@ pub enum UnsupportedState { InvalidStyleXUse { function: String, declaration: GlobalId }, #[error("Iris.StyleX.{function} {requirement}")] InvalidStyleXContext { function: String, requirement: String, declaration: GlobalId }, + #[error("Iris.React.component must be a direct non-recursive top-level initializer")] + InvalidReactComponent { declaration: GlobalId }, #[error("virtual module declaration {module_name}.{item_name} cannot be used at runtime")] VirtualModuleRuntimeReference { module_name: String, item_name: String }, } diff --git a/compiler-backend/functional/src/lib.rs b/compiler-backend/functional/src/lib.rs index a2a161224..169756bcb 100644 --- a/compiler-backend/functional/src/lib.rs +++ b/compiler-backend/functional/src/lib.rs @@ -5,6 +5,7 @@ pub mod error; pub mod initializers; pub mod optimize; pub mod pretty; +pub mod react; pub mod stylex; pub mod tree; diff --git a/compiler-backend/functional/src/optimize.rs b/compiler-backend/functional/src/optimize.rs index 1f429167b..e1ec2366d 100644 --- a/compiler-backend/functional/src/optimize.rs +++ b/compiler-backend/functional/src/optimize.rs @@ -275,6 +275,7 @@ fn is_simple_expression( | ExpressionKind::Application { .. } | ExpressionKind::UncurriedApplication { .. } | ExpressionKind::StyleX(_) + | ExpressionKind::React(_) | ExpressionKind::IfThenElse { .. } | ExpressionKind::Case { .. } | ExpressionKind::Guarded { .. } @@ -337,6 +338,7 @@ pub fn try_for_each_expression_child( } } ExpressionKind::StyleX(stylex) => stylex.try_for_each_child(&mut visit)?, + ExpressionKind::React(react) => react.try_for_each_child(&mut visit)?, ExpressionKind::IfThenElse { condition, then, else_ } => { visit(*condition)?; visit(*then)?; diff --git a/compiler-backend/functional/src/pretty.rs b/compiler-backend/functional/src/pretty.rs index 2eb4a5ecb..b7d11942a 100644 --- a/compiler-backend/functional/src/pretty.rs +++ b/compiler-backend/functional/src/pretty.rs @@ -2,6 +2,7 @@ use pretty::{Arena, DocAllocator, DocBuilder}; +use crate::react::{ReactElement, ReactExpression}; use crate::stylex::{StyleXCallTarget, StyleXConditionalCase, StyleXExpression}; use crate::tree::{ BinaryOperator, Declaration, DeclarationKind, EffectExpression, ExpressionId, ExpressionKind, @@ -100,6 +101,7 @@ impl<'a> Printer<'a, '_> { | ExpressionKind::Application { .. } | ExpressionKind::UncurriedApplication { .. } | ExpressionKind::StyleX(_) + | ExpressionKind::React(_) | ExpressionKind::Effect { .. } | ExpressionKind::SynthesizedEvidence { .. } => ExpressionPrecedence::Application, ExpressionKind::RecordUpdate { .. } => ExpressionPrecedence::RecordUpdate, @@ -230,6 +232,7 @@ impl<'a> Printer<'a, '_> { self.arena.text("uncurried.call ").append(function).append(arguments) } ExpressionKind::StyleX(stylex) => self.stylex_expression(stylex), + ExpressionKind::React(react) => self.react_expression(react), ExpressionKind::IfThenElse { condition, then, else_ } => { let condition = self.expression(*condition); let then = self.expression(*then); @@ -335,6 +338,33 @@ impl<'a> Printer<'a, '_> { } } + fn react_expression(&self, react: &ReactExpression) -> Doc<'a> { + match react { + ReactExpression::Component { render } => { + self.arena.text("react.component ").append(self.expression(*render)) + } + ReactExpression::Element { component, props, key, .. } => { + let target = match component { + ReactElement::Component(value) => self.expression(*value), + ReactElement::Intrinsic(value) => { + self.arena.text("intrinsic ").append(self.expression(*value)) + } + }; + let key = key.map(|key| self.arena.text(" key ").append(self.expression(key))); + self.arena + .text("react.element ") + .append(target) + .append(key) + .append(" ") + .append(self.expression(*props)) + } + ReactExpression::Fragment { children } => { + self.arena.text("react.fragment ").append(self.expression(*children)) + } + ReactExpression::Empty => self.arena.text("react.empty"), + } + } + fn stylex_conditional_case(&self, case: &StyleXConditionalCase) -> Doc<'a> { let selector = self.expression_at(case.selector, ExpressionPrecedence::Atom); let marker = diff --git a/compiler-backend/functional/src/react.rs b/compiler-backend/functional/src/react.rs new file mode 100644 index 000000000..e8108c2b7 --- /dev/null +++ b/compiler-backend/functional/src/react.rs @@ -0,0 +1,51 @@ +//! Functional expressions for the native React backend. + +use crate::tree::ExpressionId; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReactExpression { + Component { + render: ExpressionId, + }, + Element { + component: ReactElement, + props: ExpressionId, + key: Option, + static_multiple_children: bool, + }, + Fragment { + children: ExpressionId, + }, + Empty, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReactElement { + Component(ExpressionId), + Intrinsic(ExpressionId), +} + +impl ReactExpression { + pub fn try_for_each_child( + &self, + mut visit: impl FnMut(ExpressionId) -> Result<(), E>, + ) -> Result<(), E> { + match self { + ReactExpression::Component { render } => visit(*render)?, + ReactExpression::Element { component, props, key, .. } => { + match component { + ReactElement::Component(component) | ReactElement::Intrinsic(component) => { + visit(*component)?; + } + } + if let Some(key) = key { + visit(*key)?; + } + visit(*props)?; + } + ReactExpression::Fragment { children } => visit(*children)?, + ReactExpression::Empty => {} + } + Ok(()) + } +} diff --git a/compiler-backend/functional/src/tree.rs b/compiler-backend/functional/src/tree.rs index 2ec0ca8a3..69f7e5d19 100644 --- a/compiler-backend/functional/src/tree.rs +++ b/compiler-backend/functional/src/tree.rs @@ -9,6 +9,7 @@ use la_arena::{Arena, Idx}; use lowering::TypeId as SourceTypeId; use smol_str::SmolStr; +use crate::react::ReactExpression; use crate::stylex::StyleXExpression; pub type ExpressionId = Idx; @@ -190,6 +191,7 @@ pub enum ExpressionKind { Application { function: ExpressionId, arguments: Arc<[ExpressionId]>, synthetic: bool }, UncurriedApplication { function: ExpressionId, arguments: Arc<[ExpressionId]>, synthetic: bool }, StyleX(StyleXExpression), + React(ReactExpression), IfThenElse { condition: ExpressionId, then: ExpressionId, else_: ExpressionId }, Case { scrutinees: Arc<[ExpressionId]>, alternatives: Arc<[CaseAlternative]> }, Guarded { alternatives: Arc<[GuardedAlternative]> }, diff --git a/compiler-backend/javascript/src/convert/generator/functional/render.rs b/compiler-backend/javascript/src/convert/generator/functional/render.rs index d459e6195..0e2ca0ff3 100644 --- a/compiler-backend/javascript/src/convert/generator/functional/render.rs +++ b/compiler-backend/javascript/src/convert/generator/functional/render.rs @@ -1,6 +1,7 @@ //! Rendering functional trees as JavaScript modules. mod inline; +mod react; mod structure; mod stylex; mod syntax; @@ -55,6 +56,9 @@ pub(crate) struct Generator<'m> { external_named_imports: FxHashMap, external_references: Vec, stylex_namespace: Option, + react_jsx: Option, + react_jsxs: Option, + react_fragment: Option, foreign_import: Option, runtime_namespace: Option, lazy_global_names: FxHashMap, @@ -309,6 +313,10 @@ impl<'m> Generator<'m> { } let stylex_namespace = has_stylex.then(|| allocator.allocate("$stylex")); + let (jsx, jsxs, fragment) = react::required_imports(module); + let react_jsx = jsx.then(|| allocator.allocate("$jsx")); + let react_jsxs = jsxs.then(|| allocator.allocate("$jsxs")); + let react_fragment = fragment.then(|| allocator.allocate("$Fragment")); let has_foreign = module .declarations @@ -367,6 +375,9 @@ impl<'m> Generator<'m> { external_named_imports, external_references, stylex_namespace, + react_jsx, + react_jsxs, + react_fragment, foreign_import, runtime_namespace, lazy_global_names, @@ -471,6 +482,18 @@ fn render_imports(renderer: &mut ModuleRenderer<'_, '_, '_>) { if let Some(namespace) = &generator.stylex_namespace { writer.import_namespace(namespace, "@stylexjs/stylex"); } + let react_imports = [ + ("jsx", &generator.react_jsx), + ("jsxs", &generator.react_jsxs), + ("Fragment", &generator.react_fragment), + ]; + let react_imports = react_imports + .iter() + .filter_map(|(import, alias)| alias.as_deref().map(|alias| (*import, alias))) + .collect_vec(); + if !react_imports.is_empty() { + writer.import_named(&react_imports, "react/jsx-runtime"); + } if let Some(foreign_import) = &generator.foreign_import { let path = format!("./foreign.{}", foreign_import.kind.extension()); writer.import_namespace(&foreign_import.namespace, &path); @@ -481,6 +504,8 @@ fn render_imports(renderer: &mut ModuleRenderer<'_, '_, '_>) { } if !generator.external_references.is_empty() || generator.stylex_namespace.is_some() + || generator.react_jsx.is_some() + || generator.react_jsxs.is_some() || !generator.external_named_imports.is_empty() || generator.foreign_import.is_some() || generator.runtime_namespace.is_some() @@ -1649,6 +1674,9 @@ impl Generator<'_> { ExpressionKind::StyleX(stylex) => { self.render_stylex_expression(tree, writer, stylex, context) } + ExpressionKind::React(react) => { + self.render_react_expression(tree, writer, react, context) + } ExpressionKind::Effect { effect } => { let mut renderer = self.renderer(tree, writer, context); let value = effect_expression(&mut renderer, effect)?; @@ -1780,6 +1808,7 @@ impl Generator<'_> { } }) .is_err(), + ExpressionKind::React(_) => true, ExpressionKind::Error | ExpressionKind::IfThenElse { .. } | ExpressionKind::Case { .. } @@ -1819,6 +1848,7 @@ impl Generator<'_> { | ExpressionKind::Application { .. } | ExpressionKind::UncurriedApplication { .. } | ExpressionKind::StyleX(_) + | ExpressionKind::React(_) | ExpressionKind::IfThenElse { .. } | ExpressionKind::Case { .. } | ExpressionKind::Guarded { .. } @@ -1969,6 +1999,7 @@ impl Generator<'_> { }; expression } + ExpressionKind::React(_) => return Ok(None), ExpressionKind::SynthesizedEvidence { evidence } => { synthesized_evidence_expression(tree, evidence) } @@ -2034,6 +2065,7 @@ impl Generator<'_> { if self.expression_can_inline(child) { Ok(()) } else { Err(()) } }) .is_ok(), + ExpressionKind::React(_) => false, ExpressionKind::Error | ExpressionKind::RecordUpdate { .. } | ExpressionKind::IfThenElse { .. } @@ -3345,7 +3377,7 @@ fn collect_expression_references( collect_expression_references(module, *argument, seen, globals); } } - kind @ ExpressionKind::StyleX(_) => { + kind @ (ExpressionKind::StyleX(_) | ExpressionKind::React(_)) => { for_each_expression_child(kind, |child| { collect_expression_references(module, child, seen, globals); }); @@ -3563,7 +3595,7 @@ fn collect_expression_children( collect_expression_globals(module, *argument, false, globals); } } - kind @ ExpressionKind::StyleX(_) => { + kind @ (ExpressionKind::StyleX(_) | ExpressionKind::React(_)) => { for_each_expression_child(kind, |child| { collect_expression_globals(module, child, false, globals); }); diff --git a/compiler-backend/javascript/src/convert/generator/functional/render/react.rs b/compiler-backend/javascript/src/convert/generator/functional/render/react.rs new file mode 100644 index 000000000..fee1c5804 --- /dev/null +++ b/compiler-backend/javascript/src/convert/generator/functional/render/react.rs @@ -0,0 +1,131 @@ +//! JavaScript rendering for native React expressions. + +use functional::optimize::for_each_expression_child; +use functional::react::{ReactElement, ReactExpression}; +use functional::tree::{DeclarationKind, ExpressionId, ExpressionKind, Module}; +use rustc_hash::FxHashSet; + +use crate::error::ModuleResult; +use crate::tree::{ObjectProperty, Tree}; +use crate::writer::Writer; + +use super::{FunctionContext, Generator, RenderedExpression}; + +pub(super) fn required_imports(module: &Module) -> (bool, bool, bool) { + let mut jsx = false; + let mut jsxs = false; + let mut fragment = false; + let roots = module.declarations.iter().filter_map(|declaration| match declaration.kind { + DeclarationKind::Value(root) => Some(root), + _ => None, + }); + let mut pending = roots.collect::>(); + let mut visited = FxHashSet::default(); + while let Some(expression) = pending.pop() { + if !visited.insert(expression) { + continue; + } + let kind = &module.storage[expression].kind; + match kind { + ExpressionKind::React(ReactExpression::Element { + static_multiple_children, .. + }) => { + if *static_multiple_children { + jsxs = true + } else { + jsx = true + } + } + ExpressionKind::React(ReactExpression::Fragment { children }) => { + if fragment_has_static_children(module, *children) { + jsxs = true; + } else { + jsx = true; + } + fragment = true; + } + _ => {} + } + for_each_expression_child(kind, |child| pending.push(child)); + } + (jsx, jsxs, fragment) +} + +fn fragment_has_static_children(module: &Module, children: ExpressionId) -> bool { + matches!(module.storage[children].kind, ExpressionKind::Array { .. }) +} + +impl Generator<'_> { + pub(super) fn render_react_expression<'a, 't>( + &self, + tree: &'a mut Tree<'t>, + writer: &'a mut Writer<'t>, + react: &ReactExpression, + context: &'a mut FunctionContext, + ) -> ModuleResult { + let value = match react { + ReactExpression::Component { render } => { + return self.rendered_expression(tree, writer, *render, context); + } + ReactExpression::Element { component, props, key, static_multiple_children } => { + // A keyed source call evaluates its key before its element target and props. + let key = if let Some(key) = key { + let mut key = self.rendered_expression(tree, writer, *key, context)?; + self.materialize_rendered_expression( + tree, + writer, + &mut key, + "$reactKey", + context, + ); + Some(key.value) + } else { + None + }; + let mut target = match component { + ReactElement::Component(target) | ReactElement::Intrinsic(target) => { + self.rendered_expression(tree, writer, *target, context)? + } + }; + self.materialize_rendered_expression( + tree, + writer, + &mut target, + "$reactType", + context, + ); + let props = self.rendered_expression(tree, writer, *props, context)?.value; + let function = if *static_multiple_children { + self.react_jsxs.as_ref().expect("React expression has no jsxs import") + } else { + self.react_jsx.as_ref().expect("React expression has no jsx import") + }; + let mut arguments = vec![target.value, props]; + if let Some(key) = key { + arguments.push(key); + } + let function = tree.identifier(function); + tree.call(function, arguments) + } + ReactExpression::Fragment { children } => { + let function = if fragment_has_static_children(self.module, *children) { + self.react_jsxs.as_ref().expect("React fragment has no jsxs import") + } else { + self.react_jsx.as_ref().expect("React fragment has no jsx import") + }; + let children = self.rendered_expression(tree, writer, *children, context)?.value; + let props = tree.object(vec![ObjectProperty::Field { + name: "children".into(), + value: children, + }]); + let fragment = + self.react_fragment.as_ref().expect("React expression has no Fragment import"); + let function = tree.identifier(function); + let fragment = tree.identifier(fragment); + tree.call(function, vec![fragment, props]) + } + ReactExpression::Empty => tree.null(), + }; + Ok(RenderedExpression { value, pending_evaluation: true }) + } +} diff --git a/compiler-backend/javascript/src/convert/generator/functional/render/tail_call.rs b/compiler-backend/javascript/src/convert/generator/functional/render/tail_call.rs index 02c6ccfed..cf9bfdb6f 100644 --- a/compiler-backend/javascript/src/convert/generator/functional/render/tail_call.rs +++ b/compiler-backend/javascript/src/convert/generator/functional/render/tail_call.rs @@ -250,6 +250,7 @@ fn function_profile( | ExpressionKind::Application { .. } | ExpressionKind::UncurriedApplication { .. } | ExpressionKind::StyleX(_) + | ExpressionKind::React(_) | ExpressionKind::IfThenElse { .. } | ExpressionKind::Case { .. } | ExpressionKind::Guarded { .. } @@ -347,6 +348,7 @@ fn collect_tail_edges( | ExpressionKind::Application { .. } | ExpressionKind::UncurriedApplication { .. } | ExpressionKind::StyleX(_) + | ExpressionKind::React(_) | ExpressionKind::SynthesizedEvidence { .. } | ExpressionKind::TrivialEvidence => {} } @@ -390,6 +392,7 @@ fn application_head(module: &Module, expression: ExpressionId) -> Option>>, + dialect: Shards>, foreign: Shards>, foreign_content: Shards>>, module: Shards>>, @@ -216,6 +217,7 @@ struct InternedStorage { fn query_references_file(query: QueryKey, file_id: FileId) -> bool { match query { QueryKey::Content(id) + | QueryKey::Dialect(id) | QueryKey::Foreign(id) | QueryKey::ForeignValidation(id) | QueryKey::Parsed(id) @@ -605,6 +607,7 @@ impl QueryEngine { for dependency in dependencies { match dependency { QueryKey::Content(k) => input_changed!(content, k), + QueryKey::Dialect(k) => input_changed!(dialect, k), QueryKey::Foreign(k) => input_changed!(foreign, k), QueryKey::ForeignContent(k) => input_changed!(foreign_content, k), QueryKey::ForeignModule(k) => derived_changed!(foreign_module, k), @@ -846,6 +849,14 @@ impl QueryEngine { .ok_or(QueryError::MissingContent { file_id: id }) } + pub fn set_dialect(&self, id: FileId, dialect: SourceDialect) { + self.set_input(id, |input| &input.dialect, dialect); + } + + pub fn dialect(&self, id: FileId) -> SourceDialect { + self.get_input(QueryKey::Dialect(id), id, |input| &input.dialect).unwrap_or_default() + } + fn remove_file_queries( &self, file_id: FileId, @@ -883,6 +894,7 @@ impl QueryEngine { self.control.global.revision.fetch_add(1, Ordering::Relaxed); self.input.content.remove(&file_id); + self.input.dialect.remove(&file_id); self.input.foreign.remove(&file_id); let mut removed_modules = FxHashSet::default(); @@ -1053,8 +1065,9 @@ impl QueryEngine { |derived| &derived.parsed, |this| { let content = this.content(id)?; + let dialect = this.dialect(id); - let lexed = lexing::lex(&content); + let lexed = lexing::lex_with_jsx(&content, dialect == SourceDialect::Iris); let tokens = lexing::layout(&lexed); let parsed = parsing::parse(&lexed, &tokens); @@ -1782,9 +1795,11 @@ mod tests { } let content_states = engine.input.content.inner.iter().map(|shard| shard.read().len()); + let dialect_states = engine.input.dialect.inner.iter().map(|shard| shard.read().len()); let module_states = engine.input.module.inner.iter().map(|shard| shard.read().len()); let parsed_states = engine.derived.parsed.inner.iter().map(|shard| shard.read().len()); assert_eq!(content_states.sum::(), 0); + assert_eq!(dialect_states.sum::(), 0); assert_eq!(module_states.sum::(), 0); assert_eq!(parsed_states.sum::(), 0); } @@ -2202,6 +2217,7 @@ mod tests { let mut engine = QueryEngine::default(); let mut files = Files::default(); prim::configure(&mut engine, &mut files); + let initial_revision = engine.control.global.revision.load(Ordering::Relaxed); macro_rules! assert_trace { ($engine:expr, $field:ident($id:expr) => $trace:expr) => {{ @@ -2217,18 +2233,18 @@ mod tests { let indexed_a = engine.indexed(id).unwrap(); assert_trace!(engine, parsed(id) => Trace { - built: 25, - changed: 25, - dependencies: &[QueryKey::Content(id)] + built: initial_revision + 1, + changed: initial_revision + 1, + dependencies: &[QueryKey::Content(id), QueryKey::Dialect(id)] }); assert_trace!(engine, stabilized(id) => Trace { - built: 25, - changed: 25, + built: initial_revision + 1, + changed: initial_revision + 1, dependencies: &[QueryKey::Parsed(id)] }); assert_trace!(engine, indexed(id) => Trace { - built: 25, - changed: 25, + built: initial_revision + 1, + changed: initial_revision + 1, dependencies: &[QueryKey::Content(id), QueryKey::Parsed(id), QueryKey::Stabilized(id)] }); @@ -2238,18 +2254,18 @@ mod tests { let indexed_b = engine.indexed(id).unwrap(); assert_trace!(engine, parsed(id) => Trace { - built: 26, - changed: 25, - dependencies: &[QueryKey::Content(id)] + built: initial_revision + 2, + changed: initial_revision + 1, + dependencies: &[QueryKey::Content(id), QueryKey::Dialect(id)] }); assert_trace!(engine, stabilized(id) => Trace { - built: 26, - changed: 25, + built: initial_revision + 2, + changed: initial_revision + 1, dependencies: &[QueryKey::Parsed(id)] }); assert_trace!(engine, indexed(id) => Trace { - built: 26, - changed: 26, + built: initial_revision + 2, + changed: initial_revision + 2, dependencies: &[QueryKey::Content(id), QueryKey::Parsed(id), QueryKey::Stabilized(id)] }); @@ -2262,6 +2278,7 @@ mod tests { let mut engine = QueryEngine::default(); let mut files = Files::default(); prim::configure(&mut engine, &mut files); + let initial_revision = engine.control.global.revision.load(Ordering::Relaxed); macro_rules! assert_trace { ($engine:expr, $field:ident($id:expr) => $trace:expr) => {{ @@ -2280,18 +2297,18 @@ mod tests { let resolved_a = engine.resolved(id).unwrap(); assert_trace!(engine, parsed(id) => Trace { - built: 25, - changed: 25, - dependencies: &[QueryKey::Content(id)] + built: initial_revision + 1, + changed: initial_revision + 1, + dependencies: &[QueryKey::Content(id), QueryKey::Dialect(id)] }); assert_trace!(engine, indexed(id) => Trace { - built: 25, - changed: 25, + built: initial_revision + 1, + changed: initial_revision + 1, dependencies: &[QueryKey::Content(id), QueryKey::Parsed(id), QueryKey::Stabilized(id)] }); assert_trace!(engine, resolved(id) => Trace { - built: 25, - changed: 25, + built: initial_revision + 1, + changed: initial_revision + 1, dependencies: &[QueryKey::Indexed(id)] }); @@ -2304,18 +2321,18 @@ mod tests { let resolved_b = engine.resolved(id).unwrap(); assert_trace!(engine, parsed(id) => Trace { - built: 26, - changed: 26, - dependencies: &[QueryKey::Content(id)] + built: initial_revision + 2, + changed: initial_revision + 2, + dependencies: &[QueryKey::Content(id), QueryKey::Dialect(id)] }); assert_trace!(engine, indexed(id) => Trace { - built: 26, - changed: 25, + built: initial_revision + 2, + changed: initial_revision + 1, dependencies: &[QueryKey::Content(id), QueryKey::Parsed(id), QueryKey::Stabilized(id)] }); assert_trace!(engine, resolved(id) => Trace { - built: 26, - changed: 25, + built: initial_revision + 2, + changed: initial_revision + 1, dependencies: &[QueryKey::Indexed(id)] }); @@ -2328,18 +2345,18 @@ mod tests { let resolved_c = engine.resolved(id).unwrap(); assert_trace!(engine, parsed(id) => Trace { - built: 27, - changed: 27, - dependencies: &[QueryKey::Content(id)] + built: initial_revision + 3, + changed: initial_revision + 3, + dependencies: &[QueryKey::Content(id), QueryKey::Dialect(id)] }); assert_trace!(engine, indexed(id) => Trace { - built: 27, - changed: 25, + built: initial_revision + 3, + changed: initial_revision + 1, dependencies: &[QueryKey::Content(id), QueryKey::Parsed(id), QueryKey::Stabilized(id)] }); assert_trace!(engine, resolved(id) => Trace { - built: 27, - changed: 25, + built: initial_revision + 3, + changed: initial_revision + 1, dependencies: &[QueryKey::Indexed(id)] }); @@ -2379,6 +2396,7 @@ mod tests { let mut engine = QueryEngine::default(); let mut files = Files::default(); prim::configure(&mut engine, &mut files); + let initial_revision = engine.control.global.revision.load(Ordering::Relaxed); let parent = files.insert("./src/Parent.purs", "module Parent where"); let child = files.insert("./src/Child.purs", "module Child where"); @@ -2402,7 +2420,11 @@ mod tests { let guard = shard.read(); assert_eq!( ShowTrace(guard.get(&parent).unwrap()), - Trace { built: 25, changed: 25, dependencies: &[QueryKey::Parsed(child)] } + Trace { + built: initial_revision + 1, + changed: initial_revision + 1, + dependencies: &[QueryKey::Parsed(child)] + } ); } diff --git a/compiler-core/building/src/lifecycle.rs b/compiler-core/building/src/lifecycle.rs index 8d309fa95..5c8bbc5f9 100644 --- a/compiler-core/building/src/lifecycle.rs +++ b/compiler-core/building/src/lifecycle.rs @@ -294,6 +294,10 @@ where self.foreign_files.id(locator) } + pub fn foreign_owner(&self, locator: &str) -> Option<&SourceUnitKey> { + self.foreign_owners.get(locator) + } + fn locator_conflict(&self, unit: &SourceUnitKey) -> Option { if let Some(owner) = self.source_owners.get(unit.source()) && owner != unit diff --git a/compiler-core/building/src/lifecycle/source.rs b/compiler-core/building/src/lifecycle/source.rs index 7afff1b9e..db79dbeee 100644 --- a/compiler-core/building/src/lifecycle/source.rs +++ b/compiler-core/building/src/lifecycle/source.rs @@ -209,6 +209,7 @@ where let id = self.source_files.insert(Arc::clone(&unit.source), Arc::clone(&text)); self.source_units.insert(id, SourceUnitKey::clone(unit)); engine.set_content(id, Arc::clone(&text)); + engine.set_dialect(id, files::SourceDialect::from_path(unit.source())); registration.register(engine, id, None); let foreign_files = source_unit.foreign_files(); for kind in files::ForeignSourceKind::ALL { diff --git a/compiler-core/building/src/prim.rs b/compiler-core/building/src/prim.rs index fe1da3817..d0ad72da3 100644 --- a/compiler-core/building/src/prim.rs +++ b/compiler-core/building/src/prim.rs @@ -11,6 +11,7 @@ pub fn configure(engine: &mut QueryEngine, files: &mut Files) { let id = files.insert(path, *content); engine.set_content(id, *content); + engine.set_dialect(id, files.dialect(id)); engine.set_module_file(name, id); } } diff --git a/compiler-core/files/src/lib.rs b/compiler-core/files/src/lib.rs index 71c9f109b..fc2a417a0 100644 --- a/compiler-core/files/src/lib.rs +++ b/compiler-core/files/src/lib.rs @@ -17,6 +17,19 @@ pub struct FileId { index: u32, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SourceDialect { + #[default] + PureScript, + Iris, +} + +impl SourceDialect { + pub fn from_path(path: &str) -> SourceDialect { + if path.ends_with(".iris") { SourceDialect::Iris } else { SourceDialect::PureScript } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ForeignSourceKind { JavaScript, @@ -162,6 +175,10 @@ impl Files { Arc::clone(&file.content) } + pub fn dialect(&self, file_id: FileId) -> SourceDialect { + SourceDialect::from_path(&self.file(file_id).path) + } + pub fn remove(&mut self, path: &str) -> Option { let file_id = self.paths.remove(path)?; self.files.remove(&file_id).expect("invariant violated: expected valid FileId"); diff --git a/compiler-core/prim-constants/src/lib.rs b/compiler-core/prim-constants/src/lib.rs index 097fcac0b..7982b9696 100644 --- a/compiler-core/prim-constants/src/lib.rs +++ b/compiler-core/prim-constants/src/lib.rs @@ -22,6 +22,7 @@ define!( PRIM_ROW_LIST, "prim/Prim.RowList.purs", "Prim.RowList"; PRIM_SYMBOL, "prim/Prim.Symbol.purs", "Prim.Symbol"; PRIM_TYPE_ERROR, "prim/Prim.TypeError.purs", "Prim.TypeError"; + IRIS_REACT, "prim/Iris.React.purs", "Iris.React"; IRIS_STYLEX, "prim/Iris.StyleX.purs", "Iris.StyleX"; IRIS_STYLEX_WHEN, "prim/Iris.StyleX.When.purs", "Iris.StyleX.When"; IRIS_STYLEX_TYPES, "prim/Iris.StyleX.Types.purs", "Iris.StyleX.Types"; diff --git a/compiler-core/prim-constants/src/prim/Iris.React.purs b/compiler-core/prim-constants/src/prim/Iris.React.purs new file mode 100644 index 000000000..2f790bc5a --- /dev/null +++ b/compiler-core/prim-constants/src/prim/Iris.React.purs @@ -0,0 +1,46 @@ +module Iris.React + ( JSX + , Component + , component + , element + , elementKeyed + , intrinsic + , intrinsicKeyed + , text + , array + , fragment + , empty + ) where + +import Prim.Row as Row + +data JSX :: Type +data JSX + +data Component :: Row Type -> Type +data Component props +type role Component nominal + +foreign import component + :: forall props. (Record props -> JSX) -> Component props + +foreign import element + :: forall props. Row.Lacks "key" props + => Component props -> Record props -> JSX + +foreign import elementKeyed + :: forall props. Row.Lacks "key" props + => String -> Component props -> Record props -> JSX + +foreign import intrinsic + :: forall props. Row.Lacks "key" props + => String -> Record props -> JSX + +foreign import intrinsicKeyed + :: forall props. Row.Lacks "key" props + => String -> String -> Record props -> JSX + +foreign import text :: String -> JSX +foreign import array :: Array JSX -> JSX +foreign import fragment :: Array JSX -> JSX +foreign import empty :: JSX diff --git a/compiler-executable/iris-build/src/packages.rs b/compiler-executable/iris-build/src/packages.rs index bb00465fa..6061120e3 100644 --- a/compiler-executable/iris-build/src/packages.rs +++ b/compiler-executable/iris-build/src/packages.rs @@ -46,6 +46,8 @@ pub enum PackagesError { EscapedGitSubdirectory { name: SmolStr, subdirectory: PathBuf }, #[error("source file {path} is claimed by packages '{first}' and '{second}'")] ConflictingSource { path: PathBuf, first: SmolStr, second: SmolStr }, + #[error("source stem {stem} has both .purs and .iris files")] + AmbiguousSourceDialect { stem: PathBuf }, #[error("failed to canonicalize package path {path}: {source}")] CanonicalizePath { path: PathBuf, @@ -124,17 +126,25 @@ pub fn discover_packages(workspace: &Workspace) -> Result = BTreeMap::new(); + let mut source_stems = BTreeMap::new(); let mut files: BTreeMap> = BTreeMap::new(); for resolved in discovered.values() { let mut package_globs = Vec::new(); for directory in &resolved.source_directories { let relative = directory.strip_prefix(&workspace.root).unwrap_or(directory); - package_globs.push(relative.join(iris_spago::PURS_GLOB)); + package_globs.extend(iris_spago::SOURCE_GLOBS.map(|glob| relative.join(glob))); } source_globs.extend(package_globs.iter().cloned()); let walked = super::walk::walk_filtered(&workspace.root, package_globs, Vec::::new())?; for file in walked.files { + let stem = file.with_extension(""); + if let Some(previous) = + source_stems.insert(PathBuf::clone(&stem), PathBuf::clone(&file)) + && previous != file + { + return Err(PackagesError::AmbiguousSourceDialect { stem }); + } if let Some(owner) = owners.insert(PathBuf::clone(&file), SmolStr::clone(&resolved.name)) && owner != resolved.name diff --git a/compiler-executable/iris-build/src/session.rs b/compiler-executable/iris-build/src/session.rs index 1b2b140bd..297f74ab2 100644 --- a/compiler-executable/iris-build/src/session.rs +++ b/compiler-executable/iris-build/src/session.rs @@ -84,6 +84,8 @@ enum SessionFailure { Query(#[from] QueryError), #[error(transparent)] Walk(#[from] walk::Error), + #[error("source stem {0} has both .purs and .iris files")] + AmbiguousSourceDialect(PathBuf), } #[derive(Debug, Error)] @@ -204,24 +206,38 @@ impl BuildSession { continue; } match path.extension().and_then(|extension| extension.to_str()) { - Some("purs") => { - if self.source_paths.contains(path) - || !self.source_globs.matches(path).is_empty() - { - source_paths.insert(PathBuf::clone(path)); + Some(extension @ ("purs" | "iris")) => { + let is_source = self.source_paths.contains(path) + || !self.source_globs.matches(path).is_empty(); + if !is_source { + continue; } + let sibling_extension = if extension == "iris" { "purs" } else { "iris" }; + let sibling = path.with_extension(sibling_extension); + if path.exists() && sibling.exists() { + let sibling_is_source = self.source_paths.contains(&sibling) + || paths.iter().any(|changed| changed == &sibling && changed.exists()); + if sibling_is_source { + return Err(SessionFailure::AmbiguousSourceDialect( + path.with_extension(""), + )); + } + } + if self.source_paths.contains(&sibling) && !sibling.exists() { + source_paths.insert(sibling); + } + source_paths.insert(PathBuf::clone(path)); } Some("js" | "jsx") => { - let source_path = path.with_extension("purs"); - if self.source_paths.contains(&source_path) { - foreign_paths.insert(source_path); - } + foreign_paths.insert(PathBuf::clone(path)); } _ => {} } } let mut change = SessionChange::default(); + let mut source_paths = source_paths.into_iter().collect::>(); + source_paths.sort_by_key(|path| path.exists()); for path in source_paths { change.combine(self.observe_source_path(&path)?); if path.exists() { @@ -230,7 +246,20 @@ impl BuildSession { self.source_paths.remove(&path); } } - for source_path in foreign_paths { + for foreign_path in foreign_paths { + let purs = foreign_path.with_extension("purs"); + let iris = foreign_path.with_extension("iris"); + let source_path = + match (self.source_paths.contains(&purs), self.source_paths.contains(&iris)) { + (true, true) => { + return Err(SessionFailure::AmbiguousSourceDialect( + foreign_path.with_extension(""), + )); + } + (true, false) => purs, + (false, true) => iris, + (false, false) => continue, + }; change.combine(observe_foreign(&mut self.compilation, &source_path)?); } Ok(change) @@ -239,8 +268,8 @@ impl BuildSession { fn rescan_inputs(&mut self) -> Result { let walked = walk::walk_filtered(&self.root, &self.inputs, [&self.output])?; let current_paths = walked.files.into_iter().collect::>(); - let affected_paths = self.source_paths.union(¤t_paths).cloned().collect_vec(); - + let mut affected_paths = self.source_paths.union(¤t_paths).cloned().collect_vec(); + affected_paths.sort_by_key(|path| path.exists()); let mut change = SessionChange::default(); for path in affected_paths { change.combine(self.observe_source_path(&path)?); @@ -389,9 +418,14 @@ fn observe_source_unit( let previous_name = compilation.module_name(unit.source())?; let source = observe_disk(source_path); + let source_missing = matches!(source, DiskObservation::NotFound); let mut lifecycle = compilation.observe_source(SourceUnitKey::clone(&unit), source, ()); for kind in ForeignSourceKind::ALL { - let foreign = observe_disk(&source_path.with_extension(kind.extension())); + let foreign = if source_missing { + DiskObservation::NotFound + } else { + observe_disk(&source_path.with_extension(kind.extension())) + }; lifecycle.combine(compilation.observe_foreign(SourceUnitKey::clone(&unit), kind, foreign)); } diff --git a/compiler-executable/iris-lsp-server/src/settings.rs b/compiler-executable/iris-lsp-server/src/settings.rs index d4bb42c2d..6dbafaa03 100644 --- a/compiler-executable/iris-lsp-server/src/settings.rs +++ b/compiler-executable/iris-lsp-server/src/settings.rs @@ -131,7 +131,12 @@ fn watched_files_registration() -> RegistrationParams { kind: None, }; let options = DidChangeWatchedFilesRegistrationOptions { - watchers: vec![watcher("**/*.purs"), watcher("**/*.js"), watcher("**/*.jsx")], + watchers: vec![ + watcher("**/*.purs"), + watcher("**/*.iris"), + watcher("**/*.js"), + watcher("**/*.jsx"), + ], }; let register_options = serde_json::to_value(options) .expect("invariant violated: watched file registration options must serialize"); diff --git a/compiler-executable/iris-lsp-server/src/tests.rs b/compiler-executable/iris-lsp-server/src/tests.rs index 129bccb69..e94de9b70 100644 --- a/compiler-executable/iris-lsp-server/src/tests.rs +++ b/compiler-executable/iris-lsp-server/src/tests.rs @@ -714,6 +714,7 @@ async fn registrations_follow_the_client_capabilities() { registration["registerOptions"], json!({"watchers": [ {"globPattern": "**/*.purs"}, + {"globPattern": "**/*.iris"}, {"globPattern": "**/*.js"}, {"globPattern": "**/*.jsx"} ]}) diff --git a/compiler-executable/iris-lsp-workspace/src/handlers.rs b/compiler-executable/iris-lsp-workspace/src/handlers.rs index 0749813b0..c7c393ec1 100644 --- a/compiler-executable/iris-lsp-workspace/src/handlers.rs +++ b/compiler-executable/iris-lsp-workspace/src/handlers.rs @@ -19,7 +19,7 @@ use thiserror::Error; use crate::analysis::{CONTENT_MODIFIED, ChangeSignal, Snapshot}; use crate::state::{ DiagnosticTrigger, ReadyWorkspace, WorkspaceEffects, document_kind, observe_disk, - source_unit_from_document_uri, source_unit_from_foreign_uri, source_unit_from_source_uri, + source_unit_from_source_uri, }; #[derive(Error, Debug)] @@ -32,6 +32,8 @@ pub(crate) enum DocumentError { UnsupportedDocumentUri(Uri), #[error("Invalid content change for document {0}")] InvalidContentChange(Uri), + #[error("Source stem for {0} has both .purs and .iris files")] + AmbiguousSourceDialect(Uri), #[error("UrlParseError: {0}")] UrlParseError(#[from] url::ParseError), } @@ -130,7 +132,7 @@ fn did_open( parameters: DidOpenTextDocumentParams, ) -> Result { let uri = ¶meters.text_document.uri; - let (document, unit) = source_unit_from_document_uri(uri)?; + let (document, unit) = workspace.document_unit(uri)?; let mut events = Vec::new(); match document { @@ -174,7 +176,7 @@ fn did_change( if parameters.content_changes.is_empty() { return Ok(WorkspaceEffects::default()); } - let (document, unit) = source_unit_from_document_uri(uri)?; + let (document, unit) = workspace.document_unit(uri)?; let content = workspace.analysis.document_content(document, uri)?; let content = apply_content_changes( uri, @@ -213,7 +215,7 @@ fn did_close( parameters: DidCloseTextDocumentParams, ) -> Result { let uri = parameters.text_document.uri; - let (document, unit) = source_unit_from_document_uri(&uri)?; + let (document, unit) = workspace.document_unit(&uri)?; let disk = observe_disk(&uri); let mut events = Vec::new(); match document { @@ -260,12 +262,11 @@ fn did_change_watched_files( ) -> Result { let root = context.root.as_deref(); let mut source_units = FxHashSet::default(); - let mut foreign_units = FxHashSet::default(); + let mut foreign_changes = FxHashSet::default(); for change in parameters.changes { match document_kind(&change.uri) { Some(DocumentKind::Foreign(kind)) => { - let unit = source_unit_from_foreign_uri(&change.uri)?; - foreign_units.insert((unit, kind)); + foreign_changes.insert((change.uri, kind)); } Some(DocumentKind::Source) => { let unit = source_unit_from_source_uri(&change.uri)?; @@ -275,6 +276,46 @@ fn did_change_watched_files( } } + // A rename may be reported as only a creation, or with its deletion after its creation. Retire + // missing disk-backed owners before resolving the replacement and its shared foreign locators. + let mut removals = Vec::new(); + for unit in &source_units { + let uri = Uri::parse(unit.source())?; + if matches!(observe_disk(&uri), DiskObservation::Found(_)) { + let owner = workspace.analysis.files.read().foreign_owner(unit.foreign()).cloned(); + if let Some(owner) = owner.filter(|owner| owner != unit) { + let document = DocumentKey::Source(SourceUnitKey::clone(&owner)); + let owner_uri = Uri::parse(owner.source())?; + if workspace.analysis.files.read().is_open(&document) + || matches!(observe_disk(&owner_uri), DiskObservation::Found(_)) + { + return Err(DocumentError::AmbiguousSourceDialect(uri)); + } + let metadata = workspace.source_metadata(root, &owner, &owner_uri); + removals.push(LifecycleEvent::Source { + unit: SourceUnitKey::clone(&owner), + event: SourceEvent::DiskObserved { disk: observe_disk(&owner_uri), metadata }, + }); + for kind in files::ForeignSourceKind::ALL { + let document = DocumentKey::Foreign(SourceUnitKey::clone(&owner), kind); + if workspace.analysis.files.read().is_open(&document) { + return Err(DocumentError::AmbiguousSourceDialect(uri)); + } + removals.push(LifecycleEvent::Foreign { + unit: SourceUnitKey::clone(&owner), + kind, + event: ForeignEvent::DiskObserved { disk: DiskObservation::NotFound }, + }); + } + } + } + } + let mut effects = workspace.apply_lifecycle_events( + removals, + DiagnosticTrigger::AnalysisChange, + &context.change_signal, + ); + let mut events = Vec::new(); let mut observed_foreign = FxHashSet::default(); for unit in source_units { @@ -300,7 +341,8 @@ fn did_change_watched_files( } } - for (unit, kind) in foreign_units { + for (uri, kind) in foreign_changes { + let (_, unit) = workspace.document_unit(&uri)?; if observed_foreign.contains(&unit) { continue; } @@ -320,7 +362,6 @@ fn did_change_watched_files( if !tracked { continue; } - let uri = Uri::parse(unit.foreign_for(kind))?; let event = LifecycleEvent::Foreign { unit, kind, @@ -330,7 +371,10 @@ fn did_change_watched_files( } let trigger = DiagnosticTrigger::AnalysisChange; - Ok(workspace.apply_lifecycle_events(events, trigger, &context.change_signal)) + let subsequent = workspace.apply_lifecycle_events(events, trigger, &context.change_signal); + effects.clear_diagnostics.extend(subsequent.clear_diagnostics); + effects.collect_diagnostics.extend(subsequent.collect_diagnostics); + Ok(effects) } pub(crate) fn apply_content_changes( diff --git a/compiler-executable/iris-lsp-workspace/src/state.rs b/compiler-executable/iris-lsp-workspace/src/state.rs index 6424c128d..a58f6c4aa 100644 --- a/compiler-executable/iris-lsp-workspace/src/state.rs +++ b/compiler-executable/iris-lsp-workspace/src/state.rs @@ -98,6 +98,20 @@ impl ReadyWorkspace { } } + pub(crate) fn document_unit( + &self, + uri: &Uri, + ) -> Result<(DocumentKind, SourceUnitKey), DocumentError> { + let owner = self.analysis.files.read().foreign_owner(uri.as_str()).cloned(); + let document = document_kind(uri) + .ok_or_else(|| DocumentError::UnsupportedDocumentUri(Uri::clone(uri)))?; + let unit = match document { + DocumentKind::Source => source_unit_from_source_uri(uri)?, + DocumentKind::Foreign(_) => source_unit_from_foreign_uri(uri, owner.as_ref())?, + }; + Ok((document, unit)) + } + /// Applies lifecycle events, first telling tasks waiting for permits to drop their snapshots. pub(crate) fn apply_lifecycle_events( &mut self, @@ -119,7 +133,8 @@ impl ReadyWorkspace { let collect_diagnostics = match trigger { DiagnosticTrigger::None => Vec::new(), DiagnosticTrigger::AssociatedSource(uri) => { - let (_, unit) = source_unit_from_document_uri(&uri) + let (_, unit) = self + .document_unit(&uri) .expect("invariant violated: diagnostic trigger has an invalid document URI"); self.analysis.files.read().source_id(unit.source()).into_iter().collect_vec() } @@ -140,7 +155,7 @@ impl ReadyWorkspace { /// Collects diagnostics for the source associated with `uri`, without changing anything. pub(crate) fn associated_effects(&self, uri: &Uri) -> Result { - let (_, unit) = source_unit_from_document_uri(uri)?; + let (_, unit) = self.document_unit(uri)?; let files = self.analysis.files.read(); let collect_diagnostics = files.source_id(unit.source()).into_iter().collect_vec(); Ok(WorkspaceEffects { clear_diagnostics: Vec::new(), collect_diagnostics }) @@ -214,25 +229,13 @@ pub(crate) fn document_kind(uri: &Uri) -> Option { Some(DocumentKind::Foreign(ForeignSourceKind::JavaScript)) } else if uri.path().ends_with(".jsx") { Some(DocumentKind::Foreign(ForeignSourceKind::Jsx)) - } else if uri.path().ends_with(".purs") { + } else if uri.path().ends_with(".purs") || uri.path().ends_with(".iris") { Some(DocumentKind::Source) } else { None } } -pub(crate) fn source_unit_from_document_uri( - uri: &Uri, -) -> Result<(DocumentKind, SourceUnitKey), DocumentError> { - let document = - document_kind(uri).ok_or_else(|| DocumentError::UnsupportedDocumentUri(Uri::clone(uri)))?; - let unit = match document { - DocumentKind::Source => source_unit_from_source_uri(uri)?, - DocumentKind::Foreign(_) => source_unit_from_foreign_uri(uri)?, - }; - Ok((document, unit)) -} - fn file_uri_with_extension(uri: &Uri, extension: &str) -> Result { if uri.scheme() != "file" || uri.to_file_path().is_err() { return Err(DocumentError::InvalidFileUri(Uri::clone(uri))); @@ -266,8 +269,22 @@ pub(crate) fn source_unit_from_source_uri( pub(crate) fn source_unit_from_foreign_uri( foreign_uri: &Uri, + known_owner: Option<&SourceUnitKey>, ) -> Result { - let source_uri = file_uri_with_extension(foreign_uri, "purs")?; + if let Some(owner) = known_owner { + return Ok(SourceUnitKey::clone(owner)); + } + let purs_uri = file_uri_with_extension(foreign_uri, "purs")?; + let iris_uri = file_uri_with_extension(foreign_uri, "iris")?; + let purs_exists = purs_uri.to_file_path().is_ok_and(|path| path.exists()); + let iris_exists = iris_uri.to_file_path().is_ok_and(|path| path.exists()); + let source_uri = match (purs_exists, iris_exists) { + (true, true) => { + return Err(DocumentError::AmbiguousSourceDialect(Uri::clone(foreign_uri))); + } + (false, true) => iris_uri, + (true, false) | (false, false) => purs_uri, + }; source_unit_from_source_uri(&source_uri) } diff --git a/compiler-executable/iris-lsp-workspace/src/tests.rs b/compiler-executable/iris-lsp-workspace/src/tests.rs index 3f4dd418d..e14a523aa 100644 --- a/compiler-executable/iris-lsp-workspace/src/tests.rs +++ b/compiler-executable/iris-lsp-workspace/src/tests.rs @@ -43,7 +43,7 @@ use crate::preparation::{PreparationError, ProgressSink}; use crate::service::Actor; use crate::state::{ PreparedWorkspace, ReadyWorkspace, SourceMetadata, document_kind, observe_disk, - source_unit_from_document_uri, source_unit_from_foreign_uri, source_unit_from_source_uri, + source_unit_from_foreign_uri, source_unit_from_source_uri, }; const PATIENCE: Duration = Duration::from_secs(10); @@ -885,6 +885,16 @@ fn close(workspace: &mut ReadyWorkspace, uri: &Uri) { let _ = apply_document(workspace, &document_context(), notification); } +fn change_watched_files(workspace: &mut ReadyWorkspace, changes: Value) { + let notification = DocumentNotification::decode( + "workspace/didChangeWatchedFiles", + json!({"changes": changes}), + ) + .unwrap() + .unwrap(); + let _ = apply_document(workspace, &document_context(), notification); +} + fn assert_source_close_result( source_uri: Uri, foreign_uri: Uri, @@ -928,8 +938,8 @@ fn source_and_foreign_uris_produce_the_same_unit_key() { let jsx_uri = Uri::from_file_path(jsx_path).unwrap(); let from_source = source_unit_from_source_uri(&source_uri).unwrap(); - let from_foreign = source_unit_from_foreign_uri(&foreign_uri).unwrap(); - let from_jsx = source_unit_from_foreign_uri(&jsx_uri).unwrap(); + let from_foreign = source_unit_from_foreign_uri(&foreign_uri, None).unwrap(); + let from_jsx = source_unit_from_foreign_uri(&jsx_uri, None).unwrap(); assert_eq!(from_source, from_foreign); assert_eq!(from_source, from_jsx); @@ -938,6 +948,20 @@ fn source_and_foreign_uris_produce_the_same_unit_key() { assert_eq!(from_source.foreign_for(ForeignSourceKind::Jsx), jsx_uri.as_str()); } +#[test] +fn known_iris_owner_resolves_foreign_before_source_reaches_disk() { + let iris_uri = Uri::parse("file:///workspace/Main.iris").unwrap(); + let foreign_uri = Uri::parse("file:///workspace/Main.js").unwrap(); + let jsx_uri = Uri::parse("file:///workspace/Main.jsx").unwrap(); + let owner = source_unit_from_source_uri(&iris_uri).unwrap(); + + let from_foreign = source_unit_from_foreign_uri(&foreign_uri, Some(&owner)).unwrap(); + let from_jsx = source_unit_from_foreign_uri(&jsx_uri, Some(&owner)).unwrap(); + + assert_eq!(from_foreign, owner); + assert_eq!(from_jsx, owner); +} + #[test] fn localhost_source_and_foreign_uris_keep_the_same_authority() { // A drive segment keeps the URIs convertible to file paths on Windows as well. @@ -949,7 +973,7 @@ fn localhost_source_and_foreign_uris_keep_the_same_authority() { .unwrap(); let from_source = source_unit_from_source_uri(&source_uri).unwrap(); - let from_foreign = source_unit_from_foreign_uri(&foreign_uri).unwrap(); + let from_foreign = source_unit_from_foreign_uri(&foreign_uri, None).unwrap(); assert_eq!(from_source, from_foreign); assert_eq!(from_source.source(), source_uri.as_str()); @@ -976,7 +1000,7 @@ fn document_kind_is_bounded_to_source_and_foreign_extensions() { ); assert_eq!(document_kind(&jsx_uri), Some(DocumentKind::Foreign(ForeignSourceKind::Jsx))); assert_eq!(document_kind(&unsupported_uri), None); - assert!(source_unit_from_document_uri(&unsupported_uri).is_err()); + assert!(ready_workspace().document_unit(&unsupported_uri).is_err()); } #[test] @@ -1046,6 +1070,47 @@ fn duplicate_source_close_does_not_reconcile_foreign() { ); } +#[test] +fn watched_dialect_rename_transfers_foreign_ownership_before_foreign_edit() { + let directory = tempfile::tempdir().unwrap(); + let purs_path = directory.path().join("Main.purs"); + let iris_path = directory.path().join("Main.iris"); + let foreign_path = directory.path().join("Main.js"); + fs::write(&purs_path, "module Main where\n").unwrap(); + fs::write(&foreign_path, "export const life = 1;\n").unwrap(); + let purs_uri = Uri::from_file_path(&purs_path).unwrap(); + let iris_uri = Uri::from_file_path(&iris_path).unwrap(); + let foreign_uri = Uri::from_file_path(&foreign_path).unwrap(); + let purs_unit = source_unit_from_source_uri(&purs_uri).unwrap(); + let iris_unit = source_unit_from_source_uri(&iris_uri).unwrap(); + let mut workspace = ready_workspace(); + change_watched_files( + &mut workspace, + json!([ + {"uri": purs_uri, "type": 1}, + {"uri": foreign_uri, "type": 1} + ]), + ); + + fs::rename(&purs_path, &iris_path).unwrap(); + // Some clients omit the deletion, so the retained foreign owner is the only evidence of it. + change_watched_files(&mut workspace, json!([{"uri": iris_uri, "type": 1}])); + + fs::write(&foreign_path, "export const life = 2;\n").unwrap(); + change_watched_files(&mut workspace, json!([{"uri": foreign_uri, "type": 2}])); + + let files = workspace.analysis.files.read(); + assert_eq!(files.source_id(purs_uri.as_str()), None); + assert!(files.source_id(iris_uri.as_str()).is_some()); + assert_eq!(files.foreign_owner(foreign_uri.as_str()), Some(&iris_unit)); + let foreign_id = files.foreign_id(foreign_uri.as_str()).unwrap(); + assert_eq!( + workspace.analysis.engine.foreign_content(foreign_id).unwrap().as_ref(), + "export const life = 2;\n" + ); + assert_ne!(purs_unit, iris_unit); +} + #[test] fn disk_observation_distinguishes_content_and_absence() { let directory = tempfile::tempdir().unwrap(); diff --git a/compiler-executable/iris-spago/src/lib.rs b/compiler-executable/iris-spago/src/lib.rs index 4fef83071..162e4a319 100644 --- a/compiler-executable/iris-spago/src/lib.rs +++ b/compiler-executable/iris-spago/src/lib.rs @@ -19,7 +19,7 @@ pub use manifest::{ ManifestError, Package, SetAddress, TestConfig, Workspace, parse_manifest, read_manifest, }; pub use sources::{ - PURS_GLOB, SRC_DIRECTORY, TEST_DIRECTORY, package_source_directories, source_glob, + PURS_GLOB, SOURCE_GLOBS, SRC_DIRECTORY, TEST_DIRECTORY, package_source_directories, source_glob, }; /// PureScript release whose package ecosystem Iris supports. diff --git a/compiler-executable/iris-spago/src/sources.rs b/compiler-executable/iris-spago/src/sources.rs index 8b9f450e1..48baac170 100644 --- a/compiler-executable/iris-spago/src/sources.rs +++ b/compiler-executable/iris-spago/src/sources.rs @@ -19,6 +19,8 @@ pub const TEST_DIRECTORY: &str = "test"; /// File pattern matched within every source directory. pub const PURS_GLOB: &str = "**/*.purs"; +pub const IRIS_GLOB: &str = "**/*.iris"; +pub const SOURCE_GLOBS: [&str; 2] = [PURS_GLOB, IRIS_GLOB]; /// Joins the PureScript glob onto a source directory: `src` becomes `src/**/*.purs`. pub fn source_glob(directory: &Path) -> PathBuf { diff --git a/compiler-frontend/checking/src/source/terms.rs b/compiler-frontend/checking/src/source/terms.rs index 508726214..b503e5ac5 100644 --- a/compiler-frontend/checking/src/source/terms.rs +++ b/compiler-frontend/checking/src/source/terms.rs @@ -6,6 +6,7 @@ pub mod form_do; pub mod form_let; pub mod forms; pub mod guarded; +mod jsx; use std::sync::Arc; @@ -477,6 +478,12 @@ where form_ado::infer_ado(state, context, *map, *apply, *pure, statements, *expression) } + lowering::ExpressionKind::JsxElement { kind, resolution, attributes, children } => { + jsx::infer_element(state, context, kind, *resolution, attributes, children) + } + + lowering::ExpressionKind::JsxText { value } => jsx::infer_text(state, context, value), + lowering::ExpressionKind::Constructor { resolution } => { let Some((file_id, term_id)) = resolution else { return Ok(allocate_error_expression( diff --git a/compiler-frontend/checking/src/source/terms/collections.rs b/compiler-frontend/checking/src/source/terms/collections.rs index 9b7fe5726..787612e39 100644 --- a/compiler-frontend/checking/src/source/terms/collections.rs +++ b/compiler-frontend/checking/src/source/terms/collections.rs @@ -189,7 +189,7 @@ pub fn infer_record( where Q: ExternalQueries, { - let (type_id, fields, complete) = record_core(state, context, record, RecordMode::Infer)?; + let (type_id, fields, complete) = record_core(state, context, record, RecordMode::Infer, None)?; if complete { let kind = tree::ExpressionKind::Record { fields }; Ok(super::allocate_expression(state, type_id, kind)) @@ -204,6 +204,19 @@ pub fn check_record( record: &[lowering::ExpressionRecordItem], expected: TypeId, ) -> QueryResult +where + Q: ExternalQueries, +{ + check_record_with_field(state, context, record, expected, None) +} + +pub(super) fn check_record_with_field( + state: &mut CheckState, + context: &CheckContext, + record: &[lowering::ExpressionRecordItem], + expected: TypeId, + additional: Option<(&str, ElaboratedExpression)>, +) -> QueryResult where Q: ExternalQueries, { @@ -219,6 +232,7 @@ where context, record, RecordMode::Check { expected_fields: &expected_fields.fields }, + additional, )?; unification::subtype(state, context, record_type, expected)?; if complete { @@ -231,9 +245,14 @@ where } } - let inferred = infer_record(state, context, record)?; - unification::subtype(state, context, inferred.type_id, expected)?; - Ok(inferred) + let (type_id, fields, complete) = + record_core(state, context, record, RecordMode::Infer, additional)?; + unification::subtype(state, context, type_id, expected)?; + if complete { + Ok(super::allocate_expression(state, type_id, tree::ExpressionKind::Record { fields })) + } else { + Ok(super::allocate_error_expression(state, type_id)) + } } fn find_expected_field(expected_fields: &[RowField], label: &SmolStr) -> Option { @@ -314,6 +333,7 @@ fn record_core( context: &CheckContext, record: &[lowering::ExpressionRecordItem], mode: RecordMode<'_>, + additional: Option<(&str, ElaboratedExpression)>, ) -> QueryResult<(TypeId, Arc<[tree::RecordExpressionField]>, bool)> where Q: ExternalQueries, @@ -346,6 +366,18 @@ where checked_fields.push(field.checked); } + if let Some((label, value)) = additional { + let label = SmolStr::from(label); + let value = if let Some(expected) = expected_record_field(mode, &label) { + super::check_elaborated_expression(state, context, value, expected)? + } else { + value + }; + fields.push(RowField { label: label.clone(), id: value.type_id }); + checked_fields + .push(tree::RecordExpressionField::Field { label, expression: value.expression }); + } + let row_type = context.intern_row(fields, None); let type_id = context.intern_application(context.prim.record, row_type); Ok((type_id, checked_fields.into(), complete)) diff --git a/compiler-frontend/checking/src/source/terms/jsx.rs b/compiler-frontend/checking/src/source/terms/jsx.rs new file mode 100644 index 000000000..4ef9979a8 --- /dev/null +++ b/compiler-frontend/checking/src/source/terms/jsx.rs @@ -0,0 +1,165 @@ +use building_types::QueryResult; +use lowering::{ + ExpressionId, ExpressionRecordItem, JsxElementKind, StringLiteral, TermVariableResolution, +}; + +use super::{ + ElaboratedExpression, allocate_error_expression, allocate_expression, application, collections, +}; +use crate::context::CheckContext; +use crate::core::{toolkit, unification}; +use crate::state::CheckState; +use crate::{ExternalQueries, tree}; + +enum Argument<'a> { + String(StringLiteral), + Record(&'a [ExpressionRecordItem], &'a [ExpressionId]), + Array(&'a [ExpressionId]), + Source(ExpressionId), + Value(ElaboratedExpression), +} + +pub(super) fn infer_element( + state: &mut CheckState, + context: &CheckContext, + kind: &JsxElementKind, + resolution: Option, + attributes: &[ExpressionRecordItem], + children: &[ExpressionId], +) -> QueryResult { + if matches!(kind, JsxElementKind::Fragment) { + return infer_application(state, context, "fragment", [Argument::Array(children)]); + } + let mut props = Vec::new(); + let mut key = None; + for attribute in attributes { + if let ExpressionRecordItem::RecordField { name: Some(name), value } = attribute + && name == "key" + { + key = *value; + } else { + props.push(attribute.clone()); + } + } + let mut arguments = Vec::new(); + if let Some(key) = key { + arguments.push(Argument::Source(key)); + } + let function = match kind { + JsxElementKind::Intrinsic(tag) => { + arguments.push(Argument::String(tag.clone().into())); + if key.is_some() { "intrinsicKeyed" } else { "intrinsic" } + } + JsxElementKind::Component => { + let Some(resolution) = resolution else { + return Ok(allocate_error_expression( + state, + context.unknown("missing JSX component"), + )); + }; + let type_id = toolkit::lookup_term_variable(state, context, resolution)?; + let resolution = tree::VariableResolution::Source(resolution); + let component = + allocate_expression(state, type_id, tree::ExpressionKind::Variable { resolution }); + arguments.push(Argument::Value(component)); + if key.is_some() { "elementKeyed" } else { "element" } + } + JsxElementKind::Fragment => unreachable!(), + }; + arguments.push(Argument::Record(&props, children)); + infer_application(state, context, function, arguments) +} + +pub(super) fn infer_text( + state: &mut CheckState, + context: &CheckContext, + value: &StringLiteral, +) -> QueryResult { + infer_application(state, context, "text", [Argument::String(value.clone())]) +} + +fn react_function( + state: &mut CheckState, + context: &CheckContext, + name: &str, +) -> QueryResult { + let file_id = context.queries.module_file("Iris.React").expect("missing built-in Iris.React"); + let indexed = context.queries.indexed(file_id)?; + let (term_id, _) = indexed + .items + .iter_terms() + .find(|(_, item)| item.name.as_deref() == Some(name)) + .expect("missing built-in React function"); + let type_id = toolkit::lookup_file_term(state, context, file_id, term_id)?; + let resolution = TermVariableResolution::Reference(file_id, term_id); + let resolution = tree::VariableResolution::Source(resolution); + Ok(allocate_expression(state, type_id, tree::ExpressionKind::Variable { resolution })) +} + +fn infer_application<'a, Q: ExternalQueries>( + state: &mut CheckState, + context: &CheckContext, + name: &str, + arguments: impl IntoIterator>, +) -> QueryResult { + let mut function = react_function(state, context, name)?; + + // Check generated records against each parameter rather than inferring them + // first: a component may require a rank-polymorphic property. Materializing + // the normal application also retains evidence for constrained components. + for value in arguments { + let Some(application::UnanchoredApplication { implicit, argument, result }) = + application::check_unanchored_application(state, context, function.type_id)? + else { + let argument = state.fresh_unification(context.queries, context.prim.t); + let result = state.fresh_unification(context.queries, context.prim.t); + let expected = context.intern_function(argument, result); + unification::unify(state, context, function.type_id, expected)?; + return Ok(allocate_error_expression(state, result)); + }; + let value = super::check_expected_expression( + state, + context, + argument, + |state, argument| match value { + Argument::String(value) => { + let kind = + tree::ExpressionKind::String { kind: lowering::StringKind::String, value }; + let value = allocate_expression(state, context.prim.string, kind); + application::subtype_expression(state, context, value, argument) + } + Argument::Record(record, children) => { + let children = match children { + [] => None, + [child] => { + let jsx = react_function(state, context, "empty")?.type_id; + Some(super::check_expression(state, context, *child, jsx)?) + } + _ => Some(infer_application( + state, + context, + "array", + [Argument::Array(children)], + )?), + }; + collections::check_record_with_field( + state, + context, + record, + argument, + children.map(|children| ("children", children)), + ) + } + Argument::Array(array) => collections::check_array(state, context, array, argument), + Argument::Source(source) => { + super::check_expression(state, context, source, argument) + } + Argument::Value(value) => { + super::check_elaborated_expression(state, context, value, argument) + } + }, + )?; + function = application::materialize_application(state, function, implicit, result, value); + } + Ok(function) +} diff --git a/compiler-frontend/lexing/src/layout.rs b/compiler-frontend/lexing/src/layout.rs index 9d595ba7f..b6b5309bf 100644 --- a/compiler-frontend/lexing/src/layout.rs +++ b/compiler-frontend/lexing/src/layout.rs @@ -27,6 +27,8 @@ enum Delimiter { Of, Do, Ado, + Jsx, + JsxExpression, } impl Delimiter { @@ -110,6 +112,50 @@ impl<'l, 's> Insert<'l, 's> { fn invoke(&mut self) { match self.token { + SyntaxKind::JSX_OPEN => { + self.insert_default(); + self.push_stack(self.position, Delimiter::Jsx); + } + + SyntaxKind::JSX_CLOSE_OPEN + | SyntaxKind::JSX_TAG_END + | SyntaxKind::JSX_NAME + | SyntaxKind::JSX_TEXT + | SyntaxKind::JSX_EXPRESSION_START => { + self.insert_token(self.token); + if self.token == SyntaxKind::JSX_EXPRESSION_START { + self.push_stack(self.position, Delimiter::JsxExpression); + } + } + + SyntaxKind::JSX_SELF_CLOSE | SyntaxKind::JSX_CLOSE_END => { + self.insert_token(self.token); + self.pop_stack_if(|delimiter| delimiter == Delimiter::Jsx); + } + + SyntaxKind::JSX_EXPRESSION_END => { + while let Some((_, delimiter)) = self.layout.stack.last().copied() { + if delimiter == Delimiter::JsxExpression { + self.pop_stack(); + break; + } + if delimiter == Delimiter::Jsx { + break; + } + self.pop_stack(); + if delimiter.is_indented() { + self.insert_end(); + } + } + self.insert_token(self.token); + } + + SyntaxKind::EQUAL | SyntaxKind::STRING | SyntaxKind::RAW_STRING + if self.in_jsx_markup() => + { + self.insert_token(self.token); + } + SyntaxKind::DATA if !self.qualified => { self.insert_default(); if self.is_top_declaration(self.position) { @@ -416,6 +462,19 @@ impl<'l, 's> Insert<'l, 's> { ) } + fn in_jsx_markup(&self) -> bool { + self.layout + .stack + .iter() + .rev() + .find_map(|(_, delimiter)| match delimiter { + Delimiter::Jsx => Some(true), + Delimiter::JsxExpression => Some(false), + _ => None, + }) + .unwrap_or(false) + } + fn insert_default(&mut self) { self.collapse_and_commit(Self::offside_p); self.insert_sep(); @@ -423,8 +482,13 @@ impl<'l, 's> Insert<'l, 's> { } fn insert_start(&mut self, delimiter: Delimiter) { - if let Some((past_position, _)) = - self.layout.stack.iter().rfind(|(_, delimiter)| delimiter.is_indented()) + if let Some((past_position, _)) = self + .layout + .stack + .iter() + .rev() + .take_while(|(_, delimiter)| *delimiter != Delimiter::JsxExpression) + .find(|(_, delimiter)| delimiter.is_indented()) && self.next.column <= past_position.column { return; diff --git a/compiler-frontend/lexing/src/lexed.rs b/compiler-frontend/lexing/src/lexed.rs index 68efd7b13..14755ad8a 100644 --- a/compiler-frontend/lexing/src/lexed.rs +++ b/compiler-frontend/lexing/src/lexed.rs @@ -95,6 +95,14 @@ impl<'s> LexedBuilder<'s> { } } + pub(super) fn len(&self) -> usize { + self.kinds.len() + } + + pub(super) fn last_kind(&self) -> SyntaxKind { + self.kinds.last().copied().unwrap() + } + pub(super) fn build(self) -> Lexed<'s> { Lexed { source: self.source, kinds: self.kinds, infos: self.infos, errors: self.errors } } diff --git a/compiler-frontend/lexing/src/lexer.rs b/compiler-frontend/lexing/src/lexer.rs index 14fb18a37..8a163e3f2 100644 --- a/compiler-frontend/lexing/src/lexer.rs +++ b/compiler-frontend/lexing/src/lexer.rs @@ -78,10 +78,20 @@ pub(super) struct Lexer<'s> { qualifier_position: Position, current_position: Position, lexed: LexedBuilder<'s>, + modes: Vec, + operand_start: bool, + jsx: bool, +} + +#[derive(Debug, Clone, Copy)] +enum Mode { + PureScript { hole_depth: Option }, + Tag { closing: bool, expects_name: bool }, + Children, } impl<'s> Lexer<'s> { - pub(super) fn new(source: &'s str) -> Lexer<'s> { + pub(super) fn new(source: &'s str, jsx: bool) -> Lexer<'s> { let chars = source.chars(); let annotation = 0; let qualifier = 0; @@ -100,6 +110,9 @@ impl<'s> Lexer<'s> { lexed, qualifier_position, current_position, + modes: vec![Mode::PureScript { hole_depth: None }], + operand_start: true, + jsx, }; lexer.take_annotation(); lexer.take_qualifier(); @@ -122,13 +135,173 @@ impl<'s> Lexer<'s> { } pub(super) fn take_token(&mut self) { - self.take_token_impl(); - self.take_annotation(); - self.take_qualifier(); + match self.modes.last().copied().unwrap() { + Mode::PureScript { .. } => self.take_pure_script_token(), + Mode::Tag { .. } => self.take_tag_token(), + Mode::Children => self.take_children_token(), + } + match self.modes.last().copied().unwrap() { + Mode::PureScript { .. } => { + self.take_annotation(); + self.take_qualifier(); + } + Mode::Tag { .. } => { + // A brace starts a hole here, even in value={-1}; it cannot + // introduce a PureScript block comment until inside the hole. + self.take_annotation_whitespace(); + self.annotation = self.consumed() as u32; + self.qualifier = self.annotation; + self.qualifier_position = self.current_position; + } + Mode::Children => { + self.annotation = self.consumed() as u32; + self.qualifier = self.annotation; + self.qualifier_position = self.current_position; + } + } } } impl Lexer<'_> { + fn starts_jsx(&self) -> bool { + self.jsx + && self.first() == '<' + && self.operand_start + && self.annotation == self.qualifier + && (self.second() == '>' || jsx_name_start(self.second())) + } + + fn take_pure_script_token(&mut self) { + if self.starts_jsx() { + self.take_jsx_open(); + return; + } + + if let Some(Mode::PureScript { hole_depth: Some(depth) }) = self.modes.last().copied() { + match (self.first(), depth) { + ('}', 0) => { + self.take_kind(SyntaxKind::JSX_EXPRESSION_END); + self.modes.pop(); + self.operand_start = false; + return; + } + ('{', _) => { + self.take_kind(SyntaxKind::LEFT_CURLY); + *self.modes.last_mut().unwrap() = + Mode::PureScript { hole_depth: Some(depth + 1) }; + self.operand_start = true; + return; + } + ('}', _) => { + self.take_kind(SyntaxKind::RIGHT_CURLY); + *self.modes.last_mut().unwrap() = + Mode::PureScript { hole_depth: Some(depth - 1) }; + self.operand_start = false; + return; + } + _ => {} + } + } + + let before = self.lexed.len(); + let property = before > 0 && self.lexed.last_kind() == SyntaxKind::PERIOD; + let qualified = self.annotation < self.qualifier; + self.take_token_impl(); + if self.lexed.len() > before { + let kind = self.lexed.last_kind(); + let qualified_prefix = matches!(kind, SyntaxKind::DO | SyntaxKind::ADO) + || syntax::names::OPERATOR.contains(kind); + self.operand_start = + !property && (!qualified || qualified_prefix) && token_permits_operand(kind); + } + } + + fn take_jsx_open(&mut self) { + self.take(); + let closing = if self.first() == '/' { + self.take(); + true + } else { + false + }; + self.push(if closing { SyntaxKind::JSX_CLOSE_OPEN } else { SyntaxKind::JSX_OPEN }, None); + self.modes.push(Mode::Tag { closing, expects_name: true }); + self.operand_start = true; + } + + fn take_tag_token(&mut self) { + let Mode::Tag { closing, expects_name } = self.modes.last().copied().unwrap() else { + unreachable!() + }; + match (self.first(), self.second()) { + ('/', '>') if !closing => { + self.take(); + self.take(); + self.push(SyntaxKind::JSX_SELF_CLOSE, None); + self.modes.pop(); + self.operand_start = false; + } + ('>', _) => { + self.take(); + self.push( + if closing { SyntaxKind::JSX_CLOSE_END } else { SyntaxKind::JSX_TAG_END }, + None, + ); + self.modes.pop(); + if closing { + if matches!(self.modes.last(), Some(Mode::Children)) { + self.modes.pop(); + } + self.operand_start = false; + } else { + self.modes.push(Mode::Children); + } + } + ('=', _) if !expects_name => self.take_kind(SyntaxKind::EQUAL), + ('"', _) if !expects_name => self.take_string(), + ('{', _) if !expects_name && !closing => { + self.take_kind(SyntaxKind::JSX_EXPRESSION_START); + self.modes.push(Mode::PureScript { hole_depth: Some(0) }); + self.operand_start = true; + } + (character, _) if jsx_name_start(character) => { + let start = self.consumed(); + self.take(); + self.take_while(jsx_name_continue); + let name = &self.source[start..self.consumed()]; + let valid = + name.split('.').rev().skip(1).all(|component| { + component.chars().next().is_some_and(char::is_upper_start) + }); + self.push( + SyntaxKind::JSX_NAME, + (!valid).then_some("JSX name qualifiers must start with an uppercase letter"), + ); + *self.modes.last_mut().unwrap() = Mode::Tag { closing, expects_name: false }; + } + _ => { + self.take(); + self.push(SyntaxKind::JSX_INVALID, Some("Invalid token in JSX tag")); + } + } + } + + fn take_children_token(&mut self) { + match (self.first(), self.second()) { + ('<', _) => self.take_jsx_open(), + ('{', _) => { + self.take_kind(SyntaxKind::JSX_EXPRESSION_START); + self.modes.push(Mode::PureScript { hole_depth: Some(0) }); + self.operand_start = true; + } + _ => { + self.take(); + self.take_while(|character| character != '<' && character != '{'); + self.push(SyntaxKind::JSX_TEXT, None); + } + } + } + fn first(&self) -> char { let mut chars = self.chars.clone(); chars.next().unwrap_or(EOF_CHAR) @@ -324,6 +497,20 @@ impl Lexer<'_> { position: lp_position, }; + // Complete operator names win over fragments: (<>) remains an operator, + // while (<>) starts a parenthesized JSX expression. + let operator_name = + self.chars.clone().skip_while(|character| character.is_operator()).next() == Some(')'); + if self.jsx + && self.first() == '<' + && (self.second() == '>' || jsx_name_start(self.second())) + && !operator_name + { + self.lexed.push(SyntaxKind::LEFT_PARENTHESIS, lp_info, None); + self.operand_start = true; + return; + } + if !self.first().is_operator() { return self.lexed.push(SyntaxKind::LEFT_PARENTHESIS, lp_info, None); } @@ -526,3 +713,41 @@ impl Lexer<'_> { self.push(kind, error); } } + +fn jsx_name_start(character: char) -> bool { + character.is_lower_start() || character.is_upper_start() +} + +fn jsx_name_continue(character: char) -> bool { + character.is_name() || matches!(character, '-' | '.') +} + +fn token_permits_operand(kind: SyntaxKind) -> bool { + matches!( + kind, + SyntaxKind::LEFT_PARENTHESIS + | SyntaxKind::LEFT_CURLY + | SyntaxKind::LEFT_SQUARE + | SyntaxKind::BACKSLASH + | SyntaxKind::TICK + | SyntaxKind::COMMA + | SyntaxKind::EQUAL + | SyntaxKind::PIPE + | SyntaxKind::RIGHT_ARROW + | SyntaxKind::LEFT_ARROW + | SyntaxKind::THEN + | SyntaxKind::ELSE + | SyntaxKind::IN + | SyntaxKind::OF + | SyntaxKind::CASE + | SyntaxKind::IF + | SyntaxKind::LET + | SyntaxKind::DO + | SyntaxKind::ADO + | SyntaxKind::OPERATOR + | SyntaxKind::MINUS + | SyntaxKind::COLON + | SyntaxKind::DOUBLE_PERIOD + | SyntaxKind::LEFT_THICK_ARROW + ) +} diff --git a/compiler-frontend/lexing/src/lib.rs b/compiler-frontend/lexing/src/lib.rs index 34eaf4cba..686ff59bb 100644 --- a/compiler-frontend/lexing/src/lib.rs +++ b/compiler-frontend/lexing/src/lib.rs @@ -14,7 +14,11 @@ pub struct Position { } pub fn lex(source: &str) -> Lexed<'_> { - let mut lexer = lexer::Lexer::new(source); + lex_with_jsx(source, false) +} + +pub fn lex_with_jsx(source: &str, jsx: bool) -> Lexed<'_> { + let mut lexer = lexer::Lexer::new(source, jsx); while !lexer.is_eof() { lexer.take_token(); } diff --git a/compiler-frontend/lexing/tests/lexer.rs b/compiler-frontend/lexing/tests/lexer.rs index 7aa71611e..a770d9505 100644 --- a/compiler-frontend/lexing/tests/lexer.rs +++ b/compiler-frontend/lexing/tests/lexer.rs @@ -1,5 +1,19 @@ use test_each_file::test_each_file; +#[test] +fn jsx_requires_opt_in() { + use syntax::SyntaxKind; + + for source in ["
", "(<>
)"] { + let pure_script = lexing::lex(source); + let iris = lexing::lex_with_jsx(source, true); + assert!( + (0..pure_script.len()).all(|index| pure_script.kind(index) != SyntaxKind::JSX_OPEN) + ); + assert!((0..iris.len()).any(|index| iris.kind(index) == SyntaxKind::JSX_OPEN)); + } +} + test_each_file! { in "./compiler-frontend/lexing/tests/lexer" => |content: &str| { use std::fmt::Write; diff --git a/compiler-frontend/lowering/src/algorithm.rs b/compiler-frontend/lowering/src/algorithm.rs index ae02758ef..69ef0d1bf 100644 --- a/compiler-frontend/lowering/src/algorithm.rs +++ b/compiler-frontend/lowering/src/algorithm.rs @@ -1,3 +1,4 @@ +mod jsx; mod recursive; use std::mem; diff --git a/compiler-frontend/lowering/src/algorithm/jsx.rs b/compiler-frontend/lowering/src/algorithm/jsx.rs new file mode 100644 index 000000000..9bf63f504 --- /dev/null +++ b/compiler-frontend/lowering/src/algorithm/jsx.rs @@ -0,0 +1,135 @@ +use rustc_hash::FxHashSet; +use smol_str::SmolStr; +use stabilizing::ExpectId; +use syntax::cst; + +use super::{Context, State, recursive}; +use crate::{ + ExpressionId, ExpressionKind, ExpressionRecordItem, JsxElementKind, LoweringError, NotInScope, + StringLiteral, TermVariableResolution, +}; + +pub(super) fn lower_element( + state: &mut State, + context: &Context, + element: &cst::ExpressionJsxElement, +) -> ExpressionKind { + let expression = cst::Expression::ExpressionJsxElement(element.clone()); + let id = context.stabilized.lookup_cst(&expression).expect_id(); + let opening = element.opening(); + let name = opening.as_ref().and_then(|opening| opening.name_token()); + let name = name.as_ref().map(|name| name.text(context.source)); + + if let Some(closing) = element.closing() { + let closing_name = closing.name_token(); + let closing_name = closing_name.as_ref().map(|name| name.text(context.source)); + if name != closing_name { + state.errors.push(LoweringError::JsxTagMismatch { + id: context.stabilized.lookup_cst(&closing).expect_id(), + expected: name.map(SmolStr::from), + }); + } + } + + let (kind, resolution) = match name { + None => (JsxElementKind::Fragment, None), + Some(name) if name.contains('.') || name.starts_with(char::is_uppercase) => { + let (qualifier, name) = name + .rsplit_once('.') + .map_or((None, name), |(qualifier, name)| (Some(qualifier), name)); + let mut characters = name.chars(); + let name = characters + .next() + .into_iter() + .flat_map(char::to_lowercase) + .chain(characters) + .collect::(); + let resolution = resolve_function(state, context, id, qualifier, &name); + (JsxElementKind::Component, resolution) + } + Some(name) => (JsxElementKind::Intrinsic(name.into()), None), + }; + + let mut names = FxHashSet::default(); + let has_children = element.children().is_some_and(|children| { + children.children().any(|child| match child { + cst::Expression::ExpressionJsxText(text) => { + !text_value(context, &text).as_utf16().is_empty() + } + _ => true, + }) + }); + if has_children { + names.insert(SmolStr::from("children")); + } + let attributes = opening.iter().flat_map(cst::JsxOpening::attributes).map(|attribute| { + let name = attribute.name_token().map(|name| SmolStr::from(name.text(context.source))); + if let Some(name) = &name + && !names.insert(name.clone()) + { + state.errors.push(LoweringError::DuplicateJsxAttribute { + id: context.stabilized.lookup_cst(&attribute).expect_id(), + name: name.clone(), + }); + } + let value = + attribute.expression().map(|value| recursive::lower_expression(state, context, &value)); + ExpressionRecordItem::RecordField { name, value } + }); + let attributes = attributes.collect(); + + let children = element.children(); + let children = children.iter().flat_map(cst::JsxChildren::children).filter_map(|child| { + if let cst::Expression::ExpressionJsxText(text) = &child + && text_value(context, text).as_utf16().is_empty() + { + return None; + } + Some(recursive::lower_expression(state, context, &child)) + }); + let children = children.collect(); + + ExpressionKind::JsxElement { kind, resolution, attributes, children } +} + +pub(super) fn lower_text(context: &Context, text: &cst::ExpressionJsxText) -> ExpressionKind { + let value = text_value(context, text); + ExpressionKind::JsxText { value } +} + +fn resolve_function( + state: &mut State, + context: &Context, + id: ExpressionId, + qualifier: Option<&str>, + name: &str, +) -> Option { + let resolution = state.resolve_term_full(context, qualifier, name); + if resolution.is_none() { + let name = + qualifier.map_or_else(|| name.into(), |qualifier| format!("{qualifier}.{name}").into()); + state.errors.push(LoweringError::NotInScope(NotInScope::JsxFunction { id, name })); + } + resolution +} + +fn text_value(context: &Context, text: &cst::ExpressionJsxText) -> StringLiteral { + let Some(token) = text.text_token() else { return StringLiteral::from("") }; + let text = token.text(context.source); + let mut result = String::new(); + let mut lines = text.split('\n').peekable(); + let mut first = true; + while let Some(line) = lines.next() { + let line = line.trim_end_matches('\r'); + let line = if first { line } else { line.trim_start_matches([' ', '\t']) }; + let line = if lines.peek().is_none() { line } else { line.trim_end_matches([' ', '\t']) }; + if !line.is_empty() { + if !result.is_empty() { + result.push(' '); + } + result.push_str(line); + } + first = false; + } + StringLiteral::from(result) +} diff --git a/compiler-frontend/lowering/src/algorithm/recursive.rs b/compiler-frontend/lowering/src/algorithm/recursive.rs index dcd036cee..6c2dab5c6 100644 --- a/compiler-frontend/lowering/src/algorithm/recursive.rs +++ b/compiler-frontend/lowering/src/algorithm/recursive.rs @@ -605,6 +605,14 @@ fn lower_expression_kind( let updates = recover! { lower_record_updates(state, context, &cst.record_updates()?) }; ExpressionKind::RecordUpdate { record, updates } } + cst::Expression::ExpressionJsxElement(cst) => { + super::jsx::lower_element(state, context, cst) + } + cst::Expression::ExpressionJsxText(cst) => super::jsx::lower_text(context, cst), + cst::Expression::ExpressionJsxInterpolation(cst) => { + let parenthesized = cst.expression().map(|cst| lower_expression(state, context, &cst)); + ExpressionKind::Parenthesized { parenthesized } + } } } diff --git a/compiler-frontend/lowering/src/error.rs b/compiler-frontend/lowering/src/error.rs index f634cd0ac..341dd102e 100644 --- a/compiler-frontend/lowering/src/error.rs +++ b/compiler-frontend/lowering/src/error.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use indexing::TypeItemId; +use smol_str::SmolStr; use stabilizing::AstId; use syntax::cst; @@ -8,6 +9,8 @@ use syntax::cst; pub enum LoweringError { NotInScope(NotInScope), InvalidStringEscape { source: StringLiteralSource }, + JsxTagMismatch { id: AstId, expected: Option }, + DuplicateJsxAttribute { id: AstId, name: SmolStr }, RecursiveSynonym(RecursiveGroup), RecursiveKinds(RecursiveGroup), } @@ -33,6 +36,7 @@ pub enum NotInScope { NegateFn { id: AstId }, TermOperator { id: AstId }, TypeOperator { id: AstId }, + JsxFunction { id: crate::ExpressionId, name: SmolStr }, } #[derive(Debug, PartialEq, Eq)] diff --git a/compiler-frontend/lowering/src/tree.rs b/compiler-frontend/lowering/src/tree.rs index 31bf12b69..7dcd87b9d 100644 --- a/compiler-frontend/lowering/src/tree.rs +++ b/compiler-frontend/lowering/src/tree.rs @@ -70,7 +70,7 @@ pub enum DoStatement { Discard { expression: Option }, } -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum ExpressionRecordItem { RecordField { name: Option, value: Option }, RecordPun { id: RecordPunId, name: Option, resolution: Option }, @@ -82,6 +82,13 @@ pub struct RecordAccessLabel { pub name: SmolStr, } +#[derive(Debug, PartialEq, Eq)] +pub enum JsxElementKind { + Intrinsic(SmolStr), + Component, + Fragment, +} + #[derive(Debug, PartialEq, Eq)] pub enum ExpressionKind { Typed { @@ -179,6 +186,15 @@ pub enum ExpressionKind { record: Option, updates: Arc<[RecordUpdate]>, }, + JsxElement { + kind: JsxElementKind, + resolution: Option, + attributes: Arc<[ExpressionRecordItem]>, + children: Arc<[ExpressionId]>, + }, + JsxText { + value: StringLiteral, + }, } #[derive(Debug, PartialEq, Eq)] diff --git a/compiler-frontend/parsing/src/parser/expressions.rs b/compiler-frontend/parsing/src/parser/expressions.rs index db8fd6a0b..d7248d7a6 100644 --- a/compiler-frontend/parsing/src/parser/expressions.rs +++ b/compiler-frontend/parsing/src/parser/expressions.rs @@ -452,11 +452,125 @@ fn expression_atom(p: &mut Parser) { expression(p); p.expect(SyntaxKind::RIGHT_PARENTHESIS); m.end(p, SyntaxKind::ExpressionParenthesized); + } else if p.at(SyntaxKind::JSX_OPEN) { + expression_jsx_element(p, m); } else { m.cancel(p); } } +fn expression_jsx_element(p: &mut Parser, mut m: NodeMarker) { + let (self_closing, opening_has_name) = jsx_opening(p); + if !self_closing { + jsx_children(p); + jsx_closing(p, opening_has_name); + } + m.end(p, SyntaxKind::ExpressionJsxElement); +} + +fn jsx_opening(p: &mut Parser) -> (bool, bool) { + let mut m = p.start(); + p.expect(SyntaxKind::JSX_OPEN); + + let has_name = p.eat(SyntaxKind::JSX_NAME); + let is_fragment = !has_name && p.at(SyntaxKind::JSX_TAG_END); + if !has_name && !is_fragment { + p.error("Expected JSX element name"); + } + + while p.at(SyntaxKind::JSX_NAME) { + if is_fragment { + p.error("JSX fragments cannot have attributes"); + } + jsx_attribute(p); + } + + while !p.at(SyntaxKind::JSX_TAG_END) + && !p.at(SyntaxKind::JSX_SELF_CLOSE) + && !p.at(SyntaxKind::JSX_CLOSE_OPEN) + && !p.at_eof() + { + p.error_recover("Unexpected token in JSX opening tag"); + } + + let self_closing = p.eat(SyntaxKind::JSX_SELF_CLOSE); + if self_closing && !has_name { + p.error("Self-closing JSX elements require a name"); + } else if !self_closing { + p.expect(SyntaxKind::JSX_TAG_END); + } + m.end(p, SyntaxKind::JsxOpening); + (self_closing, has_name) +} + +fn jsx_attribute(p: &mut Parser) { + let mut m = p.start(); + p.expect(SyntaxKind::JSX_NAME); + p.expect(SyntaxKind::EQUAL); + if p.at(SyntaxKind::STRING) || p.at(SyntaxKind::RAW_STRING) { + let mut value = p.start(); + p.consume(); + value.end(p, SyntaxKind::ExpressionString); + } else if p.at(SyntaxKind::JSX_EXPRESSION_START) { + expression_jsx_interpolation(p); + } else { + p.error("Expected JSX attribute value"); + } + m.end(p, SyntaxKind::JsxAttribute); +} + +fn jsx_children(p: &mut Parser) { + let mut m = p.start(); + while !p.at(SyntaxKind::JSX_CLOSE_OPEN) && !p.at_eof() { + if p.at(SyntaxKind::JSX_TEXT) { + let mut child = p.start(); + p.consume(); + child.end(p, SyntaxKind::ExpressionJsxText); + } else if p.at(SyntaxKind::JSX_EXPRESSION_START) { + expression_jsx_interpolation(p); + } else if p.at(SyntaxKind::JSX_OPEN) { + let child = p.start(); + expression_jsx_element(p, child); + } else { + p.error_recover("Unexpected token in JSX children"); + } + } + m.end(p, SyntaxKind::JsxChildren); +} + +fn expression_jsx_interpolation(p: &mut Parser) { + let mut m = p.start(); + p.expect(SyntaxKind::JSX_EXPRESSION_START); + if p.at(SyntaxKind::JSX_EXPRESSION_END) { + p.error("JSX interpolation cannot be empty"); + } else if p.at_in(EXPRESSION_START) { + expression(p); + } else { + p.error("Expected expression in JSX interpolation"); + while !p.at(SyntaxKind::JSX_EXPRESSION_END) && !p.at_eof() { + p.error_recover("Unexpected token in JSX interpolation"); + } + } + p.expect(SyntaxKind::JSX_EXPRESSION_END); + m.end(p, SyntaxKind::ExpressionJsxInterpolation); +} + +fn jsx_closing(p: &mut Parser, opening_has_name: bool) { + let mut m = p.start(); + p.expect(SyntaxKind::JSX_CLOSE_OPEN); + let closing_has_name = p.eat(SyntaxKind::JSX_NAME); + if opening_has_name && !closing_has_name { + p.error("Expected JSX closing element name"); + } else if !opening_has_name && closing_has_name { + p.error("JSX fragment closing tag cannot have a name"); + } + while !p.at(SyntaxKind::JSX_CLOSE_END) && !p.at_eof() { + p.error_recover("Unexpected token in JSX closing tag"); + } + p.expect(SyntaxKind::JSX_CLOSE_END); + m.end(p, SyntaxKind::JsxClosing); +} + fn expression_array(p: &mut Parser, mut m: NodeMarker) { while !p.at(SyntaxKind::RIGHT_SQUARE) && !p.at_eof() { if p.at_in(EXPRESSION_START) { @@ -511,6 +625,7 @@ const EXPRESSION_ATOM_START: TokenSet = TokenSet::new(&[ SyntaxKind::LEFT_SQUARE, SyntaxKind::LEFT_CURLY, SyntaxKind::LEFT_PARENTHESIS, + SyntaxKind::JSX_OPEN, ]) .union(names::LOWER) .union(names::OPERATOR_NAME); @@ -528,5 +643,10 @@ const ARGUMENT_START: TokenSet = TokenSet::new(&[ pub(super) const EXPRESSION_START: TokenSet = ARGUMENT_START.union(TokenSet::new(&[SyntaxKind::MINUS])); -const EXPRESSION_ATOM_RECOVERY: TokenSet = - TokenSet::new(&[SyntaxKind::LAYOUT_SEPARATOR, SyntaxKind::LAYOUT_END]); +const EXPRESSION_ATOM_RECOVERY: TokenSet = TokenSet::new(&[ + SyntaxKind::LAYOUT_SEPARATOR, + SyntaxKind::LAYOUT_END, + SyntaxKind::JSX_CLOSE_OPEN, + SyntaxKind::JSX_EXPRESSION_END, + SyntaxKind::END_OF_FILE, +]); diff --git a/compiler-frontend/syntax/src/cst.rs b/compiler-frontend/syntax/src/cst.rs index 53d8d6250..d5e3ee7f1 100644 --- a/compiler-frontend/syntax/src/cst.rs +++ b/compiler-frontend/syntax/src/cst.rs @@ -186,6 +186,9 @@ create_cst_enum!( | ExpressionParenthesized | ExpressionRecordAccess | ExpressionRecordUpdate + | ExpressionJsxElement + | ExpressionJsxText + | ExpressionJsxInterpolation ); create_cst_struct!( @@ -218,6 +221,8 @@ create_cst_struct!(RecordAccessLabel, RecordUpdates); create_cst_enum!(RecordUpdate | RecordUpdateLeaf | RecordUpdateBranch); +create_cst_struct!(JsxOpening, JsxChildren, JsxClosing, JsxAttribute); + create_cst_struct!(TermOperator, TypeOperator); has_child!( @@ -951,6 +956,53 @@ has_child!( | record_updates() -> RecordUpdates ); +has_child!( + ExpressionJsxElement + | opening() -> JsxOpening + | children() -> JsxChildren + | closing() -> JsxClosing +); + +has_token!( + JsxOpening + | name_token() -> JSX_NAME +); + +has_children!( + JsxOpening + | attributes() -> JsxAttribute +); + +has_token!( + JsxClosing + | name_token() -> JSX_NAME +); + +has_token!( + JsxAttribute + | name_token() -> JSX_NAME +); + +has_child!( + JsxAttribute + | expression() -> Expression +); + +has_children!( + JsxChildren + | children() -> Expression +); + +has_token!( + ExpressionJsxText + | text_token() -> JSX_TEXT +); + +has_child!( + ExpressionJsxInterpolation + | expression() -> Expression +); + has_children!( RecordUpdates | children() -> RecordUpdate diff --git a/compiler-frontend/syntax/src/lib.rs b/compiler-frontend/syntax/src/lib.rs index d4c6f5c66..2a0f3d5fb 100644 --- a/compiler-frontend/syntax/src/lib.rs +++ b/compiler-frontend/syntax/src/lib.rs @@ -92,6 +92,18 @@ pub enum SyntaxKind { LAYOUT_END, END_OF_FILE, + // JSX + JSX_OPEN, + JSX_CLOSE_OPEN, + JSX_TAG_END, + JSX_CLOSE_END, + JSX_SELF_CLOSE, + JSX_NAME, + JSX_TEXT, + JSX_EXPRESSION_START, + JSX_EXPRESSION_END, + JSX_INVALID, + // Special ERROR, @@ -217,6 +229,14 @@ pub enum SyntaxKind { ExpressionParenthesized, ExpressionRecordAccess, ExpressionRecordUpdate, + ExpressionJsxElement, + ExpressionJsxText, + ExpressionJsxInterpolation, + + JsxOpening, + JsxChildren, + JsxClosing, + JsxAttribute, CaseTrunk, CaseBranches, diff --git a/compiler-services/iris-analysis/src/semantic_tokens.rs b/compiler-services/iris-analysis/src/semantic_tokens.rs index e3703c916..e2e646f3c 100644 --- a/compiler-services/iris-analysis/src/semantic_tokens.rs +++ b/compiler-services/iris-analysis/src/semantic_tokens.rs @@ -74,6 +74,10 @@ pub fn implementation( let mut previous = lsp_types::Position::new(0, 0); for token in root.tokens() { + if token.kind() == SyntaxKind::JSX_NAME { + push_jsx_name(&mut data, &mut previous, &positions, &token); + continue; + } let Some(classification) = classify(&token) else { continue }; push_token_ranges(&mut data, &mut previous, &positions, token.text_range(), classification); @@ -82,6 +86,40 @@ pub fn implementation( Ok(Some(SemanticTokens { result_id: None, data })) } +fn push_jsx_name( + tokens: &mut Vec, + previous: &mut lsp_types::Position, + positions: &PositionConverter<'_>, + token: &SyntaxToken, +) { + let mut range = token.text_range(); + let token_type = match token.parent().kind() { + SyntaxKind::JsxAttribute => PROPERTY, + SyntaxKind::JsxOpening | SyntaxKind::JsxClosing => { + let name = token.text(positions.content()); + if let Some(separator) = name.rfind('.') { + let qualifier_end = range.start() + syntax::TextSize::new(separator as u32 + 1); + let qualifier_range = TextRange::new(range.start(), qualifier_end); + push_token_ranges( + tokens, + previous, + positions, + qualifier_range, + TokenClassification::new(NAMESPACE), + ); + range = TextRange::new(qualifier_end, range.end()); + VARIABLE + } else if name.starts_with(char::is_uppercase) { + VARIABLE + } else { + TYPE + } + } + _ => return, + }; + push_token_ranges(tokens, previous, positions, range, TokenClassification::new(token_type)); +} + fn push_token_ranges( tokens: &mut Vec, previous: &mut lsp_types::Position, @@ -122,13 +160,23 @@ fn push_token_ranges( fn classify(token: &SyntaxToken) -> Option { let classification = match token.kind() { - SyntaxKind::CHAR | SyntaxKind::RAW_STRING | SyntaxKind::STRING => { + SyntaxKind::CHAR | SyntaxKind::RAW_STRING | SyntaxKind::STRING | SyntaxKind::JSX_TEXT => { TokenClassification::new(STRING) } SyntaxKind::INTEGER | SyntaxKind::NUMBER => TokenClassification::new(NUMBER), SyntaxKind::OPERATOR | SyntaxKind::OPERATOR_NAME - | SyntaxKind::DOUBLE_PERIOD_OPERATOR_NAME => TokenClassification::new(OPERATOR), + | SyntaxKind::DOUBLE_PERIOD_OPERATOR_NAME + | SyntaxKind::JSX_OPEN + | SyntaxKind::JSX_CLOSE_OPEN + | SyntaxKind::JSX_TAG_END + | SyntaxKind::JSX_CLOSE_END + | SyntaxKind::JSX_SELF_CLOSE + | SyntaxKind::JSX_EXPRESSION_START + | SyntaxKind::JSX_EXPRESSION_END => TokenClassification::new(OPERATOR), + SyntaxKind::EQUAL if token.parent().kind() == SyntaxKind::JsxAttribute => { + TokenClassification::new(OPERATOR) + } SyntaxKind::ADO | SyntaxKind::CASE | SyntaxKind::CLASS diff --git a/compiler-services/iris-diagnostics/src/convert.rs b/compiler-services/iris-diagnostics/src/convert.rs index 72b37d37b..375fc96af 100644 --- a/compiler-services/iris-diagnostics/src/convert.rs +++ b/compiler-services/iris-diagnostics/src/convert.rs @@ -85,7 +85,8 @@ impl ToDiagnostics for FunctionalModuleError { local_global_span(*duplicate) } FunctionalUnsupportedState::InvalidStyleXUse { declaration, .. } - | FunctionalUnsupportedState::InvalidStyleXContext { declaration, .. } => { + | FunctionalUnsupportedState::InvalidStyleXContext { declaration, .. } + | FunctionalUnsupportedState::InvalidReactComponent { declaration } => { local_global_span(*declaration) } _ => None, @@ -142,6 +143,8 @@ impl ToDiagnostics for FunctionalModuleError { format!("'Iris.StyleX.{function}' must be called directly with all of its arguments.\n\nIt cannot be passed around as a function or partially applied."), FunctionalUnsupportedState::InvalidStyleXContext { function, requirement, .. } => format!("'Iris.StyleX.{function}' {requirement}."), + FunctionalUnsupportedState::InvalidReactComponent { .. } => + "'Iris.React.component' must directly initialize a non-recursive top-level value.\n\nReact component identity must remain stable between renders.".to_owned(), FunctionalUnsupportedState::VirtualModuleRuntimeReference { module_name, item_name } => format!("'{module_name}.{item_name}' is a compile-time declaration and cannot be used at runtime."), }; @@ -329,6 +332,9 @@ impl ToDiagnostics for LoweringError { lowering::NotInScope::TypeOperator { id } => { (context.stabilized.syntax_ptr(*id), None) } + lowering::NotInScope::JsxFunction { id, name } => { + (context.stabilized.syntax_ptr(*id), Some(name.as_str())) + } }; let Some(ptr) = ptr else { return Vec::new() }; @@ -371,6 +377,21 @@ impl ToDiagnostics for LoweringError { )] } + LoweringError::JsxTagMismatch { id, expected } => { + let Some(ptr) = context.stabilized.syntax_ptr(*id) else { return Vec::new() }; + let Some(span) = context.span_from_syntax_ptr(&ptr) else { return Vec::new() }; + let expected = expected.as_deref().unwrap_or(""); + let message = format!("Expected JSX closing tag "); + vec![Diagnostic::error("JsxTagMismatch", message, span, "lowering")] + } + + LoweringError::DuplicateJsxAttribute { id, name } => { + let Some(ptr) = context.stabilized.syntax_ptr(*id) else { return Vec::new() }; + let Some(span) = context.span_from_syntax_ptr(&ptr) else { return Vec::new() }; + let message = format!("Duplicate JSX attribute '{name}'"); + vec![Diagnostic::error("DuplicateJsxAttribute", message, span, "lowering")] + } + LoweringError::RecursiveSynonym(group) => convert_recursive_group( context, &group.group, diff --git a/documentation/jsx.md b/documentation/jsx.md new file mode 100644 index 000000000..b264090c1 --- /dev/null +++ b/documentation/jsx.md @@ -0,0 +1,152 @@ +# Native React JSX (experiment) + +JSX is available in **`.iris` files**. Ordinary `.purs` files keep PureScript syntax, +including its operator lexing. Both extensions participate in the same module graph: +Iris discovers them in Spago package source directories, and either can import the other. +Do not put both `View.purs` and `View.iris` at the same source path: they share the +`View.js` / `View.jsx` foreign-module slot. + +This is an Iris extension, not syntax understood by upstream PureScript, Spago's own +compiler, or PureScript formatters. Existing packages remain `.purs`; adopting `.iris` +makes that module Iris-specific. Editor clients also need to associate `.iris` with +their PureScript language mode; Iris's server recognizes the extension. + +Iris supplies semantic highlighting for JSX without requiring a JSX TextMate grammar. +Intrinsic tags use `type`, component references use `variable`, qualified prefixes use +`namespace`, attributes use `property`, text uses `string`, and JSX delimiters use +`operator`. Expressions inside braces retain normal PureScript highlighting, including +nested JSX. Highlighting also works on incomplete tags while editing. + +For VS Code, enable semantic highlighting and associate the extension in your settings: + +```json +{ + "files.associations": { "*.iris": "purescript" }, + "editor.semanticHighlighting.enabled": true +} +``` + +## React imports are generated + +Install `react` and `react-dom` in your application. This experiment is tested with +React 19.2. Import the built-in `Iris.React` module for its types and helpers; there is +no `Runtime.purs`, `Runtime.js`, or generated `Iris.React/index.js` to ship. + +```purescript +module Main where + +import Iris.React as React + +badge :: React.Component (label :: String, children :: React.JSX) +badge = React.component \props -> + {props.children} + +view :: String -> React.JSX +view name = +
+ Hello {React.text name}! + 🌱 +
+``` + +Build with `iris build`, then pass the exported `view` result to ReactDOM's `root.render`. +Like current Reason/ReScript React integration, Iris emits automatic-runtime calls: + +```javascript +import { jsx as $jsx, jsxs as $jsxs } from "react/jsx-runtime"; +// Single children use jsx; multiple static children use jsxs. +$jsx(badge, { label: "Made with Iris", children: $jsx("span", { children: "🌱" }) }); +``` + +The output is executable ES2022 **`.js`, not preserved `` syntax**. No JSX +transform is required after Iris. A bundler still resolves React's package imports, +just as it resolves the generated StyleX import. Only required runtime names are imported. + +## Components have checked record props and stable identity + +`React.Component props` is opaque and nominal in its row parameter. A component tag +passes this value to React; it does not call the render function during JSX construction. +`React.component` wraps a one-record render function and must directly initialize a +non-recursive top-level value. Creating component functions during rendering changes +React identity and can reset hook state, so Iris rejects that use. + +Unqualified lowercase tags are intrinsic strings, including ``. +`` resolves `badge`; only the first character is lowercased (`` means `uRL`). +Qualified tags always resolve components: `` and `` both mean +`UI.button`. Imported opaque components and component-valued parameters work too. + +Custom props use ordinary PureScript row checking: + +- Closed rows require all declared fields and reject extra or incorrectly typed fields. +- Open rows preserve row polymorphism: `Component (label :: String | props)` accepts + extra fields without forgetting the required `label`. +- Bindings can express optional subsets with `Prim.Row.Union supplied rest AllowedProps`. +- Function-valued, constrained, and rank-polymorphic props use normal contextual checking. + As with other generic function applications, give a render function an explicit + signature when its props contain higher-rank or constrained fields. + +Raw intrinsic attributes currently accept open records: **Iris does not validate HTML +attribute names, event types, or void-element children**. Use React spellings such as +`className`, and ordinary typed wrappers or bindings for stricter DOM APIs. + +`key` must be a string and is passed separately to React, not exposed to the component's +props. It becomes the leading argument to the keyed element helper; JSX does not promise +JavaScript's attribute evaluation order. Element-construction helpers require +`Row.Lacks "key" props`. `ref` is an ordinary explicitly typed prop under the React 19 +convention; Iris does not synthesize forwarding. + +## Children and braces + +No meaningful nested children means no `children` field. One child becomes a single +`React.JSX`; multiple children become a React-renderable array. A component requiring +`children :: React.JSX` therefore rejects ``. An explicit `children=` prop +cannot be combined with nested children. Duplicate attributes and mismatched tags are errors. + +Literal text is a native React string. Use `React.text value` for dynamic strings, +`React.array nodes` for a dynamic array, `React.fragment nodes` for a fragment, and +`React.empty` for `null`. These helpers and `React.element` also work as first-class or +partially applied functions. There is no implicit conversion of arbitrary interpolated +values into JSX. + +Attributes use `name="text"` or `name={expression}`. Quoted attributes use PureScript +string escapes; triple-quoted strings also work. Write `disabled={true}`, not `disabled`. +Braces contain PureScript: records, lambdas, sections, comments, `let`, `case`, `do`, and +nested JSX all retain their usual meaning and local layout. + +Text preserves same-line spaces. Multiline text drops indentation and blank lines and +joins nonempty lines with spaces. Entities remain literal: `&` is not decoded. +Use `{React.text "<"}` to insert a markup delimiter. + +Parenthesize JSX used as a function argument: `render (
)` or `render $
`. +A newline alone does not start a JSX operand. Complete operator names such as `(<>)` +and `(<$>)` keep their meaning; write `( <> )` for a parenthesized empty fragment. + +## Existing React bindings are a separate interoperability boundary + +The props design follows React Basic's opaque nodes/components and the DOM bindings' +row-subset constraints. However, `React.Basic.JSX` and `React.Basic.ReactComponent` are +not the same nominal types as `Iris.React.JSX` and `Iris.React.Component`. +This experiment does not supply an interop package or make them implicitly equal. + +External JavaScript components can be declared directly with `foreign import` and a +typed `React.Component` row. Keep their identity stable. React callbacks must execute +when JavaScript calls them: a PureScript `event -> Effect Unit` returns a thunk, which +React will not execute automatically. Use the existing `EffectFn` FFI conventions or +binding-library adapters rather than casting calling conventions. + +Spreads, attribute puns, single-quoted attributes, component-tag rename/navigation, +and a compiler-owned hooks or complete DOM-props library are outside this experiment. + +## Verify compilation and browser rendering + +```sh +just t compiler jsx_ +just e2e-prepare +just e2e --test react +``` + +Compiler fixtures check types, diagnostics, elaboration, and generated imports. +The browser test builds a mixed `.purs`/`.iris` project with the real CLI and runs its +fresh output in Vitest's Playwright Chromium mode. It checks DOM output, clicks, +rerendering, and keyed reordering while preserving a stateful child. It also checks +that static fragments avoid false key warnings without disabling dynamic-list validation. diff --git a/justfile b/justfile index e3a262eb0..a893311d9 100644 --- a/justfile +++ b/justfile @@ -32,6 +32,7 @@ coverage-html: [doc("Install end-to-end test tools")] @e2e-prepare: pnpm --dir tests-e2e/tools install --frozen-lockfile + pnpm --dir tests-e2e/tools exec playwright install --with-deps chromium [doc("Run end-to-end tests")] @e2e *args="": diff --git a/tests-e2e/Cargo.toml b/tests-e2e/Cargo.toml index acf5ca378..d375db57c 100644 --- a/tests-e2e/Cargo.toml +++ b/tests-e2e/Cargo.toml @@ -44,3 +44,7 @@ path = "tests/package_manager.rs" [[test]] name = "stylex" path = "tests/stylex.rs" + +[[test]] +name = "react" +path = "tests/react.rs" diff --git a/tests-e2e/tests/package_manager/watch.rs b/tests-e2e/tests/package_manager/watch.rs index 8778caa30..b74e62657 100644 --- a/tests-e2e/tests/package_manager/watch.rs +++ b/tests-e2e/tests/package_manager/watch.rs @@ -205,12 +205,34 @@ fn rebuilds_for_ffi_changes_and_reconciles_foreign_outputs() { && generated_module_contains(&javascript_output, "43") }); + std::fs::rename(workspace.path().join("src/Main.purs"), workspace.path().join("src/Main.iris")) + .unwrap(); + workspace.write( + "src/Main.iris", + r#"module Main where + +import Iris.React as React + +foreign import value :: Int + +view =
{React.text "renamed"}
+"#, + ); + workspace.write("src/Main.js", "export const value = 44;\n"); + let module_output = workspace.path().join("output/Main/index.js"); + watch.wait_for("source dialect rename with FFI rebuild", |stdout, stderr| { + stdout.contains("Rebuild succeeded") + && generated_module_contains(&module_output, "renamed") + && generated_module_contains(&javascript_output, "44") + && !stderr.contains("source stem") + }); + std::fs::remove_file(workspace.path().join("src/Main.js")).unwrap(); - workspace.write("src/Main.jsx", "export const value = 44;\n"); + workspace.write("src/Main.jsx", "export const value = 45;\n"); let jsx_output = workspace.path().join("output/Main/foreign.jsx"); watch.wait_for("JSX FFI rebuild", |stdout, _| { stdout.contains("Changed 1 input: Main") - && generated_module_contains(&jsx_output, "44") + && generated_module_contains(&jsx_output, "45") && !javascript_output.exists() }); } diff --git a/tests-e2e/tests/react.rs b/tests-e2e/tests/react.rs new file mode 100644 index 000000000..64df0c61b --- /dev/null +++ b/tests-e2e/tests/react.rs @@ -0,0 +1,85 @@ +use std::path::Path; +use std::process::Command; + +#[path = "support.rs"] +mod support; + +use support::{TestWorkspace, assert_success}; + +#[test] +fn renders_react_with_stable_keyed_component_identity() { + let workspace = TestWorkspace::empty(); + workspace.write( + "spago.yaml", + r#"workspace: {} +package: + name: application + dependencies: [] +"#, + ); + workspace.write( + "src/Support.purs", + r#"module Support (decorate) where + +decorate :: String -> String +decorate value = value +"#, + ); + workspace.write( + "src/Main.iris", + r#"module Main (view, list) where + +import Iris.React as React +import Support (decorate) +import Widgets as UI + +foreign import counter :: React.Component (name :: String) + +item = React.component \props -> + +list children = React.fragment children + +view reversed = +
+ <>Heading: {if reversed then + <> + else + <>} + {React.text (decorate " · ready")} +
+"#, + ); + workspace.write( + "src/Widgets.iris", + r#"module Widgets (button) where + +import Iris.React as React + +button = React.component \props -> {React.text props.label} +"#, + ); + workspace.write( + "src/Main.js", + r#"import React, { useState } from "react"; + +export function counter({ name }) { + const [count, setCount] = useState(0); + return React.createElement( + "button", + { "data-name": name, onClick: () => setCount((value) => value + 1) }, + `${name}:${count}`, + ); +} +"#, + ); + + let output = workspace.command(&["build", "--quiet"]); + assert_success(&output); + + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + let script = manifest.join("tools/verify-react.mjs"); + let verification = + Command::new("node").arg(script).arg(workspace.path().join("output")).output().unwrap(); + assert_success(&verification); + workspace.assert_spago_calls("", &[&["fetch", "-p", "application"]]); +} diff --git a/tests-e2e/tools/package.json b/tests-e2e/tools/package.json index ae5462e3c..aa9a11736 100644 --- a/tests-e2e/tools/package.json +++ b/tests-e2e/tools/package.json @@ -4,6 +4,11 @@ "@babel/core": "8.0.1", "@stylexjs/babel-plugin": "0.19.0", "@stylexjs/stylex": "0.19.0", - "spago": "1.0.4" + "@vitest/browser-playwright": "4.0.4", + "playwright": "1.56.1", + "react": "19.2.0", + "react-dom": "19.2.0", + "spago": "1.0.4", + "vitest": "4.0.4" } } diff --git a/tests-e2e/tools/pnpm-lock.yaml b/tests-e2e/tools/pnpm-lock.yaml index 2606268da..a622143cc 100644 --- a/tests-e2e/tools/pnpm-lock.yaml +++ b/tests-e2e/tools/pnpm-lock.yaml @@ -17,9 +17,24 @@ importers: '@stylexjs/stylex': specifier: 0.19.0 version: 0.19.0 + '@vitest/browser-playwright': + specifier: 4.0.4 + version: 4.0.4(playwright@1.56.1)(vite@7.3.6(yaml@2.9.0))(vitest@4.0.4) + playwright: + specifier: 1.56.1 + version: 1.56.1 + react: + specifier: 19.2.0 + version: 19.2.0 + react-dom: + specifier: 19.2.0 + version: 19.2.0(react@19.2.0) spago: specifier: 1.0.4 version: 1.0.4 + vitest: + specifier: 4.0.4 + version: 4.0.4(@vitest/browser-playwright@4.0.4)(supports-color@10.2.2)(yaml@2.9.0) packages: @@ -150,6 +165,162 @@ packages: '@dual-bundle/import-meta-resolve@4.2.1': resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@isaacs/cliui@9.0.0': resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} @@ -174,6 +345,13 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + '@nodelib/fs.scandir@4.0.1': resolution: {integrity: sha512-vAkI715yhnmiPupY+dq+xenu5Tdf2TBQ66jLvBIcCddtz+5Q8LbMKaf9CIJJreez8fQ8fgaY+RaywQx8RJIWpw==} engines: {node: '>=18.18.0'} @@ -186,6 +364,150 @@ packages: resolution: {integrity: sha512-nIh/M6Kh3ZtOmlY00DaUYB4xeeV6F3/ts1l29iwl3/cfyY/OuCfUx+v08zgx8TKPTifXRcjjqVQ4KB2zOYSbyw==} engines: {node: '>=18.18.0'} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rollup/rollup-android-arm-eabi@4.63.6': + resolution: {integrity: sha512-G6xF9OVRWsbHadMfoPNUDwW/Jt70QYG+ZDUvtYCFxjf3SPhqVAO0u9ShXZ7Cf5silVoRWyN4bDjCEKnJkXkw0w==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.6': + resolution: {integrity: sha512-Us/kTH5e2anr1CQvO8MEq4eeCVcIXI3Ag7OFxDI30n2OW7Q7xWuxo8ItbYq28PG2Sm1NWmZzG9+SqVEaj25SFA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.6': + resolution: {integrity: sha512-fwaSNrSHp9PJuEh4bKtd0mYC+G3FeVDjSTmsSiSlIYu7TFCpxrfB6lk7Ao8cIbswFyklPB1ZDUV8LkLBM15VWw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.6': + resolution: {integrity: sha512-QR2tx26gCeGh4eAGnUasfnJuRc+d7Vc0Jo1/fYtKGuiKqCuhbVpbNEvqgYGBwGDZGyS04jj+zdHi9W8uvb1Sdw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.6': + resolution: {integrity: sha512-7E1wRJEW8r7WlqmCy58ugndWHrzoEV/pRt2x0D9el7Inra62+Cw00lYW9XhW6iUCtFjhNw49McsSCIzJ/nG8uw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.6': + resolution: {integrity: sha512-13KJeF+vDswMzEHmMIouCVcEQdov3CzAA3ZhM8QFmBJV/FZ/VMZI49UHDvPXTK+wokk86kYPmQ9Qb3xGGJbZoQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.6': + resolution: {integrity: sha512-hV+W1r8HM84PER4If94QuzrrD0DNk4CxobNhVUiz9ZiiWHDzJss4Jra2AYGH1a7GOTzjS1sIyZ35yYEhw/aq+g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.6': + resolution: {integrity: sha512-5dWs/GENZufRph8PEfl0TrNH/R3DHJA+VxF/dqlfV2oUZfxRtN3qmFXg2DZj8DYM8d+Vd3dgZtuVmoBE/nb2YQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.6': + resolution: {integrity: sha512-inQYLPVIUvYk+s7zpxQldqwrajUNs+t4TJjPGzsdPTvsT8gsQXVcW/sQFolHZ3YSWVk0nMMwU7TtRi0Ow+QMuw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.6': + resolution: {integrity: sha512-sH+0MV1HmDC1q7+bQnlN9ywKBmHqkA2EdD/hHYrrz654ybc+vNC1FRNyzuYKicMQVtZZ4X2jz0osJYmsOtnj4g==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.6': + resolution: {integrity: sha512-mCECvRr6HGekdBc6dvHzUWubqEti4JyOqlNePrOzl4Wimk26iW5Zy9rld2mQbC9id1NYjfF8nHLFq5fr3CuOKQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.6': + resolution: {integrity: sha512-bb9rdoCPwM4DEsbOP3CJgFnvhSJ3XKCUYyUhxnlSs/XGT0DknpOYgLCPbK8VUzwl+cZGzFQm0KbBYRaSGZC2YQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.6': + resolution: {integrity: sha512-aIJRoGCHev45JSu7JMzVrMzu1NX3nQrXx1Vb/6y4AblxW7JpCMpk4e8C6lHzvSElxi3UEcMdFJldsuWQjV1ddA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.6': + resolution: {integrity: sha512-LvjnulezHjiaM5Ga9MPCDLTVddjvcrJhZc6vEz9fxeFRGPJsPAX9HuTuPTUQ3l8d+nYpZB/xWzUpuKJlLQm3VQ==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.6': + resolution: {integrity: sha512-YmhSBeYZwJ937s8tKpYAhms48N+tj7e+oFkbkVVxfMRdAiynK2AxqRapJXB4utyXxc/iYTkMfPRQ+7ZP0IO78w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.6': + resolution: {integrity: sha512-9+YukhzqTvJvzfDXJJzkURK9TDY01nofNH3pyHGuucGC4xPvBVtIfCAqJjkjnmBEGJYNXB8sBc2EDt7xDgGenw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.6': + resolution: {integrity: sha512-9G5AtEkpN/A89BILHHEL9aYRYTuFjwa8PUQqoEg8SDrwOZBKpwYqNlQCBNI5QPwGrf7qFlb6LNFilo8FCSA3Wg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.6': + resolution: {integrity: sha512-Ezx2E5D6Siz805j41x91JiGbzuCm8i2U98U37SQ7ytY737+wsJq+P9nPL6UmqDww491zO7MTivYOTrtmTTeCSw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.6': + resolution: {integrity: sha512-8EZ1Q3PB7yUjvR4MbFAnCBF1wF0XRe5QQqbtlJXP7ZX5C8iYlSQJugUhj9oS0gbUP4jp9qGhaIgFnV9Nvod86Q==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.6': + resolution: {integrity: sha512-3JI27TALItfZ/qaxXKdbRXfV6WepUoUa3IN07FE+1xxoRdl3JIyrp5PiDF0vOHsPgEiOFkF8s3B3BRymQkdwGA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.6': + resolution: {integrity: sha512-BNOGeNFNRZDT7Hfc7b9JbTZFXkpAXeXl/CZWtxdd5CNfBaor/0RkJjg+q+E2gwVIvGqqp/NclThKFGrhuScVhw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.6': + resolution: {integrity: sha512-D3eEyLSIim5WF5fHbwnx+ryk0delN7fJyBrDTO3wVCCNBZ/cA8aCGwLS3SiuK+vRI4E7dqikAlStGuR20dBMuw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.6': + resolution: {integrity: sha512-xwbs9g8zLbHzj1poG1uh27xtubsu27Gk2nuN6kvvGDybjiTfwMksQAnF+phlvI5oeXkJWWDc6kg1t3XpgUYvrg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.6': + resolution: {integrity: sha512-Tr6rhRNnFthvXr45zWVZQ5P8jZ5UwZnXNc7Qnu80cP/VVYtSioOPmvv7wP6UMgRfkBCJr2Pm60ua0YBDSJjgnw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.6': + resolution: {integrity: sha512-0RAxO1pS/6lY1mSoD2Apm+6aPyfUlm+OWKgncIdj/eVN9I+pDY3lKgD7xsUNFei1u7MZQETGOo6m8POXMDp53A==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stylexjs/babel-plugin@0.19.0': resolution: {integrity: sha512-2SnVZIl047TtqKW0nRTn7Irz0kmoot1bxdGDE/HwfwfF7dJYHqwkIFg92s87v3hj7zxJobrHWwDx1p3qvQXW4g==} @@ -195,18 +517,71 @@ packages: '@stylexjs/stylex@0.19.0': resolution: {integrity: sha512-CnUFp7YMaDLDeemsWOfJgoC/gKM5P/yBNMcpJaE6ChJmXr7s0DJwSeGTTlHJcqqwN9OW1qGtmARWLFhGZN1pTA==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/gensync@1.0.5': resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==} '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@vitest/browser-playwright@4.0.4': + resolution: {integrity: sha512-jGKnGZ5ZKXuwQ1Ldwll/rZxk3webz4gz3kvoTYX2NH2ASPiwFGck8D09Sf2wVjCuDqebPXXd69zUIt1o4yQ5tA==} + peerDependencies: + playwright: '*' + vitest: 4.0.4 + + '@vitest/browser@4.0.4': + resolution: {integrity: sha512-1ZXztcBtRd3maKliHzWbQohsyRjam0ws6OPRWNWfGxFUOHTlNBtDnJAm8z1x7IzVkZ6JcOAumHJAbxNJh4tkDw==} + peerDependencies: + vitest: 4.0.4 + + '@vitest/expect@4.0.4': + resolution: {integrity: sha512-0ioMscWJtfpyH7+P82sGpAi3Si30OVV73jD+tEqXm5+rIx9LgnfdaOn45uaFkKOncABi/PHL00Yn0oW/wK4cXw==} + + '@vitest/mocker@4.0.4': + resolution: {integrity: sha512-UTtKgpjWj+pvn3lUM55nSg34098obGhSHH+KlJcXesky8b5wCUgg7s60epxrS6yAG8slZ9W8T9jGWg4PisMf5Q==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.4': + resolution: {integrity: sha512-lHI2rbyrLVSd1TiHGJYyEtbOBo2SDndIsN3qY4o4xe2pBxoJLD6IICghNCvD7P+BFin6jeyHXiUICXqgl6vEaQ==} + + '@vitest/runner@4.0.4': + resolution: {integrity: sha512-99EDqiCkncCmvIZj3qJXBZbyoQ35ghOwVWNnQ5nj0Hnsv4Qm40HmrMJrceewjLVvsxV/JSU4qyx2CGcfMBmXJw==} + + '@vitest/snapshot@4.0.4': + resolution: {integrity: sha512-XICqf5Gi4648FGoBIeRgnHWSNDp+7R5tpclGosFaUUFzY6SfcpsfHNMnC7oDu/iOLBxYfxVzaQpylEvpgii3zw==} + + '@vitest/spy@4.0.4': + resolution: {integrity: sha512-G9L13AFyYECo40QG7E07EdYnZZYCKMTSp83p9W8Vwed0IyCG1GnpDLxObkx8uOGPXfDpdeVf24P1Yka8/q1s9g==} + + '@vitest/utils@4.0.4': + resolution: {integrity: sha512-4bJLmSvZLyVbNsYFRpPYdJViG9jZyRvMZ35IF4ymXbRZoS+ycYghmwTGiscTXduUg2lgKK7POWIyXJNute1hjw==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -243,6 +618,10 @@ packages: caniuse-lite@1.0.30001810: resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -297,13 +676,37 @@ packages: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fastq@1.20.3: resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -316,6 +719,16 @@ packages: resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} engines: {node: '>=14.14'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fuse.js@7.5.0: resolution: {integrity: sha512-sQtrEfA+ez/3G0cCZecF70oqpCRttCexYUG4mUrtWL49ULUzUyxokt5kyqwtKzj1270RaKih+hcP3qLcumccow==} engines: {node: '>=10'} @@ -397,6 +810,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-it@14.3.1: resolution: {integrity: sha512-4Ej49aYTDFIQ+uBkfX8GBvJGccoARxxPep+7aWTs55ozbjQJpW9M26Fe53vnGgvLeVzva/amzjQQaQu9w0vMhA==} hasBin: true @@ -420,12 +836,21 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} nan@2.28.0: resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + node-releases@2.0.54: resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} @@ -449,6 +874,9 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -460,9 +888,31 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + pixelmatch@7.1.0: + resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} + hasBin: true + + playwright-core@1.56.1: + resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.56.1: + resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==} + engines: {node: '>=18'} + hasBin: true + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -474,6 +924,15 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-dom@19.2.0: + resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} + peerDependencies: + react: ^19.2.0 + + react@19.2.0: + resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} + engines: {node: '>=0.10.0'} + readline-sync@1.4.10: resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==} engines: {node: '>= 0.8.0'} @@ -482,6 +941,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rollup@4.63.6: + resolution: {integrity: sha512-w4+GKyTBqshj84F1GF3s2zxAPFZPEmNkCbQtqilsYS7GsksmX6VoV7UggfZg8sUHCCOkBe7UtQp6fZwbH311FQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -492,6 +956,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -509,10 +976,21 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + spago@1.0.4: resolution: {integrity: sha512-iv1HBJppzGJRWWd/DZ32oselcKrhq/PboBdXy5VMX4GiwRqLCUUp3loZ6Yhm8zymNRKHkN/w52cxX6k521nwng==} engines: {node: '>=22.5.0'} @@ -531,6 +1009,12 @@ packages: resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} engines: {node: '>=10.16.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + styleq@0.2.1: resolution: {integrity: sha512-L0TR0NQb+X4/ktDEKmjWyp27gla+LUYi/by5k5SjKXf6/pvZP7wbwEB5J+tqxdFVPgzbsuz+d4RTScO/QZquBw==} @@ -542,6 +1026,20 @@ packages: resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + tmp@0.2.7: resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} engines: {node: '>=14.14'} @@ -550,6 +1048,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} @@ -566,11 +1068,102 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.0.4: + resolution: {integrity: sha512-hV31h0/bGbtmDQc0KqaxsTO1v4ZQeF8ojDFuy4sZhFadwAqqvJA0LDw68QUocctI5EDpFMql/jVWKuPYHIf2Ew==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.4 + '@vitest/browser-preview': 4.0.4 + '@vitest/browser-webdriverio': 4.0.4 + '@vitest/ui': 4.0.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -776,6 +1369,84 @@ snapshots: '@dual-bundle/import-meta-resolve@4.2.1': {} + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + '@isaacs/cliui@9.0.0': {} '@isaacs/fs-minipass@4.0.1': @@ -801,6 +1472,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + '@nodelib/fs.scandir@4.0.1': dependencies: '@nodelib/fs.stat': 4.0.0 @@ -813,6 +1487,85 @@ snapshots: '@nodelib/fs.scandir': 4.0.1 fastq: 1.20.3 + '@polka/url@1.0.0-next.29': {} + + '@rollup/rollup-android-arm-eabi@4.63.6': + optional: true + + '@rollup/rollup-android-arm64@4.63.6': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.6': + optional: true + + '@rollup/rollup-darwin-x64@4.63.6': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.6': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.6': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.6': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.6': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.6': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.6': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.6': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.6': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.6': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.6': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.6': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.6': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.6': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.6': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.6': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.6': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.6': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.6': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.6': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.6': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.6': + optional: true + + '@standard-schema/spec@1.1.0': {} + '@stylexjs/babel-plugin@0.19.0(supports-color@10.2.2)': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) @@ -834,16 +1587,96 @@ snapshots: invariant: 2.2.4 styleq: 0.2.1 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/gensync@1.0.5': {} '@types/jsesc@2.5.1': {} + '@vitest/browser-playwright@4.0.4(playwright@1.56.1)(vite@7.3.6(yaml@2.9.0))(vitest@4.0.4)': + dependencies: + '@vitest/browser': 4.0.4(vite@7.3.6(yaml@2.9.0))(vitest@4.0.4) + '@vitest/mocker': 4.0.4(vite@7.3.6(yaml@2.9.0)) + playwright: 1.56.1 + tinyrainbow: 3.2.0 + vitest: 4.0.4(@vitest/browser-playwright@4.0.4)(supports-color@10.2.2)(yaml@2.9.0) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.0.4(vite@7.3.6(yaml@2.9.0))(vitest@4.0.4)': + dependencies: + '@vitest/mocker': 4.0.4(vite@7.3.6(yaml@2.9.0)) + '@vitest/utils': 4.0.4 + magic-string: 0.30.21 + pixelmatch: 7.1.0 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.2.0 + vitest: 4.0.4(@vitest/browser-playwright@4.0.4)(supports-color@10.2.2)(yaml@2.9.0) + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/expect@4.0.4': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.4 + '@vitest/utils': 4.0.4 + chai: 6.3.0 + tinyrainbow: 3.2.0 + + '@vitest/mocker@4.0.4(vite@7.3.6(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.0.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(yaml@2.9.0) + + '@vitest/pretty-format@4.0.4': + dependencies: + tinyrainbow: 3.2.0 + + '@vitest/runner@4.0.4': + dependencies: + '@vitest/utils': 4.0.4 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.4': + dependencies: + '@vitest/pretty-format': 4.0.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.4': {} + + '@vitest/utils@4.0.4': + dependencies: + '@vitest/pretty-format': 4.0.4 + tinyrainbow: 3.2.0 + argparse@2.0.1: {} asn1@0.2.6: dependencies: safer-buffer: 2.1.2 + assertion-error@2.0.1: {} + balanced-match@4.0.4: {} baseline-browser-mapping@2.11.21: {} @@ -877,6 +1710,8 @@ snapshots: caniuse-lite@1.0.30001810: {} + chai@6.3.0: {} + chownr@3.0.0: {} convert-source-map@2.0.0: {} @@ -918,12 +1753,53 @@ snapshots: env-paths@3.0.0: {} + es-module-lexer@1.7.0: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + fastq@1.20.3: dependencies: reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -939,6 +1815,12 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + fuse.js@7.5.0: {} gensync@1.0.0-beta.2: {} @@ -1006,6 +1888,10 @@ snapshots: dependencies: yallist: 3.1.1 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + markdown-it@14.3.1: dependencies: argparse: 2.0.1 @@ -1032,11 +1918,15 @@ snapshots: dependencies: minipass: 7.1.3 + mrmime@2.0.1: {} + ms@2.1.3: {} nan@2.28.0: optional: true + nanoid@3.3.19: {} + node-releases@2.0.54: {} obug@2.1.4: {} @@ -1057,24 +1947,85 @@ snapshots: lru-cache: 11.5.2 minipass: 7.1.3 + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} picomatch@4.0.7: {} + pixelmatch@7.1.0: + dependencies: + pngjs: 7.0.0 + + playwright-core@1.56.1: {} + + playwright@1.56.1: + dependencies: + playwright-core: 1.56.1 + optionalDependencies: + fsevents: 2.3.2 + + pngjs@7.0.0: {} + postcss-value-parser@4.2.0: {} + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.2 + punycode.js@2.3.1: {} punycode@2.3.1: {} queue-microtask@1.2.3: {} + react-dom@19.2.0(react@19.2.0): + dependencies: + react: 19.2.0 + scheduler: 0.27.0 + + react@19.2.0: {} + readline-sync@1.4.10: {} reusify@1.1.0: {} + rollup@4.63.6: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.6 + '@rollup/rollup-android-arm64': 4.63.6 + '@rollup/rollup-darwin-arm64': 4.63.6 + '@rollup/rollup-darwin-x64': 4.63.6 + '@rollup/rollup-freebsd-arm64': 4.63.6 + '@rollup/rollup-freebsd-x64': 4.63.6 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.6 + '@rollup/rollup-linux-arm-musleabihf': 4.63.6 + '@rollup/rollup-linux-arm64-gnu': 4.63.6 + '@rollup/rollup-linux-arm64-musl': 4.63.6 + '@rollup/rollup-linux-loong64-gnu': 4.63.6 + '@rollup/rollup-linux-loong64-musl': 4.63.6 + '@rollup/rollup-linux-ppc64-gnu': 4.63.6 + '@rollup/rollup-linux-ppc64-musl': 4.63.6 + '@rollup/rollup-linux-riscv64-gnu': 4.63.6 + '@rollup/rollup-linux-riscv64-musl': 4.63.6 + '@rollup/rollup-linux-s390x-gnu': 4.63.6 + '@rollup/rollup-linux-x64-gnu': 4.63.6 + '@rollup/rollup-linux-x64-musl': 4.63.6 + '@rollup/rollup-openbsd-x64': 4.63.6 + '@rollup/rollup-openharmony-arm64': 4.63.6 + '@rollup/rollup-win32-arm64-msvc': 4.63.6 + '@rollup/rollup-win32-ia32-msvc': 4.63.6 + '@rollup/rollup-win32-x64-gnu': 4.63.6 + '@rollup/rollup-win32-x64-msvc': 4.63.6 + fsevents: 2.3.3 + run-applescript@7.1.0: {} run-parallel@1.2.0: @@ -1083,6 +2034,8 @@ snapshots: safer-buffer@2.1.2: {} + scheduler@0.27.0: {} + semver@6.3.1: {} semver@7.8.5: {} @@ -1093,8 +2046,18 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.2: {} + spago@1.0.4: dependencies: '@nodelib/fs.walk': 3.0.1 @@ -1134,6 +2097,10 @@ snapshots: cpu-features: 0.0.10 nan: 2.28.0 + stackback@0.0.2: {} + + std-env@3.10.0: {} + styleq@0.2.1: {} supports-color@10.2.2: {} @@ -1146,12 +2113,25 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinyrainbow@3.2.0: {} + tmp@0.2.7: {} to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + tweetnacl@0.14.5: {} uc.micro@2.1.0: {} @@ -1164,10 +2144,67 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + vite@7.3.6(yaml@2.9.0): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.63.6 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + yaml: 2.9.0 + + vitest@4.0.4(@vitest/browser-playwright@4.0.4)(supports-color@10.2.2)(yaml@2.9.0): + dependencies: + '@vitest/expect': 4.0.4 + '@vitest/mocker': 4.0.4(vite@7.3.6(yaml@2.9.0)) + '@vitest/pretty-format': 4.0.4 + '@vitest/runner': 4.0.4 + '@vitest/snapshot': 4.0.4 + '@vitest/spy': 4.0.4 + '@vitest/utils': 4.0.4 + debug: 4.4.3(supports-color@10.2.2) + es-module-lexer: 1.7.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinyrainbow: 3.2.0 + vite: 7.3.6(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@vitest/browser-playwright': 4.0.4(playwright@1.56.1)(vite@7.3.6(yaml@2.9.0))(vitest@4.0.4) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.22.0: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 diff --git a/tests-e2e/tools/pnpm-workspace.yaml b/tests-e2e/tools/pnpm-workspace.yaml index ac7a28954..c9ff1a219 100644 --- a/tests-e2e/tools/pnpm-workspace.yaml +++ b/tests-e2e/tools/pnpm-workspace.yaml @@ -1,3 +1,4 @@ allowBuilds: cpu-features: false + esbuild: true ssh2: false diff --git a/tests-e2e/tools/verify-react.browser.js b/tests-e2e/tools/verify-react.browser.js new file mode 100644 index 000000000..8248752c7 --- /dev/null +++ b/tests-e2e/tools/verify-react.browser.js @@ -0,0 +1,53 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, expect, test, vi } from "vitest"; + +const generated = await import(/* @vite-ignore */ `/@fs/${process.env.IRIS_REACT_OUTPUT}/Main/index.js`); + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +let root; + +afterEach(async () => { + if (root) await act(async () => root.unmount()); + root = undefined; + document.body.replaceChildren(); + vi.restoreAllMocks(); +}); + +async function render(view) { + if (!root) { + const container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + } + await act(async () => root.render(view)); +} + +test("renders and updates native Iris React elements in a real browser", async () => { + const errors = vi.spyOn(console, "error"); + await render(generated.view(false)); + + const panel = document.querySelector("[data-testid=panel]"); + expect(panel.getAttribute("title")).toBe("Iris React"); + expect(panel.textContent).toBe("Heading: first:0second:0 · readyqualified"); + expect(panel.querySelector("[data-qualified=true]").textContent).toBe("qualified"); + + await act(async () => document.querySelector("[data-name=first]").click()); + expect(panel.textContent).toBe("Heading: first:1second:0 · readyqualified"); + + await render(generated.view(true)); + expect(panel.getAttribute("data-reversed")).toBe("true"); + expect([...panel.querySelectorAll("button")].map((button) => button.textContent)).toEqual([ + "second:0", + "first:1", + ]); + expect(errors).not.toHaveBeenCalled(); +}); + +test("retains React key validation for dynamic fragment children", async () => { + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + await render(generated.list([generated.view(false), generated.view(true)])); + expect(document.querySelectorAll("[data-testid=panel]")).toHaveLength(2); + expect(errors.mock.calls.some((arguments_) => arguments_.join(" ").includes('unique "key"'))).toBe(true); +}); diff --git a/tests-e2e/tools/verify-react.mjs b/tests-e2e/tools/verify-react.mjs new file mode 100644 index 000000000..0bbc19f79 --- /dev/null +++ b/tests-e2e/tools/verify-react.mjs @@ -0,0 +1,17 @@ +import path from "node:path"; +import process from "node:process"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const tools = path.dirname(fileURLToPath(import.meta.url)); +const output = path.resolve(process.argv[2]); +const vitest = path.join(tools, "node_modules/vitest/vitest.mjs"); +const config = path.join(tools, "vitest.react.config.mjs"); +const result = spawnSync(process.execPath, [vitest, "run", "--config", config], { + cwd: tools, + env: { ...process.env, IRIS_REACT_OUTPUT: output.replaceAll("\\", "/") }, + stdio: "inherit", +}); + +if (result.error) throw result.error; +process.exitCode = result.status ?? 1; diff --git a/tests-e2e/tools/vitest.react.config.mjs b/tests-e2e/tools/vitest.react.config.mjs new file mode 100644 index 000000000..ae93f46e8 --- /dev/null +++ b/tests-e2e/tools/vitest.react.config.mjs @@ -0,0 +1,35 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { playwright } from "@vitest/browser-playwright"; +import { defineConfig } from "vitest/config"; + +const tools = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + define: { + "process.env.IRIS_REACT_OUTPUT": JSON.stringify(process.env.IRIS_REACT_OUTPUT), + }, + resolve: { + alias: { + react: path.join(tools, "node_modules/react"), + "react-dom": path.join(tools, "node_modules/react-dom"), + }, + dedupe: ["react", "react-dom"], + }, + optimizeDeps: { + include: ["react", "react/jsx-runtime", "react-dom/client"], + }, + server: { + fs: { allow: [tools, process.env.IRIS_REACT_OUTPUT] }, + }, + test: { + include: [path.join(tools, "verify-react.browser.js")], + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [{ browser: "chromium" }], + }, + }, +}); diff --git a/tests-integration/fixtures/compiler/1791000240_jsx_diagnostics/Main.checking.snap b/tests-integration/fixtures/compiler/1791000240_jsx_diagnostics/Main.checking.snap new file mode 100644 index 000000000..92fd4dd1b --- /dev/null +++ b/tests-integration/fixtures/compiler/1791000240_jsx_diagnostics/Main.checking.snap @@ -0,0 +1,22 @@ +--- +source: tests-integration/src/fixtures.rs +assertion_line: 307 +--- +Terms +button :: Iris.React.Component ( count :: Prim.Int ) +test :: Iris.React.JSX +test2 :: Iris.React.JSX +test3 :: Iris.React.JSX +test4 :: Iris.React.JSX +test5 :: ?[missing JSX component] +test6 :: Iris.React.JSX +test7 :: Iris.React.JSX +notCallable :: Prim.Int +test8 :: Iris.React.JSX +test9 :: Iris.React.JSX +test10 :: Iris.React.JSX +test11 :: Iris.React.JSX +child :: Iris.React.Component ( children :: Iris.React.JSX ) +test12 :: Iris.React.JSX + +Types diff --git a/tests-integration/fixtures/compiler/1791000240_jsx_diagnostics/Main.diagnostics.snap b/tests-integration/fixtures/compiler/1791000240_jsx_diagnostics/Main.diagnostics.snap new file mode 100644 index 000000000..01c47e3ed --- /dev/null +++ b/tests-integration/fixtures/compiler/1791000240_jsx_diagnostics/Main.diagnostics.snap @@ -0,0 +1,121 @@ +--- +source: tests-integration/src/fixtures.rs +assertion_line: 308 +--- +Parse error · Main.iris:20:29 · JSX fragment closing tag cannot have a name +Error! · [DuplicateJsxAttribute] · Main.iris:12:28 + • + │ + • Duplicate JSX attribute 'title' + │ + │ test3 =
+ │ ╰───────────── + • + +Error! · [JsxTagMismatch] · Main.iris:14:22 + • + │ + • Expected JSX closing tag
+ │ + │ test4 =
+ │ ╰─────── + • + +Error! · [NotInScope] · Main.iris:16:9 + • + │ + • 'missing' is not in scope + │ + │ test5 = + │ ╰────────── + • + +Error! · [JsxTagMismatch] · Main.iris:20:18 + • + │ + • Expected JSX closing tag + │ + │ test7 = <>
+ │ ╰─────────── + • + +Error! · [DuplicateJsxAttribute] · Main.iris:30:15 + • + │ + • Duplicate JSX attribute 'children' + │ + │ test11 =
duplicate
+ │ ╰───────────────────── + • + +Error! · [CannotUnify] · Main.iris:8:22 + • + │ + • Cannot unify 'String' with 'Int' + │ + │ test =