Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Working agreement — solid-layouts

The operating contract for **any** coding agent working in this repository. Codex, Cursor
and Gemini CLI read `AGENTS.md` natively; Claude Code loads it through the `@AGENTS.md`
import in [`CLAUDE.md`](CLAUDE.md). Never fork these rules into a per-vendor file.

**JavaScript/TypeScript** monorepo (`packages/`, `Test-UI/`).

## Invariants (do not break these)

- **No Python.** Not a script, not `python3 -c`, not a heredoc. Reaching for it is the
tell that a step is being solved by parsing when the tool that owns the answer could
just be asked. Do not swap it for another parser either, and do not assume `jq` is
present: it does not ship with macOS. A fixed-shape field is one `sed -nE` line;
anything needing real parsing belongs in TypeScript, where it can be tested. If a task seems
to need Python, the approach is wrong.
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@AGENTS.md

# Claude Code notes — solid-layouts

The import above is binding: [`AGENTS.md`](AGENTS.md) is the working agreement for this
repository, and every Claude Code session loads it automatically. Do not copy rules here,
one source of truth, no drift. Only genuinely Claude-specific wiring belongs below.
187 changes: 181 additions & 6 deletions packages/solid-layouts-oxc/crates/transform/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ use layouts_common::{
};
use oxc_allocator::Allocator;
use oxc_ast::ast::{
BindingPattern, Declaration, Expression, Program, Statement, TSType, TSTypeName,
VariableDeclaration, VariableDeclarator,
BindingPattern, Declaration, Expression, Program, Statement, TSSignature, TSType,
TSTypeAliasDeclaration, TSTypeName, VariableDeclaration, VariableDeclarator,
};
use oxc_codegen::Codegen;
use oxc_parser::Parser;
Expand All @@ -45,6 +45,8 @@ pub struct FoundLayout {
pub parameters_span: Option<Span>,
pub body_span: Option<Span>,
pub props_span: Option<Span>,
/// Name of the props type, when it is a plain reference we can look up.
pub props_type: Option<String>,
pub statement_span: Span,
pub export_prefix_span: Option<Span>,
}
Expand Down Expand Up @@ -330,10 +332,28 @@ fn compile_library_source(
} else {
""
};
// What the component declares is its own API, and the runtime has
// to be told so it does not spread those props onto the root
// element on top of wherever the layout puts them.
let declared = layout
.props_type
.as_deref()
.map(|name| declared_prop_keys(program, name))
.unwrap_or_default();
let behaviour = if declared.is_empty() {
String::new()
} else {
let keys = declared
.iter()
.map(|key| format!("\"{key}\""))
.collect::<Vec<_>>()
.join(", ");
format!(", behaviour: [{keys}]")
};
edits.push(SourceEdit::Insert {
at: layout.statement_span.end as usize,
text: format!(
"\n{exported}const {} = __defineLayoutComponent({{ recipe: {}, layout: {raw}, embedded: true }}) as __LayoutComponent<{props}>;",
"\n{exported}const {} = __defineLayoutComponent({{ recipe: {}, layout: {raw}, embedded: true{behaviour} }}) as __LayoutComponent<{props}>;",
layout.binding,
layout.recipe.as_deref().expect("validated Layout recipe"),
),
Expand Down Expand Up @@ -453,6 +473,77 @@ fn variable_declaration<'a, 'b>(
}
}

/// The type alias behind a statement, whether or not it is exported.
fn type_alias_declaration<'a, 'b>(
statement: &'b Statement<'a>,
) -> Option<&'b TSTypeAliasDeclaration<'a>> {
match statement {
Statement::TSTypeAliasDeclaration(declaration) => Some(declaration),
Statement::ExportDeclaration(export) => match &export.declaration {
Declaration::TSTypeAliasDeclaration(declaration) => Some(declaration),
_ => None,
},
_ => None,
}
}

/// The prop names a component declares as its own API.
///
/// A props type is an intersection, and the two halves mean different things.
/// A referenced member -- `UIBaseProps`, `JSX.ButtonHTMLAttributes` -- is
/// inherited HTML, and those props belong on the element. An inline object
/// literal is what this component itself accepts, and the layout is what
/// places them.
///
/// Only the literal's keys are the component's own, and the runtime needs to
/// know them so it does not also spread them onto the root element. A
/// component declaring `onInput?: (value: string) => void` and wiring it to an
/// inner input had the caller's handler bound to the wrapper as well; the
/// inner event bubbled up to it and called the handler a second time with the
/// raw InputEvent, which is not what the signature promises. The same held for
/// every `onChange`, `onSubmit` and `onInput` that a layout translates.
fn declared_prop_keys(program: &Program<'_>, name: &str) -> Vec<String> {
for statement in &program.body {
let Some(alias) = type_alias_declaration(statement) else {
continue;
};
if alias.id.name.as_str() != name {
continue;
}
let mut keys = Vec::new();
collect_literal_keys(&alias.type_annotation, &mut keys);
return keys;
}
Vec::new()
}

/// Keys of every inline object literal in a props type, intersections included.
fn collect_literal_keys(annotation: &TSType<'_>, keys: &mut Vec<String>) {
match annotation {
TSType::TSTypeLiteral(literal) => {
for member in &literal.members {
if let TSSignature::TSPropertySignature(property) = member
&& let Some(key) = property.key.static_name()
{
let key = key.to_string();
if !keys.contains(&key) {
keys.push(key);
}
}
}
}
TSType::TSIntersectionType(intersection) => {
for part in &intersection.types {
collect_literal_keys(part, keys);
}
}
TSType::TSParenthesizedType(parenthesized) => {
collect_literal_keys(&parenthesized.type_annotation, keys);
}
_ => {}
}
}

fn as_layout(
declarator: &VariableDeclarator<'_>,
statement_span: Span,
Expand Down Expand Up @@ -487,11 +578,15 @@ fn as_layout(
},
_ => None,
});
let props_span = reference
let props = reference
.type_arguments
.as_ref()
.and_then(|arguments| arguments.params.get(1))
.map(GetSpan::span);
.and_then(|arguments| arguments.params.get(1));
let props_span = props.map(GetSpan::span);
let props_type = props.and_then(|argument| match argument {
TSType::TSTypeReference(reference) => type_name(&reference.type_name),
_ => None,
});

let (parameters, parameters_span, body_span) = match declarator.init.as_ref() {
Some(Expression::ArrowFunctionExpression(arrow)) => (
Expand All @@ -511,6 +606,7 @@ fn as_layout(
parameters_span,
body_span,
props_span,
props_type,
statement_span,
export_prefix_span: exported
.then(|| Span::new(statement_span.start, declaration_span.start)),
Expand Down Expand Up @@ -750,6 +846,85 @@ const Button: Layout<typeof button, ButtonProps> = () => {
);
}

#[test]
fn component_output_routes_the_props_a_component_declares_itself() {
let source = r#"import type { Layout } from "solid-layouts";
import { passwordField } from "./PasswordField.recipe";
export type PasswordFieldProps = UIBaseProps & {
value?: string;
onInput?: (value: string) => void;
onBlur?: () => void;
};
export const PasswordField: Layout<typeof passwordField, PasswordFieldProps> = () => {
return <input value={props.value} onInput={(event) => props.onInput?.(event.currentTarget.value)} />;
};
"#;
let mut options = TransformOptions::new("PasswordField.layout.tsx", CompilerMode::Library);
options.library_output = LibraryOutput::Component;
let result = transform(source, &options);
assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
// Declared props are the component's own API. Without this the runtime
// also spreads them onto the root, and an inner input's event bubbles
// back to the caller's handler with the raw event.
assert!(
result
.code
.contains("embedded: true, behaviour: [\"value\", \"onInput\", \"onBlur\"] })"),
"{}",
result.code
);
}

#[test]
fn component_output_leaves_inherited_html_props_as_passthrough() {
let source = r#"import type { Layout } from "solid-layouts";
import { button } from "./Button.recipe";
export type ButtonProps = Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, "type"> &
UIBaseProps & {
variant?: Variant;
};
export const Button: Layout<typeof button, ButtonProps> = () => {
return <button>{props.children}</button>;
};
"#;
let mut options = TransformOptions::new("Button.layout.tsx", CompilerMode::Library);
options.library_output = LibraryOutput::Component;
let result = transform(source, &options);
assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
// `onClick` and the rest of the HTML surface arrive through a referenced
// type, so they stay passthrough and still reach the element.
assert!(
result.code.contains("behaviour: [\"variant\"] })"),
"{}",
result.code
);
assert!(!result.code.contains("onClick"), "{}", result.code);
}

#[test]
fn component_output_omits_behaviour_when_the_props_type_is_not_local() {
let source = r#"import type { Layout } from "solid-layouts";
import type { BadgeProps } from "./types";
import { badge } from "./Badge.recipe";
export const Badge: Layout<typeof badge, BadgeProps> = () => {
return <span>{props.children}</span>;
};
"#;
let mut options = TransformOptions::new("Badge.layout.tsx", CompilerMode::Library);
options.library_output = LibraryOutput::Component;
let result = transform(source, &options);
assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
// Nothing to read means nothing claimed: the previous behaviour, which
// is the safe direction to fail in.
assert!(
result
.code
.contains("embedded: true }) as __LayoutComponent<BadgeProps>;"),
"{}",
result.code
);
}

#[test]
fn component_output_wraps_the_generated_layout_as_a_solid_component() {
let source = r#"import type { Layout } from "solid-layouts";
Expand Down
2 changes: 1 addition & 1 deletion packages/solid-layouts-oxc/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "solid-layouts-oxc",
"version": "0.2.2",
"version": "0.2.3",
"description": "The Layouts pre-pass for SolidJS, built on oxc",
"license": "MIT",
"type": "commonjs",
Expand Down
26 changes: 26 additions & 0 deletions packages/solid-layouts/src/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,32 @@ describe("defineComponent: the props split", () => {
dispose();
});

test("a declared prop stays off the root slot", () => {
// The bug this covers: a component declaring `onInput?: (value: string)
// => void` wires it to an inner input itself. If the same prop is also
// spread onto the root, the inner event bubbles back up to the wrapper
// and calls the caller's handler a second time with the raw InputEvent,
// which is not what the signature promises. Whichever fires last wins,
// and that is the bubbled one.
const { seen, layout } = capturing();

const Field = defineComponent({
recipe: button,
layout: layout as never,
behaviour: ["onInput"],
setup: () => ({ loading: () => false }),
});

const handler = () => {};
const dispose = mount(Field, { onInput: handler, onClick: handler });

expect(seen.slot?.root).not.toHaveProperty("onInput");
// The contrast that keeps this honest: an undeclared handler is plain
// HTML and still has to reach the element.
expect(seen.slot?.root).toHaveProperty("onClick", handler);
dispose();
});

test("undeclared props are HTML and reach the element", () => {
// The bucket that did not exist: `id`, `onClick`, `aria-label` and
// `data-testid` were swallowed as behaviour and never rendered.
Expand Down
Loading