diff --git a/.claude/skills/add-test/SKILL.md b/.claude/skills/add-test/SKILL.md index f8c65e40..9f689f37 100644 --- a/.claude/skills/add-test/SKILL.md +++ b/.claude/skills/add-test/SKILL.md @@ -7,11 +7,17 @@ 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 + +The Node tooling tests, such as the Metro cache version and the TypeScript setup, are in `node/__tests__/*.mjs` and run against the build. ## Conventions by domain @@ -41,14 +47,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, a type, or Node tooling? 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; `yarn build` and then `node --test node/__tests__/*.mjs` for a Node tooling test; `maestro test src/__tests__/e2e` on a device running the example app for a flow. 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..9ad9ead2 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/`, with the Node tooling tests in `node/__tests__/`, 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..4053b64a 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/`), and the Node tooling tests in `node/__tests__/`, which run against the build with `node --test node/__tests__/*.mjs` - **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..ecf53746 100644 --- a/src/__tests__/native/styled.test.ios.tsx +++ b/src/__tests__/native/styled.test.ios.tsx @@ -163,6 +163,83 @@ 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" }); +}); + +// A JavaScript consumer, whom no type stops, can write these +test.each([null, false, 0])( + "a mapping entry set to %p fails at styled() rather than at render", + (path) => { + const mapping: unknown = { + className: { target: "style", nativeStyleMapping: { color: path } }, + }; + + expect(() => styled(View, mapping as never)).toThrow( + `styled(): Invalid mapping for className: color maps to ${String(path)}`, + ); + }, +); + +test.each([ + [() => undefined, "a function"], + [Symbol("path"), "Symbol(path)"], + [Number.NaN, "NaN"], + [1n, "1n"], + [["accessibilityLabel"], "an array"], +])("a mapping entry set to %p is named in its error", (path, named) => { + const mapping: unknown = { + className: { target: "style", nativeStyleMapping: { color: path } }, + }; + + expect(() => styled(View, mapping as never)).toThrow( + `styled(): Invalid mapping for className: color maps to ${named}`, + ); +}); + +test.each([ + ["accessibilityLabel", 'nativeStyleMapping is "accessibilityLabel"'], + [["accessibilityLabel"], "nativeStyleMapping is an array"], +])( + "a native style mapping set to %p fails at styled()", + (nativeStyleMapping, error) => { + const mapping: unknown = { + className: { target: "style", nativeStyleMapping }, + }; + + expect(() => styled(View, mapping as never)).toThrow( + `styled(): Invalid mapping for className: ${error}`, + ); + }, +); + +test.each([ + [null, "null"], + [Symbol("mapping"), "Symbol(mapping)"], + [[1], "an array"], +])("a mapping set to %p fails at styled()", (value, named) => { + const mapping: unknown = { className: value }; + + expect(() => styled(View, mapping as never)).toThrow( + `styled(): Invalid mapping for className: ${named}`, + ); +}); + 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/compiler-options.test.ts b/src/__tests__/types/compiler-options.test.ts new file mode 100644 index 00000000..74281bfb --- /dev/null +++ b/src/__tests__/types/compiler-options.test.ts @@ -0,0 +1,23 @@ +import ts from "typescript"; + +import { createProgram, root } from "./_program"; + +const fixtureName = `${root}/src/__tests__/types/compiler-options.fixture.ts`; + +test("inlineVariables.exclude takes a readonly list", () => { + const program = createProgram([fixtureName], { + [fixtureName]: [ + `import { compile } from "react-native-css/compiler";`, + `const preserved = ["--brand", "--accent"] as const;`, + `compile("", { inlineVariables: { exclude: preserved } });`, + ].join("\n"), + }); + + expect( + ts + .getPreEmitDiagnostics(program, program.getSourceFile(fixtureName)) + .map(({ messageText }) => + ts.flattenDiagnosticMessageText(messageText, "\n"), + ), + ).toStrictEqual([]); +}); diff --git a/src/__tests__/types/optional-members.test.ts b/src/__tests__/types/optional-members.test.ts new file mode 100644 index 00000000..f1e52b1c --- /dev/null +++ b/src/__tests__/types/optional-members.test.ts @@ -0,0 +1,952 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; + +import ts from "typescript"; + +import { createProgram, root } from "./_program"; + +// A file walked whole, every type it declares; a file whose exports are walked for what a consumer writes to them; or a file's named types +type Walk = "all" | "exports" | readonly string[]; + +// Whether a consumer writes a value of a type or only reads it; a declaration walked whole may be either +type Polarity = "written" | "read" | "both"; + +// Whoever calls a function writes its arguments, so a consumer reads what a callback it writes is given +function argumentPolarity(polarity: Polarity): Polarity { + switch (polarity) { + case "written": + return "read"; + case "read": + return "written"; + case "both": + return "both"; + } +} + +// The props types.d.ts adds and the runtime's public types, walked whole, and the source of every other declaration file package.json exports but native-internal's, which only the generated injection code calls +function publicSurface(): readonly (readonly [string, Walk])[] { + const { exports } = JSON.parse( + readFileSync(`${root}/package.json`, "utf8"), + ) as { + readonly exports: Readonly< + Record + >; + }; + const sourceOf = (stem: string): string => { + const source = [`${root}/${stem}.ts`, `${root}/${stem}.tsx`].find((file) => + existsSync(file), + ); + if (source === undefined) throw new Error(`${stem}.d.ts has no source`); + return source; + }; + const sources = new Set(); + for (const entry of Object.values(exports)) { + const published = ( + typeof entry === "string" ? undefined : entry.import?.types + )?.match(/^\.\/dist\/typescript\/module\/(src\/.+)\.d\.ts$/)?.[1]; + if ( + published === undefined || + published.startsWith("src/native-internal/") + ) { + continue; + } + const [pattern = "", suffix] = published.split("*"); + if (suffix === undefined) { + sources.add(sourceOf(published)); + continue; + } + const folder = pattern.slice(0, pattern.lastIndexOf("/")); + const prefix = pattern.slice(folder.length + 1); + for (const name of readdirSync(`${root}/${folder}`)) { + const stem = /^(.+)\.tsx?$/.exec(name)?.[1]; + if ( + stem !== undefined && + !stem.endsWith(".d") && + stem.startsWith(prefix) && + stem.endsWith(suffix) + ) { + sources.add(`${root}/${folder}/${name}`); + } + } + } + return [ + [`${root}/types.d.ts`, "all"], + [`${root}/src/runtime.types.ts`, "all"], + ...[...sources].sort().map((file) => [file, "exports"] as const), + ]; +} + +// The compiled stylesheet compile() writes, which a consumer passes back without writing +const compiledStylesheetTypes: ReadonlySet = new Set([ + "ReactNativeCssStyleSheet", + "StyleRule", +]); + +// TypeScript's declarations promise every signature one, but the construct signature of a class without a constructor has none +const declarationOf = ( + signature: ts.Signature, +): ts.SignatureDeclaration | undefined => signature.getDeclaration(); + +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, + surface: readonly (readonly [string, Walk])[], +): readonly string[] { + const checker = program.getTypeChecker(); + const undefinedType = checker.getUndefinedType(); + const wholeFiles: ReadonlySet = new Set( + surface.flatMap(([fileName, walk]) => (walk === "all" ? [fileName] : [])), + ); + + const isOwnDeclaration = (node: ts.Node): boolean => + !node.getSourceFile().fileName.includes("/node_modules/"); + + const isTypeScriptLibrary = (node: ts.Node): boolean => + node.getSourceFile().fileName.includes("/node_modules/typescript/lib/"); + + // 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 isCompiledStylesheet = (type: ts.Type): boolean => { + const symbol = type.aliasSymbol ?? type.getSymbol(); + return ( + symbol !== undefined && + compiledStylesheetTypes.has(symbol.name) && + isOwnType(type) + ); + }; + + // What an alias another package declares resolves to, such as metro's config, whose mapped types that package applied; a resolved type keeps no trace of the alias it was written as + const libraryAliasTypes = new Set(); + const noteLibraryAliases = (node: ts.Node | undefined): void => { + if (node === undefined) return; + if (ts.isTypeReferenceNode(node)) { + const symbol = checker.getSymbolAtLocation(node.typeName); + const declared = + symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0 + ? checker.getAliasedSymbol(symbol) + : symbol; + if ( + declared?.declarations?.some( + (declaration) => + !isOwnDeclaration(declaration) && !isTypeScriptLibrary(declaration), + ) + ) { + libraryAliasTypes.add(checker.getTypeFromTypeNode(node)); + } + } + ts.forEachChild(node, noteLibraryAliases); + }; + + const typeNodeOf = (symbol: ts.Symbol): ts.TypeNode | undefined => { + const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0]; + return declaration !== undefined && + (ts.isParameter(declaration) || + ts.isPropertySignature(declaration) || + ts.isPropertyDeclaration(declaration)) + ? declaration.type + : undefined; + }; + + 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 named once over the whole walk: 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 and its parameters are written by whoever calls it, and its return type is read by them + const descendIntoSignature = ( + signature: ts.Signature, + path: string, + polarity: Polarity, + inLibrary: boolean, + ): void => { + const argument = argumentPolarity(polarity); + for (const parameter of signature.getTypeParameters() ?? []) { + const constraint = parameter.getConstraint(); + noteLibraryAliases( + parameter.symbol.declarations?.find(ts.isTypeParameterDeclaration) + ?.constraint, + ); + if (constraint !== undefined) { + descend( + constraint, + `${path}<${parameter.symbol.name}>`, + argument, + inLibrary, + ); + } + } + for (const parameter of signature.getParameters()) { + noteLibraryAliases(typeNodeOf(parameter)); + descend( + checker.getTypeOfSymbol(parameter), + `${path}(${parameter.name})`, + argument, + inLibrary, + ); + } + noteLibraryAliases(declarationOf(signature)?.type); + descend(signature.getReturnType(), `${path}()`, polarity, inLibrary); + }; + + // Call and construct signatures, index signatures and base types reach what a consumer writes too + const descendIntoStructure = ( + type: ts.Type, + path: string, + polarity: Polarity, + inLibrary: boolean, + ): void => { + for (const kind of [ts.SignatureKind.Call, ts.SignatureKind.Construct]) { + for (const signature of checker.getSignaturesOfType(type, kind)) { + descendIntoSignature(signature, path, polarity, inLibrary); + } + } + for (const index of checker.getIndexInfosOfType(type)) { + descend(index.type, `${path}[]`, polarity, inLibrary); + } + if (type.isClassOrInterface()) { + for (const base of checker.getBaseTypes(type)) { + descend(base, path, polarity, inLibrary); + } + } + }; + + // A type is walked once for each way a consumer meets it, under the first path that reaches it, so a recursive one such as `type Nested = string | Nested[]` ends + const walked: Readonly>> = { + written: new Set(), + read: new Set(), + both: 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, + polarity: Polarity, + inLibrary: boolean, + ): void => { + if ( + walked[polarity].has(type) || + walked.both.has(type) || + isCompiledStylesheet(type) + ) { + return; + } + walked[polarity].add(type); + + if (type.isUnionOrIntersection()) { + for (const part of type.types) descend(part, path, polarity, inLibrary); + } else if ( + checker.isTupleType(type) || + isMappedType(type) || + isOwnType(type) + ) { + checkMembers(type, path, polarity, inLibrary); + } else if ( + (type.flags & ts.TypeFlags.Object) !== 0 && + mentionsOwnType(type) + ) { + for (const argument of referenceArguments(type)) { + descend(argument, path, polarity, inLibrary); + } + checkMembers(type, path, polarity, inLibrary); + } + }; + + // A resolved type's members as a consumer meets them, inherited, picked and mapped ones included, and the option types they reach + const checkMembers = ( + type: ts.Type, + path: string, + polarity: Polarity, + inLibrary: boolean, + ): void => { + descendIntoStructure(type, path, polarity, inLibrary); + + for (const property of checker.getPropertiesOfType(type)) { + const memberPath = `${path}.${property.name}`; + const propertyType = checker.getTypeOfSymbol(property); + if ( + polarity !== "read" && + (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, unless another package applied it + if (!inLibrary && !libraryAliasTypes.has(type)) { + 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 + } + const ownMember = property.declarations?.some(isOwnDeclaration) ?? true; + if (ownMember || mentionsOwnType(propertyType)) { + noteLibraryAliases(typeNodeOf(property)); + descend(propertyType, memberPath, polarity, inLibrary || !ownMember); + } + } + }; + + // 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() === objectType.getText() + ); + }; + + // 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 mapped type alias that adds `?`, such as Partial, makes optional the members its source declares required; one a file walked whole declares is named at its declaration + 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 || + wholeFiles.has(declaration.getSourceFile().fileName) + ) { + 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); + noteLibraryAliases(node.type); + descend( + checker.getTypeFromTypeNode(node.type), + `${owner?.name?.text ?? ""}.${node.name.getText()}`, + "both", + false, + ); + } + + // 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(), + "both", + false, + ); + } + + 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, "both", false); + // A class's constructor, which a consumer calls, is on its static side + if (ts.isClassDeclaration(declaration)) { + descend(checker.getTypeOfSymbol(symbol), name.text, "both", false); + } + }; + + // A consumer calls, constructs or reads an exported value, so what it writes is what the value takes + const walkExports = (source: ts.SourceFile): void => { + const module = checker.getSymbolAtLocation(source); + if (module === undefined) + throw new Error(`${source.fileName} is no module`); + for (const exported of checker.getExportsOfModule(module)) { + const symbol = + (exported.flags & ts.SymbolFlags.Alias) === 0 + ? exported + : checker.getAliasedSymbol(exported); + if ((symbol.flags & ts.SymbolFlags.Value) !== 0) { + descend(checker.getTypeOfSymbol(symbol), exported.name, "read", false); + } + } + }; + + for (const [fileName, walk] of surface) { + const source = program.getSourceFile(fileName); + if (source === undefined) throw new Error(`${fileName} is not compiled`); + if (walk === "exports") { + walkExports(source); + continue; + } + const typeDeclarations: TypeDeclaration[] = []; + const collect = (node: ts.Node): void => { + if (isTypeDeclaration(node)) { + typeDeclarations.push(node); + } + ts.forEachChild(node, collect); + }; + collect(source); + if (walk === "all") { + typeDeclarations.forEach(checkDeclaration); + continue; + } + for (const name of walk) { + 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 } + export type Config = Partial<{ resolver: object }>; +} +`; + +const aliasName = `${root}/src/__tests__/types/optional-members-alias.fixture.ts`; + +const alias = `export type ProbeOptional = { [K in keyof T]?: T[K] }; +`; + +const entryName = `${root}/src/__tests__/types/optional-members-entry.fixture.ts`; + +const entry = `import type { Config } from "optional-members-fixture"; +interface StyleRule { d?: string } +export function take(options?: { flag?: boolean }): void {} +export function constrained(options: O): O { return options; } +export function component(): (props: { label?: string }) => null { return () => null; } +export function subscribe(listener: (event: { detail?: string }) => void): void {} +export declare class Builder { + constructor(options?: { built?: boolean }); + shared?: { flag?: boolean }; + add(rule: { name?: string }): this; +} +export const api = { set(value: { mode?: string }): void {} }; +export const label = "entry"; +export function rank(rule: StyleRule): number { return 0; } +export function configure(config: T): T { return config; } +export function defaults(): { reused?: boolean } { return {}; } +export function reuse(options: ReturnType): void {} +export class Plain { add(rule: { plain?: boolean }): void {} } +`; + +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 } +import type { ProbeOptional } from "./optional-members-alias.fixture"; +export interface GenericAliased { options?: ProbeOptional | undefined } +`; + +let program: ts.Program; + +beforeAll(() => { + program = createProgram([fixtureName, foreignName, aliasName, entryName], { + [fixtureName]: fixture, + [foreignName]: foreign, + [aliasName]: alias, + [entryName]: entry, + }); +}); + +test("the walk finds each form of an optional member that rejects undefined, and leaves a never member", () => { + expect( + optionalMembersRejectingUndefined(program, [[fixtureName, "all"]]), + ).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", + "95: ProbeOptional", + ]); +}); + +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("a mapped alias another file declares is named where a generic applies it, or at its declaration once that file is walked whole", () => { + expect( + optionalMembersRejectingUndefined(program, [ + [fixtureName, ["GenericAliased"]], + ]), + ).toStrictEqual(["95: ProbeOptional"]); + expect( + optionalMembersRejectingUndefined(program, [ + [aliasName, "all"], + [fixtureName, ["GenericAliased"]], + ]), + ).toStrictEqual(["1: { [K in keyof T]?: T[K] }"]); +}); + +test("the exports walk names what a consumer writes to an export, through constraints, returned functions and constructed instances, and leaves what it reads, another package's mapped types and the compiled stylesheet", () => { + expect( + optionalMembersRejectingUndefined(program, [[entryName, "exports"]]), + ).toStrictEqual([ + "take(options).flag", + "constrained.bound", + "component()(props).label", + "reuse(options).reused", + "Builder(options).built", + "Builder().add(rule).name", + "api.set(value).mode", + "Plain().add(rule).plain", + ]); +}); + +test("the public surface is the source of every declaration file package.json exports, components included, and leaves native-internal", () => { + const files = publicSurface().map(([file]) => file.slice(root.length + 1)); + + expect(files).toEqual( + expect.arrayContaining([ + "types.d.ts", + "src/runtime.types.ts", + "src/components/index.ts", + "src/components/View.tsx", + "src/metro/index.ts", + ]), + ); + expect(files.filter((file) => file.includes("native-internal"))).toEqual([]); +}); + +test("every optional member a consumer writes accepts an explicit undefined, or is never and must stay absent", () => { + const surface = publicSurface(); + + expect( + optionalMembersRejectingUndefined( + createProgram(surface.map(([file]) => file)), + surface, + ), + ).toStrictEqual([]); +}); diff --git a/src/__tests__/types/styled.test.ts b/src/__tests__/types/styled.test.ts new file mode 100644 index 00000000..5db3791e --- /dev/null +++ b/src/__tests__/types/styled.test.ts @@ -0,0 +1,28 @@ +import { createLanguageService, root } from "./_program"; + +const fixtureName = `${root}/src/__tests__/types/styled.fixture.tsx`; + +test.each(["false", '"style"'])( + "a mapping completes React Native's style keys under target %s", + (target) => { + const text = [ + `import { View } from "react-native";`, + `import { styled } from "react-native-css";`, + `export const mapped = styled(View, { labelClassName: { target: ${target}, 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.ts b/src/compiler/compiler.ts index 363ded58..5e73e932 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -146,7 +146,8 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { } if (options.inlineVariables !== false) { - const exclusionList: string[] = options.inlineVariables?.exclude ?? []; + const exclusionList: readonly string[] = + options.inlineVariables?.exclude ?? []; firstPassVisitor.Declaration = (decl) => { if ( diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..54732285 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?: readonly `--${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..523336ce 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 & { + 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 b65165e5..96a41b01 100644 --- a/src/native/react/useNativeCss.ts +++ b/src/native/react/useNativeCss.ts @@ -30,7 +30,7 @@ import { updateRules } from "./rules"; export type Config = { source: string; target: string[] | string | false; - nativeStyleMapping?: Record; + nativeStyleMapping?: Record | undefined; }; export type ComponentState = { @@ -209,7 +209,10 @@ export function useNativeCss( * Convert the styled() mapping to a config array */ export function mappingToConfig(mapping: StyledConfiguration) { - return Object.entries(mapping).flatMap(([key, value]): Config => { + // A JavaScript consumer can pass any value, so each one is checked here rather than read at render + const entries: readonly (readonly [string, unknown])[] = + Object.entries(mapping); + return entries.flatMap(([key, value]): Config => { if (value === true) { return { source: key, @@ -219,17 +222,18 @@ export function mappingToConfig(mapping: StyledConfiguration) { return { source: key, target: false }; } else if (typeof value === "string") { return { source: key, target: value.split(".") }; - } else if (typeof value === "object") { + } else if (typeof value === "object" && value !== null) { // Keep the declared deprecated alias working. The current spelling wins // when both are provided, including an intentionally empty mapping. - const mapping = value.nativeStyleMapping ?? value.nativeStyleToProp; - const nativeStyleMapping = mapping - ? Object.fromEntries( - Object.entries(mapping).map(([k, v]) => [k, v === true ? k : v]), - ) - : undefined; - - if (Array.isArray(value)) { + const nativeStyleMapping = nativeStyleMappingToConfig( + key, + ("nativeStyleMapping" in value + ? value.nativeStyleMapping + : undefined) ?? + ("nativeStyleToProp" in value ? value.nativeStyleToProp : undefined), + ); + + if (isPath(value)) { return { source: key, target: value, nativeStyleMapping }; } @@ -244,16 +248,75 @@ export function mappingToConfig(mapping: StyledConfiguration) { } else { return { source: key, target, nativeStyleMapping }; } - } else if (Array.isArray(value.target)) { + } else if (isPath(value.target)) { return { source: key, target: value.target, nativeStyleMapping }; } } } - throw new Error(`styled(): Invalid mapping for ${key}: ${value}`); + throw new Error( + `styled(): Invalid mapping for ${key}: ${describeMappingValue(value)}`, + ); }); } +function isPath(value: unknown): value is string[] { + return ( + Array.isArray(value) && value.every((part) => typeof part === "string") + ); +} + +function nativeStyleMappingToConfig( + key: string, + mapping: unknown, +): Record | undefined { + if (!mapping) { + return undefined; + } + if (typeof mapping !== "object" || Array.isArray(mapping)) { + throw new Error( + `styled(): Invalid mapping for ${key}: nativeStyleMapping is ${describeMappingValue(mapping)}`, + ); + } + return Object.fromEntries( + Object.entries(mapping).flatMap(([property, path]: [string, unknown]) => { + if (path === undefined) { + return []; + } + if (path === true) { + return [[property, property] as const]; + } + if (typeof path === "string") { + return [[property, path] as const]; + } + throw new Error( + `styled(): Invalid mapping for ${key}: ${property} maps to ${describeMappingValue(path)}`, + ); + }), + ); +} + +function describeMappingValue(value: unknown): string { + switch (typeof value) { + case "string": + return JSON.stringify(value); + case "bigint": + return `${value}n`; + case "function": + return "a function"; + case "object": + if (value === null) { + return "null"; + } + return Array.isArray(value) ? "an array" : "an object"; + case "number": + case "boolean": + case "symbol": + case "undefined": + return String(value); + } +} + // Apply native Image fitting before normal, inline, and important props merge. // The compiler's contentFit mapping remains available to Expo Image adapters. function adaptImageProps(props: Record | undefined) { diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index a32ac64d..e4cd5ef0 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -13,9 +13,9 @@ export type Effect = { observers: Set>; run(): void; /** Dependencies recorded by the last cleanupEffect() */ - dependencies?: Observable[]; + dependencies?: Observable[] | undefined; /** Dependency values recorded by the last cleanupEffect() */ - snapshots?: [Observable, unknown][]; + snapshots?: [Observable, unknown][] | undefined; }; export type Observable = { 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; } }