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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,9 @@ Generate each of these with its command. The formatter and the linter skip them
emit a small inline helper and declare it `emulated`. The one exception is a module a Vite
plugin registers, whatever the engine: an import under a `passThrough` prefix (`virtual:` by
default) reaches every output as written, and that plugin resolves it (§4.4, ADR-0004,
ADR-0064). Its module may import the engine's own runtime, such as `@styleframe/runtime`.
ADR-0064). Its module may import the engine's own runtime, such as `@styleframe/runtime`. A
type import is erased, so an output imports the types the source imports by name, a package's
included (ADR-0067).
- **The IR is plain JSON (P5).** Every node carries a span. Add a field only when a consumer reads
it. Keep `checkInvariants` true (ADR-0032).

Expand Down
14 changes: 11 additions & 3 deletions docs/adrs/0068-generic-components-and-exported-public-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
- **Status:** Proposed
- **Date:** 2026-10-24
- **Plan:** §4.2, §5.6 (layer 4), §6, §7.2 (L4), §9 M5; G2, P2, P4, P6; ADR-0033, ADR-0034,
ADR-0059, ADR-0067; amends ADR-0034 and ADR-0059
ADR-0050, ADR-0059, ADR-0067; amends ADR-0034, ADR-0050 and ADR-0059

## Context

Expand Down Expand Up @@ -82,8 +82,16 @@ green in its own lane: `types/imported-interface`, `types/extends-chain`, `types
`types/exported-types`, `generics/list`, `generics/select`, `generics/scoped-slot`,
`generics/constrained`, and `diagnostics/generic-slot-props`.

**What it amends.** ADR-0034 accepts a generic component and a generic props declaration. ADR-0059
gains the `type` kind and the generic-slot-props gap.
- **A harness rule for the types lanes:** a case whose L3 is quarantined on a target whose browser
build type-checks its output (Angular's) gets the browser stand-in of ADR-0050, so its spec
loads; its tests run, every browser layer fails with the stand-in's cause and is quarantined,
and the summary excuses their scenarios there, until the L3 entry goes.
- A diagnostics case its target has output for (UF4004 is a note) records its browser layers
`skip(no spec: …)` in the compile project: it has no spec, and its server render checks it.

**What it amends.** ADR-0034 accepts a generic component and a generic props declaration. ADR-0050's
stand-in also serves a case its target cannot compile yet. ADR-0059 gains the `type` kind and the
generic-slot-props gap.

## Consequences

Expand Down
31 changes: 28 additions & 3 deletions packages/analyzer/src/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,15 +137,16 @@ const RELATIVE_OR_ABSOLUTE = /^(?:[./\\]|[A-Za-z]:)/;
export const COMPONENT_SPECIFIER: RegExp = /\.uf\.tsx$/;

/**
* The specifiers of the `.uf.tsx` modules a parsed module imports as values, in source order,
* each once: what `compile()` asks its resolver for before the module is analysed.
* The specifiers of the `.uf.tsx` modules a parsed module imports, values or types, in source
* order, each once: what `compile()` asks its resolver for before the module is analysed.
*/
export function componentImports(parsed: ParsedModule): string[] {
const found = new Set<string>();
for (const statement of parsed.program.body) {
// A type-only import too: its outputs import the types from the output of the module's main
// component, which only the module's API names (ADR-0067).
if (
statement.type === "ImportDeclaration" &&
statement.importKind !== "type" &&
COMPONENT_SPECIFIER.test(statement.source.value)
) {
found.add(statement.source.value);
Expand Down Expand Up @@ -1261,6 +1262,11 @@ function componentImport(statement: AST.ImportDeclaration, context: ModuleContex
context.components.set(item.local, { name: item.local.name, api: component });
}
if (!names.length) return;
// A type-only import of the module before this one recorded it without names.
const typesOnly = context.imports.findIndex(
(entry) => entry.specifier === specifier && !entry.names.length,
);
if (typesOnly !== -1) context.imports.splice(typesOnly, 1);
context.imports.push(
createModuleImport(specifier, api.file, api, names, {
start: statement.start,
Expand Down Expand Up @@ -1498,6 +1504,7 @@ function checkImport(statement: AST.ImportDeclaration, context: ModuleContext):
}
if (statement.importKind === "type") {
typeImport(statement, statement.specifiers, context);
if (COMPONENT_SPECIFIER.test(specifier)) typeOnlyModule(statement, context);
return;
}
reporter.unsupported(statement, "Importing modules is not supported yet.");
Expand Down Expand Up @@ -1577,6 +1584,24 @@ function sharedImport(statement: AST.ImportDeclaration, context: ModuleContext):
);
}

/**
* A `.uf.tsx` module the source imports types from alone: recorded with its API and no names, so
* each output imports the types from the output of its main component, which the file's name
* does not give (ADR-0067). An unresolved one is left to the type oracle, which reports the types
* it cannot read (UF6001).
*/
function typeOnlyModule(statement: AST.ImportDeclaration, context: ModuleContext): void {
const specifier = statement.source.value;
const api = context.resolved.get(specifier);
if (!api || context.imports.some((entry) => entry.specifier === specifier)) return;
context.imports.push(
createModuleImport(specifier, api.file, api, [], {
start: statement.start,
end: statement.end,
}),
);
}

/**
* An import of types from a relative `.ts`, a `.uf.tsx`, a `.uf.ts` or a package (ADR-0067):
* the analyser reads them through the type oracle's shapes, and every output re-imports them
Expand Down
4 changes: 2 additions & 2 deletions packages/analyzer/test/corpus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ const NAMED_OTHERWISE: Readonly<Record<string, string>> = {

/**
* The diagnostics cases whose code `compile()` reports after the analyser, which finds nothing:
* the codegen scoper's (ADR-0063).
* the codegen scoper's (ADR-0063), and the capability check's (ADR-0068).
*/
const AFTER_ANALYSIS: ReadonlySet<string> = new Set(["unscopable-selector"]);
const AFTER_ANALYSIS: ReadonlySet<string> = new Set(["unscopable-selector", "generic-slot-props"]);

/** The warnings the corpus's feature cases hold on purpose. */
const WARNINGS: Readonly<Record<string, readonly string[]>> = {
Expand Down
13 changes: 11 additions & 2 deletions packages/codegen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,17 @@ rewrite })` prints a component's root element or fragment, every node and attrib
component's output keeps as written (a styling engine's, ADR-0064), and `addPassThroughImports`
adds them to an `ImportSet`, which prints them after its sorted imports, in the IR's order; `componentTypes`
and `typeDeclarationCode` copy the type declarations a component's props reach, exported as
the source does. `ImportSet` sorts imports and renames one that clashes with a name in its
`NameScope`: a target reserves the source's names (`sourceNames`: the component, its types,
the source does. `addTypeImports` adds the names of the module's `import type` declarations that
what a component's output prints reads (`liveTypeNames`), by the source's own names, from
`typeSpecifier`: a `.ts` module's or a package's as written, a `.uf.tsx` module's from the
output of its main component, which the module's import records with its API, a type-only one
included (`./TextField.vue` for `./text-field.uf.tsx`). `typeExportsCode` writes the module's
type exports in the output of its main component: an exported type no component reaches, a
re-export, and a re-export of an exported type only another component's output declares, each
with its span, so a target places it in source order (ADR-0067, ADR-0068). A prop another file
declares (`declaredBy`) is written in that file's names, so neither these nor `liveTypes` and
the kinds look its type up in the component's own module. `ImportSet` sorts imports and renames
one that clashes with a name in its `NameScope`: a target reserves the source's names (`sourceNames`: the component, its types,
bindings, every variable its expressions and setup code read or declare (`codeNames`), its
functions' parameters, and the types its setup's annotations name (`typeNames`)) and claims its
own (`props`, `cx`, `Show`, `setCount`) from the same scope, so nothing it adds captures or
Expand Down
104 changes: 101 additions & 3 deletions packages/codegen/src/exports.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type * as AST from "@oxc-project/types";
import type { TypeDeclaration, UfComponent, UfExport, UfModule } from "@unframework/ir";
import type { Span, TypeDeclaration, UfComponent, UfExport, UfModule } from "@unframework/ir";

import { keyOwner } from "./imports.ts";
import type { ImportSet } from "./imports.ts";
import { exportDefault, exportList, exportNamed, variableDeclaration } from "./js/builders.ts";
import { parseStatementsSource } from "./parse.ts";
import { liveTypeNames } from "./references.ts";
import { collectNames } from "./rewrite.ts";

/**
* Declares a component with the source's export shape. A lone default export or a lone export
Expand Down Expand Up @@ -55,9 +60,102 @@ export function componentTypes(component: UfComponent, module: UfModule): TypeDe

/**
* A type declaration as the source writes it, exported when the source exports it: the
* outputs keep the export, which consumers type their props with (M5). Copied as text, never
* re-printed: oxc-codegen drops comments, and a member's doc comment is part of the type.
* outputs keep the export, which consumers type their props with (ADR-0068). Copied as text,
* never re-printed: oxc-codegen drops comments, and a member's doc comment is part of the type.
*/
export function typeDeclarationCode(declaration: TypeDeclaration): string {
return declaration.exported ? `export ${declaration.code}` : declaration.code;
}

/**
* The specifier an output imports a module's types from (ADR-0067): a `.uf.tsx` module's are
* exported by the output of its main component (`keyOwner` of the API the module's import
* records, a type-only one included), `file(name)` naming a component's output file as for
* `childImports`; any other specifier, a `.ts` module's or a package's, is kept as written,
* since every output tree holds the `.ts` modules unchanged (ADR-0070). A `.uf.tsx` module with
* no API here was not resolved, which fails the compile before any output.
*/
export function typeSpecifier(
specifier: string,
module: UfModule,
file: (name: string) => string,
): string {
if (!specifier.endsWith(".uf.tsx")) return specifier;
const entry = module.imports?.find((each) => each.specifier === specifier);
const owner = entry && keyOwner(entry.api.components);
if (!entry || owner === undefined) return specifier;
const directory = entry.file.includes("/")
? entry.file.slice(0, entry.file.lastIndexOf("/") + 1)
: "";
const path = `${directory}${file(owner)}`;
return path.startsWith("../") ? path : `./${path}`;
}

/**
* Adds the `import type` names a component's output reads (ADR-0067): each name of the module's
* type imports that what the output prints reads (`liveTypeNames`), under the source's own
* local name, from `typeSpecifier`. The output of the module's main component also writes the
* module's type exports (`typeExports`), and reads what their declarations name.
*/
export function addTypeImports(
component: UfComponent,
module: UfModule,
imports: ImportSet,
file: (name: string) => string,
): void {
if (!module.typeImports?.length) return;
const read = liveTypeNames(component, module, { client: true });
if (keyOwner(module.components, module.exports) === component.name) {
for (const entry of module.typeExports ?? []) {
if (entry.kind !== "Declaration") continue;
const [statement] = parseStatementsSource(entry.code).statements;
collectNames(statement, read);
}
}
for (const entry of module.typeImports) {
for (const name of entry.names) {
if (!read.has(name.local)) continue;
imports.add(typeSpecifier(entry.specifier, module, file), name.imported, {
type: true,
local: name.local,
exact: true,
});
}
}
}

/**
* The module's type exports, as the output of its main component writes them (ADR-0068): an
* exported type no component reaches as written, a re-export from `typeSpecifier`, and a
* re-export of an exported type only another component's output declares, each with its span,
* so a target can place it in source order among the types it copies. Every other component's
* output writes none.
*/
export function typeExportsCode(
component: UfComponent,
module: UfModule,
file: (name: string) => string,
): { code: string; span: Span }[] {
if (keyOwner(module.components, module.exports) !== component.name) return [];
const exports = (module.typeExports ?? []).map((entry) => {
if (entry.kind === "Declaration") return { code: `export ${entry.code}`, span: entry.span };
const names = entry.names.map(({ imported, exported }) =>
imported === exported ? imported : `${imported} as ${exported}`,
);
const specifier = JSON.stringify(typeSpecifier(entry.specifier, module, file));
return { code: `export type { ${names.join(", ")} } from ${specifier};`, span: entry.span };
});
// An exported type only another component of the module reaches is declared in that
// component's output: the main output re-exports it, so a consumer finds every exported type
// where it imports the module's (ADR-0068).
for (const declaration of module.types) {
if (!declaration.exported || component.types.includes(declaration.name)) continue;
const sibling = module.components.find((each) => each.types.includes(declaration.name));
if (!sibling) continue;
exports.push({
code: `export type { ${declaration.name} } from ${JSON.stringify(`./${file(sibling.name)}`)};`,
span: declaration.span,
});
}
return exports.toSorted((a, b) => a.span.start - b.span.start);
}
12 changes: 10 additions & 2 deletions packages/codegen/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,15 @@ export type { ComponentModule } from "./js/print.ts";
export { Placeholders } from "./js/placeholders.ts";
export { isAlwaysNullish, nullishText, syntacticNullishness, textTemplate } from "./js/text.ts";
export type { Nullishness, TextPart } from "./js/text.ts";
export { componentTypes, exportDeclaration, exportsOf, typeDeclarationCode } from "./exports.ts";
export {
addTypeImports,
componentTypes,
exportDeclaration,
exportsOf,
typeDeclarationCode,
typeExportsCode,
typeSpecifier,
} from "./exports.ts";
export {
bindingOf,
codeKind,
Expand All @@ -53,7 +61,7 @@ export {
parseStatementsSource,
} from "./parse.ts";
export type { CodeKind, ExpressionComment, ParsedExpression, ParsedStatements } from "./parse.ts";
export { liveBindings, liveTypes, referencedBindings } from "./references.ts";
export { liveBindings, liveTypeNames, liveTypes, referencedBindings } from "./references.ts";
export type { LiveOptions, ReferencedOptions } from "./references.ts";
export {
functionBodyText,
Expand Down
17 changes: 15 additions & 2 deletions packages/codegen/src/kinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ class Kinds {
readonly #module: UfModule;
/** The declarations and bindings being read, against cycles. */
readonly #reading = new Set<string>();
/** Set while a prop another file declares is read: its names are not the module's. */
#foreign = false;

constructor(component: UfComponent, module: UfModule) {
this.#component = component;
Expand Down Expand Up @@ -194,7 +196,9 @@ class Kinds {

/** Whether a local `type` alias names only primitives: an interface is an object. */
#alias(name: string): boolean {
const declaration = this.#module.types.find((each) => each.name === name);
const declaration = this.#foreign
? undefined
: this.#module.types.find((each) => each.name === name);
if (!declaration || this.#reading.has(name)) return false;
const alias = /^type\s+[A-Za-z_$][\w$]*\s*=\s*([\s\S]*?);?\s*$/.exec(declaration.code);
if (!alias) return false;
Expand Down Expand Up @@ -377,7 +381,16 @@ class Kinds {
switch (binding.kind) {
case "prop": {
const prop = this.#component.props.find((each) => each.binding === binding.id);
return prop !== undefined && this.type(prop.type.code);
if (prop === undefined) return false;
// A member another file declares is written in that file's names (ADR-0067): none of
// the module's own aliases is the one it names.
if (!prop.declaredBy) return this.type(prop.type.code);
this.#foreign = true;
try {
return this.type(prop.type.code);
} finally {
this.#foreign = false;
}
}
case "state": {
const item = this.#component.setup.find(
Expand Down
46 changes: 38 additions & 8 deletions packages/codegen/src/references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,15 +247,39 @@ export function liveTypes(
module: UfModule,
options: LiveOptions,
): TypeDeclaration[] {
const { declared, reached } = reachTypes(component, module, options);
return [...declared.values()].filter((declaration) => reached.has(declaration.name));
}

/**
* Every type name what a component's output prints reads, as {@link liveTypes} walks it: the
* names of the declarations it copies, and the names they and the component's own annotations
* read, such as the types it imports (ADR-0067), which a target imports only where they are read.
*/
export function liveTypeNames(
component: UfComponent,
module: UfModule,
options: LiveOptions,
): Set<string> {
return reachTypes(component, module, options).names;
}

function reachTypes(
component: UfComponent,
module: UfModule,
options: LiveOptions,
): { declared: Map<string, TypeDeclaration>; reached: Set<string>; names: Set<string> } {
const declared = new Map(
module.types
.filter((declaration) => component.types.includes(declaration.name))
.map((declaration) => [declaration.name, declaration]),
);
const reached = new Set<string>();
const names = new Set<string>();
const pending: string[] = [];
const reach = (names: Iterable<string>) => {
for (const name of names) {
const reach = (found: Iterable<string>) => {
for (const name of found) {
names.add(name);
if (!declared.has(name) || reached.has(name)) continue;
reached.add(name);
pending.push(name);
Expand All @@ -271,7 +295,13 @@ export function liveTypes(
code(written.body);
};
if (component.propsParameter) type(component.propsParameter.type);
for (const prop of component.props) type(prop.type);
// A member another file declares is written in that file's names (ADR-0067), which the
// component's module does not declare or import.
for (const prop of component.props) if (!prop.declaredBy) type(prop.type);
for (const parameter of component.typeParameters ?? []) {
type(parameter.constraint);
type(parameter.default);
}
// A slot's props are its public type, as a prop's is (ADR-0054).
for (const slot of component.slots?.slots ?? []) type(slot.props);
for (const declaration of declared.values()) {
Expand Down Expand Up @@ -346,12 +376,12 @@ export function liveTypes(
}
for (let name = pending.pop(); name !== undefined; name = pending.pop()) {
const [statement] = parseStatementsSource(declared.get(name)!.code).statements;
const names = new Set<string>();
collectNames(statement, names);
names.delete(name);
reach(names);
const found = new Set<string>();
collectNames(statement, found);
found.delete(name);
reach(found);
}
return [...declared.values()].filter((declaration) => reached.has(declaration.name));
return { declared, reached, names };
}

/** Adds to `found` the bindings code references: read, called, written or emitted through. */
Expand Down
Loading
Loading