From 157684aa001868d778a9e783549310537a0289fd Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 22 Oct 2026 14:46:18 +0300 Subject: [PATCH] fix(types): accept an explicit undefined wherever a consumer writes an option Under exactOptionalPropertyTypes a bare `?: T` rejects the explicit undefined React Native's own optional props accept, so `className={condition ? "p-4" : undefined}` did not compile. Every optional member a consumer writes now takes `| undefined`: the props types.d.ts adds, runtime.types.ts, the options of the compiler, Metro, the jest helper and useNativeCss, and what the exports' parameters reach. `InlineStyleRecord`'s `s?: never` stays, marking a key that must stay absent. The props a styled() mapping adds come from one StyledSourceProps type. A mapping's keys are the style keys the compiler collects, so its type no longer resolves the target. styled() drops an entry left undefined and fails with its Invalid mapping error for any value it cannot map. optional-members.test.ts walks, with the type checker and the flag on, what a consumer writes to each entry point package.json exports, and fails on any optional member that rejects undefined. --- .claude/skills/add-test/SKILL.md | 28 +- .claude/skills/triage/SKILL.md | 2 +- DEVELOPMENT.md | 2 +- src/__tests__/native/_styled-types.tsx | 77 ++ src/__tests__/native/styled.test.ios.tsx | 77 ++ src/__tests__/types/_program.ts | 69 ++ src/__tests__/types/compiler-options.test.ts | 23 + src/__tests__/types/optional-members.test.ts | 952 +++++++++++++++++++ src/__tests__/types/styled.test.ts | 28 + src/compiler/compiler.ts | 3 +- src/compiler/compiler.types.ts | 26 +- src/jest/index.ts | 14 +- src/metro/index.ts | 11 +- src/native/api.tsx | 7 +- src/native/react/useNativeCss.ts | 89 +- src/native/reactivity.ts | 4 +- src/runtime.types.ts | 75 +- src/web/api.tsx | 17 +- types.d.ts | 24 +- 19 files changed, 1411 insertions(+), 117 deletions(-) create mode 100644 src/__tests__/types/_program.ts create mode 100644 src/__tests__/types/compiler-options.test.ts create mode 100644 src/__tests__/types/optional-members.test.ts create mode 100644 src/__tests__/types/styled.test.ts 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; } }