diff --git a/compiler/src/check.rs b/compiler/src/check.rs index 25a4c71..23a7c59 100644 --- a/compiler/src/check.rs +++ b/compiler/src/check.rs @@ -241,6 +241,8 @@ struct Sym<'a> { decl_env: Option, /// MO-10: a declared JS primitive (a fn) or an instance of one, or a part of it (an equation): what its result is foreign: Option, + /// MO-11f: a component a JS factory made — the call's arguments and the scope they name from + factory: Option<(&'a [Expr], EId)>, /// MO-10d: bound from a foreign accessor — it may hold, as a question's reader does reader: bool, } @@ -277,6 +279,7 @@ impl<'a> Sym<'a> { fn_decl: None, decl_env: None, foreign: None, + factory: None, reader: false, } } @@ -851,6 +854,11 @@ pub struct Opts<'a> { } pub struct Checker<'a> { + /// MO-11f: the components in a factory call's arguments (values there, not reads), and each factory's, by its symbol + value_ids: HashSet<*const Expr>, + factory_values: HashMap>, + /// MO-11f: the factories being expanded, per walk (markup, content, shape); a factory may be given itself (C-1 reports it) + expanding: [HashSet; 3], /// MO-10d: CN-3e asks what may hold, where a foreign reader counts (R-8 asks what may be absent, where it does not) holds_mode: Cell, pub diags: Vec, @@ -933,6 +941,9 @@ impl<'a> Checker<'a> { pub fn new(opts: Opts<'a>) -> Self { Checker { holds_mode: Cell::new(false), + value_ids: HashSet::new(), + factory_values: HashMap::new(), + expanding: Default::default(), diags: vec![], model: ModelBuilder::default(), opts, @@ -1222,6 +1233,18 @@ impl<'a> Checker<'a> { } } self.foreign_part(&mut sym, e, env); + if sym.foreign == Some(FShape::Comp) { + if let ExprKind::Call { args, .. } = &e.k { + // MO-11f: the bound name is a component + let r = self.region(None, "imported", true, Some(sym.name.clone())); + let mut props = IndexMap::new(); + props.insert("*".to_string(), PropSig { f: Catch::Set(IndexSet::new()), p: Catch::Set(IndexSet::new()), rendered: true, action: None, component: false }); + self.regions[r].sig = Some(CompSig { own: vec![], requires: vec![], props, shape: Some(crate::content::Shape { children_at: vec![vec![]], ..Default::default() }), stored: IndexMap::new() }); + sym.kind = SymKind::Comp; + sym.comp_region = Some(r); + sym.factory = Some((args.as_slice(), env)); + } + } if let Some(sf) = unwrap_server(e) { sym.kind = SymKind::Server; if let ExprKind::ServerFn { params, .. } = &sf.k { @@ -1232,7 +1255,7 @@ impl<'a> Checker<'a> { } _ => {} } - let comp_region = sym.comp_region; + let comp_region = if sym.factory.is_some() { None } else { sym.comp_region }; let s = self.bind(env, name, sym); if let Some(r) = comp_region { self.comp_syms.insert(r, s); @@ -1438,6 +1461,26 @@ impl<'a> Checker<'a> { self.bind(env, alias, Sym::new(SymKind::ErrClass, alias, region, line)); continue; } + JsDecl::Comp { props: xprops, fails: xfails, content, .. } => { + // MO-11a: a Solid component — its declaration is its summary; MO-11b: an undeclared prop is read too + let empty = || Catch::Set(IndexSet::new()); + let mut props = IndexMap::new(); + props.insert("*".to_string(), PropSig { f: empty(), p: empty(), rendered: true, action: None, component: false }); + for p in xprops { + let f = match &p.catches { + Some(c) => Catch::of(c), + None => empty(), + }; + props.insert(p.name.clone(), PropSig { f, p: empty(), rendered: true, action: None, component: p.component }); + } + let own = xfails.iter().map(|c| Own { cls: c.clone(), what: format!("{}'s root", alias), at: Some(file.clone()) }).collect(); + let r = self.region(None, "imported", true, Some(alias.clone())); + self.regions[r].sig = Some(CompSig { own, requires: vec![], props, shape: content.clone(), stored: IndexMap::new() }); + let mut s = Sym::new(SymKind::Comp, alias, region, line); + s.comp_region = Some(r); + self.bind(env, alias, s); + continue; + } JsDecl::Fn { pure: xpure, side: xside, rejects, params: xparams, refs, shape, instantiates, .. } => { if *instantiates { // MO-10a: a primitive is an instantiation (RG-6) @@ -1750,6 +1793,16 @@ impl<'a> Checker<'a> { }); } Decl::Eq { name, e, deferred, key, ty, .. } => { + if let Some(&made) = self.envs[env].vars.get(name.as_str()) { + if self.syms[made].factory.is_some() { + self.factory_values(made); + // the call runs once, here: its failures and holds are a reader's + self.readers.push(ReaderRec { region: self.reg(env), e, env, line: self.line }); + if d.exported() { + self.err("COMPONENT_AS_VALUE", "MO-11f", format!("{} is a component made by a JS factory: apply it in this module (export the components it takes instead)", name)); + } + } + } let kind = if *deferred { "deferred" } else if key.is_some() { @@ -2094,12 +2147,13 @@ impl<'a> Checker<'a> { fn expr(&mut self, e: &'a Expr, env: EId, ctx: Ctx, field: Option<&str>) { match &e.k { ExprKind::Lit(_) => {} + ExprKind::Id(_) if self.value_ids.contains(&(e as *const Expr)) => {} ExprKind::Id(name) => { let s = self.resolve(name, env, ctx); if let Some(s) = s { self.read(s, ctx, false, field); match self.syms[s].kind { - SymKind::Comp => self.err("COMPONENT_AS_VALUE", "RG-4a", format!("component {} used as a value: apply it in a view", name)), + SymKind::Comp => self.err("COMPONENT_AS_VALUE", "RG-4a", format!("component {} used as a value: apply it in a view, or pass it where JS takes a `Component`: a JS component's prop, or a JS factory's arguments (MO-11c, MO-11f)", name)), SymKind::Context => self.err("CONTEXT_AS_VALUE", "RG-7", format!("context {} used as a value: read the nearest provision with `use {}`", name, name)), _ => {} } @@ -2831,20 +2885,30 @@ impl<'a> Checker<'a> { let s = self.lookup(env, tag); let is_comp = s.is_some_and(|s| self.syms[s].kind == SymKind::Comp); if !is_comp { - self.err("UNKNOWN_COMPONENT", "RG-4a", format!("<{}> is not a component declared here", tag)); + if s.is_some_and(|s| self.syms[s].kind == SymKind::Import) { + self.err("UNKNOWN_COMPONENT", "RG-4a", format!("<{}> is imported from JS, which declares no component {}: declare it beside the module, `export component {}(props: {{ … }})` (MO-11a)", tag, tag, tag)); + } else { + self.err("UNKNOWN_COMPONENT", "RG-4a", format!("<{}> is not a component declared here", tag)); + } } - for a in attrs { + let values = self.component_values(tag, attrs, env); + let args: Vec<&'a Attr> = attrs.iter().enumerate().filter(|(i, _)| !values.iter().any(|(j, _)| j == i)).map(|(_, a)| a).collect(); + for a in &args { let h = is_handler(&a.name); let c2 = if h { Ctx::imperative() } else { Ctx::reactive() }; self.comp(if h { "handler" } else { "prop" }, env, None, Some(format!("{} {}", tag, a.name)), |c| c.expr(&a.e, env, c2, None)); } if is_comp { let cr = self.syms[s.unwrap()].comp_region.unwrap(); - self.apps.push(AppRec { comp: cr, site: region, line: self.line, tag: tag.clone(), args: attrs.iter().filter(|a| !is_handler(&a.name)).map(|a| (field_of(&a.name), &a.e, env)).collect(), passed: attrs.iter().map(|a| (field_of(&a.name), &a.e, env)).collect() }); + self.apps.push(AppRec { comp: cr, site: region, line: self.line, tag: tag.clone(), args: args.iter().filter(|a| !is_handler(&a.name)).map(|a| (field_of(&a.name), &a.e, env)).collect(), passed: args.iter().map(|a| (field_of(&a.name), &a.e, env)).collect() }); let kids = self.region(Some(region), "children", false, Some(tag.clone())); self.regions[kids].kids = Some((cr, region)); let kenv = self.env(Some(env), kids); self.markup(children, kenv, false); + for (_, v) in &values { + self.apply_value(v, m.line, kenv); + } + self.apply_factory(s.unwrap(), kids, m.line); } else { self.markup(children, env, false); } @@ -2964,37 +3028,13 @@ impl<'a> Checker<'a> { sh.children_at.push(anc.to_vec()); } } - MarkupKind::El { tag, children, .. } => { + MarkupKind::El { tag, attrs, children } => { if tag.is_empty() { self.shape_walk(children, anc, scoped, env, sh, roots); continue; } if is_upper_tag(tag) { - let Some(s) = self.lookup(env, tag) else { continue }; - if self.syms[s].kind != SymKind::Comp { - continue; - } - let ss = self.shape(s); - if anc.is_empty() { - for r in &ss.roots { - roots.insert(r.clone()); - } - } - if ss.closes_p && !scoped { - sh.closes_p = true; - } - if ss.a { - sh.a = true; - } - if ss.form { - sh.form = true; - } - for ch in &ss.children_at { - let mut a2 = anc.to_vec(); - a2.extend(ch.iter().cloned()); - let sc = scoped || ch.iter().any(|t| P_SCOPE.contains(t.as_str())); - self.shape_walk(children, &a2, sc, env, sh, roots); - } + self.shape_app(tag, attrs, children, env, anc, scoped, sh, roots); continue; } if anc.is_empty() { @@ -3036,6 +3076,164 @@ impl<'a> Checker<'a> { } } /// SV-7d: every element, text and component root of a view against the elements open above it. + /// MO-11c: the components passed to a JS component's `Component` props (by attribute index) — each checked as applied, + /// with no arguments, among the application's children (DV-24). + fn component_values(&self, tag: &str, attrs: &'a [Attr], env: EId) -> Vec<(usize, String)> { + let Some(sig) = self.lookup(env, tag).and_then(|s| self.syms[s].comp_region).and_then(|r| self.regions[r].sig.as_ref()) else { return vec![] }; + let mut out = vec![]; + for (i, a) in attrs.iter().enumerate() { + if !sig.props.get(&a.name).is_some_and(|p| p.component) { + continue; + } + if let ExprKind::Id(n) = &a.e.k { + if is_upper_tag(n) && self.lookup(env, n).is_some_and(|s| self.syms[s].kind == SymKind::Comp) { + out.push((i, n.clone())); + } + } + } + out + } + /// MO-11f: the components in a factory call's arguments — anywhere inside its record and array literals. + fn factory_values(&mut self, s: SId) -> Vec { + if let Some(v) = self.factory_values.get(&s) { + return v.clone(); + } + let (args, fenv) = self.syms[s].factory.unwrap(); + let mut out = vec![]; + fn walk<'a>(c: &mut Checker<'a>, e: &'a Expr, fenv: EId, out: &mut Vec) { + match &e.k { + ExprKind::Id(n) => { + // a value is applied by its tag, so it is capitalised as one + if is_upper_tag(n) && c.lookup(fenv, n).is_some_and(|t| c.syms[t].kind == SymKind::Comp) { + c.value_ids.insert(e as *const Expr); + out.push(n.clone()); + } + } + ExprKind::Rec(fields) => { + for f in fields { + if let Field::Kv { v, .. } = f { + walk(c, v, fenv, out); + } + } + } + ExprKind::Arr(items) => { + for x in items { + if !matches!(x.k, ExprKind::Spread(_)) { + walk(c, x, fenv, out); + } + } + } + _ => {} + } + } + for a in args { + walk(self, a, fenv, &mut out); + } + self.factory_values.insert(s, out.clone()); + out + } + /// MO-11c: `` for a component value, as `markup` applies a component with no attributes and no children. + fn apply_value(&mut self, tag: &str, line: Option, env: EId) { + if let Some(l) = line { + self.line = l as i64; + } + let region = self.reg(env); + let Some(s) = self.lookup(env, tag) else { return }; + let Some(cr) = self.syms[s].comp_region else { return }; + self.apps.push(AppRec { comp: cr, site: region, line: self.line, tag: tag.to_string(), args: vec![], passed: vec![] }); + let kids = self.region(Some(region), "children", false, Some(tag.to_string())); + self.regions[kids].kids = Some((cr, region)); + self.apply_factory(s, kids, line); + } + /// MO-11f: what a factory was given, applied among the children of its component's application, named where it was called. + fn apply_factory(&mut self, s: SId, kids: RId, line: Option) { + let Some((_, fenv)) = self.syms[s].factory else { return }; + if !self.expanding[0].insert(s) { + return; + } + let fvs = self.factory_values(s); + let fe = self.env(Some(fenv), kids); + for v in &fvs { + self.apply_value(v, line, fe); + } + self.expanding[0].remove(&s); + } + /// SV-7d at a component's application: its roots against the elements open above it, then the children it is given (and + /// its component values, MO-11c/f) where it places them. + fn content_app(&mut self, tag: &str, attrs: &'a [Attr], children: &'a [Markup], line: Option, env: EId, anc: &[String]) { + if let Some(l) = line { + self.line = l as i64; + } + let Some(s) = self.lookup(env, tag) else { return }; + if self.syms[s].kind != SymKind::Comp { + return; + } + let ss = self.shape(s); + for r in &ss.roots { + let what = if r == "#text" { format!("<{}>'s text", tag) } else { format!("<{}>, the root of <{}>,", r, tag) }; + let why = misnested(anc, r); + self.report_content(anc, what, why); + } + let why = misnested_inside(anc, &ss); + self.report_content(anc, format!("<{}>", tag), why); + for ch in &ss.children_at { + let mut a2 = anc.to_vec(); + a2.extend(ch.iter().cloned()); + self.content_check(children, env, &a2); + for (_, v) in self.component_values(tag, attrs, env) { + self.content_app(&v, &[], &[], line, env, &a2); + } + if let Some((_, fenv)) = self.syms[s].factory { + if self.expanding[1].insert(s) { + for v in self.factory_values(s) { + self.content_app(&v, &[], &[], line, fenv, &a2); + } + self.expanding[1].remove(&s); + } + } + } + } + /// SV-7d's summary of a component application inside a view (`shape`): its roots and flags, then its children and + /// component values where it places them. + #[allow(clippy::too_many_arguments)] + fn shape_app(&mut self, tag: &str, attrs: &'a [Attr], children: &'a [Markup], env: EId, anc: &[String], scoped: bool, sh: &mut Shape, roots: &mut IndexSet) { + let Some(s) = self.lookup(env, tag) else { return }; + if self.syms[s].kind != SymKind::Comp { + return; + } + let ss = self.shape(s); + if anc.is_empty() { + for r in &ss.roots { + roots.insert(r.clone()); + } + } + if ss.closes_p && !scoped { + sh.closes_p = true; + } + if ss.a { + sh.a = true; + } + if ss.form { + sh.form = true; + } + for ch in &ss.children_at { + let mut a2 = anc.to_vec(); + a2.extend(ch.iter().cloned()); + let sc = scoped || ch.iter().any(|t| P_SCOPE.contains(t.as_str())); + self.shape_walk(children, &a2, sc, env, sh, roots); + for (_, v) in self.component_values(tag, attrs, env) { + self.shape_app(&v, &[], &[], env, &a2, sc, sh, roots); + } + if let Some((_, fenv)) = self.syms[s].factory { + if self.expanding[2].insert(s) { + for v in self.factory_values(s) { + self.shape_app(&v, &[], &[], fenv, &a2, sc, sh, roots); + } + self.expanding[2].remove(&s); + } + } + } + } fn content_check(&mut self, ms: &'a [Markup], env: EId, anc: &[String]) { for m in ms { if let Some(l) = m.line { @@ -3048,29 +3246,13 @@ impl<'a> Checker<'a> { self.report_content(anc, "text".into(), why); } } - MarkupKind::El { tag, children, .. } => { + MarkupKind::El { tag, attrs, children } => { if tag.is_empty() { self.content_check(children, env, anc); continue; } if is_upper_tag(tag) { - let Some(s) = self.lookup(env, tag) else { continue }; - if self.syms[s].kind != SymKind::Comp { - continue; - } - let ss = self.shape(s); - for r in &ss.roots { - let what = if r == "#text" { format!("<{}>'s text", tag) } else { format!("<{}>, the root of <{}>,", r, tag) }; - let why = misnested(anc, r); - self.report_content(anc, what, why); - } - let why = misnested_inside(anc, &ss); - self.report_content(anc, format!("<{}>", tag), why); - for ch in &ss.children_at { - let mut a2 = anc.to_vec(); - a2.extend(ch.iter().cloned()); - self.content_check(children, env, &a2); - } + self.content_app(tag, attrs, children, m.line, env, anc); continue; } let why = misnested(anc, tag); @@ -4305,7 +4487,7 @@ impl<'a> Checker<'a> { dp = meet(dp, &along(&e.chain, false)); } } - props.insert(f, PropSig { f: df, p: dp, rendered, action: da.map(|d| (d, an)) }); + props.insert(f, PropSig { f: df, p: dp, rendered, action: da.map(|d| (d, an)), component: false }); } let e = self.esc(Some(g)); let file = self.opts.file.clone().unwrap_or_else(|| "main.rx".into()).split('/').last().unwrap_or("").to_string(); diff --git a/compiler/src/emit.rs b/compiler/src/emit.rs index ffc44a3..9697c69 100644 --- a/compiler/src/emit.rs +++ b/compiler/src/emit.rs @@ -630,6 +630,13 @@ impl<'p> Gen<'p> { self.set(env, &n.alias, b); continue; } + // MO-11a: a JS component is a JSX tag + if let Some(JsDecl::Comp { .. }) = x { + let mut c = bind(Kind::Comp, cm(&n.alias)); + c.shape = Some(FShape::Comp); + self.set(env, &n.alias, c); + continue; + } let mut b = val(mg(&n.alias)); b.client = *side == Some(crate::ast::Side::Client) || declared_side.as_ref().is_some_and(|ds| ds.iter().any(|(k, s)| *k == n.name && *s == crate::ast::Side::Client)); @@ -656,6 +663,14 @@ impl<'p> Gen<'p> { let mut b = bind(Kind::Acc, mg(name)); if let ExprKind::Call { f, .. } = &e.k { if let ExprKind::Id(fname) = &f.k { + // MO-11f: a factory's component is a JSX tag + if self.lookup(env, fname).is_some_and(|x| x.kind == Kind::Jfn && x.shape == Some(FShape::Comp)) { + let mut c = bind(Kind::Comp, cm(name)); + c.inst = Some(cm(name)); + c.shape = Some(FShape::Comp); + self.set(env, name, c); + continue; + } if let Some(jf) = self.lookup(env, fname).filter(|x| x.kind == Kind::Jfn) { b.inst = Some(format!("r${name}")); b.shape = jf.shape.clone(); @@ -692,8 +707,9 @@ impl<'p> Gen<'p> { } else { return fail(format!("{name} from \"{from}\" is not in the scenario prelude")); }; - out.push(konst(mg(alias), v)); - out.push(sexpr(rtc("reg", vec![str(alias.clone()), obj(vec![("value", id(mg(alias)))])]))); + let js = if self.lookup(env, alias).is_some_and(|b| b.kind == Kind::Comp) { cm(alias) } else { mg(alias) }; + out.push(konst(js.clone(), v)); + out.push(sexpr(rtc("reg", vec![str(alias.clone()), obj(vec![("value", id(js))])]))); } at(&mut out, n0, d.line()); } @@ -1032,7 +1048,11 @@ impl<'p> Gen<'p> { } } } - if let Some(fx) = self.foreign(e, env, &b)? { + if let Some(mut fx) = self.foreign(e, env, &b)? { + if b.kind == Kind::Comp { + fx.push(sexpr(rtc("reg", vec![str(name.clone()), obj(vec![("value", id(b.js.clone()))])]))); + return Ok(fx); + } return fin(fx, self); } if let Some(k) = key { @@ -1198,8 +1218,13 @@ impl<'p> Gen<'p> { } out.push(rtc("memo", vec![E::Null, thunk(self.expr(a, env, &reactive())?)])); } + // MO-11f: the component the factory hands back + if fb.shape == Some(FShape::Comp) { + return Ok(Some(vec![konst(b.js.clone(), call(id(fb.js.clone()), out))])); + } let r = b.inst.clone().unwrap(); let whole = match fb.shape.as_ref().unwrap() { + FShape::Comp => unreachable!(), FShape::Part(p) => part(id(r.clone()), *p), FShape::Items(items) => rtc( "memo", @@ -1465,7 +1490,7 @@ impl<'p> Gen<'p> { }; match b.kind { Kind::Acc | Kind::Src => Ok(if ctx.imp { rtc("snapshot", vec![id(b.js)]) } else { call(id(b.js), vec![]) }), - Kind::Comp => fail(format!("component {name} used as a value")), + // a component value is the compiled component (MO-11c) _ => Ok(id(b.js)), } } @@ -1690,7 +1715,10 @@ impl<'p> Gen<'p> { xs.push(self.attr(&a.name, &a.e, env)?); } if children.is_empty() { - xs.push(("children".into(), AttrValue::Expr(E::Arr(vec![])))); + // MO-11: a JS component gets JSX's own ``, with no children prop + if b.shape != Some(FShape::Comp) { + xs.push(("children".into(), AttrValue::Expr(E::Arr(vec![])))); + } el(JsxName::Ident(b.js), xs, vec![], true) } else { el(JsxName::Ident(b.js), xs, self.kids(children, env)?, false) diff --git a/compiler/src/parse.rs b/compiler/src/parse.rs index 45660c1..7c1414c 100644 --- a/compiler/src/parse.rs +++ b/compiler/src/parse.rs @@ -412,6 +412,8 @@ pub enum Shape { Part(Part), Items(Vec), Fields(Vec<(String, Part)>), + /// MO-11f: the whole result is a component + Comp, } impl Shape { pub fn has_ref(&self) -> bool { @@ -419,6 +421,7 @@ impl Shape { Shape::Part(p) => *p == Part::Ref, Shape::Items(xs) => xs.contains(&Part::Ref), Shape::Fields(fs) => fs.iter().any(|f| f.1 == Part::Ref), + Shape::Comp => false, } } } @@ -473,6 +476,9 @@ fn shape_of(t: &str) -> Shape { .collect(), ); } + if is_component_type(t) { + return Shape::Comp; + } Shape::Part(part_of(t)) } @@ -484,14 +490,166 @@ pub enum JsDecl { Fn { name: String, pure: bool, params: Vec, rejects: Option>, line: usize, side: Option, ptypes: Vec>, ret: Option, refs: Vec, shape: Shape, instantiates: bool }, Error { name: String, line: usize }, Type { name: String, line: usize, text: String }, + /// MO-11a: a Solid component + Comp { name: String, line: usize, props: Vec, fails: Vec, content: Option }, +} +/// MO-11a: a declared JS component's prop — its TypeScript type, what the component discharges for it (`None` in the list: +/// every class), and whether it takes a component value (MO-11c: its type is `Component`). +#[derive(Debug, Clone)] +pub struct JsProp { + pub name: String, + pub optional: bool, + pub ty: Option, + pub catches: Option>>, + pub component: bool, } impl JsDecl { pub fn name(&self) -> &str { match self { - JsDecl::Fn { name, .. } | JsDecl::Error { name, .. } | JsDecl::Type { name, .. } => name, + JsDecl::Fn { name, .. } | JsDecl::Error { name, .. } | JsDecl::Type { name, .. } | JsDecl::Comp { name, .. } => name, } } } + +/// MO-11: `Component` or `Component

`, and nothing that only contains one (`Component[]`). +fn is_component_type(t: &str) -> bool { + match t.strip_prefix("Component") { + Some(r) => { + let r = r.trim_start(); + if r.is_empty() { + return true; + } + if !r.starts_with('<') { + return false; + } + // the `>` that closes the first `<` ends the type + let b = r.as_bytes(); + let mut depth = 0; + for i in 0..b.len() { + if b[i] == b'<' { + depth += 1; + } else if b[i] == b'>' && (i == 0 || b[i - 1] != b'=') { + depth -= 1; + if depth == 0 { + return i == b.len() - 1; + } + } + } + false + } + None => false, + } +} +/// MO-11a: `component A(props: { href: string; children?: View }) fails(…) shape(…)`. +fn parse_js_component(c: &mut Cursor, line: usize) -> R { + c.ws(true); + let name = c.ident_or("component name")?; + c.ws(true); + c.expect("(")?; + c.ws(true); + let mut props = vec![]; + if !c.starts_with(")") { + if c.ident().as_deref() != Some("props") { + return c.fail("a component's props: `(props: { … })`"); + } + c.ws(true); + c.expect(":")?; + c.ws(true); + c.expect("{")?; + loop { + c.ws(true); + if c.eat("}") { + break; + } + let fname = c.ident_or("a prop name")?; + c.ws(false); + let optional = c.eat("?"); + c.ws(false); + let (mut ty, mut catches) = (None, None); + if c.eat(":") { + c.ws(false); + if c.keyword("catches") { + c.ws(false); + c.expect("(")?; + c.ws(true); + catches = Some(if c.eat("*") { None } else { parse_classes(c)? }); + c.ws(true); + c.expect(")")?; + c.ws(false); + } + let t0 = c.i; + let mut depth = 0i32; + while !c.eof() { + let ch = c.peek(); + if depth == 0 && (matches!(ch, ';' | ',' | '}' | '\n') || c.starts_with("//")) { + break; + } + // a string literal type + if ch == '"' || ch == '\'' || ch == '`' { + c.bump(); + while !c.eof() && c.peek() != ch { + if c.peek() == '\\' { + c.bump(); + } + c.bump(); + } + c.bump(); + continue; + } + if "([{<".contains(ch) { + depth += 1; + } else if ")]}".contains(ch) || (ch == '>' && c.i > 0 && c.src.as_bytes()[c.i - 1] != b'=') { + depth -= 1; + } + c.bump(); + } + let t = js_trim(&c.src[t0..c.i]); + ty = (!t.is_empty()).then(|| t.to_string()); + } + let component = ty.as_deref().is_some_and(is_component_type); + props.push(JsProp { name: fname, optional, ty, catches, component }); + // a newline separates fields too, as in TypeScript + c.ws(false); + if !c.eat(";") && !c.eat(",") && c.peek() != '\n' { + c.ws(true); + c.expect("}")?; + break; + } + } + c.ws(true); + } + c.expect(")")?; + let mut fails = vec![]; + let mut content = None; + loop { + c.ws(false); + if c.keyword("fails") { + c.ws(false); + c.expect("(")?; + fails = parse_classes(c)?.unwrap_or_else(|| vec!["Error".to_string()]); + c.ws(true); + c.expect(")")?; + continue; + } + if c.keyword("shape") { + c.ws(false); + c.expect("(")?; + let t0 = c.i; + while !c.eof() && c.peek() != ')' { + c.bump(); + } + let t = c.src[t0..c.i].to_string(); + c.expect(")")?; + match crate::signature::content_shape_of(Some(&t)) { + Ok(sh) => content = Some(sh), + Err(e) => return c.fail(&e), + } + continue; + } + break; + } + Ok(JsDecl::Comp { name, line, props, fails, content }) +} pub fn parse_declarations(src: &str) -> R> { let mut c = Cursor::new(src); let mut out = vec![]; @@ -502,7 +660,7 @@ pub fn parse_declarations(src: &str) -> R> { } let line = c.line(); if !c.keyword("export") { - return c.fail("a declaration file declares exports: `export pure fn …`, `export fn …`, `export error …`, `export type …`"); + return c.fail("a declaration file declares exports: `export pure fn …`, `export fn …`, `export component …`, `export error …`, `export type …`"); } c.ws(true); if c.starts_with("type") || c.starts_with("interface") { @@ -538,6 +696,10 @@ pub fn parse_declarations(src: &str) -> R> { out.push(JsDecl::Error { name, line }); continue; } + if c.keyword("component") { + out.push(parse_js_component(&mut c, line)?); + continue; + } let side = if c.keyword("server") { Some(Side::Server) } else if c.keyword("client") { @@ -621,7 +783,7 @@ pub fn parse_declarations(src: &str) -> R> { } c.ws(false); let written = c.keyword("instantiates"); - let instantiates = written || refs.iter().any(|x| *x) || shape.as_ref().is_some_and(|s| s.has_ref()); // MO-10a + let instantiates = written || refs.iter().any(|x| *x) || shape.as_ref().is_some_and(|s| s.has_ref() || *s == Shape::Comp); // MO-10a; MO-11f: a component handed back let shape = shape.unwrap_or(Shape::Part(Part::Value)); out.push(JsDecl::Fn { name, pure, params, rejects, line, side, ptypes, ret, refs, shape, instantiates }); } diff --git a/compiler/src/project.rs b/compiler/src/project.rs index 062a2a3..11a5212 100644 --- a/compiler/src/project.rs +++ b/compiler/src/project.rs @@ -226,9 +226,23 @@ fn signature_stub(sig: &ModSig) -> String { } /// MO-8: a `.d.rx` as TypeScript's `.d.ts` for the same JS module. fn declaration_file(decls: &[JsDecl]) -> String { - decls - .iter() - .map(|d| match d { + let own: IndexSet<&str> = decls.iter().filter(|d| matches!(d, JsDecl::Type { .. })).map(|d| d.name()).collect(); + let mut head: Vec = vec![]; + if decls.iter().any(|d| matches!(d, JsDecl::Comp { .. } | JsDecl::Fn { shape: crate::parse::Shape::Comp, .. })) { + // MO-11a: a field's types + if !own.contains("View") { + head.push("type View = import(\"@solidjs/web/jsx-runtime\").JSX.Element;".into()); + } + if !own.contains("Component") { + head.push("type Component

= (props: P) => View;".into()); + } + } + head.into_iter() + .chain(decls.iter().map(|d| match d { + JsDecl::Comp { name, props, .. } => { + let fs: Vec = props.iter().map(|p| format!("{}{}: {};", p.name, if p.optional { "?" } else { "" }, p.ty.as_deref().unwrap_or("any"))).collect(); + format!("export declare function {name}(props: {{ {} }}): View;", fs.join(" ")) + } JsDecl::Type { text, .. } => format!("export {text}"), JsDecl::Error { name, .. } => format!("export declare class {name}$ extends Error {{}}\nexport declare const {name}: (message?: string) => {name}$;\nexport type {name} = {name}$;"), JsDecl::Fn { name, params, ptypes, ret, .. } => { @@ -238,7 +252,7 @@ fn declaration_file(decls: &[JsDecl]) -> String { }); format!("export declare function {name}({}): {};", ps.collect::>().join(", "), ret.as_deref().unwrap_or("any")) } - }) + })) .collect::>() .join("\n") + "\n" diff --git a/compiler/src/signature.rs b/compiler/src/signature.rs index a0b62e3..36103a4 100644 --- a/compiler/src/signature.rs +++ b/compiler/src/signature.rs @@ -48,6 +48,8 @@ pub struct PropSig { pub p: Catch, pub rendered: bool, pub action: Option<(Catch, String)>, + /// MO-11c: a JS component's prop that takes a component value + pub component: bool, } #[derive(Debug, Clone)] pub struct Own { @@ -360,7 +362,8 @@ fn row_words(s: &str) -> RowWords { } RowWords { rows, fails, rest: words[i..].join(" ") } } -fn shape_of(s: Option<&str>) -> Result { +/// SV-7d's words (MO-9b; a JS component's, MO-11a). +pub fn content_shape_of(s: Option<&str>) -> Result { let mut sh = Shape::default(); let Some(s) = s else { return Ok(sh) }; for p in s.split(';').map(crate::parse::js_trim).filter(|x| !x.is_empty()) { @@ -461,7 +464,7 @@ pub fn parse_signature(src: &str, file: &str) -> Result None => (crate::parse::js_trim(&f).to_string(), String::new()), }; let fname = if fname0 == "...props" { "*".to_string() } else { fname0 }; - let mut v = PropSig { f: Catch::Set(IndexSet::new()), p: Catch::Set(IndexSet::new()), rendered: true, action: None }; + let mut v = PropSig { f: Catch::Set(IndexSet::new()), p: Catch::Set(IndexSet::new()), rendered: true, action: None, component: false }; let a = P_ACTION.captures(&spec); let rest = match &a { Some(a) => { @@ -507,7 +510,7 @@ pub fn parse_signature(src: &str, file: &str) -> Result sig.stored.entry(f).or_default().push(if t == "module" { t } else { abs_key(file, &t) }); } } - sig.shape = Some(shape_of(C_SHAPE.captures(tail).map(|x| x.get(2).unwrap().as_str())).map_err(|e| fail(&e))?); + sig.shape = Some(content_shape_of(C_SHAPE.captures(tail).map(|x| x.get(2).unwrap().as_str())).map_err(|e| fail(&e))?); exports.insert(m[1].to_string(), ExportSig::Comp { sig }); continue; } diff --git a/compiler/test/vite.mjs b/compiler/test/vite.mjs index 44454d5..ede6ef5 100644 --- a/compiler/test/vite.mjs +++ b/compiler/test/vite.mjs @@ -7,6 +7,7 @@ // Usage: SOLID_VITE_PLUGIN=../solid-vite-plugin node compiler/test/vite.mjs [filter] import { readdirSync, mkdirSync, rmSync, symlinkSync, writeFileSync, cpSync, readFileSync, existsSync, realpathSync } from "node:fs"; import { createServer } from "node:http"; +import { execFileSync } from "node:child_process"; import { createRequire } from "node:module"; import { fileURLToPath, pathToFileURL } from "node:url"; import path from "node:path"; @@ -28,10 +29,23 @@ const apps = { todo: "todo.rx", cart: "cart.rx", signup: "signup.rx", users: "users.rx", shop: "shop/main.rx", notes: "notes/main.rx", "ai-feed": "ai/feed.rx", "ai-grid": "ai/grid.rx", "ai-wizard": "ai/wizard.rx", "ai2-search": "ai2/search/main.rx", "ai2-settings": "ai2/settings/main.rx", "ai2-tracker": "ai2/tracker/main.rx", + router: "router/main.rx", }; const filter = process.argv[2]; const out = path.join(here, ".out"); mkdirSync(out, { recursive: true }); +/** @solidjs/router for router/ (09 MO-11), packed once into .out; copied into the project, so its peers are the project's Solid. */ +const ROUTER = "@solidjs/router@2.0.0-next.44"; +function routerPackage() { + const cache = path.join(out, ".router"), dir = path.join(cache, "package"); + if (!existsSync(path.join(dir, "package.json"))) { + mkdirSync(cache, { recursive: true }); + const tgz = execFileSync("npm", ["pack", ROUTER, "--silent", "--pack-destination", cache], { encoding: "utf8" }).trim().split("\n").pop(); + execFileSync("tar", ["-xzf", path.join(cache, tgz), "-C", cache]); + } + return dir; +} + /** A Vite project around an example: its sources, a page, an entry that mounts the program over rt-web.ts. */ function project(name, entry) { const dir = path.join(out, name); rmSync(dir, { recursive: true, force: true }); mkdirSync(dir, { recursive: true }); @@ -47,6 +61,7 @@ function project(name, entry) { symlinkSync(pkg, path.join(nm, "@reactivescript/compiler")); // the runtime as installed: copied, so that it takes the project's Solid (a peer), not its own dev copy for (const f of ["package.json", "dist"]) cpSync(path.join(runtime, f), path.join(nm, "@reactivescript/runtime", f), { recursive: true }); + if (name.startsWith("router")) { rmSync(path.join(nm, "@solidjs/router"), { force: true }); cpSync(routerPackage(), path.join(nm, "@solidjs/router"), { recursive: true }); } cpSync(path.join(repo, "examples"), path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src/entry.js"), `import { createApp } from "@reactivescript/runtime";\nimport main from "./${entry}";\nimport * as api from "./api.mjs";\nconst app = createApp({ root: document.getElementById("app"), api });\napp.mount(main(app));\n`); writeFileSync(path.join(dir, "index.html"), `${name}

\n`); @@ -81,19 +96,36 @@ const todo = async page => { const t2 = await page.textContent("#app"); if (!t2.includes("0 left")) throw new Error(`after toggle: ${t2}`); }; +const router = async page => { + const h1 = async () => (await page.textContent("h1"))?.trim(); + const app = async () => (await page.textContent("#app")).replace(/\s+/g, " "); + const until = async (what, ok) => { for (let i = 0; i < 40; i++) { if (await ok()) return; await page.waitForTimeout(50); } throw new Error(`${what}: ${await app()} at ${page.url()}`); }; + await page.evaluate(() => { window.__loaded = true; }); + if ((await h1()) !== "People") throw new Error(`home: ${await app()}`); + await page.click("nav >> text=Ann"); await until("after the Ann link", async () => (await h1()) === "Ann" && (await app()).includes("engineer, #1")); + if (!page.url().endsWith("/people/1")) throw new Error(`url ${page.url()}`); + await page.click("nav >> text=Bo"); await until("after the Bo link (same route, new param)", async () => (await h1()) === "Bo" && (await app()).includes("designer, #2")); + await page.click("nav >> text=Home"); await until("back home", async () => (await h1()) === "People"); + await page.click("text=Meet Bo"); await until("after navigate()", async () => (await h1()) === "Bo" && page.url().endsWith("/people/2")); + await page.click("nav >> text=Nobody"); await until("an unknown id", async () => (await app()).includes("no person 9")); + await page.goBack(); await until("history back", async () => (await h1()) === "Bo"); + if (!(await page.evaluate(() => window.__loaded))) throw new Error("the page reloaded"); +}; + for (const [name, entry] of Object.entries(apps)) { if (filter && !name.includes(filter)) continue; - const dir = project(name, entry); - try { await vite.build(config(dir, entry)); } + let dir; + try { dir = project(name, entry); await vite.build(config(dir, entry)); } catch (e) { fail++; console.log(`✗ ${name}: build: ${e.message.split("\n")[0]}`); continue; } // CP-1c: the bundle's map reaches the .rx sources const maps = readdirSync(path.join(dir, "dist/assets")).filter(f => f.endsWith(".js.map")); const sources = maps.flatMap(f => JSON.parse(readFileSync(path.join(dir, "dist/assets", f), "utf8")).sources); if (!sources.some(s => s.endsWith(".rx"))) { fail++; console.log(`✗ ${name}: the bundle's source map names no .rx file`); continue; } const dist = path.join(dir, "dist"); - const http = createServer((req, res) => { const f = path.join(dist, req.url === "/" ? "index.html" : req.url.split("?")[0]); if (!existsSync(f)) { res.statusCode = 404; return res.end(); } res.setHeader("content-type", f.endsWith(".js") ? "text/javascript" : f.endsWith(".css") ? "text/css" : "text/html"); res.end(readFileSync(f)); }); + const http = createServer((req, res) => { let f = path.join(dist, req.url === "/" ? "index.html" : req.url.split("?")[0]); if (!existsSync(f) && name === "router" && !path.extname(f)) f = path.join(dist, "index.html"); if (!existsSync(f)) { res.statusCode = 404; return res.end(); } res.setHeader("content-type", f.endsWith(".js") ? "text/javascript" : f.endsWith(".css") ? "text/css" : "text/html"); res.end(readFileSync(f)); }); // router/: a page's URL serves the app await new Promise(r => http.listen(0, "127.0.0.1", r)); - await drive(`${name} (build)`, `http://127.0.0.1:${http.address().port}/`, name === "todo" ? todo : undefined); + await drive(`${name} (build)`, `http://127.0.0.1:${http.address().port}/`, name === "todo" ? todo : name === "router" ? router : undefined); + if (name === "router") await drive("router (build, a page's URL)", `http://127.0.0.1:${http.address().port}/people/2`, async page => { await page.waitForTimeout(300); const t = await page.textContent("h1"); if (t?.trim() !== "Bo") throw new Error(`/people/2 loaded: ${await page.textContent("#app")}`); }); http.close(); } if (!filter || "shared".includes(filter)) { // two programs on one page, sharing shop/catalog.rx: no entry option, one catalog module diff --git a/examples/README.md b/examples/README.md index 6fab14a..9a7ebda 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,6 +13,7 @@ sequence of handler calls and landings. | `shop/` | `cart.rx` in four modules (09): `catalog.rx` and `cart.rx` own their state and export actions to write it, `ui.rx` a row and a panel that keeps its children's failures inside it, `main.rx` the entry — checked against their signatures only (`ref/check.ts --signature examples/shop/main.rx`). S-app-shop runs it with S-app-cart's frames | | `ai2/tracker/`, `ai2/settings/`, `ai2/search/` | the second AI test (see `ai2/README.md`): multi-module apps written cold from the spec with the checker in the loop — failures, declared contexts, modules; 36/36 frames predicted right by the spec | | `todos.rx` | a list the server owns, in layers (solid next's `examples/todos`): the server's list (a question, re-asked after every write), failures laid over it (a plain source, kept until a retry is accepted), guesses on top (per row, and adds in a list of their own); nothing keeps a copy of what the server accepted. S-app-todos | +| `router/` | `@solidjs/router` 2.0 from `.rx` (09 MO-11): `createRouter` declared as a component factory in `router.d.rx` beside a re-export, `.rx` pages named in the route table, `useParams` and `useNavigate` as primitives, per-route data as an `await` over a param with the page's own `NotFound` boundary. No scenario runs it (the runners have no router); `compiler/test/vite.mjs` builds it and navigates it in Chromium | | `signup.rx` | validation as equations, an async question with `latest`/`pending?` display-ahead, context-free components with children, an action with an early return, `errored fallback(e)` | The prelude for the scenario runners: `import pure { len, str, … } from "./prelude"`, diff --git a/examples/router/main.rx b/examples/router/main.rx new file mode 100644 index 0000000..093d134 --- /dev/null +++ b/examples/router/main.rx @@ -0,0 +1,9 @@ +// A routed app on @solidjs/router 2.0: the route table names .rx components, and the router it makes is applied here (09 MO-11f). +import { createRouter } from "./router.js" +import { Layout, Home, Person } from "./pages.rx" + +Router = createRouter({ routes: [{ path: "/", component: Layout, children: [{ path: "/", component: Home }, { path: "/people/:id", component: Person }] }] }) + +export default component App() { + +} diff --git a/examples/router/pages.rx b/examples/router/pages.rx new file mode 100644 index 0000000..873deb9 --- /dev/null +++ b/examples/router/pages.rx @@ -0,0 +1,26 @@ +// The pages the router renders: plain .rx components, named in the route table (MO-11f). +import { useParams, useNavigate } from "./router.js" +import { fetchPerson, NotFound } from "./people.js" + +export component Layout(props) { + // the router handles a link's click +
{props.children}
+} + +export component Home() { + navigate = useNavigate() + action meetBo() { navigate("/people/2") } +

People

+ +} + +export component Person() { + params = useParams() // MO-11d: the router's store; params.id follows the URL + person = await fetchPerson(params.id) // MO-11e: a route's data, asked again when the param changes + errored fallback(e: NotFound) {

{e.message}

} { + loading fallback {

loading…

} { +

{person.name}

+

{person.role}, #{params.id}

+ } + } +} diff --git a/examples/router/people.d.rx b/examples/router/people.d.rx new file mode 100644 index 0000000..70c0464 --- /dev/null +++ b/examples/router/people.d.rx @@ -0,0 +1,3 @@ +export type Person = { name: string; role: string } +export error NotFound +export pure fn fetchPerson(id: string): Promise rejects(NotFound) diff --git a/examples/router/people.js b/examples/router/people.js new file mode 100644 index 0000000..c353bfc --- /dev/null +++ b/examples/router/people.js @@ -0,0 +1,13 @@ +// A fake backend: each person answers after a delay; an unknown id rejects with NotFound. +const people = { 1: { name: "Ann", role: "engineer" }, 2: { name: "Bo", role: "designer" } }; + +export class NotFound extends Error { + constructor(id) { super(`no person ${id}`); this.name = "NotFound"; } +} + +export async function fetchPerson(id) { + await new Promise((resolve) => setTimeout(resolve, 200)); + const person = people[id]; + if (!person) throw new NotFound(id); + return person; +} diff --git a/examples/router/router.d.rx b/examples/router/router.d.rx new file mode 100644 index 0000000..38da25f --- /dev/null +++ b/examples/router/router.d.rx @@ -0,0 +1,6 @@ +// The router's factory and hooks, in rows (MO-11). Types for rx-tsc come from @solidjs/router's own .d.ts. +export type Params = Record +export type RouteDefinition = { path?: string; component?: Component; children?: RouteDefinition[] } +export pure fn createRouter(config: { routes: RouteDefinition[] }): Component +export pure fn useParams(): Params instantiates +export pure fn useNavigate(): (to: string) => void instantiates diff --git a/examples/router/router.js b/examples/router/router.js new file mode 100644 index 0000000..e42e9a5 --- /dev/null +++ b/examples/router/router.js @@ -0,0 +1,2 @@ +// @solidjs/router ships no .d.rx, so the app re-exports what it uses and declares it beside (MO-10c, MO-11). +export { createRouter, useParams, useNavigate } from "@solidjs/router"; diff --git a/guide/diagnostics.md b/guide/diagnostics.md index 97a6863..8687612 100644 --- a/guide/diagnostics.md +++ b/guide/diagnostics.md @@ -925,7 +925,8 @@ export default component App() { ### UNKNOWN_COMPONENT -A capitalised tag names a component declared or imported in this module (RG-4a). +A capitalised tag names a component declared or imported in this module (RG-4a). A JS import is a component only +when its `.d.rx` declares one: `export component X(props: { … })` (MO-11a). ```rx before export default component App() { @@ -942,7 +943,9 @@ export default component App() { ### COMPONENT_AS_VALUE -A component is applied in markup, ``, not stored or called as a function (RG-4a). +A component is applied in markup, ``, not stored or called as a function (RG-4a). The one +exception is JS that takes a component: a factory's arguments (`createRouter({ routes: [{ path: "/", component: Home }] })`) +or a JS component's prop typed `Component` (MO-11c, MO-11f). ```rx before component Greeting(props) {

{props.name}

} diff --git a/guide/libraries.md b/guide/libraries.md index 5cc3918..cba115f 100644 --- a/guide/libraries.md +++ b/guide/libraries.md @@ -76,4 +76,51 @@ What the compiler catches: unchecked: the call is then an equation, re-run (and the primitive rebuilt) whenever an argument changes. Declare primitives instead. -Rules: `spec/09-modules.md` MO-10 (and MO-8 for declaration files). +## Components and routers + +A Solid component from JS is declared the same way, with its props as TypeScript types. A function that hands back a +component (a router's `createRouter`) is declared with `Component` as its result: + +```rx +// router.d.rx, beside router.js: export { createRouter, useParams, useNavigate } from "@solidjs/router" +export type RouteDefinition = { path?: string; component?: Component; children?: RouteDefinition[] } +export pure fn createRouter(config: { routes: RouteDefinition[] }): Component +export pure fn useParams(): Record instantiates +export pure fn useNavigate(): (to: string) => void instantiates + +// ui.d.rx, beside ui.js +export component Shell(props: { header?: Component; children?: View }) +``` + +```rx +import { createRouter, useParams } from "./router.js" +import { fetchUser } from "./users.js" + +component Home() {

Home

} +component User() { + params = useParams() // the router's store: params.id follows the URL + user = await fetchUser(params.id) // asked again when the param changes + loading fallback {

Loading…

} {

{user.name}

} +} +Router = createRouter({ routes: [{ path: "/", component: Home }, { path: "/users/:id", component: User }] }) + +export default component App() { + errored fallback(e) {

{e.message}

} { + // plain links: the router handles their clicks + + } +} +``` + +- **A component is a value only where JS takes one:** in a factory's arguments (`component: Home`), or on a JS + component's prop typed `Component` (``). Anywhere else it is `COMPONENT_AS_VALUE`. +- **Each component you hand over is checked as if it were applied there:** its failures need an `errored` above + `` (or inside the page), the contexts it uses need a `provide` above ``, and in a client-only + app its first load needs a `loading` above it. A `provide` or `errored` inside a layout route is not counted for + the routes it wraps; put it above ``. +- **Apply the router in the module that makes it.** `export Router = createRouter(…)` is refused: export the pages + and make the router where you use it. +- **A JS import you apply must be declared.** Without `export component X(…)` in its `.d.rx`, `` is + `UNKNOWN_COMPONENT`. + +Rules: `spec/09-modules.md` MO-10 and MO-11 (and MO-8 for declaration files). diff --git a/ref/compile/emit.ts b/ref/compile/emit.ts index 3419107..deafdc8 100644 --- a/ref/compile/emit.ts +++ b/ref/compile/emit.ts @@ -235,7 +235,7 @@ class Gen { // names first: hoisted functions and components; every other declaration is a const for (const d of decls) { if (d.k === "import" && d.resolved) continue; - if (d.k === "import") for (const { name, alias } of d.names) { const x = d.declared ? this.mods?.set.declarations?.get(d.declared)?.find(y => y.name === name) : undefined; env.vars.set(alias, x?.kind === "fn" && x.instantiates ? { kind: "jfn", js: mg(alias), refs: x.refs ?? [], shape: x.shape ?? "value" } : { kind: "val", js: mg(alias), client: d.side === "client" || d.declaredSide?.[name] === "client" }); } // MO-10a + if (d.k === "import") for (const { name, alias } of d.names) { const x = d.declared ? this.mods?.set.declarations?.get(d.declared)?.find(y => y.name === name) : undefined; env.vars.set(alias, x?.kind === "fn" && x.instantiates ? { kind: "jfn", js: mg(alias), refs: x.refs ?? [], shape: x.shape ?? "value" } : x?.kind === "comp" ? { kind: "comp", js: cm(alias), shape: "comp" } : { kind: "val", js: mg(alias), client: d.side === "client" || d.declaredSide?.[name] === "client" }); } // MO-10a; MO-11a: a JS component is a JSX tag if (d.k === "fn") env.vars.set(d.name, { kind: d.params.some(p => p.ref) || d.decls.length ? "ifn" : "fn", js: mg(d.name), fn: d }); if (d.k === "component") env.vars.set(d.name, { kind: "comp", js: cm(d.name) }); if (d.k === "action") env.vars.set(d.name, { kind: "act", js: mg(d.name) }); @@ -243,12 +243,13 @@ class Gen { if (d.k === "error" || d.k === "context") env.vars.set(d.name, { kind: "val", js: mg(d.name) }); if (d.k === "source") env.vars.set(d.name, { kind: "src", js: mg(d.name), w: `w$${d.name}`, prev: this.u.prevOf.has(d.name) ? `p$${d.name}` : undefined }); if (d.k === "eq" && unwrapServer(d.e)) { env.vars.set(d.name, { kind: "val", js: mg(d.name) }); continue; } // SF-1d: a server fn, not an accessor - if (d.k === "eq") { const jf = d.e.k === "call" && d.e.f.k === "id" ? env.lookup(d.e.f.name) : undefined; env.vars.set(d.name, { kind: "acc", js: mg(d.name), w: this.guessedEq(d) ? `w$${d.name}` : undefined, prev: this.u.prevOf.has(d.name) ? `p$${d.name}` : undefined, ...(jf?.kind === "jfn" ? { inst: `r$${d.name}`, shape: jf.shape } : {}) }); } + if (d.k === "eq") { const jf = d.e.k === "call" && d.e.f.k === "id" ? env.lookup(d.e.f.name) : undefined; if (jf?.kind === "jfn" && jf.shape === "comp") { env.vars.set(d.name, { kind: "comp", js: cm(d.name), inst: cm(d.name), shape: "comp" }); continue; } env.vars.set(d.name, { kind: "acc", js: mg(d.name), w: this.guessedEq(d) ? `w$${d.name}` : undefined, prev: this.u.prevOf.has(d.name) ? `p$${d.name}` : undefined, ...(jf?.kind === "jfn" ? { inst: `r$${d.name}`, shape: jf.shape } : {}) }); } } for (const d of decls) if (d.k === "import" && !d.resolved) for (const { name, alias } of d.names) { const v = !d.from.startsWith(".") && !BUILTINS.has(name) && name !== "log" ? `w$${alias}` : name === "log" ? "__B.log" : d.declaredErrors?.includes(name) ? `__rt.jsError(${J(name)})` : BUILTINS.has(name) ? `__B.${name}` : d.from.endsWith("/api") ? `__rt.api(${J(name)})` : isPrims(d.from) ? `__rt.prim(${J(name)})` : null; // MO-10: the prelude's Solid primitives if (!v) throw new CompileError(`${name} from "${d.from}" is not in the scenario prelude`); - out.push(`${pad}const ${mg(alias)} = ${v};\n${pad}__rt.reg(${J(alias)}, { value: ${mg(alias)} });`); + const js = env.lookup(alias)?.kind === "comp" ? cm(alias) : mg(alias); + out.push(`${pad}const ${js} = ${v};\n${pad}__rt.reg(${J(alias)}, { value: ${js} });`); } for (const d of decls) if (d.k === "error") { const inner = this.params(env, d.params); const make = `__rt.errorClass(${J(d.name)}, ${J(d.params)}, (${d.params.map(mg).join(", ")}) => ${d.msg ? this.expr(d.msg, inner, { imp: false }) : J(d.name)})`; @@ -339,7 +340,7 @@ class Gen { return `${pad}const ${b.js} = __rt.draft(__rt.untrack(() => ${this.expr(seed.init, env, { imp: false })}), (v$d) => {\n${this.draftBody(d.e.body, inner, ind + 1)}\n${pad}});${reg}`; } if (d.e.k === "call" && d.e.f.k === "id" && env.lookup(d.e.f.name)?.kind === "ifn") return `${pad}const ${b.js} = ${this.instantiate(d.e, env)};${reg}`; // RG-6 - const fx = this.foreign(d, env, b, pad); if (fx !== null) return `${fx}${reg}`; + const fx = this.foreign(d, env, b, pad); if (fx !== null) return b.kind === "comp" ? `${fx}\n${pad}__rt.reg(${J(d.name)}, { value: ${b.js} });` : `${fx}${reg}`; if (d.key) return `${pad}const ${b.js} = __rt.keyed(${J(d.name)}, ${J(d.key)}, () => ${this.expr(d.e, env, { imp: false, tail: true })});${reg}`; if (d.e.k === "catch" && !d.deferred) { // R-f-4 over a tail await: the flight is an inner memo, read through a boundary const c = d.e; const inner = new Env(env); if (c.name) inner.vars.set(c.name, { kind: "val", js: mg(c.name) }); @@ -397,6 +398,7 @@ class Gen { return `__rt.memo(null, () => ${this.expr(a, env, { imp: false })})`; }); const r = b.inst!; const sh = f.shape!; + if (sh === "comp") return `${pad}const ${b.js} = ${f.js}(${args.join(", ")});`; // MO-11f: the component the factory hands back const whole = typeof sh === "string" ? part(r, sh) : `__rt.memo(null, () => ${"items" in sh ? `[${sh.items.map((p, i) => p === "ref" ? `${r}[${i}]()` : `${r}[${i}]`).join(", ")}]` : `({ ${sh.fields.map(([k, p]) => `${J(k)}: ${p === "ref" ? `${r}[${J(k)}]()` : `${r}[${J(k)}]`}`).join(", ")} })`})`; return `${pad}const ${r} = ${f.js}(${args.join(", ")});\n${pad}const ${b.js} = ${whole};`; } @@ -492,8 +494,7 @@ class Gen { throw new CompileError(`unbound ${name}`); } if (b.kind === "acc" || b.kind === "src") return ctx.imp ? `__rt.snapshot(${b.js})` : `${b.js}()`; - if (b.kind === "comp") throw new CompileError(`component ${name} used as a value`); - return b.js; + return b.js; // a component value is the compiled component (MO-11c) } /** R-f-4: error class names → a JS array of their values; `undefined`: every failure. */ private classes(names: string[] | undefined, env: Env): string { return names ? `[${names.map(n => n === "Error" ? "Error" : n === "Panic" ? "__rt.PANIC" : this.ref(n, env, { imp: false })).join(", ")}]` : "undefined"; } @@ -568,7 +569,7 @@ class Gen { if (/^[A-Z]/.test(m.tag)) { const b = env.lookup(m.tag); if (b?.kind !== "comp") throw new CompileError(`unknown component ${m.tag}`); const attrs = m.attrs.map(a => " " + this.attr(a.name, a.e, env)).join(""); - return m.children.length ? `<${b.js}${attrs}>${m.children.map(c => this.child(c, env, ind + 1)).join("")}` : `<${b.js}${attrs} children={[]} />`; + return m.children.length ? `<${b.js}${attrs}>${m.children.map(c => this.child(c, env, ind + 1)).join("")}` : b.shape === "comp" ? `<${b.js}${attrs} />` : `<${b.js}${attrs} children={[]} />`; // MO-11: a JS component gets JSX's own } let attrs = m.attrs.map(a => " " + (a.name === "class" && this.styleHash ? `class={${a.e.k === "lit" && typeof a.e.v === "string" ? J(`${a.e.v} ${this.styleHash}`) : `__rt.scopedClass(${this.expr(a.e, env, { imp: false })}, ${J(this.styleHash)})`}}` : this.attr(a.name, a.e, env))).join(""); if (this.styleHash && !m.attrs.some(a => a.name === "class")) attrs += ` class=${J(this.styleHash)}`; // CSS-2: every HTML element of the view diff --git a/ref/src/check.ts b/ref/src/check.ts index 73e0375..08fbb3d 100644 --- a/ref/src/check.ts +++ b/ref/src/check.ts @@ -41,7 +41,7 @@ const escF = (chain: Step[], cls: string) => !chain.some(s => s.f && caught(s.f, const escP = (chain: Step[], name: string) => !chain.some(s => s.p && (s.p === "all" || s.p.has(name))); /** MO-3: an exported component as an importer sees it — what escapes its root, the provisions it needs from above, and per prop * what it discharges on every path from its reads (`f`, `p`), and what an action reading it catches. */ -export type CompSig = { own: { cls: string; what: string; at?: string }[]; requires: string[]; props: Record; shape?: Shape; stored?: Record }; // shape: SV-7d; stored: O-3, a prop the component stores where it outlives it ("module", or a provision's key) +export type CompSig = { own: { cls: string; what: string; at?: string }[]; requires: string[]; props: Record; shape?: Shape; stored?: Record }; // shape: SV-7d; stored: O-3, a prop the component stores where it outlives it ("module", or a provision's key) const noFails = (): Fails => ({ cls: new Set(), props: [] }); /** A context key (`file#name`, `@n` for one declared in a block) → its name. */ export const keyName = (k: string) => k.split("#").pop()!.replace(/@\d+$/, ""); @@ -96,6 +96,7 @@ type Sym = { serverRejects?: () => Set; // SF-6b: what a call's promise rejects with — its body's failures, and Error (transit) foreign?: Shape; // MO-10: a declared JS primitive (a fn) or an instance of one (an equation): what its result is reader?: boolean; // MO-10d: bound from a foreign accessor — it may hold, as a question's reader does + factory?: { args: Expr[]; env: Env; values?: string[] }; // MO-11f: a component a JS factory made — the call's arguments, and the components in them fnDecl?: Extract; declEnv?: Env; // a fn's declaration and the scope it names from (SF-3: what it reaches) // a context (RG-7): its identity, whether it carries a source, and its declared rows (null: inferred from its provisions) }; const reactiveKinds = new Set(["source", "eq", "props"]); @@ -574,6 +575,12 @@ class Checker { if (!x) { this.err("NOT_EXPORTED", "MO-8", `${file} declares no export ${name} (declare it, or it is not ${d.from}'s)`); this.bind(env, alias, { kind: "import", name: alias, region: env.region, line: d.line, io: true }); continue; } if (x.kind === "type") continue; if (x.kind === "error") { this.bind(env, alias, { kind: "errclass", name: alias, region: env.region, line: d.line }); continue; } + if (x.kind === "comp") { // MO-11a: a Solid component — its declaration is its summary + const sig: CompSig = { own: x.fails.map(cls => ({ cls, what: `${alias}'s root`, at: file })), requires: [], props: { "*": { f: new Set(), p: new Set(), rendered: true } }, shape: x.content }; // MO-11b: an undeclared prop is read too + for (const p of x.props) sig.props[p.name] = { f: p.catches === "*" ? "all" : new Set(p.catches ?? []), p: new Set(), rendered: true, ...(p.component ? { component: true } : {}) }; + const r = this.region(null, "imported", { component: true, name: alias }); r.sig = sig; + this.bind(env, alias, { kind: "comp", name: alias, region: env.region, line: d.line, compRegion: r }); continue; + } if (d.pure && !x.pure) this.err("IMPORT_ROW_MISMATCH", "MO-8", `${name} is declared io in ${file} (\`export fn\`, not \`export pure fn\`); \`pure\` on the import cannot lower it`); if (d.side && x.side !== d.side) this.err("IMPORT_ROW_MISMATCH", "SF-5", `${name} is declared ${x.side ? `\`${x.side}\`` : "for both sides"} in ${file}; \`${d.side}\` on the import cannot restate it`); if (x.instantiates) { this.bind(env, alias, { kind: "fn", name: alias, region: env.region, line: d.line, params: x.params.map((n, i) => ({ name: n, ref: !!x.refs?.[i] })), instantiating: true, foreign: x.shape, rows: () => new Set(), failsT: () => ({ cls: new Set(), props: [] }), rejects: new Set() }); continue; } // MO-10a: a primitive is an instantiation (RG-6) @@ -591,6 +598,10 @@ class Checker { } if (!shape) return; sym.foreign = shape; + if (shape === "comp" && e.k === "call") { // MO-11f: the bound name is a component + const r = this.region(null, "imported", { component: true, name: sym.name }); r.sig = { own: [], requires: [], props: { "*": { f: new Set(), p: new Set(), rendered: true } }, shape: { roots: [], closesP: false, a: false, form: false, childrenAt: [[]] } }; // it renders what it was given where it is applied + sym.kind = "comp"; sym.compRegion = r; sym.factory = { args: e.args, env }; + } if (shape === "fn") sym.io = true; if (shape === "ref") sym.reader = true; } @@ -679,6 +690,8 @@ class Checker { } case "eq": { const kind = d.deferred ? "deferred" : d.key ? "keyed" : "memo"; + const made = env.vars.get(d.name); + if (made?.factory) { this.factoryValues(made); this.readers.push({ region: env.region, e: d.e, env, line: this.line }); /* the call runs once, here: its failures and holds are a reader's */ if (d.exported) this.err("COMPONENT_AS_VALUE", "MO-11f", `${d.name} is a component made by a JS factory: apply it in this module (export the components it takes instead)`); } if (d.e.k === "call" && d.e.f.k === "id") { const f = env.lookup(d.e.f.name); if (f?.kind === "fn" && f.instantiating) { const call = d.e; this.snapshotArgs(call, f, env); this.comp(kind, env, { name: d.name }, () => { this.model.call(f); call.args.forEach((a, i) => { const value = !!f.params![i] && !f.params![i].ref; if (value) this.model.snap++; this.expr(a, env, reactive); if (value) this.model.snap--; }); }); this.rowCheck(d.name, d.type, env); return; } } // E-10: a value parameter's argument is a snapshot this.comp(kind, env, { name: d.name }, () => this.expr(d.e, env, reactive)); this.rowCheck(d.name, d.type, env); return; } @@ -841,7 +854,7 @@ class Checker { const go = (x: Expr) => this.expr(x, env, ctx); switch (e.k) { case "lit": return; - case "id": { const s = this.resolve(e.name, env, ctx); if (s) this.model.read(s, ctx.scope === "imperative", false, field); if (s?.kind === "comp") this.err("COMPONENT_AS_VALUE", "RG-4a", `component ${e.name} used as a value: apply it in a view`); if (s?.kind === "context") this.err("CONTEXT_AS_VALUE", "RG-7", `context ${e.name} used as a value: read the nearest provision with \`use ${e.name}\``); return; } + case "id": { if (this.valueIds.has(e)) return; const s = this.resolve(e.name, env, ctx); if (s) this.model.read(s, ctx.scope === "imperative", false, field); if (s?.kind === "comp") this.err("COMPONENT_AS_VALUE", "RG-4a", `component ${e.name} used as a value: apply it in a view, or pass it where JS takes a \`Component\`: a JS component's prop, or a JS factory's arguments (MO-11c, MO-11f)`); if (s?.kind === "context") this.err("CONTEXT_AS_VALUE", "RG-7", `context ${e.name} used as a value: read the nearest provision with \`use ${e.name}\``); return; } case "pre": { const s = this.resolve(e.name, env, ctx); if (s) this.model.read(s, ctx.scope === "imperative", true); if (s && !reactiveKinds.has(s.kind)) this.err("PRE_OF_NON_REACTIVE", "C-2", `pre of ${e.name}, which is not reactive`); if (e.init) go(e.init); return; } case "use": { if (ctx.server) this.err("SERVER_CAPTURE", "SF-3", `a server function's body reads \`use ${e.name}\`: a provision is the client's — pass it as an argument`); const c = this.ctxOf(e.name, env); if (c) this.model.read(c, ctx.scope === "imperative"); if (c && !c.ctx!.dflt) this.uses.push({ region: env.region, name: c.ctx!.key, line: this.line, display: e.name }); return; } case "peek": this.model.snap++; go(e.e); this.model.snap--; return; // E-5: a snapshot @@ -1108,14 +1121,17 @@ class Checker { case "el": { if (/^[A-Z]/.test(m.tag)) { const s = env.lookup(m.tag); - if (!s || s.kind !== "comp") { this.err("UNKNOWN_COMPONENT", "RG-4a", `<${m.tag}> is not a component declared here`); } - for (const a of m.attrs) this.comp(isHandler(a.name) ? "handler" : "prop", env, { at: `${m.tag} ${a.name}` }, () => this.expr(a.e, env, { scope: isHandler(a.name) ? "imperative" : "reactive" })); // R-2: an argument is read where the component reads it + if (!s || s.kind !== "comp") { this.err("UNKNOWN_COMPONENT", "RG-4a", s?.kind === "import" ? `<${m.tag}> is imported from JS, which declares no component ${m.tag}: declare it beside the module, \`export component ${m.tag}(props: { … })\` (MO-11a)` : `<${m.tag}> is not a component declared here`); } + const values = this.componentValues(m, env); + const args = m.attrs.filter(a => !values.has(a)); + for (const a of args) this.comp(isHandler(a.name) ? "handler" : "prop", env, { at: `${m.tag} ${a.name}` }, () => this.expr(a.e, env, { scope: isHandler(a.name) ? "imperative" : "reactive" })); // R-2: an argument is read where the component reads it if (s?.kind === "comp") { s.compRegion!.appSites.push(env.region); - this.apps.push({ comp: s.compRegion!, site: env.region, line: this.line, tag: m.tag, args: m.attrs.filter(a => !isHandler(a.name)).map(a => ({ field: a.name === "..." ? "*" : a.name, e: a.e, env })), passed: m.attrs.map(a => ({ field: a.name === "..." ? "*" : a.name, e: a.e, env })) }); // a spread is every field: "*" // R-2: passing is not reading — checked where the component reads it + this.apps.push({ comp: s.compRegion!, site: env.region, line: this.line, tag: m.tag, args: args.filter(a => !isHandler(a.name)).map(a => ({ field: a.name === "..." ? "*" : a.name, e: a.e, env })), passed: args.map(a => ({ field: a.name === "..." ? "*" : a.name, e: a.e, env })) }); // a spread is every field: "*" // R-2: passing is not reading — checked where the component reads it const kids = this.region(env.region, "children", { provParent: s.compRegion!, name: m.tag }); // RG-4c: children are named by the caller, owned where the component reads them kids.kids = { comp: s.compRegion!, site: env.region }; - this.markup(m.children, new Env(env, kids)); + this.markup([...m.children, ...values.values()], new Env(env, kids)); + if (s.factory && !this.expanding.markup.has(s)) { this.expanding.markup.add(s); this.markup(this.applied(this.factoryValues(s), m.line), new Env(s.factory.env, kids)); this.expanding.markup.delete(s); } // MO-11f: named where the factory was called } else this.markup(m.children, env); break; } @@ -1165,6 +1181,33 @@ class Checker { } } + /** MO-11c: a component passed to a JS component's `Component` prop — checked as applied, with no arguments, among the + * application's children (DV-24). */ + private componentValues(m: Extract, env: Env): Map["attrs"][number], Markup> { + const out = new Map["attrs"][number], Markup>(); + const sig = env.lookup(m.tag)?.compRegion?.sig; + for (const a of m.attrs) if (sig?.props[a.name]?.component && a.e.k === "id" && /^[A-Z]/.test(a.e.name) && env.lookup(a.e.name)?.kind === "comp") out.set(a, this.applied([a.e.name], m.line)[0]); + return out; + } + /** MO-11c: `` for each component value, as the checks see it. */ + private applied(tags: string[], line?: number): Markup[] { return tags.map(tag => ({ k: "el", tag, attrs: [], children: [], line }) as Markup); } + /** MO-11f: the factories being expanded, per walk (a factory may be given itself; C-1 reports that cycle). */ + private expanding = { markup: new Set(), content: new Set(), shape: new Set() }; + + /** MO-11f: the components in a factory call's arguments — anywhere inside its record and array literals. */ + private valueIds = new Set(); + private factoryValues(s: Sym): string[] { + const f = s.factory!; if (f.values) return f.values; + const out: string[] = []; + const walk = (e: Expr) => { + if (e.k === "id" && /^[A-Z]/.test(e.name) && f.env.lookup(e.name)?.kind === "comp") { this.valueIds.add(e); out.push(e.name); } // a value is applied by its tag, so it is capitalised as one + else if (e.k === "rec") { for (const x of e.fields) if (!("spread" in x)) walk(x.v); } + else if (e.k === "arr") { for (const x of e.items) if (x.k !== "spread") walk(x); } + }; + f.args.forEach(walk); + return f.values = out; + } + // ---- SV-7d: the content model across templates ---- private shapes = new Map(); // null: being computed (a recursive component contributes nothing more) /** What a component contributes where it is applied (content.ts Shape): from its signature, or its body. */ @@ -1184,7 +1227,11 @@ class Checker { const s = env.lookup(m.tag); if (s?.kind !== "comp") break; const S = this.shape(s); if (!anc.length) S.roots.forEach(r => roots.add(r)); if (S.closesP && !scoped) sh.closesP = true; if (S.a) sh.a = true; if (S.form) sh.form = true; - for (const ch of S.childrenAt) walk(m.children, [...anc, ...ch], scoped || ch.some(t => P_SCOPE.has(t)), env); // the caller's children, where that component puts them + for (const ch of S.childrenAt) { // the caller's children, where that component puts them + const sc = scoped || ch.some(t => P_SCOPE.has(t)); + walk([...m.children, ...this.componentValues(m, env).values()], [...anc, ...ch], sc, env); + if (s.factory && !this.expanding.shape.has(s)) { this.expanding.shape.add(s); walk(this.applied(this.factoryValues(s), m.line), [...anc, ...ch], sc, s.factory.env); this.expanding.shape.delete(s); } + } break; } if (!anc.length) roots.add(m.tag); @@ -1214,7 +1261,10 @@ class Checker { const s = env.lookup(m.tag); if (s?.kind !== "comp") break; const S = this.shape(s); for (const r of S.roots) report(r === "#text" ? `<${m.tag}>'s text` : `<${r}>, the root of <${m.tag}>,`, misnested(anc, r)); report(`<${m.tag}>`, misnestedInside(anc, S)); - for (const ch of S.childrenAt) this.contentCheck(m.children, env, [...anc, ...ch]); + for (const ch of S.childrenAt) { + this.contentCheck([...m.children, ...this.componentValues(m, env).values()], env, [...anc, ...ch]); + if (s.factory && !this.expanding.content.has(s)) { this.expanding.content.add(s); this.contentCheck(this.applied(this.factoryValues(s), m.line), s.factory.env, [...anc, ...ch]); this.expanding.content.delete(s); } + } break; } report(`<${m.tag}>`, misnested(anc, m.tag)); diff --git a/ref/src/parse.ts b/ref/src/parse.ts index 69cb7c9..4b77474 100644 --- a/ref/src/parse.ts +++ b/ref/src/parse.ts @@ -1,5 +1,7 @@ // Hand-rolled scanner/parser for the abstract syntax (spec/02-core.md §1). import type { Decl, Expr, Markup, Param, Program, Stmt, Target } from "./ast.ts"; +import type { Shape as ContentShape } from "./content.ts"; +import { contentShapeOf } from "./signature.ts"; /** A parse error; `code` names the habit it looks like when `hint` recognises one (guide/diagnostics.md), else `PARSE`. */ export class ParseError extends Error { code = "PARSE"; } @@ -134,16 +136,21 @@ export function parseProgram(src: string): Program { export type JsDecl = | { kind: "fn"; name: string; pure: boolean; params: string[]; rejects?: string[]; line: number; side?: "server" | "client"; ptypes?: (string | undefined)[]; ret?: string; refs?: boolean[]; shape?: Shape; instantiates?: boolean } // side: 12 SF-5; types: text, for TY (types/), `ref` taken out; MO-10: refs (ref parameters), shape (what its result is), instantiates (written, or inferred from a ref) | { kind: "error"; name: string; line: number } - | { kind: "type"; name: string; line: number; text?: string }; + | { kind: "type"; name: string; line: number; text?: string } + | { kind: "comp"; name: string; line: number; props: JsProp[]; fails: string[]; content?: ContentShape }; // MO-11a: a Solid component +/** MO-11a: a declared JS component's prop — its TypeScript type, what the component discharges for it, and whether it takes a + * component value (MO-11c: its type is `Component`). */ +export type JsProp = { name: string; optional: boolean; type?: string; catches?: string[] | "*"; component: boolean }; export function parseDeclarations(src: string): JsDecl[] { const c = new Cursor(src); const out: JsDecl[] = []; for (;;) { c.ws(); if (c.eof()) return out; const line = c.line; - if (!c.keyword("export")) c.fail("a declaration file declares exports: `export pure fn …`, `export fn …`, `export error …`, `export type …`"); + if (!c.keyword("export")) c.fail("a declaration file declares exports: `export pure fn …`, `export fn …`, `export component …`, `export error …`, `export type …`"); c.ws(); if (c.startsWith("type") || c.startsWith("interface")) { const t0 = c.i; c.keyword("type") || c.keyword("interface"); c.ws(); const name = c.ident() ?? c.fail("type name"); c.ws(); if (c.eat("=")) { c.ws(); parseType(c, false, true); } else if (c.startsWith("{")) { let depth = 0; do { if (c.peek() === "{") depth++; if (c.peek() === "}") depth--; c.i++; } while (!c.eof() && depth); } out.push({ kind: "type", name, line, text: c.src.slice(t0, c.i) }); continue; } if (c.keyword("error")) { c.ws(); const name = c.ident() ?? c.fail("error class name"); out.push({ kind: "error", name, line }); continue; } + if (c.keyword("component")) { out.push(parseJsComponent(c, line)); continue; } const side = c.keyword("server") ? "server" as const : c.keyword("client") ? "client" as const : undefined; c.ws(); // 12 SF-5: runs on one side only const pure = c.keyword("pure"); c.ws(); if (!c.keyword("fn")) c.fail("fn"); @@ -156,15 +163,58 @@ export function parseDeclarations(src: string): JsDecl[] { c.ws(false); let rejects: string[] | undefined; if (c.keyword("rejects")) { c.ws(); c.expect("("); rejects = parseClasses(c) ?? ["Error"]; c.ws(); c.expect(")"); } c.ws(false); const written = c.keyword("instantiates"); - const instantiates = written || refs.some(x => x) || shapeHasRef(shape); // MO-10a: inferred from a ref on either side + const instantiates = written || refs.some(x => x) || shapeHasRef(shape) || shape === "comp"; // MO-10a: inferred from a ref on either side; MO-11f: a component handed back out.push({ kind: "fn", name, pure, params, rejects, line, ptypes, ret, ...(side ? { side } : {}), ...(instantiates ? { refs, shape: shape ?? "value", instantiates } : {}) }); } } +/** MO-11: `Component` or `Component

`, and nothing that only contains one (`Component[]`). */ +function isComponentType(t: string): boolean { + const m = /^Component\s*/.exec(t); if (!m) return false; const r = t.slice(m[0].length); if (!r) return true; if (r[0] !== "<") return false; + let depth = 0; for (let i = 0; i < r.length; i++) { if (r[i] === "<") depth++; else if (r[i] === ">" && r[i - 1] !== "=" && --depth === 0) return i === r.length - 1; } + return false; +} +/** MO-11a: `component A(props: { href: string; children?: View }) fails(…) shape(…)`. */ +function parseJsComponent(c: Cursor, line: number): JsDecl { + c.ws(); const name = c.ident() ?? c.fail("component name"); c.ws(); c.expect("("); c.ws(); + const props: JsProp[] = []; + if (!c.startsWith(")")) { + if (c.ident() !== "props") c.fail("a component's props: `(props: { … })`"); c.ws(); c.expect(":"); c.ws(); c.expect("{"); + for (;;) { + c.ws(); if (c.eat("}")) break; + const fname = c.ident() ?? c.fail("a prop name"); c.ws(false); const optional = c.eat("?"); c.ws(false); + let type: string | undefined; let catches: string[] | "*" | undefined; + if (c.eat(":")) { + c.ws(false); + if (c.keyword("catches")) { c.ws(false); c.expect("("); c.ws(); if (c.eat("*")) catches = "*"; else catches = parseClasses(c) ?? "*"; c.ws(); c.expect(")"); c.ws(false); } + const t0 = c.i; let depth = 0; + while (!c.eof()) { + const ch = c.peek(); if (depth === 0 && (ch === ";" || ch === "," || ch === "}" || ch === "\n" || c.startsWith("//"))) break; + if (ch === '"' || ch === "'" || ch === "`") { c.i++; while (!c.eof() && c.peek() !== ch) { if (c.peek() === "\\") c.i++; c.i++; } c.i++; continue; } // a string literal type + if ("([{<".includes(ch)) depth++; else if (")]}".includes(ch) || (ch === ">" && c.src[c.i - 1] !== "=")) depth--; c.i++; + } + type = c.src.slice(t0, c.i).trim() || undefined; + } + props.push({ name: fname, optional, ...(type ? { type } : {}), ...(catches ? { catches } : {}), component: !!type && isComponentType(type) }); + c.ws(false); if (!c.eat(";") && !c.eat(",") && c.peek() !== "\n") { c.ws(); c.expect("}"); break; } // a newline separates fields too, as in TypeScript + } + c.ws(); + } + c.expect(")"); + let fails: string[] = []; let content: ContentShape | undefined; + for (;;) { + c.ws(false); + if (c.keyword("fails")) { c.ws(false); c.expect("("); fails = parseClasses(c) ?? ["Error"]; c.ws(); c.expect(")"); continue; } + if (c.keyword("shape")) { c.ws(false); c.expect("("); const t0 = c.i; while (!c.eof() && c.peek() !== ")") c.i++; const t = c.src.slice(t0, c.i); c.expect(")"); try { content = contentShapeOf(t); } catch (e) { c.fail((e as Error).message); } continue; } + break; + } + return { kind: "comp", name, line, props, fails, ...(content ? { content } : {}) }; +} + /** MO-10b: what a primitive's result is — a reader (`ref T`, an accessor), an io function, a value; or a tuple or record of * those (only the result and its parts are declared). */ export type Part = "ref" | "fn" | "value"; -export type Shape = Part | { items: Part[] } | { fields: [string, Part][] }; +export type Shape = Part | "comp" | { items: Part[] } | { fields: [string, Part][] }; // "comp": the whole result is a component (MO-11f) function splitTop(s: string, seps: string): string[] { const out: string[] = []; let depth = 0, from = 0; for (let i = 0; i < s.length; i++) { const ch = s[i]; if ("([{<".includes(ch)) depth++; else if (")]}>".includes(ch) && !(ch === ">" && s[i - 1] === "=")) depth--; else if (depth === 0 && seps.includes(ch)) { out.push(s.slice(from, i)); from = i + 1; } } @@ -178,6 +228,7 @@ function partOf(t: string): Part { function shapeOf(t: string): Shape { if (t.startsWith("[") && t.endsWith("]")) return { items: splitTop(t.slice(1, -1), ",").map(partOf) }; if (t.startsWith("{") && t.endsWith("}")) return { fields: splitTop(t.slice(1, -1), ",;").map(f => { const i = f.indexOf(":"); return [f.slice(0, i).trim().replace(/\?$/, ""), partOf(f.slice(i + 1).trim())] as [string, Part]; }) }; + if (isComponentType(t)) return "comp"; return partOf(t); } export function shapeHasRef(s?: Shape): boolean { return !!s && (s === "ref" || (typeof s === "object" && ("items" in s ? s.items : s.fields.map(f => f[1])).includes("ref"))); } diff --git a/ref/src/signature.ts b/ref/src/signature.ts index d1a79da..a8900d2 100644 --- a/ref/src/signature.ts +++ b/ref/src/signature.ts @@ -105,7 +105,8 @@ function rowWords(s: string): { rows: string[]; fails: string[]; rest: string } for (; i < words.length; i++) { const m = ROW.exec(words[i]); if (!m) break; if (m[2] !== undefined) fails = classesOf(m[2]); else rows.push(m[1]); } return { rows, fails, rest: words.slice(i).join(" ") }; } -function shapeOf(s: string | undefined): Shape { +/** SV-7d's words, `roots p div; closes-p; children-at div` (MO-9b; a JS component's, MO-11a). */ +export function contentShapeOf(s: string | undefined): Shape { if (!s) return { ...EMPTY_SHAPE, roots: [], childrenAt: [] }; const sh: Shape = { roots: [], closesP: false, a: false, form: false, childrenAt: [] }; for (const p of s.split(";").map(x => x.trim()).filter(Boolean)) { @@ -158,7 +159,7 @@ export function parseSignature(src: string, file: string): ModSig { const fl = /\bfails\(([^)]*)\)/.exec(tail); if (fl) sig.own = classesOf(fl[1]).map(c => ({ cls: c, what: `${m![1]}'s root`, at: file.split("/").pop() })); const us = /\buses\(([^)]*)\)/.exec(tail); if (us) sig.requires = us[1].split(",").map(k => absKey(file, k.trim())); const so = /\bretains\(([^)]*)\)/.exec(tail); if (so) { sig.stored = {}; for (const x of split(so[1], ",")) { const i = x.indexOf(":"); const f0 = x.slice(0, i).trim(); const f = f0 === "...props" ? "*" : f0; const t = x.slice(i + 1).trim(); (sig.stored[f] ??= []).push(t === "module" ? t : absKey(file, t)); } } // O-3 - sig.shape = shapeOf(/\bshape\(([^)]*)\)/.exec(tail)?.[1]); + sig.shape = contentShapeOf(/\bshape\(([^)]*)\)/.exec(tail)?.[1]); exports.set(m[1], { kind: "comp", sig }); return; } if ((m = /^([A-Za-z_$][\w$]*)\s*(?::\s*(.*))?$/.exec(s))) { const r = rowWords(m[2] ?? ""); if (r.rest) fail("an equation's rows"); exports.set(m[1], { kind: "eq", rows: r.rows.filter(x => x !== "io"), fails: r.fails, io: r.rows.includes("io") }); return; } diff --git a/ref/types/project.ts b/ref/types/project.ts index 4cded7c..fc33188 100644 --- a/ref/types/project.ts +++ b/ref/types/project.ts @@ -107,12 +107,15 @@ function signatureStub(sig: ModSig): string { } /** MO-8: a `.d.rx` as TypeScript's `.d.ts` for the same JS module. */ function declarationFile(decls: JsDecl[]): string { - return decls.map(d => { + const own = new Set(decls.filter(d => d.kind === "type").map(d => d.name)); + const head = decls.some(d => d.kind === "comp" || (d.kind === "fn" && d.shape === "comp")) ? [...own.has("View") ? [] : ["type View = import(\"@solidjs/web/jsx-runtime\").JSX.Element;"], ...own.has("Component") ? [] : ["type Component

= (props: P) => View;"]] : []; // MO-11a: a field's types + return [...head, ...decls.map(d => { if (d.kind === "type") return `export ${d.text ?? `type ${d.name} = any`}`; + if (d.kind === "comp") return `export declare function ${d.name}(props: { ${d.props.map(p => `${p.name}${p.optional ? "?" : ""}: ${p.type ?? "any"};`).join(" ")} }): View;`; if (d.kind === "error") return `export declare class ${d.name}$ extends Error {}\nexport declare const ${d.name}: (message?: string) => ${d.name}$;\nexport type ${d.name} = ${d.name}$;`; const ps = d.params.map((n, i) => `${n}${d.ptypes?.[i] ? `: ${d.ptypes[i]}` : ""}`).join(", "); return `export declare function ${d.name}(${ps}): ${d.ret ?? "any"};`; - }).join("\n") + "\n"; + })].join("\n") + "\n"; } class Proj { diff --git a/ref/types/run.ts b/ref/types/run.ts index e06ac25..dead019 100644 --- a/ref/types/run.ts +++ b/ref/types/run.ts @@ -44,6 +44,7 @@ function add(sc: Scenario, title: string, src: string, expect: string | null) { const rel = relative(root, p.path.startsWith("/") ? p.path : "/" + p.path); const f = join(dir, rel); mkdirSync(dirname(f), { recursive: true }); writeFileSync(f, p.code); files.set(f, p); } + for (const [p, text] of sc.modules) if (/(^|\/)node_modules\/.*package\.json$/.test(p)) { const f = join(dir, relative(root, p.startsWith("/") ? p : "/" + p)); mkdirSync(dirname(f), { recursive: true }); writeFileSync(f, text); } // MO-10c: a package's own, so its projected `.d.ts` resolves writeFileSync(join(dir, "__prelude.ts"), PRELUDE); units.push({ sc: sc.name, title, expect, dir, files }); } diff --git a/scenarios/S-module-5.md b/scenarios/S-module-5.md new file mode 100644 index 0000000..a0b94e9 --- /dev/null +++ b/scenarios/S-module-5.md @@ -0,0 +1,160 @@ +# S-module-5 — Solid components from JS: applied, and given components as values + +Rules: MO-11a, MO-11b, MO-11c, DV-24, MO-10c, R-f-5, RG-7b, SV-7d, RG-4a, TY-4b. + +## Modules + +```json +-- module: ./node_modules/@demo/ui/package.json +{ "name": "@demo/ui", "types": "./dist/index.d.ts", "exports": { ".": { "solid": "./dist/index.jsx", "default": "./dist/index.js" } } } +``` + +```rx +-- module: ./node_modules/@demo/ui/dist/index.d.rx +// a Solid component library, declared beside the file its import resolves to (MO-10c) +export component Shell(props: { + header?: Component // a field per line, as in TypeScript + children?: View +}) shape(roots div; closes-p; children-at div) +export component Link(props: { href: string; children?: View }) shape(roots a; a; children-at a) +export component Divider(props: { mark: "," | ";" | "}"; pages?: Component[] }) +export pure fn pageList(): Component[] +export pure fn pick(): Component<{}> | Array +``` + +```rx +-- module: ./theme.rx +export context theme: string +export component Swatch() { + {use theme} +} +``` + +`Shell` renders the component it is given as `header`, then its children, +in a `

`; `Link` renders an `` around its children (MO-11a). + +## Program + +```rx +import pure { fetch, len } from "./prelude" +import { Shell, Link, Divider, pageList, pick } from "@demo/ui" + +component Title() { + name = await fetch("name") +

{name}

+} +export default component App() { + pages = pageList() + picked = pick() + errored fallback(e) {

failed

} { + home

{len(pages)} {picked.length}

+ } +} +``` + +Accepted. `Title` is passed as a value to `header`, typed `Component`, and +checked as applied among ``'s children (MO-11c), so its fetch's +`Error` is discharged by the `errored` above ``. A string literal type +with `;` in it is one field, and `Component[]` or +`Component<{}> | Array` is a value, not a component (only +`Component` and `Component

` are). The runners that execute have no +`@demo/ui` to run. + +## Rejected variants + +```rx +-- reject: UNHANDLED_FAILURE +import pure { fetch } from "./prelude" +import { Shell } from "@demo/ui" +component Title() { + name = await fetch("name") +

{name}

+} +export default component App() { + +} +``` + +What a component value lets out is checked above the application it is +passed to, on every path, whether or not the library renders it (MO-11c). + +```rx +-- reject: COMPONENT_AS_VALUE +import { Link } from "@demo/ui" +component Home() { home } +export default component App() { + +} +``` + +`href` is a `string`, not a `Component`: a component is a value only where +a JS component's declaration asks for one. + +```rx +-- reject: COMPONENT_AS_VALUE +component Home() { home } +component Frame(props) {
{props.page}
} +export default component App() { + +} +``` + +An `.rx` component cannot take one: it has no way to apply a value. + +```rx +-- reject: UNKNOWN_COMPONENT +import { Badge } from "./badge.js" +export default component App() { + +} +``` + +A JS import with no declaration is not a component (MO-11a): the message +names the line to write in `./badge.d.rx`. + +```rx +-- reject: CONTEXT_NOT_FOUND +import { Shell } from "@demo/ui" +import { theme, Swatch } from "./theme.rx" +export default component App() { + +} +``` + +RG-7b over a component value: `Swatch` uses `theme`, and nothing above +`` provides it. + +```rx +-- reject: CONTENT_MODEL +import { Link } from "@demo/ui" +export default component App() { +
home +} +``` + +`Link`'s declared shape is an ``, which the HTML parser does not nest in +another (SV-7d over a JS component's `shape(…)`). + +```rx +-- reject: CONTENT_MODEL +import { Shell } from "@demo/ui" +component Back() { back } +component Wrapped() { } +export default component App() { + +} +``` + +A component value counts in the shape of the component that passes it: +`Wrapped` contains an ``, through `Shell`'s `header`. + +```rx +-- types: TS2322 +import { Link } from "@demo/ui" +export default component App() { + home +} +``` + +TY-4b: the declaration's field types are the component's props, checked +where it is applied (`href` is a `string`). diff --git a/scenarios/S-module-6.md b/scenarios/S-module-6.md new file mode 100644 index 0000000..58441b8 --- /dev/null +++ b/scenarios/S-module-6.md @@ -0,0 +1,218 @@ +# S-module-6 — a router: a component factory, its route table, its hooks, and per-route data + +Rules: MO-11d, MO-11e, MO-11f, DV-24, MO-10a, MO-10b, MO-10c, R-f-5, RG-7b, R-io-3. + +## Modules + +```json +-- module: ./node_modules/@solidjs/router/package.json +{ "name": "@solidjs/router", "types": "./dist/index.d.ts", "exports": { ".": { "solid": "./dist/index.jsx", "default": "./dist/index.js" } } } +``` + +```rx +-- module: ./node_modules/@solidjs/router/dist/index.d.rx +// what @solidjs/router 2.0 would ship beside dist/index.jsx (MO-10c); an app without it re-exports the router from a local module +export type Params = Record +export type RouteDefinition = { path?: string; component?: any; children?: RouteDefinition[] } +export pure fn createRouter(config: { routes: RouteDefinition[] }): Component +export pure fn useParams(): Params instantiates +export pure fn useNavigate(): (to: string) => void instantiates +``` + +```rx +-- module: ./theme.rx +export context theme: string +export component Swatch() { + {use theme} +} +``` + +`createRouter` hands back a component, so its call is an instantiation +(MO-10a, inferred from the `Component` result) and the name it is bound to +is applied as a component (MO-11f). Links are plain ``: the router +handles their clicks. + +## Program + +```rx +import pure { fetch } from "./prelude" +import { createRouter, useParams, useNavigate } from "@solidjs/router" + +component Layout(props) { + +
{props.children}
+} +component Home() { + navigate = useNavigate() + action meetBo() { navigate("/people/2") } +

Home

+} +component Person() { + params = useParams() + person = await fetch(`person:${params.id}`) +

{person.name}

+} +Router = createRouter({ routes: [{ path: "/", component: Layout, children: [{ path: "/", component: Home }, { path: "/people/:id", component: Person }] }] }) + +export default component App() { + errored fallback(e) {

failed

} { + loading fallback {

loading…

} { + + } + } +} +``` + +Accepted. The route table names `Layout`, `Home` and `Person` as values: +each is checked as applied among the children of ``, so the +`errored` above it discharges `Person`'s `Error` (MO-11f, R-f-5). `params` +is the router's store, read as a value whose fields track (MO-11d), and +`person` is asked again when `params.id` changes (MO-11e). `navigate` is io, +called from an action. The runners that execute have no router to run; the +Vite suite builds `examples/router/` and navigates it in Chromium. + +## Rejected variants + +```rx +-- reject: UNHANDLED_FAILURE +import pure { fetch } from "./prelude" +import { createRouter, useParams } from "@solidjs/router" +component Person() { + params = useParams() + person = await fetch(`person:${params.id}`) +

{person.name}

+} +Router = createRouter({ routes: [{ path: "/people/:id", component: Person }] }) +export default component App() { + +} +``` + +A route the router may render is checked where the router is applied: its +`Error` escapes ``, with no boundary above (MO-11f). + +```rx +-- reject: CONTEXT_NOT_FOUND +import { createRouter } from "@solidjs/router" +import { theme, Swatch } from "./theme.rx" +component Layout(props) { + provide theme = "dark" +
{props.children}
+} +Router = createRouter({ routes: [{ path: "/", component: Layout, children: [{ path: "/", component: Swatch }] }] }) +export default component App() { + +} +``` + +DV-24: the router renders `Swatch` inside `Layout`, where `theme` is +provided, but the checker does not know that: both are checked as children +of ``. Providing `theme` above `` is accepted. + +```rx +-- reject: COMPONENT_AS_VALUE +import { createRouter } from "@solidjs/router" +component Home() {

Home

} +export Router = createRouter({ routes: [{ path: "/", component: Home }] }) +export default component App() { + +} +``` + +A component a factory made is applied in its own module, where the checker +knows what it was given (MO-11f). + +```rx +-- reject: COMPONENT_AS_VALUE +import { useParams } from "@solidjs/router" +component Home() {

Home

} +export default component App() { + params = useParams(Home) +

{params.id}

+} +``` + +Only a function declared to hand back a `Component` takes components in its +arguments. + +```rx +-- reject: IO_IN_REACTIVE +import { useNavigate } from "@solidjs/router" +export default component App() { + navigate = useNavigate() + done = navigate("/") +

{done}

+} +``` + +`useNavigate` hands back a function, which is io (MO-10b, R-io-3). + +```rx +-- reject: UNHANDLED_FAILURE +import pure { fetch } from "./prelude" +import { createRouter } from "@solidjs/router" +component Page() { + name = await fetch("name") +

{name}

+} +Inner = createRouter({ routes: [{ path: "/", component: Page }] }) +Outer = createRouter({ routes: [{ path: "/", component: Inner }] }) +export default component App() { + +} +``` + +A factory's component handed to another factory brings what it was given +with it: `Page` is checked under ``. + +```rx +-- reject: CONTENT_MODEL +import { createRouter } from "@solidjs/router" +component Home() {
home
} +Router = createRouter({ routes: [{ path: "/", component: Home }] }) +export default component App() { +

+} +``` + +A factory's component renders what it was given where it is applied, with no +element of its own: `Home`'s `
` would close the `

` (SV-7d). + +```rx +-- reject: C-1 +import { createRouter } from "@solidjs/router" +Router = createRouter({ routes: [{ path: "/", component: Router }] }) +export default component App() { + +} +``` + +A factory given its own component is a cycle in the static graph (C-1). + +```rx +-- reject: UNHANDLED_FAILURE +import pure { fetch } from "./prelude" +import { createRouter } from "@solidjs/router" +component Home() {

Home

} +Router = createRouter({ routes: [{ path: "/", component: Home }], base: await fetch("base") }) +export default component App() { + errored fallback(e) {

failed

} { } +} +``` + +The factory is called once, where it is bound: its arguments are read +there, so a failure in them needs a boundary above the binding, not above +`` (MO-11f). + +```rx +-- reject: COMPONENT_AS_VALUE +import { createRouter } from "@solidjs/router" +import { Swatch as swatch } from "./theme.rx" +Router = createRouter({ routes: [{ path: "/", component: swatch }] }) +export default component App() { + +} +``` + +A component value is applied as its tag would be, so its name is +capitalised like one (RG-4a). diff --git a/spec/09-modules.md b/spec/09-modules.md index b62221e..1ae5013 100644 --- a/spec/09-modules.md +++ b/spec/09-modules.md @@ -296,6 +296,166 @@ export component Search(props: { start: boolean, q: string }) { Solid's batching, and AC-1d cannot say so; the guide teaches it. A plain closure passed as a callback option is unchecked, as O-4a's is. +## MO-11 — Solid components and routers (taken by default 2026-11-13; veto here) + +A Solid component is a JS function from props to a view. Its declaration +(MO-8) says so, and `.rx` applies it as it applies its own. A JS function may +also hand back a component, which is how `@solidjs/router` 2.0 makes its +router from a route table, and either may take components as values where +the declaration asks for them. Nothing is added at run time: an application +is the same JSX, a factory's call is MO-10's once-only call, and a component +value is the compiled component. + +```rx +// router.d.rx, beside ./router.js (`export { createRouter, useParams, useNavigate } from "@solidjs/router"`) +export type Params = Record +export type RouteDefinition = { path?: string; component?: Component; children?: RouteDefinition[] } +export pure fn createRouter(config: { routes: RouteDefinition[] }): Component +export pure fn useParams(): Params instantiates +export pure fn useNavigate(): (to: string) => void instantiates +``` + +```rx +import { createRouter, useParams } from "./router.js" +import { fetchPerson, NotFound } from "./people.js" + +component Layout(props) { + // the router handles a link's click +
{props.children}
+} +component Home() {

People

} +component Person() { + params = useParams() // once, in the page's region (MO-10a); a store: params.id tracks + person = await fetchPerson(params.id) // per-route data: asked again when the param changes + errored fallback(e: NotFound) {

{e.message}

} { + loading fallback {

loading…

} {

{person.name}

} + } +} +Router = createRouter({ routes: [{ path: "/", component: Layout, children: [ + { path: "/", component: Home }, { path: "/people/:id", component: Person }] }] }) + +export default component App() { + +} +``` + +- **MO-11a (Declared components).** A JS module's `.d.rx` may declare a + component: `export component Link(props: { href: string; children?: View })`. + Each field is a TypeScript type (TY-4b): `View` for a view (07 §6), + `Component` (`Component

`) for a component value (MO-11c). A field may + start with `catches(…)`, what the component discharges for it (MO-3). + After the props, `fails(…)` says what escapes its root and `shape(…)` what + SV-7d needs where it is applied (MO-9b's words). Absent, each is empty. + The application is any component's, `home`, and it + lowers to the import as a JSX tag; with no children it is JSX's ``, + with no `children` prop (an `.rx` component's is an empty view). A JS + import applied without a declaration is `UNKNOWN_COMPONENT`, and the + message names the line to write. +- **MO-11b (What the checks see).** A declared JS component is one step of + the static application tree, with its declaration as its summary (MO-3): + an argument's failures, and its children's (RG-4c), continue at the + application unless the field `catches` them, a prop it does not declare + included; it needs no provision. What its own body reads is outside the + graph (CN-3b): its pending is not seen, and its failures are not in the + row unless `fails(…)` names them. Solid's boundaries take them at run + time, as they take a panic (CN-6b). +- **MO-11c (Components as values).** A component, declared in this module + or imported, and named with a capital as its tag would be, may be the value of a JS component's prop whose declared type + is `Component` (``), or sit anywhere inside the + record and array literals of a factory's arguments (MO-11f). Those are the + only places a component is a value (`COMPONENT_AS_VALUE` elsewhere): `.rx` + has no way to apply a value, so only JS takes one. The checker checks it + as applied there, with no arguments, among the children of the + application (for a factory, of every application of the component it made): + what escapes it (R-f-5), the provisions it uses (RG-7b), a first load it + waits on (CN-3d) and its content model (SV-7d) are checked above that + application, on every path, whether or not the library ever renders it. + Its props are the library's (`params`, `location`, `children`) and carry + no rows. It lowers to the compiled component itself. +- **MO-11d (Hooks).** A library's hooks are MO-10's primitives. `useParams` + and `useLocation` hand back a store, a value whose fields track at run + time (MO-10b), so `id = params.id` follows the URL. `useNavigate` hands + back a function, which is io (R-io-3) and called from an action or a + handler. Each is called once, in the component's region (MO-10a), which + is where Solid's `useContext` finds the router. A hook called with no + router above throws at run time; the checker does not know the router's + context. +- **MO-11e (Route data).** A route's data is an `await` equation over its + params, in the route's component: `person = await fetchPerson(params.id)`. + A change of `params.id` is a new question, held as any re-ask is (H-2); a + newly matched route mounts its component, whose question holds where + nothing above it shows a fallback. Its failures and its first load are + checked above `` or in the page (MO-11c). The router's `query()` + is a JS function: wrapped in a JS module and declared `pure fn`, it is + awaited like any other, and its cache is the library's. A route's + `preload` is a closure handed to JS, unchecked (MO-10e). +- **MO-11f (Component factories).** A JS fn whose declared result is + `Component` hands back a component: `createRouter(config): Component`. Its + call is an instantiation (MO-10a; `instantiates` is inferred from the + result), so it is bound once (`Router = createRouter({ … })`), and the + bound name is a component, applied in a view (``) and never read + as a value. The components in its arguments are values there (MO-11c). + The checker knows what the call was given only in the module that makes + it, so the made component is applied there: `export Router = …` is + `COMPONENT_AS_VALUE`; export the components it takes instead. For SV-7d + the made component has no element of its own: what it was given is + checked where it is applied, and counts in the shape of the component that + applies it (as a component value does). Links need + nothing of the language: the router handles a click on any ``. +- **DV-24 (a component value is owned at its application) — taken by + default 2026-11-13.** Solid lets a library render a component value + anywhere: under any owner, any number of times, or never. MO-11c's check + assumes it is rendered beneath the owner of the application it was passed + to, which is what `@solidjs/router` does (every route renders inside + ``, so an `errored` or a `provide` above `` reaches it). + A library that renders one under a root of its own escapes the check + (CN-3b); Solid's run-time errors stand there. The assumption also gives + away precision: a route component is checked as a child of ``, + not of its parent route's `props.children`, so a `provide` or an + `errored` in a layout route is not credited to the routes it wraps. A + layout that provides a context to its pages is refused + (`CONTEXT_NOT_FOUND`); provide it above ``. + +### Design note: why declared components and factories, not a routing form + +`@solidjs/router` 2.0 (`2.0.0-next.44`) is a route table, not markup: +`createRouter({ routes })` returns the router component, a route is an +object whose `component` is a component, and a link is a plain ``. +Three ways were weighed for using it from `.rx`. + +- **(a) Declared JS components and factories (taken).** MO-11a to MO-11f. + The router's own API is usable as it is, and so is any Solid component + library: two declaration forms and one value rule, with no router + semantics in the language. What the checks keep across the boundary: + R-f-5 and RG-7b over every route as if rendered at `` (MO-11c); + CN-3d over every route's first load, so a client-only app cannot ship a + page that shows nothing while it loads; SV-7d where a declaration writes + `shape(…)`; owner order on both sides is Solid's, since the router and + the compiled components are both Solid code (CP-4a). What they lose: + which route renders when, a layout route's boundaries and provisions + (DV-24), and the hooks' context (MO-11d). Types are TypeScript's, from the + router's own `.d.ts`, in `rx-tsc` (TY-1c). +- **(b) A routing form in the language** (`routes { "/people/:id" => Person }`, + compiled onto `createRouter`). The checker would know the route tree, so + it could credit a layout's `errored` and `provide`, type each route's + params from its path, and check a link's target. The cost is a form whose + meaning is one library's, a second place to follow the router's releases + (its 2.0 API moved from ``/`` markup to a route table while + this was written), and no help for any other component library. Most of + what it would prove can be added to (a) later as declaration words. +- **(c) Something smaller.** Only the hooks from `.rx`, with the route table + in TSX. That needs TSX to import an `.rx` page as a Solid component, but a + compiled module's default export is a function of the app (CP-4a), not a + component, and the projection's default export is drafted elsewhere (#65). + It also checks nothing across the routes. (a) does not need it; it + remains a separate interop question. + +Executable: S-module-5 (declared components and component values, checked +only), S-module-6 (the router: a factory, hooks, route data; checked only, +against a package that ships its `.d.rx`), and `examples/router/`, built by +Vite with `@solidjs/router` 2.0.0-next.44 and driven in Chromium +(`compiler/test/vite.mjs`). + --- ## Diagnostics this document adds @@ -307,6 +467,7 @@ export component Search(props: { start: boolean, q: string }) { | `MODULE_CYCLE` | S | MO-5 | | `NOT_EXPORTED`, `MODULE_NOT_FOUND` | S | MO-6a; MO-8b for a declared JS module | | `CONTEXT_NOT_FOUND` across modules | S | MO-7, RG-7b | +| `UNKNOWN_COMPONENT` for an undeclared JS component, `COMPONENT_AS_VALUE` outside MO-11c's places | S | MO-11a, MO-11c, MO-11f | ## Implementation @@ -374,3 +535,30 @@ export component Search(props: { start: boolean, q: string }) { re-export declares it; a project-level folder of declarations keyed by package was the alternative, not taken. Built in `ref/` and the crate (S-module-4). +8. **Solid components and factories are declared, and components are values + only to JS** (MO-11; taken by default 2026-11-13, veto here). Open for a + maintainer: + - (a) over a routing form (b): the design note above. + - A JS component's fields are TypeScript types, with `catches(…)` as the + only row word on a field. Its own failures default to none (CN-6b), not + to `Error` as a JS fn's rejections do (MO-8a): `.rx` does not await a + component's body. + - A component is a value only on a `Component` prop of a JS component or + inside a factory's argument literals; an `.rx` component cannot take + one (it would need a form to apply a value). + - A factory's component is applied only in the module that makes it + (MO-11f). Exporting it would need its summary to carry the escaping + rows of the components it was given. + - DV-24: a component value is checked as applied among the children of + the application it reaches. A word that says where the library renders + it (a route's `component` inside its parent's `props.children`) would + credit layout boundaries and provisions; not added. + - The hooks' context is not checked statically (MO-11d). A `uses(…)` + word naming a JS context, satisfied by the factory's component, would + make `useParams` outside the router a compile error; not added. + - `.rx` has no type arguments, so `useParams<{ id: string }>()` cannot be + written: a param is the router's `string | undefined` in `rx-tsc`. + `defineRoute`'s typed params, or a declaration word for them, are open. + - Not covered: TSX importing an `.rx` page (CP-4a's default export, #65), + and the router under SSR (`ref/ssr` runs no JS component; a server + render needs the router's `url`). diff --git a/spec/10-server.md b/spec/10-server.md index fed1edf..417e795 100644 --- a/spec/10-server.md +++ b/spec/10-server.md @@ -160,6 +160,8 @@ by default — see the list at the end. ## Divergences from Solid's server render and hydration (`ref/ssr/`) +DV-24 (a component value handed to JS is checked as owned where it is passed) is a static assumption, not a run-time divergence; it is recorded with its rule, 09 MO-11. + Each has a standalone repro with nothing of ReactiveScript in it — one `app.jsx` compiled by `@solidjs/compiler` for both sides — in `ref/oracle/repros/`, re-run on `next` @ `0847122` (2026-10-26). diff --git a/spec/99-diagnostics.md b/spec/99-diagnostics.md index e4a3b5b..5841ca0 100644 --- a/spec/99-diagnostics.md +++ b/spec/99-diagnostics.md @@ -89,7 +89,7 @@ named. | `CYCLE` | S | C-1 (CN-3a: union of branches; only an equation's own `pre` breaks it) | S-check-1, S-core-1 | | `PRE_OF_NON_REACTIVE` | S | C-2 | S-check-1 | | `UNBOUND` | S | lexical scope (a component sees its declaration site, RG-7a) | S-check-1 | -| `UNKNOWN_COMPONENT`, `COMPONENT_AS_VALUE` | S | RG-4a | S-check-1, S-region-5 | +| `UNKNOWN_COMPONENT`, `COMPONENT_AS_VALUE` | S | RG-4a; MO-11a, MO-11c, MO-11f for JS components and factories | S-check-1, S-region-5, S-module-5, S-module-6 | | `INSTANTIATION_NOT_BOUND` | S | E-9 / RG-6 | S-region-6 | | `SNAPSHOT_ARGUMENT` | S | E-10 | S-region-6 | | `SNAPSHOT_INITIALIZER` | S | E-4 / DV-6 | S-surface-1 |