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
22 changes: 17 additions & 5 deletions packages/solid-layouts-oxc/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ function resolvePublicPackageEntry(root, module, subpath = ".") {
);
}

function validateComponent(module, packageRoot, name, component) {
function validateComponent(module, packageRoot, name, component, solid) {
if (component?.kind === "embedded") return;
if (component?.kind !== "generated") {
throw new Error(`${module}: component ${name} has unsupported manifest kind ${JSON.stringify(component?.kind)}`);
Expand Down Expand Up @@ -214,7 +214,19 @@ function validateComponent(module, packageRoot, name, component) {
}
}

if (!entrySource.includes(APPLICATION_BOUNDARY)) {
/*
* The boundary this major actually emits, not the 1.9 one.
*
* `boundaryFor()` already encodes the mapping and the library generator uses
* it, so a `solid: 2` build writes `solid-layouts/solid-2/application-boundary`
* into the entry. Checking for the hardcoded 1.9 spelling made the pairing we
* generate the pairing we reject: the entry is correct and validation fails on
* output this package just produced.
*
* `includes` on the Solid 2 specifier is not satisfied by the 1.9 one either
* way round, because neither string contains the other as written.
*/
if (!entrySource.includes(boundaryFor(solid).specifier)) {
throw new Error(`${module}: ${name} entry has no application compiler boundary`);
}
if (!new RegExp(`\\bexport\\s+const\\s+${component.recipeExport}\\b`).test(recipeSource)) {
Expand All @@ -229,7 +241,7 @@ function validateComponent(module, packageRoot, name, component) {
}
}

function resolveLayoutSource(root, configured) {
function resolveLayoutSource(root, configured, solid) {
const module = typeof configured === "string" ? configured : configured.module;
if (!module) throw new Error("configured Layout source is missing its module name");
const packageRoot = typeof configured === "string"
Expand Down Expand Up @@ -271,7 +283,7 @@ function resolveLayoutSource(root, configured) {
}
const exports = Object.keys(components).sort();
if (exports.length === 0) throw new Error(`${module} Layout manifest has no components`);
for (const name of exports) validateComponent(module, packageRoot, name, components[name]);
for (const name of exports) validateComponent(module, packageRoot, name, components[name], solid);

return {
module,
Expand All @@ -291,7 +303,7 @@ function compileApplication(options = {}) {
if (!Array.isArray(layouts) || layouts.length === 0) {
throw new Error("application compiler requires at least one Layout package");
}
const sources = layouts.map((configured) => resolveLayoutSource(root, configured));
const sources = layouts.map((configured) => resolveLayoutSource(root, configured, options.solid));
const rootSources = sources.map(({ module, exports, publicEntry }) => ({
module,
exports,
Expand Down
31 changes: 31 additions & 0 deletions packages/solid-layouts-oxc/application.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,34 @@ test("rejects a generated entry that disagrees with its component record", () =>
"entry call site disagrees",
);
});

/*
* The boundary check has to follow the major being compiled.
*
* `boundaryFor()` already maps 1 and 2 to different specifiers, and the library
* generator uses it, so a `solid: 2` library writes the solid-2 spelling into
* its entry. Validation greping for the hardcoded 1.9 spelling made the pairing
* this package generates the pairing it rejects: a correct Solid 2 bundle failed
* with "entry has no application compiler boundary", naming a file that was
* exactly right.
*/
test("accepts a solid-2 entry when compiling for Solid 2", () => {
const { root, packageRoot } = fixture();
const copy = makePackageEditable(root, packageRoot);
const entryPath = join(copy, "index.ts");
const entry = readFileSync(entryPath, "utf8").replaceAll(
"solid-layouts/application-boundary",
"solid-layouts/solid-2/application-boundary",
);
writeFileSync(entryPath, entry);
expect(() =>
compileApplication({ root, layouts: ["@pathscale/test-ui"], solid: 2 }),
).not.toThrow();
});

test("rejects a 1.9 entry when compiling for Solid 2", () => {
const { root, packageRoot } = fixture();
expect(() => compileApplication({ root, layouts: ["@pathscale/test-ui"], solid: 2 })).toThrow(
"entry has no application compiler boundary",
);
});
101 changes: 85 additions & 16 deletions packages/solid-layouts-oxc/crates/transform/src/linter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,15 @@ struct RenderedSlot {
slot_api: bool,
}

struct RecipeUsage {
rendered: HashSet<String>,
has_legacy_layout: bool,
filename: String,
source: String,
span: Span,
recipe_name: String,
}

impl<'a> Visit<'a> for Usage {
fn visit_member_expression(&mut self, member: &MemberExpression<'a>) {
if let Expression::Identifier(object) = member.object()
Expand Down Expand Up @@ -276,6 +285,7 @@ pub fn lint_project(files: &[ProjectFile]) -> Vec<ProjectDiagnostic> {
.map(|file| canonical(Path::new(&file.filename)))
.collect();
let mut recipes: HashMap<(String, String), Recipe> = HashMap::new();
let mut recipe_usage: HashMap<(String, String), RecipeUsage> = HashMap::new();
let mut diagnostics = Vec::new();

for file in files {
Expand Down Expand Up @@ -367,7 +377,8 @@ pub fn lint_project(files: &[ProjectFile]) -> Vec<ProjectDiagnostic> {
});
continue;
};
let Some(recipe) = recipes.get(&(recipe_file, export_name.clone())) else {
let recipe_key = (recipe_file, export_name.clone());
let Some(recipe) = recipes.get(&recipe_key) else {
diagnostics.push(ProjectDiagnostic {
filename: file.filename.clone(),
source: file.source.clone(),
Expand All @@ -390,6 +401,22 @@ pub fn lint_project(files: &[ProjectFile]) -> Vec<ProjectDiagnostic> {
rendered.iter().map(|slot| slot.name.as_str()).collect();
let declared_names: HashSet<_> =
recipe.slots.iter().map(|(name, _)| name.as_str()).collect();
let uses_slot_api = rendered.iter().any(|slot| slot.slot_api);

let aggregate = recipe_usage
.entry(recipe_key)
.or_insert_with(|| RecipeUsage {
rendered: HashSet::new(),
has_legacy_layout: false,
filename: file.filename.clone(),
source: file.source.clone(),
span: layout.span,
recipe_name: recipe_name.clone(),
});
aggregate
.rendered
.extend(rendered_names.iter().map(|name| (*name).to_owned()));
aggregate.has_legacy_layout |= !uses_slot_api;

for span in usage
.computed_slots
Expand Down Expand Up @@ -424,21 +451,6 @@ pub fn lint_project(files: &[ProjectFile]) -> Vec<ProjectDiagnostic> {
});
}
}
for (slot, _) in &recipe.slots {
if !rendered_names.contains(slot.as_str()) {
diagnostics.push(ProjectDiagnostic {
filename: file.filename.clone(),
source: file.source.clone(),
rule: "slot-unused",
suggestion: None,
diagnostic: Diagnostic::error(
format!("declared slot `{slot}` is not rendered by `{recipe_name}`"),
layout.span,
),
});
}
}
let uses_slot_api = rendered.iter().any(|slot| slot.slot_api);
if !uses_slot_api {
diagnostics.push(ProjectDiagnostic {
filename: file.filename.clone(),
Expand Down Expand Up @@ -470,6 +482,41 @@ pub fn lint_project(files: &[ProjectFile]) -> Vec<ProjectDiagnostic> {
}
}

// A compound component deliberately splits one recipe across several
// Layout exports, and those exports may live in separate source files.
// Validate the recipe against their union. Requiring every leaf export to
// render every shared slot reports correct compounds as wholly invalid.
for (recipe_key, usage) in recipe_usage {
let Some(recipe) = recipes.get(&recipe_key) else {
continue;
};
// A legacy layout uses ordinary class composition rather than the
// slot API. Its warning is actionable, but absence from `slot.*` is
// not proof that the recipe slot is dead. Keep strict unused
// validation for recipes whose complete compound uses the typed slot
// contract.
if usage.has_legacy_layout {
continue;
}
for (slot, _) in &recipe.slots {
if !usage.rendered.contains(slot) {
diagnostics.push(ProjectDiagnostic {
filename: usage.filename.clone(),
source: usage.source.clone(),
rule: "slot-unused",
suggestion: None,
diagnostic: Diagnostic::error(
format!(
"declared slot `{slot}` is not rendered by `{}`",
usage.recipe_name
),
usage.span,
),
});
}
}
}

diagnostics
}

Expand Down Expand Up @@ -849,6 +896,27 @@ export const Button: Layout<typeof button> = () => <button {...slot.root}><i {..
);
}

#[test]
fn compound_layouts_share_the_recipe_slot_contract() {
let diagnostics = lint(
RECIPE,
r#"import type { Layout } from "solid-layouts";
import { button } from "./Button.recipe";
export const Button: Layout<typeof button> = () => <button {...slot.root} />;
export const ButtonIcon: Layout<typeof button> = () => <i {...slot.icon} />;
"#,
);
assert!(
diagnostics.iter().all(|item| item.rule != "slot-unused"),
"{}",
diagnostics
.iter()
.map(|item| item.diagnostic.message.as_str())
.collect::<Vec<_>>()
.join("\n")
);
}

#[test]
fn unresolved_recipes_and_slot_mismatches_are_errors() {
let diagnostics = lint(
Expand Down Expand Up @@ -915,6 +983,7 @@ export const Button: Layout<typeof button> = () => <button class={twMerge("butto
.iter()
.any(|item| item.diagnostic.message.contains("legacy component-shaped"))
);
assert!(diagnostics.iter().all(|item| item.rule != "slot-unused"));
assert!(
diagnostics
.iter()
Expand Down
Loading
Loading