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
12 changes: 12 additions & 0 deletions packages/target-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>({ items }: ListProps<T> & 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<T>`), 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) => <li key={…}>…)`, with the index only when something reads it;
Expand Down
23 changes: 21 additions & 2 deletions packages/target-react/src/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
jsxFillValue,
pascalCase,
rewriteExpression,
typeNames,
} from "@unframework/codegen";
import type { JsxContext } from "@unframework/codegen";
import type {
Expand Down Expand Up @@ -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;
Expand All @@ -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(
Expand Down
136 changes: 129 additions & 7 deletions packages/target-react/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
addTypeImports,
bindingOf,
componentTypes,
composableUse,
Expand All @@ -12,6 +13,8 @@ import {
printComponentModule,
printExpression,
spreadStyleExpression,
typeExportsCode,
typeNames,
} from "@unframework/codegen";
import type { EmitContext, JsxContext, OutputFile, Target } from "@unframework/codegen";
import type {
Expand All @@ -29,7 +32,10 @@ import type {
StyleAttribute,
TextNode,
TypeDeclaration,
TypeParameter,
TypeText,
UfComponent,
UfModule,
} from "@unframework/ir";

import { compilerBailout, declaredNames } from "./bailouts.ts";
Expand All @@ -56,6 +62,7 @@ import { titleChildren } from "./title.ts";

type Parameter = Parameters<typeof js.functionDeclaration>[1][number];
type BindingProperty = ReturnType<typeof js.bindingProperty>;
type TypeParameters = ReturnType<typeof js.functionDeclaration>["typeParameters"];

/** The React 19 target. */
export const react: Target = defineTarget({
Expand Down Expand Up @@ -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),
];
Expand Down Expand Up @@ -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<T extends Item = Item>` (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
Expand Down Expand Up @@ -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,
Expand All @@ -482,6 +533,7 @@ function compositionInterfaces(plan: ReactPlan): TypeDeclaration[] {
`${event}?: (${PARAMETER}: ${written}) => void;`,
];
}),
generic.models?.declared,
);
}
if (interfaces.slots) {
Expand All @@ -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) {
Expand All @@ -508,15 +561,20 @@ function compositionInterfaces(plan: ReactPlan): TypeDeclaration[] {
const parameters = fn ? parametersOf(fn) : "";
return `${name}${fn ? exposedType(fn, parameters) : "(): void"};`;
}),
generic.expose?.declared,
);
}
if (interfaces.attributes) {
const members: string[] = [];
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;
}
Expand Down Expand Up @@ -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 (`<T extends string>`) and passed by name (`<T>`). An interface that names none
* takes none.
*/
function interfaceParameters(
plan: ReactPlan,
): Partial<Record<"events" | "models" | "slots" | "expose", { declared: string; passed: string }>> {
const { component, composition } = plan;
const parameters = component.typeParameters ?? [];
if (!parameters.length) return {};
const written = (texts: readonly (TypeText | undefined)[]) => {
const named = new Set<string>();
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
Expand All @@ -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;
Expand Down
Loading
Loading