diff --git a/nix/checked-candidate.nix b/nix/checked-candidate.nix index 8fc6be4..9533963 100644 --- a/nix/checked-candidate.nix +++ b/nix/checked-candidate.nix @@ -165,6 +165,7 @@ let interface = "${compile dependency}/candidate.json"; generator = protocol; typescript = runtimePackages.camino-replica-core.dependencies; + reactSdk = runtimePackages.camino-react; dependencies = buildDependencies dependency; }) ] diff --git a/src/react-build/declarations.ts b/src/react-build/declarations.ts index 72635ae..2a7c042 100644 --- a/src/react-build/declarations.ts +++ b/src/react-build/declarations.ts @@ -101,13 +101,28 @@ export function presentationDeclarations( if (!child.name || names.has(child.name) || !child.slot || !child.root) throw Error(`${name}: child requires a unique name, slot and root`); names.add(child.name); + // A local module identifies a closed role, including its generic arguments. + // This avoids inventing an import alias for a generic application solely + // to select that role in the React preparation manifest. + let role: ReactChildDeclaration["role"]; + if (child.role) { + const authored = object(child.role, `${name}.${child.name} role`); + if (typeof authored.module === "string") { + const target = pkg.modules?.find((entry) => entry.displayName === authored.module); + if (!target || Object.keys(authored).length !== 1) + throw Error(`${name}.${child.name}: expected a local module role`); + role = { interface: target.interfaceRevisionId, member: target.memberId }; + } else { + role = { ...child.role, interface: imported(child.role.interface) }; + } + } if (child.each && child.with) throw Error(`${name}.${child.name}: cannot combine each and with`); if (child.each && (!Number.isSafeInteger(child.each.limit) || child.each.limit < 1)) throw Error(`${name}.${child.name}: finite positive collection limit required`); return { ...child, subject: imported(child.subject), - ...(child.role ? { role: { ...child.role, interface: imported(child.role.interface) } } : {}), + ...(role ? { role } : {}), }; }); artifact.modules.push({ module: module.id, composition, children: parsed }); diff --git a/test/prepared-presentations.test.ts b/test/prepared-presentations.test.ts index 5895fbb..d456f7a 100644 --- a/test/prepared-presentations.test.ts +++ b/test/prepared-presentations.test.ts @@ -121,6 +121,19 @@ async function fixture(replacement = "First", change?: (artifact: ReactPreparati return { workspace: workspace.workspace, pkg: workspace.workspace.packageImports[0]!, authored, interfaces }; } +test("a local module names its checked role without a synthetic import alias", async () => { + const { pkg, authored, interfaces } = await fixture(); + const source = structuredClone(authored) as any; + source.modules.LibraryCard.children[0].role = { module: "First" }; + const declared = presentationDeclarations(JSON.stringify(source), pkg, [...interfaces.values()]); + assert.deepEqual(declared.artifact.modules[0]!.children[0]!.role, { interface: "row-role@1", member: "row-render" }); + source.modules.LibraryCard.children[0].role = { module: "Missing" }; + assert.throws( + () => presentationDeclarations(JSON.stringify(source), pkg, [...interfaces.values()]), + /local module role/, + ); +}); + test("workspace expands distinct providers' private inputs without parent field declarations", async () => { const { pkg } = await fixture(); const parent = pkg.checkedQueries!.find((query) => query.declaration.id === "react:input:library-card:data")!;