From 7fcbd163d9f65856f994db91f40aac97b0bb1e00 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 17:53:46 +0300 Subject: [PATCH 1/3] fix(types): accept an explicit undefined in every optional member this package declares React Native declares its optional props as `?: T | undefined`, and the props this package adds were bare `?: string`. Under exactOptionalPropertyTypes the two differ, so `className={condition ? "p-4" : undefined}` failed to compile, and declaration merging cannot widen an existing prop from the consumer side. Every optional member of types.d.ts and runtime.types.ts now takes `| undefined`, except InlineStyleRecord's `s?: never`, which marks a key that must stay absent. The props a mapping adds come from one StyledSourceProps type, which the unmapped overload uses with DefaultStyledConfiguration, the default mapping both platforms pass. A test walks both files with the type checker under the flag and fails on any optional member that rejects undefined, so a member added later is covered too. --- src/__tests__/types/optional-members.test.ts | 185 +++++++++++++++++++ src/native/api.tsx | 7 +- src/runtime.types.ts | 72 ++++---- src/web/api.tsx | 17 +- types.d.ts | 24 +-- 5 files changed, 242 insertions(+), 63 deletions(-) create mode 100644 src/__tests__/types/optional-members.test.ts diff --git a/src/__tests__/types/optional-members.test.ts b/src/__tests__/types/optional-members.test.ts new file mode 100644 index 00000000..6b7e25c3 --- /dev/null +++ b/src/__tests__/types/optional-members.test.ts @@ -0,0 +1,185 @@ +import { join } from "node:path"; + +import ts from "typescript"; + +const root = join(__dirname, "../../..").replaceAll("\\", "/"); +const declarationFiles = ["types.d.ts", "src/runtime.types.ts"] as const; + +function createProgram( + rootNames: readonly string[], + virtualFiles: Readonly> = {}, +): ts.Program { + const { config } = ts.readConfigFile(`${root}/tsconfig.json`, (path) => + ts.sys.readFile(path), + ); + const { options } = ts.parseJsonConfigFileContent(config, ts.sys, root); + const compilerOptions = { ...options, exactOptionalPropertyTypes: true }; + const host = ts.createCompilerHost(compilerOptions, true); + + return ts.createProgram(rootNames, compilerOptions, { + ...host, + 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); + }, + }); +} + +// Under exactOptionalPropertyTypes a bare `?: T` rejects an explicit undefined, which React Native's own optional props accept +function optionalMembersRejectingUndefined( + program: ts.Program, + fileName: string, +): readonly string[] { + const checker = program.getTypeChecker(); + const source = program.getSourceFile(fileName); + if (source === undefined) throw new Error(`${fileName} is not compiled`); + + const rejectsUndefined = (type: ts.Type): boolean => + // `?: never` marks a key that must stay absent + (type.flags & ts.TypeFlags.Never) === 0 && + !checker.isTypeAssignableTo(checker.getUndefinedType(), type); + + const typeNodeRejectsUndefined = (node: ts.TypeNode | undefined) => + node !== undefined && rejectsUndefined(checker.getTypeFromTypeNode(node)); + + // A mapped type over `keyof T`, or over a `K extends keyof T`, keeps T's optional members optional unless it says -? + const isOptionalMappedType = (node: ts.MappedTypeNode): boolean => { + if (node.questionToken !== undefined) { + return node.questionToken.kind !== ts.SyntaxKind.MinusToken; + } + + const constraint = node.typeParameter.constraint; + if (constraint === undefined) return false; + if (isKeyOf(constraint)) return true; + + const keyDeclaration = ts.isTypeReferenceNode(constraint) + ? checker.getSymbolAtLocation(constraint.typeName)?.declarations?.[0] + : undefined; + return ( + keyDeclaration !== undefined && + ts.isTypeParameterDeclaration(keyDeclaration) && + keyDeclaration.constraint !== undefined && + isKeyOf(keyDeclaration.constraint) + ); + }; + + // A library's mapped type that adds `?`, such as Partial, makes optional members of its argument's, which this file never spells out + const addsOptionalMembersRejectingUndefined = ( + node: ts.TypeReferenceNode, + ): boolean => { + const type = checker.getTypeFromTypeNode(node); + const declaration = type.aliasSymbol?.declarations?.[0]; + const argument = type.aliasTypeArguments?.[0]; + return ( + declaration !== undefined && + argument !== undefined && + declaration.getSourceFile() !== source && + ts.isTypeAliasDeclaration(declaration) && + ts.isMappedTypeNode(declaration.type) && + declaration.type.questionToken !== undefined && + declaration.type.questionToken.kind !== ts.SyntaxKind.MinusToken && + checker + .getPropertiesOfType(argument) + .some((property) => rejectsUndefined(checker.getTypeOfSymbol(property))) + ); + }; + + const offenders: string[] = []; + + const visit = (node: ts.Node): void => { + const rejects = + ((ts.isPropertySignature(node) || + ts.isPropertyDeclaration(node) || + ts.isNamedTupleMember(node)) && + node.questionToken !== undefined && + typeNodeRejectsUndefined(node.type)) || + ((ts.isMethodSignature(node) || ts.isMethodDeclaration(node)) && + node.questionToken !== undefined) || + (ts.isOptionalTypeNode(node) && typeNodeRejectsUndefined(node.type)) || + (ts.isMappedTypeNode(node) && + isOptionalMappedType(node) && + typeNodeRejectsUndefined(node.type)) || + (ts.isTypeReferenceNode(node) && + addsOptionalMembersRejectingUndefined(node)); + + if (rejects) { + const { line } = source.getLineAndCharacterOfPosition( + node.getStart(source), + ); + offenders.push(`${line + 1}: ${node.getText(source).split("\n")[0]}`); + } + + ts.forEachChild(node, visit); + }; + visit(source); + + return offenders; +} + +function isKeyOf(node: ts.TypeNode): boolean { + return ( + ts.isTypeOperatorNode(node) && node.operator === ts.SyntaxKind.KeyOfKeyword + ); +} + +test("the walk finds each form of an optional member that rejects undefined, and leaves a never member", () => { + const fileName = `${root}/src/__tests__/types/optional-members.fixture.ts`; + const program = createProgram([fileName], { + [fileName]: `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]; +`, + }); + + expect(optionalMembersRejectingUndefined(program, fileName)).toStrictEqual([ + "3: value?: string;", + "6: bare?: string;", + "9: method?(): void;", + "16: passThrough?: boolean;", + "19: { [K in keyof T]: string }", + "22: { [Q in K]: string }", + "23: Partial<{ passThrough: boolean }>", + "25: number?", + ]); +}); + +test("every optional member of the declarations accepts an explicit undefined, or is never and must stay absent", () => { + const fileNames = declarationFiles.map((file) => `${root}/${file}`); + const program = createProgram(fileNames); + + expect( + Object.fromEntries( + declarationFiles.map((file) => [ + file, + optionalMembersRejectingUndefined(program, `${root}/${file}`), + ]), + ), + ).toStrictEqual({ "types.d.ts": [], "src/runtime.types.ts": [] }); +}); 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/runtime.types.ts b/src/runtime.types.ts index 69cab6ca..557d72fa 100644 --- a/src/runtime.types.ts +++ b/src/runtime.types.ts @@ -20,37 +20,32 @@ import type { DotNotation, ResolveDotPath } 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 & { +type StyledSourceProps = { [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,32 @@ interface StyledConfigurationObject< T extends ComponentPropsDotNotation | false, > { target: T; - nativeStyleMapping?: T extends false - ? NativeStyleMapping> - : NativeStyleMapping< - ResolveDotPath>, - ComponentProps - >; + nativeStyleMapping?: + | (T extends false + ? NativeStyleMapping> + : NativeStyleMapping< + ResolveDotPath>, + ComponentProps + >) + | 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; + [K in keyof T as K extends string ? K : never]?: + | true + | DotNotation + | undefined; } & { - fill?: true | DotNotation; - stroke?: true | DotNotation; + fill?: true | DotNotation | undefined; + stroke?: true | DotNotation | undefined; } : Record>; 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; } } From 49ff1c93d7e69acdf602edf0eb528ab57fbd817c Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 17:54:24 +0300 Subject: [PATCH 2/3] fix(types): type a nativeStyleMapping by its target's keys, and skip an entry left undefined StyledConfigurationObject called ResolveDotPath>, the reverse of its declaration ResolveDotPath, so a string target's mapping type was never and only the target:false branch's Record checked a mapping. With the arguments in order, an entry may be left undefined, and mappingToConfig drops it, where nativeStyleMapping called `path.split(".")` on it and threw. --- src/__tests__/native/styled.test.ios.tsx | 19 +++++++++++++++++++ src/native/react/useNativeCss.ts | 6 +++++- src/runtime.types.ts | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) 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/native/react/useNativeCss.ts b/src/native/react/useNativeCss.ts index f38c77c2..24f54362 100644 --- a/src/native/react/useNativeCss.ts +++ b/src/native/react/useNativeCss.ts @@ -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 557d72fa..4495a867 100644 --- a/src/runtime.types.ts +++ b/src/runtime.types.ts @@ -81,7 +81,7 @@ interface StyledConfigurationObject< | (T extends false ? NativeStyleMapping> : NativeStyleMapping< - ResolveDotPath>, + ResolveDotPath, Extract>, ComponentProps >) | undefined; From a224c294d2db292c5b9315006aed961ecf37199c Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 20:52:45 +0300 Subject: [PATCH 3/3] fix(types): accept undefined on every option type, and any style key in a mapping A nativeStyleMapping key names a key of the compiled style, which a -rn- declaration can add under any name, so its keys stay open while React Native's style keys complete; the type no longer resolves the target, which halves its instantiations. The class props styled() adds stay writable, and the compiler, Metro, jest, Babel and useNativeCss options accept an explicit undefined. --- src/__tests__/types/_program.ts | 67 ++++++++ src/__tests__/types/optional-members.test.ts | 168 ++++++++++++------- src/__tests__/types/styled.test.ts | 96 +++++++++++ src/babel/helpers.ts | 6 +- src/compiler/compiler.types.ts | 22 +-- src/jest/index.ts | 18 +- src/metro/index.ts | 8 +- src/native/react/useNativeCss.ts | 2 +- src/runtime.types.ts | 31 ++-- 9 files changed, 307 insertions(+), 111 deletions(-) create mode 100644 src/__tests__/types/_program.ts create mode 100644 src/__tests__/types/styled.test.ts diff --git a/src/__tests__/types/_program.ts b/src/__tests__/types/_program.ts new file mode 100644 index 00000000..45b3a2ec --- /dev/null +++ b/src/__tests__/types/_program.ts @@ -0,0 +1,67 @@ +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, + 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: (...args) => ts.sys.readDirectory(...args), + 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 index 6b7e25c3..eb8ed8fc 100644 --- a/src/__tests__/types/optional-members.test.ts +++ b/src/__tests__/types/optional-members.test.ts @@ -1,38 +1,30 @@ -import { join } from "node:path"; - import ts from "typescript"; -const root = join(__dirname, "../../..").replaceAll("\\", "/"); -const declarationFiles = ["types.d.ts", "src/runtime.types.ts"] as const; - -function createProgram( - rootNames: readonly string[], - virtualFiles: Readonly> = {}, -): ts.Program { - const { config } = ts.readConfigFile(`${root}/tsconfig.json`, (path) => - ts.sys.readFile(path), - ); - const { options } = ts.parseJsonConfigFileContent(config, ts.sys, root); - const compilerOptions = { ...options, exactOptionalPropertyTypes: true }; - const host = ts.createCompilerHost(compilerOptions, true); - - return ts.createProgram(rootNames, compilerOptions, { - ...host, - 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); - }, - }); -} +import { createProgram, root } from "./_program"; + +// Every declaration of a file, or only the option types a consumer writes +const declarations: readonly (readonly [string, readonly string[] | "all"])[] = + [ + ["types.d.ts", "all"], + ["src/runtime.types.ts", "all"], + [ + "src/compiler/compiler.types.ts", + ["CompilerOptions", "InlineVariableOptions"], + ], + ["src/metro/index.ts", ["WithReactNativeCSSOptions"]], + [ + "src/jest/index.ts", + ["RegisterCSSOptions", "CompileWithAutoDebugOptions"], + ], + ["src/babel/helpers.ts", ["PluginOpts"]], + ["src/native/react/useNativeCss.ts", ["Config"]], + ]; // 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); @@ -46,42 +38,72 @@ function optionalMembersRejectingUndefined( const typeNodeRejectsUndefined = (node: ts.TypeNode | undefined) => node !== undefined && rejectsUndefined(checker.getTypeFromTypeNode(node)); - // A mapped type over `keyof T`, or over a `K extends keyof T`, keeps T's optional members optional unless it says -? + // 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) + ); + }; + + // 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 node.questionToken.kind !== ts.SyntaxKind.MinusToken; } - const constraint = node.typeParameter.constraint; - if (constraint === undefined) return false; - if (isKeyOf(constraint)) return true; + return constraint !== undefined && isKeyOfConstraint(constraint); + }; - const keyDeclaration = ts.isTypeReferenceNode(constraint) - ? checker.getSymbolAtLocation(constraint.typeName)?.declarations?.[0] - : undefined; + // { [K in keyof T]: T[K] } keeps each member's own type, undefined included + const isTransparentMappedType = (node: ts.MappedTypeNode): boolean => { + if (node.type === undefined || !ts.isIndexedAccessTypeNode(node.type)) { + return false; + } + const { objectType, indexType } = node.type; return ( - keyDeclaration !== undefined && - ts.isTypeParameterDeclaration(keyDeclaration) && - keyDeclaration.constraint !== undefined && - isKeyOf(keyDeclaration.constraint) + 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) ); }; - // A library's mapped type that adds `?`, such as Partial, makes optional members of its argument's, which this file never spells out + // A library's mapped type that adds `?`, such as Partial, makes members its source file never spells out const addsOptionalMembersRejectingUndefined = ( - node: ts.TypeReferenceNode, + node: ts.TypeReferenceNode | ts.ExpressionWithTypeArguments, ): boolean => { - const type = checker.getTypeFromTypeNode(node); + const type = ts.isTypeReferenceNode(node) + ? checker.getTypeFromTypeNode(node) + : checker.getTypeAtLocation(node); const declaration = type.aliasSymbol?.declarations?.[0]; const argument = type.aliasTypeArguments?.[0]; + if ( + declaration === undefined || + argument === undefined || + declaration.getSourceFile() === source || + !ts.isTypeAliasDeclaration(declaration) || + !ts.isMappedTypeNode(declaration.type) || + declaration.type.questionToken === undefined || + declaration.type.questionToken.kind === ts.SyntaxKind.MinusToken + ) { + return false; + } + // Over a type parameter nothing proves its members accept undefined return ( - declaration !== undefined && - argument !== undefined && - declaration.getSourceFile() !== source && - ts.isTypeAliasDeclaration(declaration) && - ts.isMappedTypeNode(declaration.type) && - declaration.type.questionToken !== undefined && - declaration.type.questionToken.kind !== ts.SyntaxKind.MinusToken && + argument.isTypeParameter() || checker .getPropertiesOfType(argument) .some((property) => rejectsUndefined(checker.getTypeOfSymbol(property))) @@ -102,8 +124,10 @@ function optionalMembersRejectingUndefined( (ts.isOptionalTypeNode(node) && typeNodeRejectsUndefined(node.type)) || (ts.isMappedTypeNode(node) && isOptionalMappedType(node) && + !isTransparentMappedType(node) && typeNodeRejectsUndefined(node.type)) || - (ts.isTypeReferenceNode(node) && + ((ts.isTypeReferenceNode(node) || + ts.isExpressionWithTypeArguments(node)) && addsOptionalMembersRejectingUndefined(node)); if (rejects) { @@ -115,15 +139,22 @@ function optionalMembersRejectingUndefined( ts.forEachChild(node, visit); }; - visit(source); - return offenders; -} + if (names === "all") { + visit(source); + } else { + for (const statement of source.statements) { + if ( + (ts.isInterfaceDeclaration(statement) || + ts.isTypeAliasDeclaration(statement)) && + names.includes(statement.name.text) + ) { + visit(statement); + } + } + } -function isKeyOf(node: ts.TypeNode): boolean { - return ( - ts.isTypeOperatorNode(node) && node.operator === ts.SyntaxKind.KeyOfKeyword - ); + return offenders; } test("the walk finds each form of an optional member that rejects undefined, and leaves a never member", () => { @@ -155,6 +186,13 @@ 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] }; `, }); @@ -167,19 +205,23 @@ export type Named = [first: string, second?: number | undefined]; "22: { [Q in K]: string }", "23: Partial<{ passThrough: boolean }>", "25: number?", + "28: Partial", + "29: Partial", + "31: { [K in KeysOf]: string }", ]); }); -test("every optional member of the declarations accepts an explicit undefined, or is never and must stay absent", () => { - const fileNames = declarationFiles.map((file) => `${root}/${file}`); - const program = createProgram(fileNames); +test("every optional member a consumer writes accepts an explicit undefined, or is never and must stay absent", () => { + const program = createProgram( + declarations.map(([file]) => `${root}/${file}`), + ); expect( Object.fromEntries( - declarationFiles.map((file) => [ + declarations.map(([file, names]) => [ file, - optionalMembersRejectingUndefined(program, `${root}/${file}`), + optionalMembersRejectingUndefined(program, `${root}/${file}`, names), ]), ), - ).toStrictEqual({ "types.d.ts": [], "src/runtime.types.ts": [] }); + ).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..733916e7 --- /dev/null +++ b/src/__tests__/types/styled.test.ts @@ -0,0 +1,96 @@ +import ts from "typescript"; + +import { createLanguageService, createProgram, root } from "./_program"; + +const fixtureName = `${root}/src/__tests__/types/styled.fixture.tsx`; + +const prelude = [ + `import type { ComponentProps } from "react";`, + `import { ActivityIndicator, Image, Text, TextInput, View } from "react-native";`, + `import { styled } from "react-native-css";`, + `const StyledView = styled(View);`, + `const MappedText = styled(Text, { className: { target: "style" } });`, +] as const; + +function compiles(cases: Readonly>) { + const names = Object.keys(cases); + const text = [ + ...prelude, + ...names.map((name) => `export const ${name} = ${cases[name]};`), + ].join("\n"); + const program = createProgram([fixtureName], { [fixtureName]: text }); + const source = program.getSourceFile(fixtureName); + if (source === undefined) throw new Error("the fixture is not compiled"); + + const failing = new Set( + ts.getPreEmitDiagnostics(program, source).map((diagnostic) => { + const { line } = source.getLineAndCharacterOfPosition( + diagnostic.start ?? 0, + ); + return names[line - prelude.length] ?? `prelude line ${line + 1}`; + }), + ); + return Object.fromEntries( + [...names, ...failing] + .filter((name, index, all) => all.indexOf(name) === index) + .map((name) => [name, !failing.has(name)]), + ); +} + +test("a mapping takes any style key, a -rn- declaration's among them, and a value naming a prop", () => { + expect( + compiles({ + colorOfView: `styled(View, { className: { target: "style", nativeStyleMapping: { color: "accessibilityLabel" } } })`, + colorOfActivityIndicator: `styled(ActivityIndicator, { className: { target: "style", nativeStyleMapping: { color: "color" } } })`, + rnDeclaration: `styled(TextInput, { className: { target: "style", nativeStyleMapping: { selectionColor: true } } })`, + keptName: `styled(Image, { className: { target: "style", nativeStyleMapping: { tintColor: true } } })`, + leftUndefined: `styled(View, { className: { target: "style", nativeStyleMapping: { backgroundColor: undefined, fill: undefined } } })`, + intoStyleFromTargetFalse: `styled(View, { labelClassName: { target: false, nativeStyleMapping: { color: "style.borderColor" } } })`, + deprecatedAlias: `styled(Text, { className: { target: "style", nativeStyleToProp: { color: "accessibilityLabel" } } })`, + noSuchProp: `styled(View, { className: { target: "style", nativeStyleMapping: { color: "accessibilityLabl" } } })`, + noSuchPropFromTargetFalse: `styled(View, { labelClassName: { target: false, nativeStyleMapping: { color: "style.borderColour" } } })`, + falseValue: `styled(View, { className: { target: "style", nativeStyleMapping: { color: false } } })`, + }), + ).toStrictEqual({ + colorOfView: true, + colorOfActivityIndicator: true, + rnDeclaration: true, + keptName: true, + leftUndefined: true, + intoStyleFromTargetFalse: true, + deprecatedAlias: true, + noSuchProp: false, + noSuchPropFromTargetFalse: false, + falseValue: false, + }); +}); + +test("a mapping completes React Native's style keys, whatever the target", () => { + const text = [ + ...prelude, + `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", "fill", "stroke", "tintColor"]); +}); + +test("a styled component's class props are writable, as the component's own props are", () => { + expect( + compiles({ + defaultConfiguration: `(props: ComponentProps) => { props.className = "x"; }`, + ownConfiguration: `(props: ComponentProps) => { props.className = "x"; }`, + }), + ).toStrictEqual({ defaultConfiguration: true, ownConfiguration: true }); +}); diff --git a/src/babel/helpers.ts b/src/babel/helpers.ts index c66f630c..8b045e58 100644 --- a/src/babel/helpers.ts +++ b/src/babel/helpers.ts @@ -3,9 +3,9 @@ import tBabelTypes, { type CallExpression } from "@babel/types"; export type BabelTypes = typeof tBabelTypes; export interface PluginOpts { - target?: string; - runtime?: string; - commonjs?: boolean; + target?: string | undefined; + runtime?: string | undefined; + commonjs?: boolean | undefined; } export interface PluginState { diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..6d43b6a7 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; } /** diff --git a/src/jest/index.ts b/src/jest/index.ts index a8d249f5..a76a920e 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -31,10 +31,15 @@ const debugDefault = Boolean( process.env.NODE_OPTIONS.includes("--inspect"), ); -export function registerCSS( - css: string, - options: CompilerOptions & { debug?: boolean } = {}, -) { +type RegisterCSSOptions = CompilerOptions & { + debug?: boolean | undefined; +}; + +type CompileWithAutoDebugOptions = CompilerOptions & { + debug?: boolean | "verbose" | undefined; +}; + +export function registerCSS(css: string, options: RegisterCSSOptions = {}) { const { debug = debugDefault } = options; const compiled = compileWithAutoDebug(css, options); @@ -57,10 +62,7 @@ export function registerCSS( export function compileWithAutoDebug( css: string, - { - debug = debugDefault, - ...options - }: CompilerOptions & { debug?: boolean | "verbose" } = {}, + { debug = debugDefault, ...options }: CompileWithAutoDebugOptions = {}, ) { const logger = debug ? (text: string) => { diff --git a/src/metro/index.ts b/src/metro/index.ts index 63290e59..9203bdcd 100644 --- a/src/metro/index.ts +++ b/src/metro/index.ts @@ -10,12 +10,12 @@ import { setupTypeScript } from "./typescript"; export interface WithReactNativeCSSOptions extends CompilerOptions { /* Specify the path to the TypeScript environment file. Defaults types-env.d.ts */ - typescriptEnvPath?: string; + typescriptEnvPath?: string | undefined; /* Disable generation of the types-env.d.ts file. Defaults false */ - disableTypeScriptGeneration?: boolean; + disableTypeScriptGeneration?: boolean | undefined; /** Add className to all React Native primitives. Defaults false */ - globalClassNamePolyfill?: boolean; - hexColors?: boolean; + globalClassNamePolyfill?: boolean | undefined; + hexColors?: boolean | undefined; } const metroOverrideResolution = { diff --git a/src/native/react/useNativeCss.ts b/src/native/react/useNativeCss.ts index 24f54362..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 = { diff --git a/src/runtime.types.ts b/src/runtime.types.ts index 4495a867..e7663acf 100644 --- a/src/runtime.types.ts +++ b/src/runtime.types.ts @@ -16,12 +16,12 @@ import type { ViewStyle, } from "react-native"; -import type { DotNotation, ResolveDotPath } from "react-native-css/utilities"; +import type { DotNotation } from "react-native-css/utilities"; /******************************** API ********************************/ type StyledSourceProps = { - [K in keyof M as K extends string + -readonly [K in keyof M as K extends string ? M[K] extends undefined | false ? never : M[K] extends true | string | object @@ -77,29 +77,18 @@ interface StyledConfigurationObject< T extends ComponentPropsDotNotation | false, > { target: T; - nativeStyleMapping?: - | (T extends false - ? NativeStyleMapping> - : NativeStyleMapping< - ResolveDotPath, Extract>, - ComponentProps - >) - | undefined; + 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 - | undefined; - } & { - fill?: true | DotNotation | undefined; - stroke?: true | DotNotation | undefined; - } - : 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 | undefined;