diff --git a/.changeset/brave-ducks-occur.md b/.changeset/brave-ducks-occur.md new file mode 100644 index 00000000000..5498ec92646 --- /dev/null +++ b/.changeset/brave-ducks-occur.md @@ -0,0 +1,11 @@ +--- +"@fluentui-react-native/interactive-hooks": patch +"@fluentui-react-native/use-slot": patch +"@fluentui-react-native/button": patch +"@fluentui-react-native/switch": patch +"@fluentui-react-native/chip": patch +"@fluentui-react-native/framework-base": patch +"@fluentui-react-native/adapters": patch +--- + +Add a common config package with a strict tsconfig, then fix some core packages to build with stronger types diff --git a/packages/components/Button/src/ToggleButton/__snapshots__/ToggleButton.test.tsx.snap b/packages/components/Button/src/ToggleButton/__snapshots__/ToggleButton.test.tsx.snap index bff788bf319..d580c17b25c 100644 --- a/packages/components/Button/src/ToggleButton/__snapshots__/ToggleButton.test.tsx.snap +++ b/packages/components/Button/src/ToggleButton/__snapshots__/ToggleButton.test.tsx.snap @@ -14,7 +14,7 @@ exports[`ToggleButton default 1`] = ` accessibilityState={ { "busy": undefined, - "checked": undefined, + "checked": false, "disabled": false, "expanded": undefined, "selected": undefined, diff --git a/packages/components/Chip/src/__tests__/__snapshots__/Chip.test.tsx.snap b/packages/components/Chip/src/__tests__/__snapshots__/Chip.test.tsx.snap index 56bd2b201fb..86e60907b09 100644 --- a/packages/components/Chip/src/__tests__/__snapshots__/Chip.test.tsx.snap +++ b/packages/components/Chip/src/__tests__/__snapshots__/Chip.test.tsx.snap @@ -5,7 +5,7 @@ exports[`Chip component tests Chip all props 1`] = ` accessibilityState={ { "busy": undefined, - "checked": undefined, + "checked": false, "disabled": undefined, "expanded": undefined, "selected": undefined, @@ -99,7 +99,7 @@ exports[`Chip component tests Chip tokens 1`] = ` accessibilityState={ { "busy": undefined, - "checked": undefined, + "checked": false, "disabled": undefined, "expanded": undefined, "selected": undefined, @@ -169,7 +169,7 @@ exports[`Chip component tests Empty Chip 1`] = ` accessibilityState={ { "busy": undefined, - "checked": undefined, + "checked": false, "disabled": undefined, "expanded": undefined, "selected": undefined, diff --git a/packages/components/Switch/src/__tests__/__snapshots__/Switch.test.tsx.snap b/packages/components/Switch/src/__tests__/__snapshots__/Switch.test.tsx.snap index 5e31d6bc58b..d2a214b0f7b 100644 --- a/packages/components/Switch/src/__tests__/__snapshots__/Switch.test.tsx.snap +++ b/packages/components/Switch/src/__tests__/__snapshots__/Switch.test.tsx.snap @@ -14,7 +14,7 @@ exports[`Switch Default 1`] = ` accessibilityState={ { "busy": undefined, - "checked": undefined, + "checked": false, "disabled": undefined, "expanded": undefined, "selected": undefined, @@ -29,6 +29,7 @@ exports[`Switch Default 1`] = ` } } accessible={true} + checked={false} collapsable={false} focusable={true} onAccessibilityAction={[Function]} @@ -140,7 +141,7 @@ exports[`Switch Disabled 1`] = ` accessibilityState={ { "busy": undefined, - "checked": undefined, + "checked": false, "disabled": true, "expanded": undefined, "selected": undefined, @@ -155,6 +156,7 @@ exports[`Switch Disabled 1`] = ` } } accessible={false} + checked={false} collapsable={false} focusable={false} onAccessibilityAction={[Function]} diff --git a/packages/config/README.md b/packages/config/README.md new file mode 100644 index 00000000000..9c4b2d16c6d --- /dev/null +++ b/packages/config/README.md @@ -0,0 +1,3 @@ +# `@fluentui-react-native/config` + +Shared configuration package for fluentui-react-native. Right now this is just for tsconfig files but this should start to aggregate various other configurations to make it easier to maintain things. diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 00000000000..4ac9d782ab7 --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,21 @@ +{ + "name": "@fluentui-react-native/config", + "version": "0.0.0", + "private": true, + "description": "Shared configuration files for Fluent UI React Native packages", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/fluentui-react-native", + "directory": "packages/configs/config" + }, + "exports": { + "./tsconfig.strict.json": "./tsconfig.strict.json" + }, + "dependencies": { + "@rnx-kit/tsconfig": "catalog:" + }, + "furn": { + "packageType": "tooling" + } +} diff --git a/packages/config/tsconfig.strict.json b/packages/config/tsconfig.strict.json new file mode 100644 index 00000000000..d54ec1c45a9 --- /dev/null +++ b/packages/config/tsconfig.strict.json @@ -0,0 +1,7 @@ +{ + "extends": "@rnx-kit/tsconfig/tsconfig.node.json", + "compilerOptions": { + "outDir": "lib", + "jsx": "react-jsx" + } +} diff --git a/packages/framework-base/package.json b/packages/framework-base/package.json index 664595cfa42..04029ee3a98 100644 --- a/packages/framework-base/package.json +++ b/packages/framework-base/package.json @@ -43,6 +43,7 @@ }, "devDependencies": { "@babel/core": "catalog:", + "@fluentui-react-native/config": "workspace:*", "@fluentui-react-native/eslint-config-rules": "workspace:*", "@fluentui-react-native/kit-config": "workspace:*", "@fluentui-react-native/react-configs": "workspace:*", diff --git a/packages/framework-base/src/component-patterns/phasedComponent.ts b/packages/framework-base/src/component-patterns/phasedComponent.ts index 6e4052a3181..5f49b30b256 100644 --- a/packages/framework-base/src/component-patterns/phasedComponent.ts +++ b/packages/framework-base/src/component-patterns/phasedComponent.ts @@ -17,20 +17,22 @@ export function getPhasedRender(component: React.ComponentType): // if this has a phased render function, return it if ((component as PhasedComponent)._phasedRender) { return (component as PhasedComponent)._phasedRender; - } else if ((component as ComposableFunction)._staged) { + } else { // for backward compatibility check for staged render and return a wrapper that maps the signature const staged = (component as ComposableFunction)._staged; - return (props: TProps) => { - const { children, ...rest } = props as React.PropsWithChildren; - const inner = staged(rest as TProps, ...React.Children.toArray(children)); - // staged render functions were not consistently marking contents as composable, though they were treated - // as such in useHook. To maintain compatibility we mark the returned function as composable here. This was - // dangerous, but this shim is necessary for backward compatibility. The newer pattern is explicit about this. - if (typeof inner === 'function' && !(inner as LegacyDirectComponent)._canCompose) { - return Object.assign(inner, { _canCompose: true }); - } - return inner; - }; + if (staged) { + return (props: TProps) => { + const { children, ...rest } = props as React.PropsWithChildren; + const inner = staged(rest as TProps, ...React.Children.toArray(children)); + // staged render functions were not consistently marking contents as composable, though they were treated + // as such in useHook. To maintain compatibility we mark the returned function as composable here. This was + // dangerous, but this shim is necessary for backward compatibility. The newer pattern is explicit about this. + if (typeof inner === 'function' && !(inner as LegacyDirectComponent)._canCompose) { + return Object.assign(inner, { _canCompose: true }); + } + return inner; + }; + } } } return undefined; @@ -43,9 +45,9 @@ export function getPhasedRender(component: React.ComponentType): */ export function phasedComponent(getInnerPhase: PhasedRender): FunctionComponent { return Object.assign( - (props: React.PropsWithChildren) => { + (props: TProps) => { // pull out children from props - const { children, ...outerProps } = props; + const { children, ...outerProps } = props as React.PropsWithChildren; const Inner = getInnerPhase(outerProps as TProps); return renderForJsxRuntime(Inner, { children }); }, diff --git a/packages/framework-base/src/component-patterns/render.ts b/packages/framework-base/src/component-patterns/render.ts index 0e1542169c5..c5ef43fe886 100644 --- a/packages/framework-base/src/component-patterns/render.ts +++ b/packages/framework-base/src/component-patterns/render.ts @@ -1,6 +1,7 @@ import React from 'react'; import * as ReactJSX from 'react/jsx-runtime'; import type { RenderType, RenderResult, DirectComponent, LegacyDirectComponent } from './render.types'; +import { extractChildren, splitPropsAndChildren } from '../utilities/typeUtils'; export type CustomRender = () => RenderResult; @@ -20,13 +21,13 @@ function asLegacyDirectComponent(type: RenderType): LegacyDirectComponen export function renderForJsxRuntime( type: React.ElementType, - props: React.PropsWithChildren, + props: TProps, key?: React.Key, - jsxFn: typeof ReactJSX.jsx = undefined, + jsxFn?: typeof ReactJSX.jsx, ): RenderResult { const legacyDirect = asLegacyDirectComponent(type); if (legacyDirect) { - const { children, ...rest } = props; + const [rest, children] = splitPropsAndChildren(props); const newProps = { ...rest, key }; return legacyDirect(newProps, ...React.Children.toArray(children)) as RenderResult; } @@ -38,14 +39,14 @@ export function renderForJsxRuntime( // auto-detect whether to use jsx or jsxs based on number of children, 0 or 1 = jsx, more than 1 = jsxs if (!jsxFn) { - if (React.Children.count(props.children) > 1) { + if (React.Children.count(extractChildren(props)) > 1) { jsxFn = ReactJSX.jsxs; } else { jsxFn = ReactJSX.jsx; } } // Extract key from props to avoid React 19 warning about spreading key prop - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { key: propsKey, ...propsWithoutKey } = props as any; // Use explicitly passed key, or fall back to key from props const finalKey = key ?? propsKey; diff --git a/packages/framework-base/src/component-patterns/render.types.ts b/packages/framework-base/src/component-patterns/render.types.ts index cfbd3941879..73e0b8e886f 100644 --- a/packages/framework-base/src/component-patterns/render.types.ts +++ b/packages/framework-base/src/component-patterns/render.types.ts @@ -100,7 +100,7 @@ export type SlotFn = { * Children will be passed as part of the props for component rendering. The `children` prop will be * automatically inferred and typed correctly by the prop type. */ -export type PhasedRender = (props: TProps) => React.ComponentType>; +export type PhasedRender = (props: TProps) => React.ComponentType; /** * Component type for a component that can be rendered in two phases, with the attached phased render function. diff --git a/packages/framework-base/src/immutable-merge/Merge.test.ts b/packages/framework-base/src/immutable-merge/Merge.test.ts index 62252a972f3..14a5732144f 100644 --- a/packages/framework-base/src/immutable-merge/Merge.test.ts +++ b/packages/framework-base/src/immutable-merge/Merge.test.ts @@ -99,11 +99,6 @@ const mergeOptions: MergeOptions = { }, }; -interface IDeepObj { - a: { b: { c: number } }; - b: { c: { d: { d: string } } }; -} - const deep1 = { a: { b: { c: 1 } }, b: { c: { d: { d: 'foo' } } }, @@ -183,9 +178,9 @@ describe('Immutable merge unit tests', () => { const obj1 = { a: 'a', b: 1 }; const obj2 = { b: 2, c: true }; const merged = { a: 'a', b: 2, c: true }; - expect(immutableMerge(obj1, obj2)).toEqual(merged); - expect(immutableMergeCore(0, obj1, obj2)).toEqual(merged); - expect(immutableMergeCore(true, obj1, obj2)).toEqual(merged); + expect(immutableMerge(obj1, obj2)).toEqual(merged); + expect(immutableMergeCore(0, obj1, obj2)).toEqual(merged); + expect(immutableMergeCore(true, obj1, obj2)).toEqual(merged); }); const dm1 = { @@ -199,14 +194,14 @@ describe('Immutable merge unit tests', () => { }; test('deep merge', () => { - expect(immutableMerge(dm1, dm2)).toEqual({ + expect(immutableMerge(dm1, dm2)).toEqual({ a: { b: { c: { foo: 'foo', bar: 'bar2', baz: 'baz' } }, i: 'world' }, d: { e: 1, f: { g: 'hello', h: 2 }, j: 4 }, }); }); test('merge zero levels', () => { - expect(immutableMergeCore(0, dm1, dm2)).toEqual(dm2); + expect(immutableMergeCore(0, dm1, dm2)).toEqual(dm2); }); test('merge one level deep', () => { @@ -214,8 +209,8 @@ describe('Immutable merge unit tests', () => { a: dm2.a, d: { ...dm1.d, ...dm2.d }, }; - expect(immutableMergeCore(1, dm1, dm2)).toEqual(result); - expect(immutableMergeCore({ object: 0 }, dm1, dm2)).toEqual(result); + expect(immutableMergeCore(1, dm1, dm2)).toEqual(result); + expect(immutableMergeCore({ object: 0 }, dm1, dm2)).toEqual(result); }); test('merge with empty object', () => { @@ -226,14 +221,14 @@ describe('Immutable merge unit tests', () => { }); test('merge sett1 and sett2', () => { - const merged = immutableMergeCore(mergeOptions, sett1, sett2) as IFakeSettings; + const merged = immutableMergeCore(mergeOptions, sett1, sett2); expect(merged).toEqual(sett1plus2); - expect(merged!.root.style).toBe(sett1.root.style); + expect(merged!.root!.style).toBe(sett1.root!.style); expect(merged!.fakeSlot!.style).toBe(sett2.fakeSlot!.style); }); test('merge sett1 and sett3', () => { - const merged = immutableMergeCore(mergeOptions, sett1, sett3) as IFakeSettings; + const merged = immutableMergeCore(mergeOptions, sett1, sett3); expect(merged).toEqual(sett1plus3); expect(merged!.fakeSlot).toBe(sett1.fakeSlot); }); @@ -244,7 +239,7 @@ describe('Immutable merge unit tests', () => { }); test('deepMerge', () => { - const merged = immutableMergeCore(-1, deep1, deep2) as IDeepObj; + const merged = immutableMergeCore(-1, deep1, deep2); expect(merged).toEqual(deepMerged); expect(merged.b.c.d).toBe(deep1.b.c.d); expect(merged.a.b).not.toBe(deep2.a.b); @@ -259,14 +254,14 @@ describe('Immutable merge unit tests', () => { const merged = processImmutable(changeMeOption1, singleToChange); expect(merged).toEqual(singleWithChanges); expect(merged).not.toBe(singleToChange); - expect((merged as any).b).toBe(singleToChange.b); + expect(merged.b).toBe(singleToChange.b); }); test('single process with change - alternative', () => { const merged = processImmutable(changeMeOption2, singleToChange); expect(merged).toEqual(singleWithChanges); expect(merged).not.toBe(singleToChange); - expect((merged as any).b).toBe(singleToChange.b); + expect(merged.b).toBe(singleToChange.b); }); const withArray1 = { @@ -296,15 +291,15 @@ describe('Immutable merge unit tests', () => { }; test('last writer wins for objects and non-objects', () => { - const merged = immutableMerge(withObj, withNonObj); + const merged = immutableMerge(withObj, withNonObj); expect(merged).toEqual(withNonObj); - const merged2 = immutableMerge(withNonObj, withObj); + const merged2 = immutableMerge(withNonObj, withObj); expect(merged2).toEqual(withObj); }); const arrayMerger = (...targets: any[]) => { const arrays = targets.filter((t) => Array.isArray(t)); - let result = []; + let result: any[] = []; for (const v of arrays) { if (v.length > 0) { result = result.concat(...v); diff --git a/packages/framework-base/src/immutable-merge/Merge.ts b/packages/framework-base/src/immutable-merge/Merge.ts index b77d9577d0a..da0e9bbed3b 100644 --- a/packages/framework-base/src/immutable-merge/Merge.ts +++ b/packages/framework-base/src/immutable-merge/Merge.ts @@ -1,3 +1,6 @@ +import { getEntityType, isObject } from '../utilities/typeUtils'; +import type { ObjectMerger, ObjectMergerWithOptions } from '../utilities/mergeTypes'; + /** * The basic options for recursion at a given level. Two types for two behaviors: * @@ -28,19 +31,6 @@ export type BuiltinRecursionHandlers = 'appendArray'; */ export type RecursionHandler = BuiltinRecursionHandlers | CustomRecursionHandler; -/** - * Base object type for merges, avoids using object since that is too broad. In particular things like null and arrays - * are not valid object types for the purposes of this library. - */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export type ObjectBase = {}; - -/** - * - */ -export type TypeofResult = 'undefined' | 'object' | 'boolean' | 'number' | 'string' | 'symbol' | 'bigint' | 'function'; -export type ExpandedTypeof = TypeofResult | 'array' | 'null'; - /** * configuration object for the merge, key names are matched with a few exceptions: * - object: matches non-array object types @@ -51,6 +41,12 @@ export interface MergeOptions { [objectTypeOrKeyName: string]: RecursionOption | RecursionHandler | MergeOptions; } +/** + * Union type for the options parameter of the merge core function, this allows for either a simple recursion option + * that applies to all keys and types, or a full configuration object for more control. + */ +export type MergeCoreOptions = RecursionOption | MergeOptions; + /** * built in handlers for the module */ @@ -72,25 +68,6 @@ function normalizeOptions(options: RecursionOption | MergeOptions): [MergeOption : [options, true]; } -/** - * Provide a more sensible type result that expands upon the built in typeof operator - * In particular this will differentiate arrays and nulls from standard objects - * @param val - value to check type - */ -function getEntityType(val: unknown): ExpandedTypeof { - switch (typeof val) { - case 'object': - if (val === null) { - return 'null'; - } else if (Array.isArray(val)) { - return 'array'; - } - return 'object'; - default: - return typeof val as TypeofResult; - } -} - /** resolve custom handlers if they are applicable */ function resolveIfHandler(option: RecursionHandler | RecursionOption | MergeOptions): CustomRecursionHandler | MergeOptions | undefined { return typeof option === 'function' ? option : typeof option === 'string' ? _builtinHandlers[option] : undefined; @@ -141,22 +118,13 @@ function getHandlerForPropertyOfType( return result; } -/** - * Assign properties of source objects to a new target object. This is just a type wrapper around Object.assign - * @param objs - array of objects to merge - * @returns the result of object assign on the objects, typed to T - */ -function assignToNewObject(...objs: T[]): T { - return Object.assign({}, ...objs); -} - /** * Filter a set of unknown values to only include those that extend ObjectBase * @param values - array of values to filter * @returns the filtered set of values */ -export function filterToObjects(values: unknown[]): T[] { - return values.filter((v) => v && getEntityType(v) === 'object' && Object.getOwnPropertyNames(v).length > 0) as T[]; +export function filterToObjects>(values: unknown[]): T[] { + return values.filter((v) => v && isObject(v) && Object.getOwnPropertyNames(v).length > 0) as T[]; } /** @@ -172,15 +140,19 @@ export function filterToObjects(values: unkno * is true the routine will progress through all branches of the hierarchy. Useful if using a processor function that needs to be run. * @param objs - an array of objects to merge together */ -function immutableMergeWorker(mergeOptions: RecursionOption | MergeOptions, singleMode: boolean, ...objs: T[]): T { - const setToMerge = filterToObjects(objs); +function immutableMergeWorker( + mergeOptions: RecursionOption | MergeOptions, + singleMode: boolean, + ...objs: unknown[] +): Record | undefined { + const setToMerge = filterToObjects(objs); const [options, mightRecurse] = normalizeOptions(mergeOptions); const processSingle = singleMode && setToMerge.length === 1; // there is work to do if there is more than one object to merge or if we are processing single objects if (setToMerge.length > 1 || (processSingle && setToMerge.length === 1)) { // now assign everything to get the normal property precedence (and merge all the keys) - let result = processSingle ? undefined : assignToNewObject(...setToMerge); + let result = processSingle ? undefined : Object.assign({}, ...setToMerge); const processSet = result || setToMerge[0]; for (const key in processSet) { @@ -193,11 +165,9 @@ function immutableMergeWorker(mergeOptions: RecursionOptio if (handler !== undefined) { const values = setToMerge.map((set) => set[key]).filter((v) => v !== undefined); const updatedVal = - typeof handler === 'function' - ? handler(...values) - : immutableMergeWorker(handler, singleMode, ...filterToObjects(values)); + typeof handler === 'function' ? handler(...values) : immutableMergeWorker(handler, singleMode, ...filterToObjects(values)); if (updatedVal !== originalVal) { - result = result || assignToNewObject(...setToMerge); + result = result || Object.assign({}, ...setToMerge); result[key] = updatedVal; } } @@ -222,9 +192,7 @@ function immutableMergeWorker(mergeOptions: RecursionOptio * * @param objs - variable input array of typed objects to merge */ -export function immutableMerge(...objs: (T | undefined)[]): T | undefined { - return immutableMergeWorker(true, false, ...objs); -} +export const immutableMerge: ObjectMerger = (...objs: unknown[]) => immutableMergeWorker(true, false, ...objs); /** * Version of immutable merge that can be configured to behave in a variety of manners. See the documentation for details. @@ -232,12 +200,8 @@ export function immutableMerge(...objs: (T | undefined)[]) * @param options - configuration options for the merge, this dictates what keys will be handled in what way * @param objs - set of objects to merge together */ -export function immutableMergeCore( - options: RecursionOption | MergeOptions, - ...objs: (T | undefined)[] -): T | undefined { - return immutableMergeWorker(options, false, ...objs); -} +export const immutableMergeCore: ObjectMergerWithOptions = (options: MergeCoreOptions, ...objs: unknown[]) => + immutableMergeWorker(options, false, ...objs); /** * Process one or more immutable objects ensuring that handlers are called on every entry that applies. If a single object @@ -250,6 +214,5 @@ export function immutableMergeCore( * @param processors - set of processor functions for handling keys * @param objs - one or more objects to process. If multiple objects are passed they will be merged */ -export function processImmutable(options: MergeOptions, ...objs: (T | undefined)[]): T | undefined { - return immutableMergeWorker(options, true, ...objs); -} +export const processImmutable: ObjectMergerWithOptions = (options: MergeOptions, ...objs: unknown[]) => + immutableMergeWorker(options, true, ...objs); diff --git a/packages/framework-base/src/index.ts b/packages/framework-base/src/index.ts index a577b3d8299..dc53f266371 100644 --- a/packages/framework-base/src/index.ts +++ b/packages/framework-base/src/index.ts @@ -1,29 +1,27 @@ // immutable-merge exports -export { immutableMerge, immutableMergeCore, processImmutable, filterToObjects } from './immutable-merge/Merge'; +export { immutableMerge, immutableMergeCore, processImmutable, filterToObjects } from './immutable-merge/Merge.ts'; export type { BuiltinRecursionHandlers, CustomRecursionHandler, MergeOptions, - ObjectBase, RecursionHandler, RecursionOption, -} from './immutable-merge/Merge'; +} from './immutable-merge/Merge.ts'; // memo-cache exports -export type { GetMemoValue, GetTypedMemoValue } from './memo-cache/getMemoCache'; -export { getMemoCache, getTypedMemoCache } from './memo-cache/getMemoCache'; -export { memoize } from './memo-cache/memoize'; +export type { GetMemoValue, GetTypedMemoValue } from './memo-cache/getMemoCache.ts'; +export { getMemoCache, getTypedMemoCache } from './memo-cache/getMemoCache.ts'; +export { memoize } from './memo-cache/memoize.ts'; // merge-props exports -export type { StyleProp } from './merge-props/mergeStyles.types'; -export { mergeStyles } from './merge-props/mergeStyles'; -export { mergeProps } from './merge-props/mergeProps'; +export { mergeStyles } from './merge-props/mergeStyles.ts'; +export { mergeProps } from './merge-props/mergeProps.ts'; // component pattern exports - extracting from elements -export { extractChildren, extractProps, extractStyle } from './component-patterns/extract'; +export { extractChildren, extractProps, extractStyle } from './component-patterns/extract.ts'; // component pattern exports - rendering utilities -export { renderForJsxRuntime, renderSlot, asDirectComponent } from './component-patterns/render'; +export { renderForJsxRuntime, renderSlot, asDirectComponent } from './component-patterns/render.ts'; // component pattern exports - core types export type { @@ -41,20 +39,25 @@ export type { FinalRender, SlotFn, NativeReactType, -} from './component-patterns/render.types'; +} from './component-patterns/render.types.ts'; // component pattern exports - component builders -export { directComponent } from './component-patterns/directComponent'; -export { getPhasedRender, phasedComponent } from './component-patterns/phasedComponent'; -export { stagedComponent } from './component-patterns/stagedComponent'; +export { directComponent } from './component-patterns/directComponent.ts'; +export { getPhasedRender, phasedComponent } from './component-patterns/phasedComponent.ts'; +export { stagedComponent } from './component-patterns/stagedComponent.ts'; // component pattern exports - legacy JSX handlers -export { withSlots } from './component-patterns/withSlots'; +export { withSlots } from './component-patterns/withSlots.ts'; // jsx runtime exports -export { jsx, jsxs } from './jsx-runtime'; -export type { FurnJSX } from './jsx-namespace'; +export { jsx, jsxs } from './jsx-runtime.ts'; +export type { FurnJSX } from './jsx-namespace.ts'; // general utilities -export { filterProps } from './utilities/filterProps'; -export type { PropsFilter } from './utilities/filterProps'; +export { filterProps } from './utilities/filterProps.ts'; +export type { PropsFilter } from './utilities/filterProps.ts'; + +// core type utilities exports +export type { StyleProp, ObjectBase, ObjectFallback } from './utilities/baseTypes.ts'; +export type { ObjectMerger, ObjectMergerWithOptions, StyleMerger } from './utilities/mergeTypes.ts'; +export type { ExpandedTypeof, TypeofResult } from './utilities/typeUtils.ts'; diff --git a/packages/framework-base/src/memo-cache/getCacheEntry.test.ts b/packages/framework-base/src/memo-cache/getCacheEntry.test.ts index 5d619eb5dc1..bb92e0885c8 100644 --- a/packages/framework-base/src/memo-cache/getCacheEntry.test.ts +++ b/packages/framework-base/src/memo-cache/getCacheEntry.test.ts @@ -40,34 +40,34 @@ describe('Memo cache unit tests', () => { test('string gets keyed correctly', () => { const base: TestEntry = {}; const key = 'foo'; - expect(getCacheEntry(base, [key])).toBe(base.str[key]); + expect(getCacheEntry(base, [key])).toBe(base.str![key]); }); test('number gets keyed correctly', () => { const base: TestEntry = {}; const val = 235; const key = val + ''; - expect(getCacheEntry(base, [val])).toBe(base.str[key]); + expect(getCacheEntry(base, [val])).toBe(base.str![key]); }); test('bool gets keyed correctly', () => { const base: TestEntry = {}; const val = true; const key = val + ''; - expect(getCacheEntry(base, [val])).toBe(base.str[key]); + expect(getCacheEntry(base, [val])).toBe(base.str![key]); }); test('false bool gets keyed correctly', () => { const base: TestEntry = {}; const val = false; const key = val + ''; - expect(getCacheEntry(base, [val])).toBe(base.str[key]); + expect(getCacheEntry(base, [val])).toBe(base.str![key]); }); test('object gets keyed correctly', () => { const base: TestEntry = {}; const key = {}; - expect(getCacheEntry(base, [key])).toBe(base.obj.get(key)); + expect(getCacheEntry(base, [key])).toBe(base.obj!.get(key)); }); test('function gets keyed correctly', () => { @@ -75,7 +75,7 @@ describe('Memo cache unit tests', () => { const key = () => { return 'hello world'; }; - expect(getCacheEntry(base, [key])).toBe(base.obj.get(key)); + expect(getCacheEntry(base, [key])).toBe(base.obj!.get(key)); }); test('basic string retrieval', () => { diff --git a/packages/framework-base/src/memo-cache/getCacheEntry.ts b/packages/framework-base/src/memo-cache/getCacheEntry.ts index 5d7b6206458..c8f9f16db79 100644 --- a/packages/framework-base/src/memo-cache/getCacheEntry.ts +++ b/packages/framework-base/src/memo-cache/getCacheEntry.ts @@ -35,7 +35,13 @@ function jumpToCacheEntry(entry: CacheEntry, val: any): CacheEntry { if (typeof val === 'object' || typeof val === 'function') { // objects and functions will be treated as key values in a WeakMap const byObj = (entry.obj ??= new WeakMap()); - return byObj.get(val) || byObj.set(val, {}).get(val); + + let newEntry = byObj.get(val); + if (!newEntry) { + newEntry = {}; + byObj.set(val, newEntry); + } + return newEntry; } // otherwise convert everything to a string and store it in the str object (using it as a map) const key = val + ''; @@ -49,7 +55,7 @@ function jumpToCacheEntry(entry: CacheEntry, val: any): CacheEntry { * @param entry - entry to use as the base of the cache walk * @param args - array of arguments to use to progress deeper into the cache */ -export function getCacheEntry(entry: CacheEntry, args: unknown[]): CacheEntry { +export function getCacheEntry(entry: CacheEntry, args?: unknown[]): CacheEntry { // in the case where the args array exists and is > 0 length: // - walk the cache from entry, like a linked list, jumping to the next entry by key, building it up as you go // - otherwise if there are no args just use the noargs branch diff --git a/packages/framework-base/src/memo-cache/getMemoCache.test.ts b/packages/framework-base/src/memo-cache/getMemoCache.test.ts index b896cce902b..1f2c8ff1862 100644 --- a/packages/framework-base/src/memo-cache/getMemoCache.test.ts +++ b/packages/framework-base/src/memo-cache/getMemoCache.test.ts @@ -58,8 +58,8 @@ describe('getMemoCache unit tests', () => { test('memo calls function only once for empty inputs', () => { const memoValue = getMemoCache(); const fn = getObjFactory(); - const [o1] = memoValue(fn, undefined); - const [o2] = memoValue(fn, undefined); + const [o1] = memoValue(fn, []); + const [o2] = memoValue(fn, []); expect(o2).toBe(o1); }); diff --git a/packages/framework-base/src/memo-cache/getMemoCache.ts b/packages/framework-base/src/memo-cache/getMemoCache.ts index b8fd9131463..fd3b558479e 100644 --- a/packages/framework-base/src/memo-cache/getMemoCache.ts +++ b/packages/framework-base/src/memo-cache/getMemoCache.ts @@ -8,8 +8,8 @@ export type ValueFactory = () => T; * - Typed: the cache will enforce the type of both the factory and returned value * - Untyped: the cache will infer the type on each call from the factory return value */ -export type GetTypedMemoValue = (factory: T | ValueFactory, keys: unknown[]) => [T, GetTypedMemoValue]; -export type GetMemoValue = (factory: T | ValueFactory, keys: unknown[]) => [T, GetMemoValue]; +export type GetTypedMemoValue = (factory: T | ValueFactory, keys?: unknown[]) => [T, GetTypedMemoValue]; +export type GetMemoValue = (factory: T | ValueFactory, keys?: unknown[]) => [T, GetMemoValue]; /** base node used to remember references when a globalKey is set */ const _baseEntry: CacheEntry = {}; @@ -21,13 +21,13 @@ const _baseEntry: CacheEntry = {}; * @param factory - generally a function who's results will be cached, and returned via the set of keys * @param keys - an ordered array of values of any type, used as keys to look up the entry */ -function getMemoValueWorker(entry: CacheEntry, factory: T | ValueFactory, keys: unknown[]): [T, GetMemoValue] { +function getMemoValueWorker(entry: CacheEntry, factory: T | ValueFactory, keys?: unknown[]): [T, GetMemoValue] { const foundEntry = getCacheEntry(entry, keys); // check the key being set, not the value to disambiguate an undefined factory result/value from never having run the factory if (!Object.prototype.hasOwnProperty.call(foundEntry, 'value')) { foundEntry.value = typeof factory === 'function' ? (factory as ValueFactory)() : factory; } - return [foundEntry.value as T, (fact: U | ValueFactory, args: unknown[]) => getMemoValueWorker(foundEntry, fact, args)]; + return [foundEntry.value as T, (fact: U | ValueFactory, args?: unknown[]) => getMemoValueWorker(foundEntry, fact, args)]; } /** diff --git a/packages/framework-base/src/merge-props/index.ts b/packages/framework-base/src/merge-props/index.ts deleted file mode 100644 index 352d8c4eddf..00000000000 --- a/packages/framework-base/src/merge-props/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type { StyleProp } from './mergeStyles.types'; -export { mergeStyles } from './mergeStyles'; -export { mergeProps } from './mergeProps'; diff --git a/packages/framework-base/src/merge-props/mergeProps.ts b/packages/framework-base/src/merge-props/mergeProps.ts index f9e009843ea..b53e0ee3953 100644 --- a/packages/framework-base/src/merge-props/mergeProps.ts +++ b/packages/framework-base/src/merge-props/mergeProps.ts @@ -1,5 +1,6 @@ import type { MergeOptions } from '../immutable-merge/Merge'; import { immutableMergeCore, filterToObjects } from '../immutable-merge/Merge'; +import type { ObjectMerger } from '../utilities/mergeTypes'; import { mergeStyles } from './mergeStyles'; @@ -15,6 +16,4 @@ const mergePropsOptions: MergeOptions = { * Merge props together, flattening and merging styles as appropriate * @param props - props to merge together */ -export function mergeProps(...props: (TProps | undefined)[]): TProps { - return immutableMergeCore(mergePropsOptions, ...filterToObjects(props)); -} +export const mergeProps: ObjectMerger = (...props: unknown[]) => immutableMergeCore(mergePropsOptions, ...filterToObjects(props)); diff --git a/packages/framework-base/src/merge-props/mergeStyles.test.ts b/packages/framework-base/src/merge-props/mergeStyles.test.ts index 7be505ed326..45fb49b4eac 100644 --- a/packages/framework-base/src/merge-props/mergeStyles.test.ts +++ b/packages/framework-base/src/merge-props/mergeStyles.test.ts @@ -1,5 +1,5 @@ import { flattenStyle, mergeAndFlattenStyles, mergeStyles } from './mergeStyles'; -import type { StyleProp } from './mergeStyles.types'; +import type { StyleProp } from '../utilities/baseTypes.ts'; type OpaqueColorValue = symbol & { __TYPE__: 'Color' }; type ColorValue = string | OpaqueColorValue; diff --git a/packages/framework-base/src/merge-props/mergeStyles.ts b/packages/framework-base/src/merge-props/mergeStyles.ts index 03e05b87ebc..e3b01a6d2d1 100644 --- a/packages/framework-base/src/merge-props/mergeStyles.ts +++ b/packages/framework-base/src/merge-props/mergeStyles.ts @@ -1,16 +1,18 @@ import { immutableMerge } from '../immutable-merge/Merge'; import { getMemoCache } from '../memo-cache/getMemoCache'; +import type { StyleMerger } from '../utilities/mergeTypes'; -import type { StyleProp } from './mergeStyles.types'; +import type { StyleProp } from '../utilities/baseTypes.ts'; /** * Take a react-native style, which may be a recursive array, and return as a flattened - * style. This is analagous to the flatten routine that is part of the style sheet API + * style. This is analogous to the flatten routine that is part of the style sheet API * * @param style - StyleProp to flatten, this can be a TStyle or an array + * @internal */ -export function flattenStyle(style: StyleProp): T { - return Array.isArray(style) ? immutableMerge(...style.map((v) => flattenStyle(v))) : ((style || {}) as T); +export function flattenStyle(style: StyleProp): object { + return Array.isArray(style) ? (immutableMerge(...style.map((v) => flattenStyle(v))) as object) : style || {}; } /** @@ -19,31 +21,14 @@ export function flattenStyle(style: StyleProp): T { * @param styles - array of styles to merge together. The styles will be flattened as part of the process */ -// Overload for 2 arguments with potentially different types -export function mergeAndFlattenStyles( - style1: StyleProp, - style2: StyleProp, -): (T1 & T2) | undefined; - -// Overload for 3 arguments with potentially different types -export function mergeAndFlattenStyles( - style1: StyleProp, - style2: StyleProp, - style3: StyleProp, -): (T1 & T2 & T3) | undefined; - -// General fallback for any number of arguments of the same type -export function mergeAndFlattenStyles(...styles: StyleProp[]): TStyle | undefined; - -// Implementation -export function mergeAndFlattenStyles(...styles: StyleProp[]): object | undefined { +export const mergeAndFlattenStyles: StyleMerger = (...styles: StyleProp[]) => { // baseline merge and flatten the objects return immutableMerge( - ...styles.map((styleProp: StyleProp) => { + ...styles.map((styleProp: StyleProp) => { return flattenStyle(styleProp); }), ); -} +}; const _styleCache = getMemoCache(); @@ -51,38 +36,12 @@ const _styleCache = getMemoCache(); * Function overloads to allow merging styles of different types. * This is useful when merging token-based styles with React Native StyleProp types. */ - -// Overload for 1 argument, forces flattening of sub arrays -export function mergeStyles(style1: StyleProp): T1 | undefined; - -// Overload for 2 arguments with potentially different types -export function mergeStyles(style1: StyleProp, style2: StyleProp): (T1 & T2) | undefined; - -// Overload for 3 arguments with potentially different types -export function mergeStyles( - style1: StyleProp, - style2: StyleProp, - style3: StyleProp, -): (T1 & T2 & T3) | undefined; - -// Overload for 4 arguments with potentially different types -export function mergeStyles( - style1: StyleProp, - style2: StyleProp, - style3: StyleProp, - style4: StyleProp, -): (T1 & T2 & T3 & T4) | undefined; - -// General fallback for any number of arguments of the same type -export function mergeStyles(...styles: StyleProp[]): TStyle | undefined; - -// Implementation -export function mergeStyles(...styles: StyleProp[]): object | undefined { +export const mergeStyles: StyleMerger = (...styles: StyleProp[]) => { // filter the style set to just objects (which might be arrays or plain style objects) - const inputs = styles.filter((s) => typeof s === 'object') as object[]; + const inputs = styles.filter((s) => s !== null && typeof s === 'object'); // now memo the results if there is more than one element or if the one element is an array return inputs.length > 1 || (inputs.length === 1 && Array.isArray(inputs[0])) ? _styleCache(() => mergeAndFlattenStyles(undefined, ...inputs), inputs)[0] : inputs[0] || {}; -} +}; diff --git a/packages/framework-base/src/merge-props/mergeStyles.types.ts b/packages/framework-base/src/merge-props/mergeStyles.types.ts deleted file mode 100644 index 5a2e3b38560..00000000000 --- a/packages/framework-base/src/merge-props/mergeStyles.types.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * This is a copy of the react-native style prop type, copied here to avoid RN dependencies for web clients - */ -type Falsy = undefined | null | false | '' | 0; -type RecursiveArray = readonly (T | RecursiveArray)[] | (T | RecursiveArray)[]; -/** Keep a brand of 'T' so that calls to `StyleSheet.flatten` can take `RegisteredStyle` and return `T`. */ -type RegisteredStyle = number & { __registeredStyleBrand: T }; - -export type StyleProp = T | RegisteredStyle | RecursiveArray | Falsy> | Falsy; diff --git a/packages/framework-base/src/utilities/baseTypes.ts b/packages/framework-base/src/utilities/baseTypes.ts new file mode 100644 index 00000000000..38cefe36f53 --- /dev/null +++ b/packages/framework-base/src/utilities/baseTypes.ts @@ -0,0 +1,28 @@ +/** + * This is a copy of the react-native style prop type, copied here to avoid RN dependencies this early in the dependency tree. + */ +type Falsy = undefined | null | false | ''; +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +interface RecursiveArray extends Array | RecursiveArray> {} +/** Keep a brand of 'T' so that calls to `StyleSheet.flatten` can take `RegisteredStyle` and return `T`. */ +type RegisteredStyle = number & { __registeredStyleBrand: T }; +export type StyleProp = T | RegisteredStyle | RecursiveArray | Falsy> | Falsy; + +/** + * This is the baseline for acceptance object types, meaning for T extends ObjectBase. The options here + * are: + * - {} an empty object, which works but is a bit too loose for general use + * - Record which is fine with types but doesn't work with + * interfaces as they have no implicit index signature + * - object which is the built in object type, slightly stricter than {} but still allows for interfaces + * + * There's no perfect option here but object is the best overall choice. + */ +export type ObjectBase = object; + +/** + * For fallback object types it is better to use the stricter Record type, as it + * is more likely to catch issues with unexpected properties and is still compatible with the + * ObjectBase type. + */ +export type ObjectFallback = Record; diff --git a/packages/framework-base/src/utilities/baseTypes.validate.ts b/packages/framework-base/src/utilities/baseTypes.validate.ts new file mode 100644 index 00000000000..3c5d6ae9d62 --- /dev/null +++ b/packages/framework-base/src/utilities/baseTypes.validate.ts @@ -0,0 +1,104 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/** + * Type validation that the base types behave as expected. This code is never run and is not included + * in other files, but will cause build breaks if the types no longer behave as expected. + */ + +import type { StyleProp, ObjectBase, ObjectFallback } from './baseTypes.ts'; +import type { StyleProp as RNStyleProp } from 'react-native'; + +/** + * Validate that StyleProp is compatible with React Native's StyleProp type, as this is a critical part of our type system for styles and we want to ensure it remains compatible with RN's types. + */ +export type ValidateStyleProp = StyleProp extends RNStyleProp ? true : never; + +type StyleBase = { + color?: string; + fontSize?: number; +}; + +type TestProps = { + p1?: string; + p2?: number; + p3?: boolean; + style?: StyleProp; +}; + +const typeProps: TestProps = { + p1: 'string', + p2: 123, + p3: true, + style: { + color: 'red', + fontSize: 16, + }, +}; + +interface IStyleBase { + color?: string; + fontSize?: number; +} + +interface ITestProps { + p1?: string; + p2?: number; + p3?: boolean; + style?: StyleProp; +} + +const interfaceProps: ITestProps = { + p1: 'string', + p2: 123, + p3: true, + style: { + color: 'red', + fontSize: 16, + }, +}; + +export function validateBaseTypes() { + // This function is never called, but if the types of the base types change in a way that breaks compatibility with expected types, this will cause a build error and alert us to the issue. + + // Test that StyleProp is compatible with React Native's StyleProp type + const stylePropTest: ValidateStyleProp = true; + const stylePropTest2: ValidateStyleProp = true; + + // just using the values to stop typescript complaints + if (!stylePropTest || !stylePropTest2) { + throw new Error("StyleProp is not compatible with React Native's StyleProp type"); + } + + // Test that ObjectBase is compatible with object and Record + + const objectBaseTest1: ObjectBase = {}; + const objectBaseTest2: ObjectBase = { key: 'value' }; + const objectBaseTest3: ObjectBase = new Date(); + const objectBaseTest4: ObjectBase = typeProps; + const objectBaseTest5: ObjectBase = interfaceProps; + const objectBaseTest6: ObjectFallback = {}; + const objectBaseTest7: ObjectFallback = { key: 'value' }; + // @ts-expect-error - this should error because Date is not compatible with Record due to its properties not being string keys and unknown values + const objectBaseTest8: ObjectFallback = new Date(); + const objectBaseTest9: ObjectFallback = typeProps; + // @ts-expect-error - this should error because interfaceProps is not compatible with Record due to the style property being a StyleProp type which is not compatible with Record + const objectBaseTest10: ObjectFallback = interfaceProps; + + // cross assignment + const baseFromFallback: ObjectBase = objectBaseTest7; + // @ts-expect-error - this should error because ObjectFallback is not compatible with ObjectBase due to ObjectBase allowing for more types of objects than ObjectFallback + const fallbackFromBase: ObjectFallback = objectBaseTest2; + + return { + ...objectBaseTest1, + ...objectBaseTest2, + ...objectBaseTest3, + ...objectBaseTest4, + ...objectBaseTest5, + ...objectBaseTest6, + ...objectBaseTest7, + ...objectBaseTest8, + ...objectBaseTest9, + ...objectBaseTest10, + ...baseFromFallback, + }; +} diff --git a/packages/framework-base/src/utilities/filterProps.ts b/packages/framework-base/src/utilities/filterProps.ts index b3ee7bbe935..d78c5c003f4 100644 --- a/packages/framework-base/src/utilities/filterProps.ts +++ b/packages/framework-base/src/utilities/filterProps.ts @@ -1,13 +1,14 @@ -import { mergeProps } from '../merge-props/mergeProps'; +import { mergeProps } from '../merge-props/mergeProps.ts'; +import { isObject } from './typeUtils.ts'; export type PropsFilter = (propName: string) => boolean; export function filterProps(props: TProps, filter?: PropsFilter): TProps { - if (filter && typeof props === 'object' && !Array.isArray(props)) { - const propsToRemove = filter ? Object.keys(props).filter((key) => !filter(key)) : undefined; + if (filter && isObject(props)) { + const propsToRemove = filter ? Object.keys(props).filter((key) => !filter(key)) : []; if (propsToRemove?.length > 0) { const propsToRemoveObj = Object.fromEntries(propsToRemove.map((prop) => [prop, undefined])) as TProps; - return mergeProps(props, propsToRemoveObj); + return mergeProps(props, propsToRemoveObj); } } return props; diff --git a/packages/framework-base/src/utilities/mergeTypes.ts b/packages/framework-base/src/utilities/mergeTypes.ts new file mode 100644 index 00000000000..ae9d67e9b84 --- /dev/null +++ b/packages/framework-base/src/utilities/mergeTypes.ts @@ -0,0 +1,55 @@ +import type { StyleProp, ObjectFallback } from './baseTypes.ts'; + +/** + * Overloaded function types for an object merger, similar to Object.assign but with better type inference and support for + * undefined values. + */ +export type ObjectMerger = { + // T1 defined overloads + (o1: T1, ...objs: undefined[]): T1; + (o1: T1, o2: T2, ...objs: undefined[]): T1 & T2; + (o1: T1, o2: T2, o3: T3, ...objs: undefined[]): T1 & T2 & T3; + // T1 undefined overloads + (o1: undefined, o2: T2, ...objs: undefined[]): T2; + (o1: undefined, o2: T2, o3: T3, ...objs: undefined[]): T2 & T3; + // T2 undefined overload + (o1: T1, o2: undefined, o3: T3, ...objs: undefined[]): T1 & T3; + // rest overloads + (...objs: unknown[]): T | undefined; +}; + +/** + * Overloaded function types for an object merger that takes options, similar to Object.assign but with better type inference and support for + * undefined values, and with an options parameter to control merge behavior. + */ +export type ObjectMergerWithOptions = { + // T1 defined overloads + (opt: TOptions, o1: T1, ...objs: undefined[]): T1; + (opt: TOptions, o1: T1, o2: T2, ...objs: undefined[]): T1 & T2; + (opt: TOptions, o1: T1, o2: T2, o3: T3, ...objs: undefined[]): T1 & T2 & T3; + // T1 undefined overloads + (opt: TOptions, o1: undefined, o2: T2, ...objs: undefined[]): T2; + (opt: TOptions, o1: undefined, o2: T2, o3: T3, ...objs: undefined[]): T2 & T3; + // T2 undefined overload + (opt: TOptions, o1: T1, o2: undefined, o3: T3, ...objs: undefined[]): T1 & T3; + // rest overloads + (opt: TOptions, ...objs: unknown[]): T | undefined; +}; + +/** + * Overloaded function types for a style merger, which is similar to an object merger but specifically for merging styles that may be in the form of StyleProp types. + * This includes support for merging styles of different types, which is useful when merging token-based styles with React Native StyleProp types. + */ +export type StyleMerger = { + // T1 defined overloads + (o1: StyleProp, ...objs: undefined[]): T1; + (o1: StyleProp, o2: StyleProp, ...objs: undefined[]): T1 & T2; + (o1: StyleProp, o2: StyleProp, o3: StyleProp, ...objs: undefined[]): T1 & T2 & T3; + // T1 undefined overloads + (o1: StyleProp, o2: StyleProp, ...objs: undefined[]): T2; + (o1: StyleProp, o2: StyleProp, o3: StyleProp, ...objs: undefined[]): T2 & T3; + // T2 undefined overload + (o1: StyleProp, o2: StyleProp, o3: StyleProp, ...objs: undefined[]): T1 & T3; + // rest overloads + (...objs: unknown[]): T | undefined; +}; diff --git a/packages/framework-base/src/utilities/typeUtils.ts b/packages/framework-base/src/utilities/typeUtils.ts new file mode 100644 index 00000000000..02850270f2b --- /dev/null +++ b/packages/framework-base/src/utilities/typeUtils.ts @@ -0,0 +1,53 @@ +/** + * + */ +export type TypeofResult = 'undefined' | 'object' | 'boolean' | 'number' | 'string' | 'symbol' | 'bigint' | 'function'; +export type ExpandedTypeof = TypeofResult | 'array' | 'null'; + +/** + * Provide a more sensible type result that expands upon the built in typeof operator + * In particular this will differentiate arrays and nulls from standard objects + * @param val - value to check type + */ +export function getEntityType(val: unknown): ExpandedTypeof { + switch (typeof val) { + case 'object': + if (val === null) { + return 'null'; + } else if (Array.isArray(val)) { + return 'array'; + } + return 'object'; + default: + return typeof val as TypeofResult; + } +} + +/** + * Assertion function for types related to objects (objects with string keys and some value types). + * This is used to narrow down types in situations where we want to ensure we are working with a plain + * object and not something else (like an array or null). + * @param value some value of unknown type + * @returns an assertion that the value is an object with string keys and unknown values (not an array or null) + */ +export function isObject>(value: unknown): value is T { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Helper to split props into children and non-children props. + * @param props unknown props type object to split + * @returns a tuple of the non-children props and the children + */ +export function splitPropsAndChildren(props: TProps): [Omit, React.ReactNode] { + const { children, ...rest } = props as React.PropsWithChildren; + return [rest as Omit, children]; +} + +/** + * Helper to get the children from an unknown props type object. + */ +export function extractChildren(props: TProps): React.ReactNode { + const { children } = props as React.PropsWithChildren; + return children; +} diff --git a/packages/framework-base/tsconfig.json b/packages/framework-base/tsconfig.json index 1c424a0c1d4..ada7af9e8b4 100644 --- a/packages/framework-base/tsconfig.json +++ b/packages/framework-base/tsconfig.json @@ -1,9 +1,7 @@ { - "extends": "@fluentui-react-native/scripts/configs/tsconfig.json", + "extends": "@fluentui-react-native/config/tsconfig.strict.json", "compilerOptions": { - "outDir": "lib", - "allowJs": true, - "checkJs": true + "outDir": "lib" }, "include": ["src"] } diff --git a/packages/framework/use-slot/package.json b/packages/framework/use-slot/package.json index 64cbae3f298..1284c471014 100644 --- a/packages/framework/use-slot/package.json +++ b/packages/framework/use-slot/package.json @@ -41,6 +41,7 @@ "devDependencies": { "@babel/core": "catalog:", "@fluentui-react-native/babel-config": "workspace:*", + "@fluentui-react-native/config": "workspace:*", "@fluentui-react-native/eslint-config-rules": "workspace:*", "@fluentui-react-native/jest-config": "workspace:*", "@fluentui-react-native/kit-config": "workspace:*", diff --git a/packages/framework/use-slot/src/useSlot.test.tsx b/packages/framework/use-slot/src/useSlot.test.tsx index c37f28a5813..2ac27b80eed 100644 --- a/packages/framework/use-slot/src/useSlot.test.tsx +++ b/packages/framework/use-slot/src/useSlot.test.tsx @@ -121,6 +121,7 @@ describe('useSlot tests', () => { }); const tree2 = component2!.toJSON(); expect(tree2).toMatchSnapshot(); + // @ts-expect-error - we know the structure of the tree here and want to compare the text nodes directly, this is not a general pattern expect(tree1!['HeaderCaptionText1']).toEqual(tree2!['HeaderCaptionText2']); }); }); diff --git a/packages/framework/use-slot/src/useSlot.ts b/packages/framework/use-slot/src/useSlot.ts index 6052882938e..12e37894590 100644 --- a/packages/framework/use-slot/src/useSlot.ts +++ b/packages/framework/use-slot/src/useSlot.ts @@ -48,7 +48,7 @@ export function useSlot( const { propsToMerge, innerComponent } = slotData; if (propsToMerge) { // merge in props from phase one if they haven't been captured in the phased render - innerProps = mergeProps(propsToMerge, innerProps); + innerProps = mergeProps(propsToMerge, innerProps); } if (filter) { // filter the final props if a filter is specified diff --git a/packages/framework/use-slot/tsconfig.json b/packages/framework/use-slot/tsconfig.json index 89a07a88a93..ada7af9e8b4 100644 --- a/packages/framework/use-slot/tsconfig.json +++ b/packages/framework/use-slot/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@fluentui-react-native/scripts/configs/tsconfig.json", + "extends": "@fluentui-react-native/config/tsconfig.strict.json", "compilerOptions": { "outDir": "lib" }, diff --git a/packages/utils/adapters/package.json b/packages/utils/adapters/package.json index 055492ec06a..ec2b9d2add0 100644 --- a/packages/utils/adapters/package.json +++ b/packages/utils/adapters/package.json @@ -34,6 +34,7 @@ }, "devDependencies": { "@babel/core": "catalog:", + "@fluentui-react-native/config": "workspace:*", "@fluentui-react-native/eslint-config-rules": "workspace:*", "@fluentui-react-native/kit-config": "workspace:*", "@fluentui-react-native/react-configs": "workspace:*", diff --git a/packages/utils/adapters/src/filterProps.ts b/packages/utils/adapters/src/filterProps.ts index 30db1220afc..ae79d100976 100644 --- a/packages/utils/adapters/src/filterProps.ts +++ b/packages/utils/adapters/src/filterProps.ts @@ -1,6 +1,4 @@ import { getViewMask, getTextMask, getImageMask } from './filters'; -import type { ViewProps, TextProps, ImageProps } from 'react-native'; -import type { IFilterMask } from './filter.types'; /** * Filters props based on the provided mask. Each filter function is memoized to only compute the mask once, @@ -12,7 +10,7 @@ import type { IFilterMask } from './filter.types'; * @param propName - The name of the prop to check against the view mask */ export const filterViewProps = (() => { - let viewMask: IFilterMask | undefined; + let viewMask: Record | undefined; return (propName: string): boolean => { viewMask ??= getViewMask(); return Boolean(viewMask[propName]); @@ -24,7 +22,7 @@ export const filterViewProps = (() => { * @param propName - The name of the prop to check against the text mask */ export const filterTextProps = (() => { - let textMask: IFilterMask | undefined; + let textMask: Record | undefined; return (propName: string): boolean => { textMask ??= getTextMask(); return Boolean(textMask[propName]); @@ -36,7 +34,7 @@ export const filterTextProps = (() => { * @param propName - The name of the prop to check against the image mask */ export const filterImageProps = (() => { - let imageMask: IFilterMask | undefined; + let imageMask: Record | undefined; return (propName: string): boolean => { imageMask ??= getImageMask(); return Boolean(imageMask[propName]); diff --git a/packages/utils/adapters/tsconfig.json b/packages/utils/adapters/tsconfig.json index 89a07a88a93..ada7af9e8b4 100644 --- a/packages/utils/adapters/tsconfig.json +++ b/packages/utils/adapters/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@fluentui-react-native/scripts/configs/tsconfig.json", + "extends": "@fluentui-react-native/config/tsconfig.strict.json", "compilerOptions": { "outDir": "lib" }, diff --git a/packages/utils/interactive-hooks/package.json b/packages/utils/interactive-hooks/package.json index 1ba8bd5d65c..02d118e2605 100644 --- a/packages/utils/interactive-hooks/package.json +++ b/packages/utils/interactive-hooks/package.json @@ -39,6 +39,7 @@ "devDependencies": { "@babel/core": "catalog:", "@fluentui-react-native/babel-config": "workspace:*", + "@fluentui-react-native/config": "workspace:*", "@fluentui-react-native/eslint-config-rules": "workspace:*", "@fluentui-react-native/jest-config": "workspace:*", "@fluentui-react-native/kit-config": "workspace:*", diff --git a/packages/utils/interactive-hooks/src/__tests__/events.types.test.ts b/packages/utils/interactive-hooks/src/__tests__/events.types.test.ts index 6685f5b264b..8edae17b2a5 100644 --- a/packages/utils/interactive-hooks/src/__tests__/events.types.test.ts +++ b/packages/utils/interactive-hooks/src/__tests__/events.types.test.ts @@ -3,7 +3,7 @@ import type { AccessibilityActionEvent, GestureResponderEvent } from 'react-nati import { isAccessibilityActionEvent, isGestureResponderEvent, isKeyPressEvent } from '../events.types'; import type { KeyPressEvent } from '../useKeyProps.types'; -const createMockEvent = (nativeEvent) => { +const createMockEvent = (nativeEvent: Record) => { return { nativeEvent: nativeEvent, currentTarget: null, @@ -33,7 +33,7 @@ const createMockEvent = (nativeEvent) => { }; }; -const mockGestureEvent: GestureResponderEvent = createMockEvent({ +const mockGestureEvent = createMockEvent({ changedTouches: [], identifier: '', locationX: 0, @@ -43,15 +43,15 @@ const mockGestureEvent: GestureResponderEvent = createMockEvent({ target: '', timestamp: 0, touches: [], -}); +}) as unknown as GestureResponderEvent; -const mockKeyPressEvent: KeyPressEvent = createMockEvent({ +const mockKeyPressEvent = createMockEvent({ key: 'enter', -}); +}) as unknown as KeyPressEvent; -const mockAccessibilityEvent: AccessibilityActionEvent = createMockEvent({ +const mockAccessibilityEvent = createMockEvent({ actionName: 'longpress', -}); +}) as unknown as AccessibilityActionEvent; describe('InteractionEvent type guard tests', () => { it('has correct output from isGestureResponderEvent when input is type GestureResponderEvent', () => { diff --git a/packages/utils/interactive-hooks/src/useAsPressable.ts b/packages/utils/interactive-hooks/src/useAsPressable.ts index f69abece65a..5de2ba28d4f 100644 --- a/packages/utils/interactive-hooks/src/useAsPressable.ts +++ b/packages/utils/interactive-hooks/src/useAsPressable.ts @@ -8,11 +8,12 @@ import type { IHoverState, IFocusState, IWithPressableEvents, + IWithPartialPressableEvents, } from './useAsPressable.types'; +import type { BlurEvent, FocusEvent, MouseEvent, GestureResponderEvent } from 'react-native'; import type { PressableFocusProps, PressableHoverProps, PressablePressProps } from './usePressableState.types'; -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -type ObjectBase = {}; +type ObjectBase = object; /** * hover specific state and callback helper @@ -21,7 +22,7 @@ function useHoverHelper(props: PressableHoverProps): [PressableHoverProps, IHove const [hoverState, setHoverState] = React.useState({ hovered: false }); const onHoverIn = React.useCallback( - (e) => { + (e: MouseEvent) => { setHoverState({ hovered: true }); if (props.onHoverIn) { props.onHoverIn(e); @@ -31,7 +32,7 @@ function useHoverHelper(props: PressableHoverProps): [PressableHoverProps, IHove ); const onHoverOut = React.useCallback( - (e) => { + (e: MouseEvent) => { setHoverState({ hovered: false }); if (props.onHoverOut) { props.onHoverOut(e); @@ -48,7 +49,7 @@ function useHoverHelper(props: PressableHoverProps): [PressableHoverProps, IHove function useFocusHelper(props: PressableFocusProps): [PressableFocusProps, IFocusState] { const [focusState, setFocusState] = React.useState({ focused: false }); const onFocus = React.useCallback( - (e) => { + (e: FocusEvent) => { setFocusState({ focused: true }); if (props.onFocus) { props.onFocus(e); @@ -58,7 +59,7 @@ function useFocusHelper(props: PressableFocusProps): [PressableFocusProps, IFocu ); const onBlur = React.useCallback( - (e) => { + (e: BlurEvent) => { setFocusState({ focused: false }); if (props.onBlur) { props.onBlur(e); @@ -76,7 +77,7 @@ function usePressHelper(props: PressablePressProps): [PressablePressProps, IPres const [pressState, setPressState] = React.useState({ pressed: false }); const onPressIn = React.useCallback( - (e) => { + (e: GestureResponderEvent) => { setPressState({ pressed: true }); if (props.onPressIn) { props.onPressIn(e); @@ -86,7 +87,7 @@ function usePressHelper(props: PressablePressProps): [PressablePressProps, IPres ); const onPressOut = React.useCallback( - (e) => { + (e: GestureResponderEvent) => { setPressState({ pressed: false }); if (props.onPressOut) { props.onPressOut(e); @@ -103,7 +104,7 @@ function usePressHelper(props: PressablePressProps): [PressablePressProps, IPres * as each of these calls will create a new instance of the Pressability class. * @param props - input props for the component */ -export function useFocusState(props: IWithPressableOptions): [IWithPressableEvents, IFocusState] { +export function useFocusState(props: IWithPressableOptions): [IWithPartialPressableEvents, IFocusState] { const [focusProps, focusState] = useFocusHelper(props); return [{ ...props, ...usePressability({ ...props, ...focusProps }) }, focusState]; } @@ -113,7 +114,7 @@ export function useFocusState(props: IWithPressableOptions * as each of these calls will create a new instance of the Pressability class. * @param props - input props for the component */ -export function usePressState(props: IWithPressableOptions): [IWithPressableEvents, IPressState] { +export function usePressState(props: IWithPressableOptions): [IWithPartialPressableEvents, IPressState] { const [pressProps, pressState] = usePressHelper(props); return [{ ...props, ...usePressability({ ...props, ...pressProps }) }, pressState]; } @@ -123,7 +124,7 @@ export function usePressState(props: IWithPressableOptions * as each of these calls will create a new instance of the Pressability class. * @param props - input props for the component */ -export function useHoverState(props: IWithPressableOptions): [IWithPressableEvents, IHoverState] { +export function useHoverState(props: IWithPressableOptions): [IWithPartialPressableEvents, IHoverState] { const [hoverProps, hoverState] = useHoverHelper(props); return [{ ...props, ...usePressability({ ...props, ...hoverProps }) }, hoverState]; } @@ -139,7 +140,7 @@ export function useAsPressable(props: IWithPressableOption const pressabilityProps = usePressability({ ...props, ...hoverProps, ...focusProps, ...pressProps }); return { - props: { ...props, ...pressabilityProps }, + props: { ...props, ...pressabilityProps } as IWithPressableEvents, state: { ...hoverState, ...focusState, ...pressState }, }; } diff --git a/packages/utils/interactive-hooks/src/useAsPressable.types.ts b/packages/utils/interactive-hooks/src/useAsPressable.types.ts index 981b6ee9261..88b0cc7221a 100644 --- a/packages/utils/interactive-hooks/src/useAsPressable.types.ts +++ b/packages/utils/interactive-hooks/src/useAsPressable.types.ts @@ -1,7 +1,6 @@ import type { PressabilityConfig, EventHandlers } from './usePressability'; -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -type ObjectBase = {}; +type ObjectBase = object; export type IPressState = { pressed?: boolean; @@ -25,6 +24,8 @@ export type IWithPressableOptions = T & IPressableOptions; export type IWithPressableEvents = T & EventHandlers; +export type IWithPartialPressableEvents = T & Partial; + export type IPressableHooks = { props: IWithPressableEvents; state: IPressableState; diff --git a/packages/utils/interactive-hooks/src/useAsToggle.ts b/packages/utils/interactive-hooks/src/useAsToggle.ts index bc990e28a21..0c8d5216493 100644 --- a/packages/utils/interactive-hooks/src/useAsToggle.ts +++ b/packages/utils/interactive-hooks/src/useAsToggle.ts @@ -15,7 +15,7 @@ export type OnChangeCallback = () => void; * state.isChecked - Whether or not component is currently checked or selected */ export function useAsToggle(defaultChecked?: boolean, checked?: boolean, userCallback?: OnToggleCallback): [boolean, OnChangeCallback] { - const [isChecked, setChecked] = React.useState(defaultChecked ?? checked); + const [isChecked = false, setChecked] = React.useState(defaultChecked ?? checked); const onChange = React.useCallback(() => { userCallback && userCallback(!isChecked); diff --git a/packages/utils/interactive-hooks/src/useAsToggleWithEvent.ts b/packages/utils/interactive-hooks/src/useAsToggleWithEvent.ts index 3d6a081b4e6..80092cadd6d 100644 --- a/packages/utils/interactive-hooks/src/useAsToggleWithEvent.ts +++ b/packages/utils/interactive-hooks/src/useAsToggleWithEvent.ts @@ -22,7 +22,7 @@ export function useAsToggleWithEvent( checked?: boolean, userCallback?: OnToggleWithEventCallback, ): [boolean, OnChangeWithEventCallback] { - const [isChecked, setChecked] = useControllableValue(checked, defaultChecked); + const [isChecked = false, setChecked] = useControllableValue(checked, defaultChecked); const onChange = React.useCallback( (e: any) => { diff --git a/packages/utils/interactive-hooks/src/usePressability.ts b/packages/utils/interactive-hooks/src/usePressability.ts index 4ff8f0a5210..2099ca01f17 100644 --- a/packages/utils/interactive-hooks/src/usePressability.ts +++ b/packages/utils/interactive-hooks/src/usePressability.ts @@ -1,5 +1,6 @@ import type { PressableProps, GestureResponderEvent, BlurEvent, MouseEvent } from 'react-native'; +// @ts-expect-error - types are still in flow, we are explicitly creating a typed wrapper around this import usePressabilityBase from 'react-native/Libraries/Pressability/usePressability'; export type Rect = { diff --git a/packages/utils/interactive-hooks/src/usePressableState.ts b/packages/utils/interactive-hooks/src/usePressableState.ts index d35427b07f8..fe74ecf3ab2 100644 --- a/packages/utils/interactive-hooks/src/usePressableState.ts +++ b/packages/utils/interactive-hooks/src/usePressableState.ts @@ -11,6 +11,8 @@ import type { PressablePropsExtended, } from './usePressableState.types'; +import type { MouseEvent, FocusEvent, BlurEvent, GestureResponderEvent } from 'react-native'; + /** * hover specific state and callback helper */ @@ -19,7 +21,7 @@ export function useHoverHelper(props: PressableHoverProps): [PressableHoverProps const { onHoverIn, onHoverOut } = props; const _onHoverIn = React.useCallback( - (e) => { + (e: MouseEvent) => { setHoverState({ hovered: true }); onHoverIn?.(e); }, @@ -27,7 +29,7 @@ export function useHoverHelper(props: PressableHoverProps): [PressableHoverProps ); const _onHoverOut = React.useCallback( - (e) => { + (e: MouseEvent) => { setHoverState({ hovered: false }); onHoverOut?.(e); }, @@ -43,7 +45,7 @@ export function useFocusHelper(props: PressableFocusProps): [PressableFocusProps const [focusState, setFocusState] = React.useState({ focused: false }); const { onFocus, onBlur } = props; const _onFocus = React.useCallback( - (e) => { + (e: FocusEvent) => { setFocusState({ focused: true }); onFocus?.(e); }, @@ -51,7 +53,7 @@ export function useFocusHelper(props: PressableFocusProps): [PressableFocusProps ); const _onBlur = React.useCallback( - (e) => { + (e: BlurEvent) => { setFocusState({ focused: false }); onBlur?.(e); }, @@ -68,7 +70,7 @@ export function usePressHelper(props: PressablePressProps): [PressablePressProps const { onPressIn, onPressOut } = props; const _onPressIn = React.useCallback( - (e) => { + (e: GestureResponderEvent) => { setPressState({ pressed: true }); onPressIn?.(e); }, @@ -76,7 +78,7 @@ export function usePressHelper(props: PressablePressProps): [PressablePressProps ); const _onPressOut = React.useCallback( - (e) => { + (e: GestureResponderEvent) => { setPressState({ pressed: false }); onPressOut?.(e); }, diff --git a/packages/utils/interactive-hooks/tsconfig.json b/packages/utils/interactive-hooks/tsconfig.json index b2b278d2ca1..4706a2ee614 100644 --- a/packages/utils/interactive-hooks/tsconfig.json +++ b/packages/utils/interactive-hooks/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@fluentui-react-native/scripts/configs/tsconfig.json", + "extends": "@fluentui-react-native/config/tsconfig.strict.json", "compilerOptions": { "outDir": "lib", "lib": ["es2022", "dom"] diff --git a/yarn.lock b/yarn.lock index 9a16db481b9..8b8ab3ac31f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2660,6 +2660,7 @@ __metadata: resolution: "@fluentui-react-native/adapters@workspace:packages/utils/adapters" dependencies: "@babel/core": "catalog:" + "@fluentui-react-native/config": "workspace:*" "@fluentui-react-native/eslint-config-rules": "workspace:*" "@fluentui-react-native/kit-config": "workspace:*" "@fluentui-react-native/react-configs": "workspace:*" @@ -3188,6 +3189,14 @@ __metadata: languageName: unknown linkType: soft +"@fluentui-react-native/config@workspace:*, @fluentui-react-native/config@workspace:packages/config": + version: 0.0.0-use.local + resolution: "@fluentui-react-native/config@workspace:packages/config" + dependencies: + "@rnx-kit/tsconfig": "catalog:" + languageName: unknown + linkType: soft + "@fluentui-react-native/contextual-menu@workspace:*, @fluentui-react-native/contextual-menu@workspace:packages/components/ContextualMenu": version: 0.0.0-use.local resolution: "@fluentui-react-native/contextual-menu@workspace:packages/components/ContextualMenu" @@ -4280,6 +4289,7 @@ __metadata: resolution: "@fluentui-react-native/framework-base@workspace:packages/framework-base" dependencies: "@babel/core": "catalog:" + "@fluentui-react-native/config": "workspace:*" "@fluentui-react-native/eslint-config-rules": "workspace:*" "@fluentui-react-native/kit-config": "workspace:*" "@fluentui-react-native/react-configs": "workspace:*" @@ -4458,6 +4468,7 @@ __metadata: "@babel/core": "catalog:" "@fluentui-react-native/adapters": "workspace:*" "@fluentui-react-native/babel-config": "workspace:*" + "@fluentui-react-native/config": "workspace:*" "@fluentui-react-native/eslint-config-rules": "workspace:*" "@fluentui-react-native/framework-base": "workspace:*" "@fluentui-react-native/jest-config": "workspace:*" @@ -6007,6 +6018,7 @@ __metadata: dependencies: "@babel/core": "catalog:" "@fluentui-react-native/babel-config": "workspace:*" + "@fluentui-react-native/config": "workspace:*" "@fluentui-react-native/eslint-config-rules": "workspace:*" "@fluentui-react-native/framework-base": "workspace:*" "@fluentui-react-native/jest-config": "workspace:*"