diff --git a/packages/target-react/README.md b/packages/target-react/README.md index b44b6a903..7ef116e6c 100644 --- a/packages/target-react/README.md +++ b/packages/target-react/README.md @@ -10,6 +10,18 @@ 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', slots' and handle's interfaces take the type + parameters their members name, with those their constraints name (`ListSlots`), and setup + code a type parameter types stays inside the component. A form control's `v-model` of such a + type casts the control's string to it (`event.target.value as T`, or `as typeof chosen` where + the state's type is inferred). - 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..7bb301cb3 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 { @@ -244,13 +245,13 @@ export class ReactComposer { break; case "select-multiple": state = ["value", read]; - written = "Array.from(EVENT.target.selectedOptions, (option) => option.value)"; + written = `Array.from(EVENT.target.selectedOptions, (option) => option.value)${this.#cast(binding, read)}`; break; case "text": case "textarea": case "select": state = ["value", shown]; - written = "EVENT.target.value"; + written = `EVENT.target.value${this.#cast(binding, read)}`; break; default: return control satisfies never; @@ -271,6 +272,24 @@ 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. Without a declared type, its type may be inferred from one (`ref(initial)`), + * so it is cast to the type of what it reads, ` as typeof chosen`. Nothing otherwise. + */ + #cast(binding: BindingId, read: string): string { + const { component } = this.#plan; + if (!component.typeParameters?.length) return ""; + 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) return /^[\w$]+$/.test(read) ? ` as typeof ${read}` : ""; + const names = typeNames(type); + return component.typeParameters.some(({ name }) => names.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..a9edfbc63 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) { @@ -508,6 +561,7 @@ function compositionInterfaces(plan: ReactPlan): TypeDeclaration[] { const parameters = fn ? parametersOf(fn) : ""; return `${name}${fn ? exposedType(fn, parameters) : "(): void"};`; }), + generic.expose?.declared, ); } if (interfaces.attributes) { @@ -515,8 +569,12 @@ function compositionInterfaces(plan: ReactPlan): TypeDeclaration[] { if (composition.fallthrough) { members.push("className?: string;", `style?: ${type("CSSProperties")};`); } - if (composition.expose) members.push(`ref?: ${type("Ref")}<${composition.expose.handle}>;`); - declare(interfaces.attributes, members); + if (composition.expose) { + const handle = `${composition.expose.handle}${generic.expose?.passed ?? ""}`; + members.push(`ref?: ${type("Ref")}<${handle}>;`); + } + // The handle is the attributes' one member a type parameter may type. + declare(interfaces.attributes, members, generic.expose?.declared); } return declarations; } @@ -618,10 +676,67 @@ 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, slots and + * handle (ADR-0068): those of a generic component's that the members name, with those their + * constraints and defaults name in turn, in the component's order, declared as the component + * declares them (``) and passed by name (``). An interface that names none + * takes none. + */ +function interfaceParameters( + plan: ReactPlan, +): Partial> { + const { component, composition } = plan; + const parameters = component.typeParameters ?? []; + if (!parameters.length) return {}; + const written = (texts: readonly (TypeText | undefined)[]) => { + const named = new Set(); + const read = (text: TypeText | undefined) => { + if (text) for (const name of typeNames(text)) named.add(name); + }; + texts.forEach(read); + let kept: TypeParameter[] = []; + while (true) { + const next = parameters.filter(({ name }) => named.has(name)); + if (next.length === kept.length) break; + kept = next; + for (const parameter of kept) { + read(parameter.constraint); + read(parameter.default); + } + } + if (!kept.length) return undefined; + return { + declared: `<${kept.map(typeParameterText).join(", ")}>`, + passed: `<${kept.map(({ name }) => name).join(", ")}>`, + }; + }; + const exposed = component.exposes?.functions.flatMap((id) => { + const item = component.setup.find((entry) => entry.kind === "Function" && entry.binding === id); + if (item?.kind !== "Function") return []; + return [...item.function.parameters.map(({ type }) => type), item.function.returnType]; + }); + return { + events: written( + component.emits?.events.flatMap((event) => event.parameters.map(({ type }) => type)) ?? [], + ), + models: written([...composition.models.values()].map(({ item }) => item.type)), + slots: written(component.slots?.slots.map((slot) => slot.props) ?? []), + expose: written(composition.expose ? (exposed ?? []) : []), + }; +} + +/** 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 +752,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 && `${attributes}${generic.expose?.passed ?? ""}`, + ] .filter((part) => part !== undefined) .join(" & "); const { scope } = composition; diff --git a/packages/target-react/src/plan.ts b/packages/target-react/src/plan.ts index dbbf97d29..a328add50 100644 --- a/packages/target-react/src/plan.ts +++ b/packages/target-react/src/plan.ts @@ -9,11 +9,13 @@ import { addPassThroughImports, codeKind, + codeNames, moduleHelperName, ImportSet, parseCodeSource, pascalCase, sourceNames, + typeNames, } from "@unframework/codegen"; import { codeOf, @@ -37,6 +39,7 @@ import type { RefAttribute, RenderNode, Span, + TypeText, UfComponent, UfModule, } from "@unframework/ir"; @@ -889,13 +892,33 @@ function eventParameterModes( /** * The names the output must leave alone: the source's (`sourceNames`), but for the DOM event * types that only the annotations React rewrites name (`KeyboardEvent` in `(event: - * KeyboardEvent)` on a synthetic listener), which the output imports from `react` instead. + * KeyboardEvent)` on a synthetic listener), which the output imports from `react` instead; and + * the types the output imports and exports by the source's names (ADR-0067, ADR-0068), so the + * interfaces React declares (`CardEvents`) never take one. */ function reservedNames( component: UfComponent, module: UfModule, listeners: ReadonlyMap, eventModes: ReadonlyMap>, +): Set { + const names = sourceAndEventNames(component, module, listeners, eventModes); + for (const entry of module.typeImports ?? []) { + for (const name of entry.names) names.add(name.local); + } + for (const entry of module.typeExports ?? []) { + if (entry.kind === "Declaration") names.add(entry.name); + else for (const name of entry.names) names.add(name.exported); + } + return names; +} + +/** The source's names, but for the DOM event types React rewrites (`reservedNames`). */ +function sourceAndEventNames( + component: UfComponent, + module: UfModule, + listeners: ReadonlyMap, + eventModes: ReadonlyMap>, ): Set { const rewritten = new Set(); for (const item of component.setup) { @@ -958,21 +981,38 @@ function reservedNames( /** * The `localConst` and `localFn` bindings that capture nothing of the component: their code reads * only globals, its own names and other such bindings, and writes, emits and calls `nextTick` - * (a hook's result on React) not at all. An `Id` is the instance's, so never. + * (a hook's result on React) not at all. An `Id` is the instance's, so never, and neither is a + * binding whose types or code name a type parameter of a generic component, which exists only + * inside it (ADR-0068). */ function hoistable( component: UfComponent, kindOf: (id: BindingId) => Binding["kind"] | undefined, ): Set { const code = new Map(); + const parameters = new Set(component.typeParameters?.map(({ name }) => name)); + const typed = new Set(); + const names = (types: readonly (TypeText | undefined)[], pieces: readonly Code[]) => { + const found = new Set(); + for (const type of types) if (type) for (const name of typeNames(type)) found.add(name); + for (const piece of pieces) { + for (const name of codeNames(piece.code, codeKind(piece, component))) found.add(name); + } + return [...found].some((name) => parameters.has(name)); + }; for (const item of component.setup) { - if (item.kind === "Const") code.set(item.binding, [item.value]); - else if (item.kind === "Function") { + if (item.kind === "Const") { + code.set(item.binding, [item.value]); + if (parameters.size && names([item.type], [item.value])) typed.add(item.binding); + } else if (item.kind === "Function") { const fn = item.function; - code.set(item.binding, [ + const pieces = [ fn.body, ...fn.parameters.flatMap((parameter) => (parameter.default ? [parameter.default] : [])), - ]); + ]; + code.set(item.binding, pieces); + const types = [...fn.parameters.map((parameter) => parameter.type), fn.returnType]; + if (parameters.size && names(types, pieces)) typed.add(item.binding); } } const verdicts = new Map(); @@ -984,6 +1024,7 @@ function hoistable( const pieces = code.get(id); const free = pieces !== undefined && + !typed.has(id) && pieces.every((piece) => piece.refs.every((reference) => { switch (reference.kind) { diff --git a/packages/target-react/test/composition.test.ts b/packages/target-react/test/composition.test.ts index 700c89485..71ef14f83 100644 --- a/packages/target-react/test/composition.test.ts +++ b/packages/target-react/test/composition.test.ts @@ -176,6 +176,65 @@ 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> = { + // Setup code a generic component's type parameter types, which stays inside the component. + genericSetup: `export interface ListProps { label: string; items: T[] } +export default function List({ label, items }: ListProps) { + const empty: T[] = []; + function describe(item: T): string { + return String(item); + } + return
      {(items.length ? items : empty).map((item, index) =>
    • {describe(item)}
    • )}
    ; +}`, + // A handle that a type parameter types (ADR-0054, ADR-0068). + genericExpose: `import { defineExpose, ref } from "unframework"; +export default function Picker({ items }: { items: T[] }) { + const picked = ref(""); + function choose(item: T): void { + picked.value = String(item); + } + defineExpose({ choose }); + return

    {items.length} {picked.value}

    ; +}`, + // An interface whose members name one of two type parameters declares only that one. + genericSubset: `import { defineSlots } from "unframework"; +import type { Element } from "unframework"; +export default function Pair({ items, tag }: { items: T[]; tag: U }) { + const slots = defineSlots<{ item?(props: { item: T }): Element }>(); + return
      {items.map((item, index) =>
    • {slots.item?.({ item })}
    • )}
    ; +}`, + // A field bound to state whose type is inferred from a value of the type parameter. + genericInferred: `import { ref } from "unframework"; +export default function Pick({ initial }: { initial: T }) { + const chosen = ref(initial); + 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"; +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 +427,125 @@ 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("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"; +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