diff --git a/.claude/skills/add-test/SKILL.md b/.claude/skills/add-test/SKILL.md index f8c65e40..1da9bc2b 100644 --- a/.claude/skills/add-test/SKILL.md +++ b/.claude/skills/add-test/SKILL.md @@ -7,11 +7,15 @@ allowed-tools: Read, Grep, Glob, Edit, Write ## Context -react-native-css has three test domains in `src/__tests__/`: +react-native-css has these test domains in `src/__tests__/`: - `babel/` — Babel plugin tests using `babel-plugin-tester` - `compiler/` — CSS compilation tests verifying JSON output +- `e2e/` — Maestro flows for the example app - `native/` — Runtime tests for style application +- `types/` — Type tests that build a TypeScript program over the package's declarations +- `vendor/` — Tests of third-party CSS, such as Tailwind's output +- `web/` — Runtime tests for the web ## Conventions by domain @@ -41,14 +45,30 @@ Verify CSS → JSON compilation output structure. Test runtime style application on native platform. +### Web tests (`src/__tests__/web/`) + +Test the web runtime, such as `useCssElement` and `assignStyle`. + +### Vendor tests (`src/__tests__/vendor/`) + +Compile a third party's CSS, such as Tailwind's through `vendor/tailwind/_tailwind.tsx`'s `render`, and assert the styles it produces. + +### End-to-end flows (`src/__tests__/e2e/`) + +Maestro flows that drive the example app on a device. + +### Type tests (`src/__tests__/types/`) + +A case `tsc` alone decides goes in `src/__tests__/native/_styled-types.tsx` or `src/__fixtures__/types/component-mapping.tsx`, which jest skips and `yarn typecheck` checks, with `@ts-expect-error` above a call the types must reject. A case that needs the compiler API, such as an editor's completions or a walk over the declarations, builds a TypeScript program in `src/__tests__/types/`. + ## Steps 1. **Identify the feature**: What needs testing? Use `$ARGUMENTS` as the starting point. -2. **Determine the domain**: Is this a babel transform, compiler output, or runtime behavior? +2. **Determine the domain**: Is this a babel transform, compiler output, native or web runtime behavior, a third party's CSS, a flow on a device, or a type? 3. **Find existing tests**: Search the appropriate `src/__tests__/` subdirectory for similar tests. 4. **Write the test**: Follow the conventions of the domain. -5. **Run the test**: Execute `yarn test`. Note: ignore `ExperimentalWarning: VM Modules` warnings — they're expected. +5. **Run the test**: Execute `yarn test`, and `yarn typecheck` for a case `tsc` alone decides. Note: ignore `ExperimentalWarning: VM Modules` warnings — they're expected. diff --git a/.claude/skills/triage/SKILL.md b/.claude/skills/triage/SKILL.md index 46bb04fc..47be458b 100644 --- a/.claude/skills/triage/SKILL.md +++ b/.claude/skills/triage/SKILL.md @@ -103,7 +103,7 @@ Create a minimal reproduction. The approach depends on the repo and what's being ### react-native-css issues -Tests are organized by domain in `src/__tests__/` with three subdirectories: `native/`, `compiler/`, `babel/`. Before writing a test, read a few existing tests in the relevant subdirectory to match the conventions exactly. +Tests are organized by domain in `src/__tests__/`: `babel/`, `compiler/`, `e2e/`, `native/`, `types/`, `vendor/` and `web/`, as the `add-test` skill describes. Before writing a test, read a few existing tests in the relevant subdirectory to match the conventions exactly. **For native styling issues** (most common, e.g., wrong style output, className not working): diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4ce58467..27dd22e9 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -118,7 +118,7 @@ yarn example start:debug # Rebuild + start with debug logging ## Testing - **Runner:** Jest with experimental ESM support (`NODE_OPTIONS="--experimental-vm-modules"`) -- **Convention:** Tests in `src/__tests__/` organized by domain (`babel/`, `compiler/`, `native/`) +- **Convention:** Tests in `src/__tests__/` organized by domain (`babel/`, `compiler/`, `e2e/`, `native/`, `types/`, `vendor/`, `web/`) - **Babel tests** use `babel-plugin-tester` - **Compiler tests** verify JSON output structure - **Run specific suites:** `yarn test babel`, `yarn test compiler` diff --git a/src/__tests__/native/_styled-types.tsx b/src/__tests__/native/_styled-types.tsx index 3e9e3d0f..7a5b5c27 100644 --- a/src/__tests__/native/_styled-types.tsx +++ b/src/__tests__/native/_styled-types.tsx @@ -1,7 +1,11 @@ +import type { ComponentProps } from "react"; import { + ActivityIndicator, + Image, Modal, ScrollView, StatusBar, + Text, TextInput, View, type StyleProp, @@ -60,6 +64,79 @@ export const rejected = [ // @ts-expect-error An explicit mapping must name a real target property. rootStyled(Base, { customClassName: "missing" }); +// A mapping takes any style key, a -rn- declaration's among them, an entry left undefined, and a value naming a prop +export const mappings = [ + rootStyled(View, { + className: { + target: "style", + nativeStyleMapping: { color: "accessibilityLabel" }, + }, + }), + rootStyled(ActivityIndicator, { + className: { target: "style", nativeStyleMapping: { color: "color" } }, + }), + rootStyled(TextInput, { + className: { + target: "style", + nativeStyleMapping: { selectionColor: true }, + }, + }), + rootStyled(Image, { + className: { target: "style", nativeStyleMapping: { tintColor: true } }, + }), + rootStyled(View, { + className: { + target: "style", + nativeStyleMapping: { backgroundColor: undefined }, + }, + }), + rootStyled(View, { + labelClassName: { + target: false, + nativeStyleMapping: { color: "style.borderColor" }, + }, + }), + rootStyled(Text, { + className: { + target: "style", + nativeStyleToProp: { color: "accessibilityLabel" }, + }, + }), +]; + +export const rejectedMappings = [ + // @ts-expect-error A mapping's value must name a prop. + rootStyled(View, { + className: { + target: "style", + nativeStyleMapping: { color: "accessibilityLabl" }, + }, + }), + // @ts-expect-error A target:false mapping's value must name a prop too. + rootStyled(View, { + labelClassName: { + target: false, + nativeStyleMapping: { color: "style.borderColour" }, + }, + }), + // @ts-expect-error A mapping's value is true or the path of a prop. + rootStyled(View, { + className: { + target: "style", + nativeStyleMapping: { color: false }, + }, + }), +]; + +// A styled component's class props are writable, as the component's own props are +export function writeClassNames( + rootProps: ComponentProps, + mappedProps: ComponentProps, +) { + rootProps.className = "p-4"; + mappedProps.customClassName = "p-4"; +} + export const components = [ , Hello, diff --git a/src/__tests__/native/styled.test.ios.tsx b/src/__tests__/native/styled.test.ios.tsx index 3f4ef620..21e55876 100644 --- a/src/__tests__/native/styled.test.ios.tsx +++ b/src/__tests__/native/styled.test.ios.tsx @@ -163,6 +163,25 @@ test.each(["nativeStyleMapping", "nativeStyleToProp"] as const)( }, ); +test("a mapping entry set to undefined maps nothing", () => { + registerCSS(`.subject { color: blue; background-color: red; }`); + const StyledView = styled(View, { + className: { + target: "style", + nativeStyleMapping: { + color: "accessibilityLabel", + backgroundColor: undefined, + }, + }, + }); + + render(); + + const props = screen.getByTestId(testID).props; + expect(props.accessibilityLabel).toBe("#00f"); + expect(props.style).toEqual({ backgroundColor: "#f00" }); +}); + test.each([false, true])( "current mapping wins over deprecated alias (empty: %s)", (empty) => { diff --git a/src/__tests__/types/_program.ts b/src/__tests__/types/_program.ts new file mode 100644 index 00000000..90fb2007 --- /dev/null +++ b/src/__tests__/types/_program.ts @@ -0,0 +1,69 @@ +import { join } from "node:path"; + +import ts from "typescript"; + +export const root = join(__dirname, "../../..").replaceAll("\\", "/"); + +// The repository's own compiler options with exactOptionalPropertyTypes on, and a fixture's unused names allowed +export function compilerOptions(): ts.CompilerOptions { + const { config } = ts.readConfigFile(`${root}/tsconfig.json`, (path) => + ts.sys.readFile(path), + ); + const { options } = ts.parseJsonConfigFileContent(config, ts.sys, root); + return { + ...options, + exactOptionalPropertyTypes: true, + noUnusedLocals: false, + noUnusedParameters: false, + }; +} + +export function createProgram( + rootNames: readonly string[], + virtualFiles: Readonly> = {}, +): ts.Program { + const options = compilerOptions(); + const host = ts.createCompilerHost(options, true); + + return ts.createProgram(rootNames, options, { + ...host, + getCurrentDirectory: () => root, + fileExists: (name) => name in virtualFiles || host.fileExists(name), + readFile: (name) => virtualFiles[name] ?? host.readFile(name), + getSourceFile: (name, languageVersion, ...rest) => { + const text = virtualFiles[name]; + return text === undefined + ? host.getSourceFile(name, languageVersion, ...rest) + : ts.createSourceFile(name, text, languageVersion, true); + }, + }); +} + +export function createLanguageService( + virtualFiles: Readonly>, +): ts.LanguageService { + const options = compilerOptions(); + + return ts.createLanguageService( + { + getScriptFileNames: () => Object.keys(virtualFiles), + getScriptVersion: () => "1", + getScriptSnapshot: (name) => { + const text = virtualFiles[name] ?? ts.sys.readFile(name); + return text === undefined + ? undefined + : ts.ScriptSnapshot.fromString(text); + }, + getCurrentDirectory: () => root, + getCompilationSettings: () => options, + getDefaultLibFileName: ts.getDefaultLibFilePath, + fileExists: (name) => name in virtualFiles || ts.sys.fileExists(name), + readFile: (name) => virtualFiles[name] ?? ts.sys.readFile(name), + readDirectory: (path, extensions, excludes, includes, depth) => + ts.sys.readDirectory(path, extensions, excludes, includes, depth), + directoryExists: (name) => ts.sys.directoryExists(name), + getDirectories: (name) => ts.sys.getDirectories(name), + }, + ts.createDocumentRegistry(), + ); +} diff --git a/src/__tests__/types/optional-members.test.ts b/src/__tests__/types/optional-members.test.ts new file mode 100644 index 00000000..e7c61930 --- /dev/null +++ b/src/__tests__/types/optional-members.test.ts @@ -0,0 +1,689 @@ +import ts from "typescript"; + +import { createProgram, root } from "./_program"; + +// Every declaration of a file, or only the option types a consumer writes, with the option types their members reach +const declarations: readonly (readonly [string, readonly string[] | "all"])[] = + [ + ["types.d.ts", "all"], + ["src/runtime.types.ts", "all"], + ["src/compiler/compiler.types.ts", ["CompilerOptions"]], + ["src/metro/index.ts", ["WithReactNativeCSSOptions"]], + ["src/jest/index.ts", ["RegisterCSSOptions"]], + ["src/native/react/useNativeCss.ts", ["Config"]], + ]; + +type TypeDeclaration = + | ts.InterfaceDeclaration + | ts.TypeAliasDeclaration + | ts.ClassDeclaration; + +const isTypeDeclaration = (node: ts.Node): node is TypeDeclaration => + ts.isInterfaceDeclaration(node) || + ts.isTypeAliasDeclaration(node) || + ts.isClassDeclaration(node); + +type OptionalMemberDeclaration = + | ts.PropertySignature + | ts.PropertyDeclaration + | ts.MethodSignature + | ts.MethodDeclaration + | ts.NamedTupleMember; + +const isOptionalMemberDeclaration = ( + node: ts.Node, +): node is OptionalMemberDeclaration => + (ts.isPropertySignature(node) || + ts.isPropertyDeclaration(node) || + ts.isMethodSignature(node) || + ts.isMethodDeclaration(node) || + ts.isNamedTupleMember(node)) && + node.questionToken !== undefined; + +// Under exactOptionalPropertyTypes a bare `?: T` rejects an explicit undefined, which React Native's own optional props accept +function optionalMembersRejectingUndefined( + program: ts.Program, + fileName: string, + names: readonly string[] | "all" = "all", +): readonly string[] { + const checker = program.getTypeChecker(); + const source = program.getSourceFile(fileName); + if (source === undefined) throw new Error(`${fileName} is not compiled`); + const undefinedType = checker.getUndefinedType(); + + const isOwnDeclaration = (node: ts.Node): boolean => + !node.getSourceFile().fileName.includes("/node_modules/"); + + // A type parameter, such as one an augmentation of a React Native interface redeclares, stands for a type rather than being one + const isOwnType = (type: ts.Type): boolean => + (type.flags & ts.TypeFlags.TypeParameter) === 0 && + ((type.aliasSymbol ?? type.getSymbol())?.declarations?.some( + isOwnDeclaration, + ) ?? + false); + + const partsOf = (type: ts.Type): readonly ts.Type[] => + type.isUnion() ? type.types : [type]; + + // An optional member reads as its type and TypeScript's missing type, which is not the undefined a written `| undefined` adds + const acceptsUndefined = (type: ts.Type): boolean => { + const written = partsOf(type).filter( + (part) => + part === undefinedType || (part.flags & ts.TypeFlags.Undefined) === 0, + ); + // `?: never` marks a key that must stay absent + return ( + written.length === 0 || + written.some((part) => checker.isTypeAssignableTo(undefinedType, part)) + ); + }; + + // Each place is reported once: a member its declaration makes optional under the type that declares it, anything else under the first path or line that reaches it + const offenders = new Set(); + const reported = new Set(); + const report = (place: ts.Node | string, name: string): void => { + if (reported.has(place)) return; + reported.add(place); + offenders.add(name); + }; + + const lineOf = (node: ts.Node): string => { + const nodeSource = node.getSourceFile(); + const { line } = nodeSource.getLineAndCharacterOfPosition( + node.getStart(nodeSource), + ); + const written = node.getText(nodeSource).replace(/\s+/g, " "); + return `${line + 1}: ${written.length > 80 ? `${written.slice(0, 79)}…` : written}`; + }; + + const declaringTypeName = ( + member: OptionalMemberDeclaration, + ): string | undefined => { + const owner = member.parent; + if (ts.isInterfaceDeclaration(owner) || ts.isClassDeclaration(owner)) { + return owner.name?.text; + } + return ts.isTypeLiteralNode(owner) && + ts.isTypeAliasDeclaration(owner.parent) + ? owner.parent.name.text + : undefined; + }; + + const reportDeclaredMember = ( + member: OptionalMemberDeclaration, + anonymousName: () => string, + ): void => { + const owner = declaringTypeName(member); + report( + member, + owner === undefined + ? anonymousName() + : `${owner}.${member.name.getText(member.getSourceFile())}`, + ); + }; + + const typeNodeRejectsUndefined = (node: ts.TypeNode | undefined): boolean => + node !== undefined && !acceptsUndefined(checker.getTypeFromTypeNode(node)); + + // An optional method's type is a function, which an explicit undefined never matches + const declarationRejectsUndefined = ( + declaration: OptionalMemberDeclaration, + ): boolean => + ts.isMethodSignature(declaration) || + ts.isMethodDeclaration(declaration) || + typeNodeRejectsUndefined(declaration.type); + + const referenceArguments = (type: ts.Type): readonly ts.Type[] => + (type.flags & ts.TypeFlags.Object) !== 0 && + ((type as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference) !== 0 + ? checker.getTypeArguments(type as ts.TypeReference) + : []; + + // A library's generic, such as Map or Promise, holds this package's types in its arguments, through any union or intersection; a type is answered once, so a recursive one ends + const mentionsOwnTypeMemo = new Map(); + const mentionsOwnType = (type: ts.Type): boolean => { + const known = mentionsOwnTypeMemo.get(type); + if (known !== undefined) return known; + mentionsOwnTypeMemo.set(type, false); + const mentions = + isOwnType(type) || + (type.isUnionOrIntersection() && type.types.some(mentionsOwnType)) || + (type.aliasTypeArguments ?? []).some(mentionsOwnType) || + referenceArguments(type).some(mentionsOwnType); + mentionsOwnTypeMemo.set(type, mentions); + return mentions; + }; + + const isMappedType = (type: ts.Type): boolean => + (type.flags & ts.TypeFlags.Object) !== 0 && + ((type as ts.ObjectType).objectFlags & ts.ObjectFlags.Mapped) !== 0; + + // A signature's type parameters' constraints, its parameters and its return type, such as a component it returns, reach what a consumer writes + const descendIntoSignature = ( + signature: ts.Signature, + path: string, + ): void => { + for (const parameter of signature.getTypeParameters() ?? []) { + const constraint = parameter.getConstraint(); + if (constraint !== undefined) { + descend(constraint, `${path}<${parameter.symbol.name}>`); + } + } + for (const parameter of signature.getParameters()) { + descend(checker.getTypeOfSymbol(parameter), `${path}(${parameter.name})`); + } + descend(signature.getReturnType(), `${path}()`); + }; + + // Call and construct signatures, index signatures and base types reach what a consumer writes too + const descendIntoStructure = (type: ts.Type, path: string): void => { + for (const kind of [ts.SignatureKind.Call, ts.SignatureKind.Construct]) { + for (const signature of checker.getSignaturesOfType(type, kind)) { + descendIntoSignature(signature, path); + } + } + for (const index of checker.getIndexInfosOfType(type)) { + descend(index.type, `${path}[]`); + } + if (type.isClassOrInterface()) { + for (const base of checker.getBaseTypes(type)) { + descend(base, path); + } + } + }; + + // A type is walked once, under the first path that reaches it, so a recursive one such as `type Nested = string | Nested[]` ends + const walkedTypes = new Set(); + + // An option type sits in a member's type itself, in a union or an intersection, in a tuple, in a generic's arguments, or behind a mapped type + const descend = (type: ts.Type, path: string): void => { + if (walkedTypes.has(type)) return; + walkedTypes.add(type); + + if (type.isUnionOrIntersection()) { + for (const part of type.types) descend(part, path); + } else if ( + checker.isTupleType(type) || + isMappedType(type) || + isOwnType(type) + ) { + checkMembers(type, path); + } else if ( + (type.flags & ts.TypeFlags.Object) !== 0 && + mentionsOwnType(type) + ) { + for (const argument of referenceArguments(type)) descend(argument, path); + checkMembers(type, path); + } + }; + + // A resolved type's members as a consumer writes them, inherited, picked and mapped ones included, and the option types they reach + const checkMembers = (type: ts.Type, path: string): void => { + descendIntoStructure(type, path); + + for (const property of checker.getPropertiesOfType(type)) { + const memberPath = `${path}.${property.name}`; + const propertyType = checker.getTypeOfSymbol(property); + if ( + (property.flags & ts.SymbolFlags.Optional) !== 0 && + !acceptsUndefined(propertyType) + ) { + const declaration = property.declarations?.find( + isOptionalMemberDeclaration, + ); + if ( + declaration === undefined || + !declarationRejectsUndefined(declaration) + ) { + // A mapped type made the member optional or gave it a type without undefined, so the fix is where the mapped type is applied + report(memberPath, memberPath); + } else if (isOwnDeclaration(declaration)) { + reportDeclaredMember(declaration, () => memberPath); + } + // A member another package declares optional without undefined, such as a React Native prop, is that package's + } + if ( + (property.declarations?.some(isOwnDeclaration) ?? true) || + mentionsOwnType(propertyType) + ) { + descend(propertyType, memberPath); + } + } + }; + + // keyof T through any alias, or a type parameter declared as one + const isKeyOfConstraint = (node: ts.TypeNode): boolean => { + if ((checker.getTypeFromTypeNode(node).flags & ts.TypeFlags.Index) !== 0) { + return true; + } + const declaration = ts.isTypeReferenceNode(node) + ? checker.getSymbolAtLocation(node.typeName)?.declarations?.[0] + : undefined; + return ( + declaration !== undefined && + ts.isTypeParameterDeclaration(declaration) && + declaration.constraint !== undefined && + isKeyOfConstraint(declaration.constraint) + ); + }; + + const addsQuestionToken = (node: ts.MappedTypeNode): boolean => + node.questionToken !== undefined && + node.questionToken.kind !== ts.SyntaxKind.MinusToken; + + // A mapped type over keyof T keeps T's optional members optional unless it says -? + const isOptionalMappedType = (node: ts.MappedTypeNode): boolean => { + if (node.questionToken !== undefined) { + return addsQuestionToken(node); + } + const constraint = node.typeParameter.constraint; + return constraint !== undefined && isKeyOfConstraint(constraint); + }; + + // { [K in keyof T]: T[K] } keeps each member's own type, undefined included, as long as it adds no ? + const isTransparentMappedType = (node: ts.MappedTypeNode): boolean => { + if ( + node.questionToken !== undefined || + node.type === undefined || + !ts.isIndexedAccessTypeNode(node.type) + ) { + return false; + } + const { objectType, indexType } = node.type; + return ( + ts.isTypeReferenceNode(indexType) && + ts.isIdentifier(indexType.typeName) && + indexType.typeName.text === node.typeParameter.name.text && + node.typeParameter.constraint !== undefined && + ts.isTypeOperatorNode(node.typeParameter.constraint) && + node.typeParameter.constraint.operator === ts.SyntaxKind.KeyOfKeyword && + node.typeParameter.constraint.type.getText(source) === + objectType.getText(source) + ); + }; + + // The mapped type alias behind a type, when the alias adds `?`, as a library's Partial does + const optionalMappedAlias = ( + type: ts.Type, + ): ts.TypeAliasDeclaration | undefined => { + const declaration = type.aliasSymbol?.declarations?.[0]; + return declaration !== undefined && + ts.isTypeAliasDeclaration(declaration) && + ts.isMappedTypeNode(declaration.type) && + addsQuestionToken(declaration.type) + ? declaration + : undefined; + }; + + // A library's mapped type that adds `?`, such as Partial, makes optional the members its source declares required + const addsOptionalMembersRejectingUndefined = ( + node: ts.TypeReferenceNode | ts.ExpressionWithTypeArguments, + ): boolean => { + const type = ts.isTypeReferenceNode(node) + ? checker.getTypeFromTypeNode(node) + : checker.getTypeAtLocation(node); + const declaration = optionalMappedAlias(type); + const argument = type.aliasTypeArguments?.[0]; + if ( + declaration === undefined || + argument === undefined || + declaration.getSourceFile() === source + ) { + return false; + } + // Over a type parameter nothing proves its members accept undefined + return ( + argument.isTypeParameter() || + checker + .getPropertiesOfType(argument) + .some( + (property) => + (property.flags & ts.SymbolFlags.Optional) === 0 && + !acceptsUndefined(checker.getTypeOfSymbol(property)), + ) + ); + }; + + // A generic declaration resolves only once instantiated, so its members are read as written + const visitGeneric = (node: ts.Node): void => { + if (isOptionalMemberDeclaration(node)) { + if (declarationRejectsUndefined(node)) { + reportDeclaredMember(node, () => lineOf(node)); + } + } else if ( + (ts.isOptionalTypeNode(node) && typeNodeRejectsUndefined(node.type)) || + (ts.isMappedTypeNode(node) && + isOptionalMappedType(node) && + !isTransparentMappedType(node) && + typeNodeRejectsUndefined(node.type)) || + ((ts.isTypeReferenceNode(node) || + ts.isExpressionWithTypeArguments(node)) && + addsOptionalMembersRejectingUndefined(node)) + ) { + report(node, lineOf(node)); + } + + // A member a generic declaration types without its parameters resolves as any other + if ( + (ts.isPropertySignature(node) || ts.isPropertyDeclaration(node)) && + node.type !== undefined + ) { + const owner = ts.findAncestor(node.parent, isTypeDeclaration); + descend( + checker.getTypeFromTypeNode(node.type), + `${owner?.name?.text ?? ""}.${node.name.getText(source)}`, + ); + } + + // A generic declaration inherits the members of what it extends as resolved + if ( + ts.isExpressionWithTypeArguments(node) && + ts.isHeritageClause(node.parent) + ) { + const inherited = checker.getTypeAtLocation(node); + descend(inherited, inherited.getSymbol()?.name ?? node.getText(source)); + } + + ts.forEachChild(node, visitGeneric); + }; + + const checkDeclaration = (declaration: TypeDeclaration): void => { + const name = declaration.name; + if (name === undefined) return; + if ((declaration.typeParameters?.length ?? 0) > 0) { + visitGeneric(declaration); + return; + } + const symbol = checker.getSymbolAtLocation(name); + if (symbol === undefined) return; + descend(checker.getDeclaredTypeOfSymbol(symbol), name.text); + // A class's constructor, which a consumer calls, is on its static side + if (ts.isClassDeclaration(declaration)) { + descend(checker.getTypeOfSymbol(symbol), name.text); + } + }; + + const typeDeclarations: TypeDeclaration[] = []; + const collect = (node: ts.Node): void => { + if (isTypeDeclaration(node)) { + typeDeclarations.push(node); + } + ts.forEachChild(node, collect); + }; + collect(source); + + if (names === "all") { + typeDeclarations.forEach(checkDeclaration); + } else { + for (const name of names) { + const named = typeDeclarations.filter( + (declaration) => declaration.name?.text === name, + ); + // A renamed or moved option type would otherwise pass with nothing checked + if (named.length === 0) { + throw new Error(`${fileName} declares no ${name}`); + } + named.forEach(checkDeclaration); + } + } + + return [...offenders]; +} + +const fixtureName = `${root}/src/__tests__/types/optional-members.fixture.ts`; + +const foreignName = `${root}/node_modules/optional-members-fixture/index.d.ts`; + +const foreign = `declare module "optional-members-fixture" { + export interface Rect { x: number; y?: number | undefined; label?: string } + export interface ForeignProps { label?: string } +} +`; + +const fixture = `type Maybe = T | undefined; +interface Source { + value?: string; +} +export interface Props { + bare?: string; + widened?: string | undefined; + absent?: never; + method?(): void; + aliased?: Maybe; + voided?: void; + parenthesised?: (string | undefined); + indexed?: Source["value"]; +} +export declare class Options { + passThrough?: boolean; + widened?: boolean | undefined; +} +export type Inherited = { [K in keyof T]: string }; +export type Stripped = { [K in keyof T]-?: string }; +export type Optional = { [K in keyof T]?: string | undefined }; +export type Picked = { [Q in K]: string }; +export type Partly = Partial<{ passThrough: boolean }>; +export type PartlyWidened = Partial<{ passThrough: boolean | undefined }>; +export type Pair = [string, number?]; +export type Named = [first: string, second?: number | undefined]; +interface Base { passThrough: boolean } +export interface ExtendsPartial extends Partial {} +export type GenericPartial = Partial; +type KeysOf = keyof T; +export type ViaAlias = { [K in KeysOf]: string }; +export type Same = { [K in keyof T]: T[K] }; +export type Mutable = { -readonly [K in keyof T]: T[K] }; +export type OptionalSame = { [K in keyof T]?: T[K] }; +export type ReadonlyOptionalSame = { +readonly [K in keyof T]+?: T[K] }; +interface LabelOptions { label?: string | undefined; plain: number } +export type Labels = { [K in keyof LabelOptions]: string }; +export type RenamedLabels = { [K in keyof LabelOptions as \`\${K}Text\`]: string }; +interface Shared { filename?: string } +export interface SharedOptions extends Shared { root?: string | undefined } +export type PickedShared = Pick; +type Flags = "experimental"; +export interface FlagOptions { features?: Partial> | undefined } +interface Item { name?: string | undefined; tag?: string } +export interface ListOptions { items?: readonly Item[] | undefined } +export interface WrappedOptions { + partial?: Partial | undefined; + frozen?: Readonly | undefined; + joined?: (Source & { extra?: number }) | undefined; +} +export interface GenericOptions extends Shared { value?: T | undefined } +export interface TupleOptions { pair?: [string, number?] | undefined } +export interface Callable { (component: string, options?: { passThrough?: boolean }): void } +interface AliasOptions { target?: string } +export interface IndexedOptions { aliases?: Record | undefined } +export interface MappedOptions { lookup?: Map | undefined } +export interface ExtendsForeignPartial extends Partial {} +type OwnOptional = { [K in keyof T]?: T[K] }; +export interface OwnMappedOptions { rect?: OwnOptional | undefined } +export interface GenericHolder { item?: Item | undefined; value?: T | undefined } +export interface ForeignOptions { + rect?: Partial | undefined; + props?: import("optional-members-fixture").ForeignProps | undefined; +} +export interface Returns { make(): { flag?: boolean } } +export type Factory = (mapping: M) => { produced?: string }; +export declare class Constructed { constructor(options?: { built?: boolean }) } +export interface Constructs { new (options?: { made?: boolean }): object } +interface PresetOptions { minify?: boolean } +export interface UnionArguments { + presets?: Record | undefined; + maybe?: Map | undefined; +} +export interface UnionElements { items?: readonly (Item | null)[] | undefined } +export interface NestedMapped { + viewport?: Readonly> | undefined; + picked?: Pick, "x"> | undefined; +} +export interface GenericForeignPartial { props?: Partial | undefined; value?: T | undefined } +interface Holder { inner?: string } +export declare class GenericClass { + flag?: boolean; + method?(): void; + holder?: Holder | undefined; + value?: T | undefined; +} +export type GenericPair = [T, number?]; +export type GenericNamed = [first: T, second?: number]; +export interface Wrapper { box?: { inner?: string; value?: T | undefined } | undefined } +export interface FirstUse { wrapper?: Wrapper | undefined } +export interface SecondUse { wrapper?: Wrapper | undefined } +type Nested = string | Item | Nested[]; +export interface RecursiveOptions { nested?: Nested | undefined } +`; + +let program: ts.Program; + +beforeAll(() => { + program = createProgram([fixtureName, foreignName], { + [fixtureName]: fixture, + [foreignName]: foreign, + }); +}); + +test("the walk finds each form of an optional member that rejects undefined, and leaves a never member", () => { + expect(optionalMembersRejectingUndefined(program, fixtureName)).toStrictEqual( + [ + "Source.value", + "Props.bare", + "Props.method", + "Options.passThrough", + "19: { [K in keyof T]: string }", + "22: { [Q in K]: string }", + "Partly.passThrough", + "Pair.1", + "ExtendsPartial.passThrough", + "29: Partial", + "31: { [K in KeysOf]: string }", + "34: { [K in keyof T]?: T[K] }", + "35: { +readonly [K in keyof T]+?: T[K] }", + "Labels.label", + "RenamedLabels.labelText", + "Shared.filename", + "FlagOptions.features.experimental", + "Item.tag", + "WrappedOptions.joined.extra", + "TupleOptions.pair.1", + "Callable(options).passThrough", + "AliasOptions.target", + "ExtendsForeignPartial.x", + "58: { [K in keyof T]?: T[K] }", + "OwnMappedOptions.rect.x", + "Returns.make().flag", + "Factory.constrained", + "Factory().produced", + "Constructed(options).built", + "Constructs(options).made", + "PresetOptions.minify", + "NestedMapped.viewport.x", + "NestedMapped.picked.x", + "Holder.inner", + "GenericClass.flag", + "GenericClass.method", + "87: number?", + "88: second?: number", + "Wrapper.box.inner", + ], + ); +}); + +test.each([ + ["Callable", ["Callable(options).passThrough"]], + ["IndexedOptions", ["AliasOptions.target"]], + ["MappedOptions", ["Item.tag"]], + ["GenericHolder", ["Item.tag"]], + ["Returns", ["Returns.make().flag"]], + ["Factory", ["Factory.constrained", "Factory().produced"]], + ["Constructed", ["Constructed(options).built"]], + ["Constructs", ["Constructs(options).made"]], + ["UnionArguments", ["PresetOptions.minify", "Item.tag"]], + ["UnionElements", ["Item.tag"]], + [ + "GenericClass", + ["GenericClass.flag", "GenericClass.method", "Holder.inner"], + ], + ["GenericPair", ["87: number?"]], + ["GenericNamed", ["88: second?: number"]], + ["RecursiveOptions", ["Item.tag"]], +])( + "the walk reaches the options %s holds, and names each under the type that declares it", + (name, expected) => { + expect( + optionalMembersRejectingUndefined(program, fixtureName, [name]), + ).toStrictEqual(expected); + }, +); + +test.each([ + ["ExtendsForeignPartial", ["ExtendsForeignPartial.x"]], + ["OwnMappedOptions", ["OwnMappedOptions.rect.x"]], + ["ForeignOptions", ["ForeignOptions.rect.x"]], + ["NestedMapped", ["NestedMapped.viewport.x", "NestedMapped.picked.x"]], + ["GenericForeignPartial", []], +])( + "in %s a member a mapped type makes optional is named where the mapped type is applied, and one its source already makes optional stays the source's", + (name, expected) => { + expect( + optionalMembersRejectingUndefined(program, fixtureName, [name]), + ).toStrictEqual(expected); + }, +); + +test("the walk reaches an option type in an array, an intersection, a library's alias and what a generic type extends", () => { + expect( + optionalMembersRejectingUndefined(program, fixtureName, [ + "ListOptions", + "WrappedOptions", + "GenericOptions", + ]), + ).toStrictEqual([ + "Item.tag", + "WrappedOptions.partial.passThrough", + "Source.value", + "WrappedOptions.joined.extra", + "Shared.filename", + ]); +}); + +test("a member two instantiations of a generic reach is named once, under the first path", () => { + expect( + optionalMembersRejectingUndefined(program, fixtureName, [ + "FirstUse", + "SecondUse", + ]), + ).toStrictEqual(["FirstUse.wrapper.box.inner"]); +}); + +test("a listed type is checked with the members it inherits from a declaration the list leaves out, named under that declaration", () => { + expect( + optionalMembersRejectingUndefined(program, fixtureName, ["SharedOptions"]), + ).toStrictEqual(["Shared.filename"]); +}); + +test("a listed type the file no longer declares fails the walk", () => { + expect(() => + optionalMembersRejectingUndefined(program, fixtureName, ["Missing"]), + ).toThrow("declares no Missing"); +}); + +test("every optional member a consumer writes accepts an explicit undefined, or is never and must stay absent", () => { + const packageProgram = createProgram( + declarations.map(([file]) => `${root}/${file}`), + ); + + expect( + Object.fromEntries( + declarations.map(([file, names]) => [ + file, + optionalMembersRejectingUndefined( + packageProgram, + `${root}/${file}`, + names, + ), + ]), + ), + ).toStrictEqual(Object.fromEntries(declarations.map(([file]) => [file, []]))); +}); diff --git a/src/__tests__/types/styled.test.ts b/src/__tests__/types/styled.test.ts new file mode 100644 index 00000000..f7983799 --- /dev/null +++ b/src/__tests__/types/styled.test.ts @@ -0,0 +1,25 @@ +import { createLanguageService, root } from "./_program"; + +const fixtureName = `${root}/src/__tests__/types/styled.fixture.tsx`; + +test("a mapping completes React Native's style keys, whatever the target", () => { + const text = [ + `import { View } from "react-native";`, + `import { styled } from "react-native-css";`, + `export const mapped = styled(View, { labelClassName: { target: false, nativeStyleMapping: { /*keys*/ } } });`, + ].join("\n"); + const completions = createLanguageService({ + [fixtureName]: text, + }).getCompletionsAtPosition(fixtureName, text.indexOf("/*keys*/"), {}); + + expect( + completions?.entries + .map(({ name }) => name) + .filter((name) => + ["backgroundColor", "color", "tintColor", "fill", "stroke"].includes( + name, + ), + ) + .sort(), + ).toStrictEqual(["backgroundColor", "color", "tintColor"]); +}); diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..1ad52b26 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -8,20 +8,20 @@ import type { import { VAR_SYMBOL } from "../native/reactivity"; export interface CompilerOptions { - filename?: string; - projectRoot?: string; - inlineRem?: number | false; - inlineVariables?: false | InlineVariableOptions; - selectorPrefix?: string; - stylesheetOrder?: number; - features?: FeatureFlagRecord; - logger?: (message: string) => void | Debugger; - hexColors?: boolean; - colorPrecision?: number; + filename?: string | undefined; + projectRoot?: string | undefined; + inlineRem?: number | false | undefined; + inlineVariables?: false | InlineVariableOptions | undefined; + selectorPrefix?: string | undefined; + stylesheetOrder?: number | undefined; + features?: FeatureFlagRecord | undefined; + logger?: ((message: string) => void) | Debugger | undefined; + hexColors?: boolean | undefined; + colorPrecision?: number | undefined; } export interface InlineVariableOptions { - exclude?: `--${string}`[]; + exclude?: `--${string}`[] | undefined; } /** @@ -244,7 +244,9 @@ export type SpecificityValue = number | undefined; /****************************** Compiler ********************************/ type FeatureFlags = never; -export type FeatureFlagRecord = Partial>; +export type FeatureFlagRecord = Partial< + Record +>; /** @internal */ export type PathTokens = string | string[]; diff --git a/src/jest/index.ts b/src/jest/index.ts index a8d249f5..2cc7d7e3 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -31,10 +31,11 @@ const debugDefault = Boolean( process.env.NODE_OPTIONS.includes("--inspect"), ); -export function registerCSS( - css: string, - options: CompilerOptions & { debug?: boolean } = {}, -) { +type RegisterCSSOptions = Omit & { + readonly debug?: boolean | "verbose" | undefined; +}; + +export function registerCSS(css: string, options: RegisterCSSOptions = {}) { const { debug = debugDefault } = options; const compiled = compileWithAutoDebug(css, options); @@ -57,10 +58,7 @@ export function registerCSS( export function compileWithAutoDebug( css: string, - { - debug = debugDefault, - ...options - }: CompilerOptions & { debug?: boolean | "verbose" } = {}, + { debug = debugDefault, ...options }: RegisterCSSOptions = {}, ) { const logger = debug ? (text: string) => { diff --git a/src/metro/index.ts b/src/metro/index.ts index 63290e59..52a103b6 100644 --- a/src/metro/index.ts +++ b/src/metro/index.ts @@ -9,13 +9,12 @@ import { nativeResolver, webResolver } from "./resolver"; import { setupTypeScript } from "./typescript"; export interface WithReactNativeCSSOptions extends CompilerOptions { - /* Specify the path to the TypeScript environment file. Defaults types-env.d.ts */ - typescriptEnvPath?: string; - /* Disable generation of the types-env.d.ts file. Defaults false */ - disableTypeScriptGeneration?: boolean; + /** Specify the path to the TypeScript environment file. Defaults react-native-css-env.d.ts */ + typescriptEnvPath?: string | undefined; + /** Disable generation of the react-native-css-env.d.ts file. Defaults false */ + disableTypeScriptGeneration?: boolean | undefined; /** Add className to all React Native primitives. Defaults false */ - globalClassNamePolyfill?: boolean; - hexColors?: boolean; + globalClassNamePolyfill?: boolean | undefined; } const metroOverrideResolution = { diff --git a/src/native/api.tsx b/src/native/api.tsx index 6736f746..475ff741 100644 --- a/src/native/api.tsx +++ b/src/native/api.tsx @@ -1,5 +1,5 @@ /* eslint-disable */ -import { useContext, useEffect, useState, type ComponentType } from "react"; +import { useContext, useEffect, useState } from "react"; import { Appearance, type ViewStyle } from "react-native"; import type { StyleDescriptor } from "react-native-css/compiler"; @@ -7,6 +7,7 @@ import { VariableContext } from "react-native-css/native-internal"; import type { ColorScheme, + DefaultStyledConfiguration, Props, ReactComponent, Styled, @@ -32,9 +33,9 @@ export { export { useNativeCss }; -const defaultMapping: StyledConfiguration> = { +const defaultMapping = { className: "style", -}; +} as const satisfies DefaultStyledConfiguration; /** * Generates a new Higher-Order component the wraps the base component and applies the styles. diff --git a/src/native/react/useNativeCss.ts b/src/native/react/useNativeCss.ts index f38c77c2..aba77b1b 100644 --- a/src/native/react/useNativeCss.ts +++ b/src/native/react/useNativeCss.ts @@ -28,7 +28,7 @@ import { updateRules } from "./rules"; export type Config = { source: string; target: string[] | string | false; - nativeStyleMapping?: Record; + nativeStyleMapping?: Record | undefined; }; export type ComponentState = { @@ -210,7 +210,11 @@ export function mappingToConfig(mapping: StyledConfiguration) { const mapping = value.nativeStyleMapping ?? value.nativeStyleToProp; const nativeStyleMapping = mapping ? Object.fromEntries( - Object.entries(mapping).map(([k, v]) => [k, v === true ? k : v]), + Object.entries(mapping).flatMap(([property, path]) => + path === undefined + ? [] + : [[property, path === true ? property : path] as const], + ), ) : undefined; diff --git a/src/runtime.types.ts b/src/runtime.types.ts index 69cab6ca..b9967d38 100644 --- a/src/runtime.types.ts +++ b/src/runtime.types.ts @@ -16,41 +16,36 @@ import type { ViewStyle, } from "react-native"; -import type { DotNotation, ResolveDotPath } from "react-native-css/utilities"; +import type { DotNotation } from "react-native-css/utilities"; /******************************** API ********************************/ -export type StyledReactElement< - C extends ReactComponent, - M extends StyledConfiguration, -> = ReactElement< - ComponentProps & { - [K in keyof M as K extends string - ? M[K] extends undefined | false - ? never - : M[K] extends true | string | object - ? K - : never - : never]?: string; - } ->; - -export type StyledProps> = P & { - [K in keyof M as K extends string +type StyledSourceProps = { + -readonly [K in keyof M as K extends string ? M[K] extends undefined | false ? never : M[K] extends true | string | object ? K : never - : never]?: string; + : never]?: string | undefined; }; +export type StyledReactElement< + C extends ReactComponent, + M extends StyledConfiguration, +> = ReactElement & StyledSourceProps>; + +export type StyledProps> = P & + StyledSourceProps; + export interface Styled { ( component: C, mapping?: undefined, options?: StyledOptions, - ): ComponentType & { className?: string }>; + ): ComponentType< + ComponentPropsWithRef & StyledSourceProps + >; >( component: C, mapping: M & StyledConfiguration, @@ -61,17 +56,11 @@ export interface Styled { type StyledComponent< C extends ReactComponent, M extends StyledConfiguration, -> = ComponentType< - ComponentPropsWithRef & { - [K in keyof M as K extends string - ? M[K] extends undefined | false - ? never - : M[K] extends true | string | object - ? K - : never - : never]?: string; - } ->; +> = ComponentType & StyledSourceProps>; + +export interface DefaultStyledConfiguration { + readonly className: "style"; +} export type StyledConfiguration< C extends ReactComponent, @@ -88,27 +77,21 @@ interface StyledConfigurationObject< T extends ComponentPropsDotNotation | false, > { target: T; - nativeStyleMapping?: T extends false - ? NativeStyleMapping> - : NativeStyleMapping< - ResolveDotPath>, - ComponentProps - >; + nativeStyleMapping?: NativeStyleMapping> | undefined; /** @deprecated Please use nativeStyleMapping */ nativeStyleToProp?: StyledConfigurationObject["nativeStyleMapping"]; } -type NativeStyleMapping = T extends object - ? { - [K in keyof T as K extends string ? K : never]: true | DotNotation; - } & { - fill?: true | DotNotation; - stroke?: true | DotNotation; - } - : Record>; +// A -rn- declaration puts its camel-cased name in the style, so a key may be any string beside React Native's style keys +type NativeStyleMapping = Readonly< + Partial>> +> & + Readonly>>; + +type NativeStyleMappingValue = true | DotNotation | undefined; export interface StyledOptions { - passThrough?: boolean; + passThrough?: boolean | undefined; } /*************************** React Helpers ***************************/ diff --git a/src/web/api.tsx b/src/web/api.tsx index 28e09ab8..7e0aea9e 100644 --- a/src/web/api.tsx +++ b/src/web/api.tsx @@ -1,9 +1,4 @@ -import { - createElement, - useMemo, - type ComponentType, - type PropsWithChildren, -} from "react"; +import { createElement, useMemo, type PropsWithChildren } from "react"; import { Appearance } from "react-native"; import type { @@ -13,12 +8,16 @@ import type { StyledOptions, } from "react-native-css"; -import type { ReactComponent, Styled } from "../runtime.types"; +import type { + DefaultStyledConfiguration, + ReactComponent, + Styled, +} from "../runtime.types"; import { assignStyle } from "./assign-style"; -const defaultMapping: StyledConfiguration> = { +const defaultMapping = { className: "style", -}; +} as const satisfies DefaultStyledConfiguration; export const styled: Styled = ( baseComponent: ReactComponent, diff --git a/types.d.ts b/types.d.ts index 924b9a7d..9811ec12 100644 --- a/types.d.ts +++ b/types.d.ts @@ -10,44 +10,44 @@ import type { declare module "@react-native/virtualized-lists" { export interface VirtualizedListWithoutRenderItemProps extends ScrollViewProps { - ListFooterComponentClassName?: string; - ListHeaderComponentClassName?: string; + ListFooterComponentClassName?: string | undefined; + ListHeaderComponentClassName?: string | undefined; } } declare module "react-native" { interface ButtonProps { - className?: string; + className?: string | undefined; } interface ScrollViewProps extends ViewProps, ScrollViewPropsIOS, ScrollViewPropsAndroid, Touchable { - contentContainerClassName?: string; + contentContainerClassName?: string | undefined; } interface FlatListProps extends VirtualizedListProps { - columnWrapperClassName?: string; + columnWrapperClassName?: string | undefined; } interface ImageBackgroundProps extends ImagePropsBase { - imageClassName?: string; + imageClassName?: string | undefined; } interface ImagePropsBase { - className?: string; + className?: string | undefined; } interface ViewProps { - className?: string; + className?: string | undefined; } interface TextProps { - className?: string; + className?: string | undefined; } interface SwitchProps { - className?: string; + className?: string | undefined; } interface TouchableWithoutFeedbackProps { - className?: string; + className?: string | undefined; } interface KeyboardAvoidingViewProps extends ViewProps { - contentContainerClassName?: string; + contentContainerClassName?: string | undefined; } }