From e10273f4442d01b105deea91c8e7f543a979ae42 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Wed, 28 Oct 2026 14:11:23 +0200 Subject: [PATCH 1/2] feat(target-react): emit cross-file types and generics React's output imports the types its source imports by name, from a `.ts` module or a package as written and from a child's output, and the main component's output writes the module's type exports in source order (ADR-0067, ADR-0068). A generic component is a generic function component, and the events', models' and slots' interfaces whose members name a type parameter take it too. A form control bound to a model of such a type casts its string value to it. The types and generics cases pass every live layer on React, with consumer fixtures for each, so React's quarantine is empty again and its test helpers read these cases. The canary injector also finds the body of a generic function component. Co-authored-by: multica-agent --- packages/target-react/README.md | 10 ++ packages/target-react/src/compose.ts | 19 ++- packages/target-react/src/index.ts | 109 +++++++++++++++++- .../target-react/test/composition.test.ts | 47 ++++++++ packages/target-react/test/fixtures.ts | 11 +- .../__output__/react/Options.tsx | 6 +- .../constrained/__output__/react/Table.tsx | 2 +- .../generics/list/__output__/react/List.tsx | 2 +- .../scoped-slot/__output__/react/Picker.tsx | 6 +- .../select/__output__/react/Select.tsx | 10 +- .../__output__/react/Form.tsx | 2 +- .../__output__/react/Button.tsx | 2 + .../extends-chain/__output__/react/Button.tsx | 1 + .../__output__/react/Badge.tsx | 1 + .../__output__/react/Chip.tsx | 1 + .../package-types/__output__/react/Status.tsx | 2 + .../shared-type/__output__/react/Palette.tsx | 3 + .../shared-type/__output__/react/Swatch.tsx | 1 + .../__output__/react/TextField.tsx | 1 + tests/integration/harness/canaries.ts | 17 ++- .../integration/harness/canaries.unit.test.ts | 12 ++ tests/integration/harness/quarantine/react.ts | 35 +----- .../generics/constrained/Consumer.tsx | 5 + .../generics/constrained/MisuseProp.tsx | 6 + .../consumers/generics/list/Consumer.tsx | 5 + .../consumers/generics/list/MisuseProp.tsx | 6 + .../generics/scoped-slot/Consumer.tsx | 6 + .../generics/scoped-slot/MisuseSlot.tsx | 7 ++ .../consumers/generics/select/Consumer.tsx | 8 ++ .../consumers/generics/select/MisuseModel.tsx | 9 ++ .../types/child-type-import/Consumer.tsx | 7 ++ .../types/child-type-import/MisuseProp.tsx | 6 + .../types/child-type-import/MisuseType.tsx | 9 ++ .../types/exported-types/Consumer.tsx | 10 ++ .../types/exported-types/MisuseProp.tsx | 6 + .../types/exported-types/MisuseType.tsx | 12 ++ .../types/extends-chain/Consumer.tsx | 5 + .../types/extends-chain/MisuseProp.tsx | 6 + .../types/imported-interface/Consumer.tsx | 5 + .../types/imported-interface/MisuseProp.tsx | 6 + .../types/intersection-props/Consumer.tsx | 5 + .../types/intersection-props/MisuseProp.tsx | 6 + .../types/package-types/Consumer.tsx | 5 + .../types/package-types/MisuseProp.tsx | 6 + .../consumers/types/shared-type/Consumer.tsx | 8 ++ .../types/shared-type/MisuseProp.tsx | 6 + .../types/shared-type/MisuseType.tsx | 9 ++ .../types/utility-types/Consumer.tsx | 8 ++ .../types/utility-types/MisuseProp.tsx | 6 + .../types/utility-types/MisuseType.tsx | 9 ++ 50 files changed, 427 insertions(+), 65 deletions(-) create mode 100644 tests/toolchains/react/consumers/generics/constrained/Consumer.tsx create mode 100644 tests/toolchains/react/consumers/generics/constrained/MisuseProp.tsx create mode 100644 tests/toolchains/react/consumers/generics/list/Consumer.tsx create mode 100644 tests/toolchains/react/consumers/generics/list/MisuseProp.tsx create mode 100644 tests/toolchains/react/consumers/generics/scoped-slot/Consumer.tsx create mode 100644 tests/toolchains/react/consumers/generics/scoped-slot/MisuseSlot.tsx create mode 100644 tests/toolchains/react/consumers/generics/select/Consumer.tsx create mode 100644 tests/toolchains/react/consumers/generics/select/MisuseModel.tsx create mode 100644 tests/toolchains/react/consumers/types/child-type-import/Consumer.tsx create mode 100644 tests/toolchains/react/consumers/types/child-type-import/MisuseProp.tsx create mode 100644 tests/toolchains/react/consumers/types/child-type-import/MisuseType.tsx create mode 100644 tests/toolchains/react/consumers/types/exported-types/Consumer.tsx create mode 100644 tests/toolchains/react/consumers/types/exported-types/MisuseProp.tsx create mode 100644 tests/toolchains/react/consumers/types/exported-types/MisuseType.tsx create mode 100644 tests/toolchains/react/consumers/types/extends-chain/Consumer.tsx create mode 100644 tests/toolchains/react/consumers/types/extends-chain/MisuseProp.tsx create mode 100644 tests/toolchains/react/consumers/types/imported-interface/Consumer.tsx create mode 100644 tests/toolchains/react/consumers/types/imported-interface/MisuseProp.tsx create mode 100644 tests/toolchains/react/consumers/types/intersection-props/Consumer.tsx create mode 100644 tests/toolchains/react/consumers/types/intersection-props/MisuseProp.tsx create mode 100644 tests/toolchains/react/consumers/types/package-types/Consumer.tsx create mode 100644 tests/toolchains/react/consumers/types/package-types/MisuseProp.tsx create mode 100644 tests/toolchains/react/consumers/types/shared-type/Consumer.tsx create mode 100644 tests/toolchains/react/consumers/types/shared-type/MisuseProp.tsx create mode 100644 tests/toolchains/react/consumers/types/shared-type/MisuseType.tsx create mode 100644 tests/toolchains/react/consumers/types/utility-types/Consumer.tsx create mode 100644 tests/toolchains/react/consumers/types/utility-types/MisuseProp.tsx create mode 100644 tests/toolchains/react/consumers/types/utility-types/MisuseType.tsx diff --git a/packages/target-react/README.md b/packages/target-react/README.md index b44b6a903..801d93292 100644 --- a/packages/target-react/README.md +++ b/packages/target-react/README.md @@ -10,6 +10,16 @@ component, written as a React developer would write it (plan §6): name that is `_` and more, and takes `_` before any other: `_props`, `__`): unused bindings fail L5. A component whose root is an element or a component, and that keeps fallthrough on, also takes `className` and `style` (see Composition below). +- the types the source imports, by name, where the output reads them (ADR-0067): from a `.ts` + module or a package as written, `import type { BadgeProps } from "./types"`, and from a + child's output, `import Field, { type FieldProps } from "./Field"`. The output of the module's + main component also writes the module's type exports in source order among its types (ADR-0068): + `export type { Tone } from "./types"`, and an exported type no component reaches. +- a generic component as a generic function component (ADR-0068), + `export default function List({ items }: ListProps & ListAttributes)`, its constraints + and defaults as written. The events', models' and slots' interfaces whose members name a type + parameter take them too (`ListSlots`), and a form control's `v-model` of such a type casts + the control's string to it (`event.target.value as T`). - expressions as the source writes them, a ref's value read as React's (`count.value` is `count`); conditionals as ternary chains ending in `null` (never `&&`, which renders a `0`); lists as `.map((item, index) =>
  • …)`, with the index only when something reads it; diff --git a/packages/target-react/src/compose.ts b/packages/target-react/src/compose.ts index cb8ba4077..55a60de58 100644 --- a/packages/target-react/src/compose.ts +++ b/packages/target-react/src/compose.ts @@ -13,6 +13,7 @@ import { jsxFillValue, pascalCase, rewriteExpression, + typeNames, } from "@unframework/codegen"; import type { JsxContext } from "@unframework/codegen"; import type { @@ -250,7 +251,7 @@ export class ReactComposer { case "textarea": case "select": state = ["value", shown]; - written = "EVENT.target.value"; + written = `EVENT.target.value${this.#cast(binding)}`; break; default: return control satisfies never; @@ -271,6 +272,22 @@ export class ReactComposer { return parts; } + /** + * ` as T` where a state's or a model's declared type names a type parameter of a generic + * component (ADR-0068): the control's value is a `string`, which TypeScript cannot narrow to + * the type argument. Nothing otherwise. + */ + #cast(binding: BindingId): string { + const { component } = this.#plan; + const item = component.setup.find( + (entry) => (entry.kind === "State" || entry.kind === "Model") && entry.binding === binding, + ); + const type = item && "type" in item ? item.type : undefined; + if (!type || !component.typeParameters?.length) return ""; + const read = typeNames(type); + return component.typeParameters.some(({ name }) => read.has(name)) ? ` as ${type.code}` : ""; + } + /** Whether a state or a model may hold `null` or `undefined`, as its declaration says. */ #nullish(binding: BindingId): boolean { const item = this.#plan.component.setup.find( diff --git a/packages/target-react/src/index.ts b/packages/target-react/src/index.ts index f0dde08bc..19185f406 100644 --- a/packages/target-react/src/index.ts +++ b/packages/target-react/src/index.ts @@ -1,4 +1,5 @@ import { + addTypeImports, bindingOf, componentTypes, composableUse, @@ -12,6 +13,8 @@ import { printComponentModule, printExpression, spreadStyleExpression, + typeExportsCode, + typeNames, } from "@unframework/codegen"; import type { EmitContext, JsxContext, OutputFile, Target } from "@unframework/codegen"; import type { @@ -29,7 +32,10 @@ import type { StyleAttribute, TextNode, TypeDeclaration, + TypeParameter, + TypeText, UfComponent, + UfModule, } from "@unframework/ir"; import { compilerBailout, declaredNames } from "./bailouts.ts"; @@ -56,6 +62,7 @@ import { titleChildren } from "./title.ts"; type Parameter = Parameters[1][number]; type BindingProperty = ReturnType; +type TypeParameters = ReturnType["typeParameters"]; /** The React 19 target. */ export const react: Target = defineTarget({ @@ -246,11 +253,15 @@ export const react: Target = defineTarget({ ...statements, js.returnStatement(tree), ]); + fn.typeParameters = typeParameters(component, jsx); const hoisted = [...keyDeclarations(plan), ...setup.module].map((statement) => jsx.placeholders.statements(`${statement}\n`), ); + // The types it imports, by name, from where the source imports them, a child's from its + // output (`./Field`), and the module's type exports (ADR-0067, ADR-0068). + addTypeImports(component, module, plan.names, (name) => name); const types = [ - ...componentTypes(component, module), + ...moduleTypes(component, module), ...eventsInterface(plan), ...compositionInterfaces(plan), ]; @@ -302,6 +313,45 @@ export const react: Target = defineTarget({ }, }); +/** + * The types the output declares before the component, in source order: those its props reach, + * copied as written, and in the output of the module's main component the module's type exports + * (ADR-0068), each written as its own declaration. + */ +function moduleTypes(component: UfComponent, module: UfModule): TypeDeclaration[] { + const exported = typeExportsCode(component, module, (name) => name).map(({ code, span }) => ({ + name: "", + exported: false, + code, + span, + })); + return [...componentTypes(component, module), ...exported].toSorted( + (a, b) => a.span.start - b.span.start, + ); +} + +/** + * A generic component's type parameters, `function List` (ADR-0068): a + * generic function component, its constraints and defaults copied as written. `null` for a + * component without them. + */ +function typeParameters(component: UfComponent, context: JsxContext): TypeParameters { + if (!component.typeParameters?.length) return null; + const type = (text: TypeText | undefined) => (text ? context.placeholders.type(text.code) : null); + const params = component.typeParameters.map((parameter) => ({ + type: "TSTypeParameter" as const, + name: js.bindingIdentifier(parameter.name), + constraint: type(parameter.constraint), + default: type(parameter.default), + in: false, + out: false, + const: false, + start: 0, + end: 0, + })); + return { type: "TSTypeParameterDeclaration", params, start: 0, end: 0 }; +} + /** * The children's outputs and the keys' (ADR-0053, ADR-0054): each is the sibling file its * component names (`./Field`), imported as the source imports it. A component that renders @@ -465,11 +515,12 @@ function compositionInterfaces(plan: ReactPlan): TypeDeclaration[] { const { composition, component } = plan; const type = (name: string) => plan.names.add("react", name, { type: true }); const declarations: TypeDeclaration[] = []; - const declare = (name: string, members: string[]) => { - const text = `interface ${name} {\n${members.map((member) => ` ${member}`).join("\n")}\n}`; + const declare = (name: string, members: string[], parameters = "") => { + const text = `interface ${name}${parameters} {\n${members.map((member) => ` ${member}`).join("\n")}\n}`; declarations.push({ name, exported: true, code: text, span: component.span }); }; const { interfaces } = composition; + const generic = interfaceParameters(plan); if (interfaces.models) { declare( interfaces.models, @@ -482,6 +533,7 @@ function compositionInterfaces(plan: ReactPlan): TypeDeclaration[] { `${event}?: (${PARAMETER}: ${written}) => void;`, ]; }), + generic.models?.declared, ); } if (interfaces.slots) { @@ -493,6 +545,7 @@ function compositionInterfaces(plan: ReactPlan): TypeDeclaration[] { ? `${slotProp(slot.name, true)}?: (props: ${slot.props.code}) => ${node};` : `${slotProp(slot.name, false)}?: ${node};`, ), + generic.slots?.declared, ); } if (composition.expose && component.exposes) { @@ -618,10 +671,49 @@ function eventsInterface(plan: ReactPlan): TypeDeclaration[] { .join(", "); return ` ${plan.events.get(event.name)!.prop}?: (${parameters}) => void;`; }); - const code = `interface ${plan.eventsInterface} {\n${members.join("\n")}\n}`; + const parameters = interfaceParameters(plan).events?.declared ?? ""; + const code = `interface ${plan.eventsInterface}${parameters} {\n${members.join("\n")}\n}`; return [{ name: plan.eventsInterface, exported: true, code, span: emits.span }]; } +/** + * The type parameters of the interfaces the output declares for its events, models and slots + * (ADR-0068): where a generic component's members name one of its type parameters, the interface + * takes them all, declared as the component declares them (``) and passed by + * name (``). + */ +function interfaceParameters( + plan: ReactPlan, +): Partial> { + const { component, composition } = plan; + const parameters = component.typeParameters ?? []; + if (!parameters.length) return {}; + const written = { + declared: `<${parameters.map(typeParameterText).join(", ")}>`, + passed: `<${parameters.map(({ name }) => name).join(", ")}>`, + }; + const names = (texts: readonly (TypeText | undefined)[]) => + texts.some((text) => { + const read = text ? typeNames(text) : new Set(); + return parameters.some(({ name }) => read.has(name)); + }) + ? written + : undefined; + return { + events: names( + component.emits?.events.flatMap((event) => event.parameters.map(({ type }) => type)) ?? [], + ), + models: names([...composition.models.values()].map(({ item }) => item.type)), + slots: names(component.slots?.slots.map((slot) => slot.props) ?? []), + }; +} + +/** A type parameter as its component declares it, `T extends Item = Item` (ADR-0068). */ +function typeParameterText(parameter: TypeParameter): string { + const constraint = parameter.constraint ? ` extends ${parameter.constraint.code}` : ""; + return `${parameter.name}${constraint}${parameter.default ? ` = ${parameter.default.code}` : ""}`; +} + /** * The component's parameters, as the source declares its props (ADR-0034): the destructured * props in source order, with their defaults, then the events' props that code emits (ADR-0047), or @@ -637,7 +729,14 @@ function propsParameters( const parameter = component.propsParameter; const { names, composition } = plan; const { slots, models, attributes } = composition.interfaces; - const typeCode = [parameter?.type.code, plan.eventsInterface, models, slots, attributes] + const generic = interfaceParameters(plan); + const typeCode = [ + parameter?.type.code, + plan.eventsInterface && `${plan.eventsInterface}${generic.events?.passed ?? ""}`, + models && `${models}${generic.models?.passed ?? ""}`, + slots && `${slots}${generic.slots?.passed ?? ""}`, + attributes, + ] .filter((part) => part !== undefined) .join(" & "); const { scope } = composition; diff --git a/packages/target-react/test/composition.test.ts b/packages/target-react/test/composition.test.ts index 700c89485..63f52cc22 100644 --- a/packages/target-react/test/composition.test.ts +++ b/packages/target-react/test/composition.test.ts @@ -176,6 +176,23 @@ export function Pill({ on }: { on: boolean }) { * source passes L3, L4 and L5 as an output must, and the shape that once broke is pinned. */ const PROBES: Readonly> = { + // A generic component (ADR-0068) whose event, model and scoped slot name its type parameter. + generic: `import { defineEmits, defineModel, defineSlots } from "unframework"; +import type { Element } from "unframework"; +export interface ChooserProps { options: readonly T[] } +export default function Chooser({ options }: ChooserProps) { + const emit = defineEmits<{ pick: [value: T] }>(); + const value = defineModel("value", { required: true }); + const slots = defineSlots<{ option?(props: { option: T }): Element }>(); + return ( +
    + + {options.map((option) => {slots.option?.({ option })})} +
    + ); +}`, // A root spread that may hold `class`, beside the root's fallthrough `class`. rootSpread: `export default function Box({ attrs, text }: { attrs: { class?: string; title?: string }; text: string }) { return
    {text}
    ; @@ -368,3 +385,33 @@ describe("react composition beyond the corpus", () => { expect(odd).toContain("setTo(value: { to?: number; by: number }): void;"); }); }); + +describe("react types and generics", () => { + it("writes a generic component's type parameters on it and on the interfaces that name them", async () => { + const chooser = (await outputs(PROBES.generic!)).get("Chooser")!; + expect(chooser).toContain("export default function Chooser({"); + expect(chooser).toContain("export interface ChooserEvents {"); + expect(chooser).toContain("export interface ChooserModels {"); + expect(chooser).toContain("export interface ChooserSlots {"); + expect(chooser).toContain( + "}: ChooserProps & ChooserEvents & ChooserModels & ChooserSlots & ChooserAttributes) {", + ); + // A control gives a string, which TypeScript cannot narrow to the type argument. + expect(chooser).toContain("valueRef.current = event.target.value as T;"); + expect(chooser).not.toContain("interface ChooserAttributes<"); + }); + + it("writes the module's type exports in source order among the types it copies", async () => { + const button = ( + await outputs(`export type Tone = "primary" | "secondary"; +export type { Size } from "./sizes"; +export type Variant = "solid" | "ghost"; +export default function Button({ tone }: { tone: Tone }) { + return ; +}`) + ).get("Button")!; + expect(button).toContain( + 'export type Tone = "primary" | "secondary";\n\nexport type { Size } from "./sizes";\n\nexport type Variant = "solid" | "ghost";', + ); + }); +}); diff --git a/packages/target-react/test/fixtures.ts b/packages/target-react/test/fixtures.ts index 780b7620e..75f1e71e1 100644 --- a/packages/target-react/test/fixtures.ts +++ b/packages/target-react/test/fixtures.ts @@ -130,23 +130,16 @@ export function lower(source: string, file = "Fixture.uf.tsx"): UfModule { return module; } -/** - * M5's types and generics cases (ADR-0067, ADR-0068), which React emits from its types lane, layer - * 16 of M5 (UXF-462): until then `harness/quarantine/react.ts` lists their failing cells, and the lane - * brings them back here. - */ -const TYPES_LANE = /(?:^|\/)(?:types|generics|diagnostics\/generic-slot-props)\//; - /** * Every committed golden output of a target, read from the integration corpus, but the scoped * sheets (`.css`), which no framework compiler or checker reads: the compile project - * parses them instead (ADR-0063), and the cases of its types lane (`TYPES_LANE`). + * parses them instead (ADR-0063). */ export function goldens(targetName: string): ToolchainFile[] { const pattern = new RegExp(`(?:^|/)__output__/${targetName}/[^/]+$`); return readdirSync(cases, { recursive: true }) .map((entry) => String(entry).split("\\").join("/")) - .filter((entry) => pattern.test(entry) && !entry.endsWith(".css") && !TYPES_LANE.test(entry)) + .filter((entry) => pattern.test(entry) && !entry.endsWith(".css")) .toSorted() .map((entry) => { const path = join(cases, entry); diff --git a/tests/integration/cases/diagnostics/generic-slot-props/__output__/react/Options.tsx b/tests/integration/cases/diagnostics/generic-slot-props/__output__/react/Options.tsx index f7663b0e7..60aa96755 100644 --- a/tests/integration/cases/diagnostics/generic-slot-props/__output__/react/Options.tsx +++ b/tests/integration/cases/diagnostics/generic-slot-props/__output__/react/Options.tsx @@ -1,6 +1,6 @@ import type { CSSProperties, ReactNode } from "react"; -export interface OptionsSlots { +export interface OptionsSlots { renderOption?: (props: { option: T }) => ReactNode; } @@ -9,13 +9,13 @@ export interface OptionsAttributes { style?: CSSProperties; } -export default function Options({ +export default function Options({ options, renderOption, className, style, ...attributes -}: { options: T[] } & OptionsSlots & OptionsAttributes) { +}: { options: T[] } & OptionsSlots & OptionsAttributes) { return (
      {options.map((option) => ( diff --git a/tests/integration/cases/generics/constrained/__output__/react/Table.tsx b/tests/integration/cases/generics/constrained/__output__/react/Table.tsx index c87c7920a..c766e5c58 100644 --- a/tests/integration/cases/generics/constrained/__output__/react/Table.tsx +++ b/tests/integration/cases/generics/constrained/__output__/react/Table.tsx @@ -10,7 +10,7 @@ export interface TableAttributes { style?: CSSProperties; } -export default function Table({ +export default function Table({ caption, rows, className, diff --git a/tests/integration/cases/generics/list/__output__/react/List.tsx b/tests/integration/cases/generics/list/__output__/react/List.tsx index faf54e83c..9fd02f17c 100644 --- a/tests/integration/cases/generics/list/__output__/react/List.tsx +++ b/tests/integration/cases/generics/list/__output__/react/List.tsx @@ -11,7 +11,7 @@ export interface ListAttributes { style?: CSSProperties; } -export default function List({ +export default function List({ label, items, selected, diff --git a/tests/integration/cases/generics/scoped-slot/__output__/react/Picker.tsx b/tests/integration/cases/generics/scoped-slot/__output__/react/Picker.tsx index 633d532cc..a912e5fac 100644 --- a/tests/integration/cases/generics/scoped-slot/__output__/react/Picker.tsx +++ b/tests/integration/cases/generics/scoped-slot/__output__/react/Picker.tsx @@ -4,7 +4,7 @@ export interface PickerProps { items: T[]; } -export interface PickerSlots { +export interface PickerSlots { renderItem?: (props: { item: T }) => ReactNode; } @@ -13,13 +13,13 @@ export interface PickerAttributes { style?: CSSProperties; } -export default function Picker({ +export default function Picker({ items, renderItem, className, style, ...attributes -}: PickerProps & PickerSlots & PickerAttributes) { +}: PickerProps & PickerSlots & PickerAttributes) { return (
        {items.map((item, index) => ( diff --git a/tests/integration/cases/generics/select/__output__/react/Select.tsx b/tests/integration/cases/generics/select/__output__/react/Select.tsx index a63d427a5..b8d2387cc 100644 --- a/tests/integration/cases/generics/select/__output__/react/Select.tsx +++ b/tests/integration/cases/generics/select/__output__/react/Select.tsx @@ -5,7 +5,7 @@ export interface SelectProps { options: readonly T[]; } -export interface SelectModels { +export interface SelectModels { value: T; onValueChange?: (value: T) => void; } @@ -15,7 +15,7 @@ export interface SelectAttributes { style?: CSSProperties; } -export default function Select({ +export default function Select({ label, options, value, @@ -23,7 +23,7 @@ export default function Select({ className, style, ...attributes -}: SelectProps & SelectModels & SelectAttributes) { +}: SelectProps & SelectModels & SelectAttributes) { const [, setLocalValue] = useState({ value: value }); return ( @@ -32,8 +32,8 @@ export default function Select({ ; +} diff --git a/tests/toolchains/react/consumers/generics/select/MisuseModel.tsx b/tests/toolchains/react/consumers/generics/select/MisuseModel.tsx new file mode 100644 index 000000000..4c26ab1cc --- /dev/null +++ b/tests/toolchains/react/consumers/generics/select/MisuseModel.tsx @@ -0,0 +1,9 @@ +import { useState } from "react"; + +import Select from "../../../../../integration/cases/generics/select/__output__/react/Select"; + +export function MisuseModel() { + const [count, setCount] = useState(1); + // @uf-expect TS2322 Select.model:value + return ; +}`, + // A multiple select bound to a model of the type parameter's array. + genericMultiple: `import { defineModel } from "unframework"; +export default function Multi({ options }: { options: readonly T[] }) { + const value = defineModel("value", { required: true }); + return ( + + ); +}`, // A generic component (ADR-0068) whose event, model and scoped slot name its type parameter. generic: `import { defineEmits, defineModel, defineSlots } from "unframework"; import type { Element } from "unframework"; @@ -401,6 +443,98 @@ describe("react types and generics", () => { expect(chooser).not.toContain("interface ChooserAttributes<"); }); + it("keeps setup code that a type parameter types inside the component", async () => { + const list = (await outputs(PROBES.genericSetup!)).get("List")!; + const start = list.indexOf("export default function List("); + // A setup `const` the component keeps is a `useState` snapshot, read once per instance. + expect(list.indexOf("const [empty] = useState([]);")).toBeGreaterThan(start); + expect(list.indexOf("function describe(item: T): string {")).toBeGreaterThan(start); + }); + + it("gives a handle that a type parameter types the parameter, where the ref is typed too", async () => { + const picker = (await outputs(PROBES.genericExpose!)).get("Picker")!; + expect(picker).toContain("export interface PickerHandle {"); + expect(picker).toContain("export interface PickerAttributes {"); + expect(picker).toContain("ref?: Ref>;"); + expect(picker).toContain("}: { items: T[] } & PickerAttributes) {"); + }); + + it("declares on an interface only the type parameters its members name", async () => { + const pair = (await outputs(PROBES.genericSubset!)).get("Pair")!; + expect(pair).toContain("export interface PairSlots {"); + expect(pair).toContain("& PairSlots &"); + }); + + it("closes an interface's type parameters over the constraints that name others", async () => { + const pick = ( + await outputs(`import { defineSlots } from "unframework"; +import type { Element } from "unframework"; +export default function Pick({ items, chosen }: { items: T[]; chosen: U }) { + const slots = defineSlots<{ pick?(props: { chosen: U }): Element }>(); + return

        {slots.pick?.({ chosen })}

        ; +}`) + ).get("Pick")!; + expect(pick).toContain("export interface PickSlots {"); + expect(pick).toContain("& PickSlots &"); + }); + + it("casts a field's value to state whose type a type parameter's value infers", async () => { + const pick = (await outputs(PROBES.genericInferred!)).get("Pick")!; + expect(pick).toContain("setChosen(event.target.value as typeof chosen)"); + }); + + it("casts a multiple select's values to a model of the type parameter's array", async () => { + const multi = (await outputs(PROBES.genericMultiple!)).get("Multi")!; + expect(multi).toContain( + "Array.from(event.target.selectedOptions, (option) => option.value) as T[]", + ); + }); + + it("names its own interfaces apart from the types the module imports and exports", async () => { + const exported = ( + await outputs(`import { defineEmits } from "unframework"; +export type CardAttributes = "unused"; +export type { CardEvents } from "./types"; +export default function Card({ title }: { title: string }) { + const emit = defineEmits<{ open: [] }>(); + return ; +}`) + ).get("Card")!; + expect(exported).toContain('export type CardAttributes = "unused";'); + expect(exported).toContain('export type { CardEvents } from "./types";'); + expect(exported).not.toMatch(/interface CardEvents\b/); + expect(exported).not.toMatch(/interface CardAttributes\b/); + // A type import the resolver records, which only a local type declaration reads. + const { module } = analyze( + parseModule( + "Fixture.uf.tsx", + `import { defineEmits } from "unframework"; +export default function Card({ title }: { title: string }) { + const emit = defineEmits<{ open: [] }>(); + return ; +}`, + ), + ); + const span = { start: 0, end: 0 }; + const withImport = { + ...module!, + typeImports: [ + { + specifier: "./types", + names: [{ imported: "CardEvents", local: "CardEvents", span }], + span, + }, + ], + }; + const [file] = target.emit(withImport.components[0]!, { + module: withImport, + options: undefined, + report: () => {}, + }); + const imported = file!.contents; + expect(imported).not.toMatch(/interface CardEvents\b/); + }); + it("writes the module's type exports in source order among the types it copies", async () => { const button = ( await outputs(`export type Tone = "primary" | "secondary";