Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/__tests__/native/styled.test.ios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<StyledView testID={testID} className="subject" />);

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) => {
Expand Down
185 changes: 185 additions & 0 deletions src/__tests__/types/optional-members.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> = {},
): 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> = T | undefined;
interface Source {
value?: string;
}
export interface Props {
bare?: string;
widened?: string | undefined;
absent?: never;
method?(): void;
aliased?: Maybe<string>;
voided?: void;
parenthesised?: (string | undefined);
indexed?: Source["value"];
}
export declare class Options {
passThrough?: boolean;
widened?: boolean | undefined;
}
export type Inherited<T> = { [K in keyof T]: string };
export type Stripped<T> = { [K in keyof T]-?: string };
export type Optional<T> = { [K in keyof T]?: string | undefined };
export type Picked<T, K extends keyof T> = { [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": [] });
});
7 changes: 4 additions & 3 deletions src/native/api.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
/* 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";
import { VariableContext } from "react-native-css/native-internal";

import type {
ColorScheme,
DefaultStyledConfiguration,
Props,
ReactComponent,
Styled,
Expand All @@ -32,9 +33,9 @@ export {

export { useNativeCss };

const defaultMapping: StyledConfiguration<ComponentType<{ style: unknown }>> = {
const defaultMapping = {
className: "style",
};
} as const satisfies DefaultStyledConfiguration;

/**
* Generates a new Higher-Order component the wraps the base component and applies the styles.
Expand Down
6 changes: 5 additions & 1 deletion src/native/react/useNativeCss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,11 @@ export function mappingToConfig(mapping: StyledConfiguration<any>) {
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;

Expand Down
72 changes: 33 additions & 39 deletions src/runtime.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,37 +20,32 @@ import type { DotNotation, ResolveDotPath } from "react-native-css/utilities";

/******************************** API ********************************/

export type StyledReactElement<
C extends ReactComponent,
M extends StyledConfiguration<C>,
> = ReactElement<
ComponentProps<C> & {
[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, M extends StyledConfiguration<any>> = P & {
type StyledSourceProps<M> = {
[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<C>,
> = ReactElement<ComponentProps<C> & StyledSourceProps<M>>;

export type StyledProps<P, M extends StyledConfiguration<any>> = P &
StyledSourceProps<M>;

export interface Styled {
<const C extends ReactComponent>(
component: C,
mapping?: undefined,
options?: StyledOptions,
): ComponentType<ComponentPropsWithRef<C> & { className?: string }>;
): ComponentType<
ComponentPropsWithRef<C> & StyledSourceProps<DefaultStyledConfiguration>
>;
<const C extends ReactComponent, const M extends StyledConfiguration<C>>(
component: C,
mapping: M & StyledConfiguration<C>,
Expand All @@ -61,17 +56,11 @@ export interface Styled {
type StyledComponent<
C extends ReactComponent,
M extends StyledConfiguration<C>,
> = ComponentType<
ComponentPropsWithRef<C> & {
[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<ComponentPropsWithRef<C> & StyledSourceProps<M>>;

export interface DefaultStyledConfiguration {
readonly className: "style";
}

export type StyledConfiguration<
C extends ReactComponent,
Expand All @@ -88,27 +77,32 @@ interface StyledConfigurationObject<
T extends ComponentPropsDotNotation<C> | false,
> {
target: T;
nativeStyleMapping?: T extends false
? NativeStyleMapping<string, ComponentProps<C>>
: NativeStyleMapping<
ResolveDotPath<T, ComponentProps<C>>,
ComponentProps<C>
>;
nativeStyleMapping?:
| (T extends false
? NativeStyleMapping<string, ComponentProps<C>>
: NativeStyleMapping<
ResolveDotPath<ComponentProps<C>, Extract<T, string>>,
ComponentProps<C>
>)
| undefined;
/** @deprecated Please use nativeStyleMapping */
nativeStyleToProp?: StyledConfigurationObject<C, T>["nativeStyleMapping"];
}

type NativeStyleMapping<T, S> = T extends object
? {
[K in keyof T as K extends string ? K : never]: true | DotNotation<S>;
[K in keyof T as K extends string ? K : never]?:
| true
| DotNotation<S>
| undefined;
} & {
fill?: true | DotNotation<S>;
stroke?: true | DotNotation<S>;
fill?: true | DotNotation<S> | undefined;
stroke?: true | DotNotation<S> | undefined;
}
: Record<string, true | DotNotation<S>>;

export interface StyledOptions {
passThrough?: boolean;
passThrough?: boolean | undefined;
}

/*************************** React Helpers ***************************/
Expand Down
17 changes: 8 additions & 9 deletions src/web/api.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<ComponentType<{ style: unknown }>> = {
const defaultMapping = {
className: "style",
};
} as const satisfies DefaultStyledConfiguration;

export const styled: Styled = (
baseComponent: ReactComponent,
Expand Down
Loading