diff --git a/.changeset/nice-stamps-win.md b/.changeset/nice-stamps-win.md new file mode 100644 index 00000000000..ca22c6dd2ab --- /dev/null +++ b/.changeset/nice-stamps-win.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 +--- + +Change base furn packages to stricter 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/framework-base/src/component-patterns/phasedComponent.ts b/packages/framework-base/src/component-patterns/phasedComponent.ts index 6193707cd27..8614211e5d9 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 22358450714..ceef1ad405e 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.ts'; +import { extractChildren, splitPropsAndChildren } from '../utilities/typeUtils.ts'; 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 df68822103d..8079be58e66 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 4985beecbc9..0f579e34916 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..54f90e4f9e4 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.ts'; +import type { ObjectMerger, ObjectMergerWithOptions } from '../utilities/mergeTypes.ts'; + /** * 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 58f5af762e7..ee17d8e0fd9 100644 --- a/packages/framework-base/src/index.ts +++ b/packages/framework-base/src/index.ts @@ -4,7 +4,6 @@ export type { BuiltinRecursionHandlers, CustomRecursionHandler, MergeOptions, - ObjectBase, RecursionHandler, RecursionOption, } from './immutable-merge/Merge.ts'; @@ -15,7 +14,6 @@ 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.ts'; export { mergeStyles } from './merge-props/mergeStyles.ts'; export { mergeProps } from './merge-props/mergeProps.ts'; @@ -58,3 +56,8 @@ export type { FurnJSX } from './jsx-namespace.ts'; // general utilities 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 eaf12873a47..ca74351f4c7 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 a43b1ab8bc8..934e432b298 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 ae656cc8dde..b02cfd52675 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 index e8a4571f2a9..ddc67901d81 100644 --- a/packages/framework-base/src/merge-props/index.ts +++ b/packages/framework-base/src/merge-props/index.ts @@ -1,3 +1,2 @@ -export type { StyleProp } from './mergeStyles.types.ts'; export { mergeStyles } from './mergeStyles.ts'; export { mergeProps } from './mergeProps.ts'; diff --git a/packages/framework-base/src/merge-props/mergeProps.ts b/packages/framework-base/src/merge-props/mergeProps.ts index 2332b5037d4..0bd62a3f20e 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.ts'; import { immutableMergeCore, filterToObjects } from '../immutable-merge/Merge.ts'; +import type { ObjectMerger } from '../utilities/mergeTypes.ts'; import { mergeStyles } from './mergeStyles.ts'; @@ -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 3642e18910e..52201117741 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.ts'; -import type { StyleProp } from './mergeStyles.types.ts'; +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 75f1bc2d5d7..46b4c611a63 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.ts'; import { getMemoCache } from '../memo-cache/getMemoCache.ts'; +import type { StyleMerger } from '../utilities/mergeTypes.ts'; -import type { StyleProp } from './mergeStyles.types.ts'; +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..b93dd70140f --- /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> {} +/** 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 c1c86435ab5..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.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 b0dd41f41f6..7c5e7a85ebd 100644 --- a/packages/framework-base/tsconfig.json +++ b/packages/framework-base/tsconfig.json @@ -1,9 +1,7 @@ { - "extends": "@fluentui-react-native/scripts/tsconfig", + "extends": "@fluentui-react-native/scripts/tsconfig-strict", "compilerOptions": { "outDir": "lib", - "allowJs": true, - "checkJs": true, "rootDir": "src" }, "include": ["src"] diff --git a/packages/framework/use-slot/src/useSlot.test.tsx b/packages/framework/use-slot/src/useSlot.test.tsx index 7a6a839260b..49615739ef2 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 f35c32a1019..bbd71ec1bc8 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 83975437e4d..7c5e7a85ebd 100644 --- a/packages/framework/use-slot/tsconfig.json +++ b/packages/framework/use-slot/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@fluentui-react-native/scripts/tsconfig", + "extends": "@fluentui-react-native/scripts/tsconfig-strict", "compilerOptions": { "outDir": "lib", "rootDir": "src" 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 83975437e4d..7c5e7a85ebd 100644 --- a/packages/utils/adapters/tsconfig.json +++ b/packages/utils/adapters/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@fluentui-react-native/scripts/tsconfig", + "extends": "@fluentui-react-native/scripts/tsconfig-strict", "compilerOptions": { "outDir": "lib", "rootDir": "src" 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 db84d13a61d..b79aa31db74 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 @@ -22,7 +23,7 @@ function useHoverHelper(props: PressableHoverProps): [PressableHoverProps, IHove const { onHoverIn: onHoverInProp, onHoverOut: onHoverOutProp } = props; const onHoverIn = React.useCallback( - (e) => { + (e: MouseEvent) => { setHoverState({ hovered: true }); if (onHoverInProp) { onHoverInProp(e); @@ -32,7 +33,7 @@ function useHoverHelper(props: PressableHoverProps): [PressableHoverProps, IHove ); const onHoverOut = React.useCallback( - (e) => { + (e: MouseEvent) => { setHoverState({ hovered: false }); if (onHoverOutProp) { onHoverOutProp(e); @@ -50,7 +51,7 @@ function useFocusHelper(props: PressableFocusProps): [PressableFocusProps, IFocu const [focusState, setFocusState] = React.useState({ focused: false }); const { onBlur: onBlurProp, onFocus: onFocusProp } = props; const onFocus = React.useCallback( - (e) => { + (e: FocusEvent) => { setFocusState({ focused: true }); if (onFocusProp) { onFocusProp(e); @@ -60,7 +61,7 @@ function useFocusHelper(props: PressableFocusProps): [PressableFocusProps, IFocu ); const onBlur = React.useCallback( - (e) => { + (e: BlurEvent) => { setFocusState({ focused: false }); if (onBlurProp) { onBlurProp(e); @@ -79,7 +80,7 @@ function usePressHelper(props: PressablePressProps): [PressablePressProps, IPres const { onPressIn: onPressInProp, onPressOut: onPressOutProp } = props; const onPressIn = React.useCallback( - (e) => { + (e: GestureResponderEvent) => { setPressState({ pressed: true }); if (onPressInProp) { onPressInProp(e); @@ -89,7 +90,7 @@ function usePressHelper(props: PressablePressProps): [PressablePressProps, IPres ); const onPressOut = React.useCallback( - (e) => { + (e: GestureResponderEvent) => { setPressState({ pressed: false }); if (onPressOutProp) { onPressOutProp(e); @@ -106,7 +107,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]; } @@ -116,7 +117,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]; } @@ -126,7 +127,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]; } @@ -142,7 +143,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 7a93a3fb035..2b180c1a35d 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 83975437e4d..7c5e7a85ebd 100644 --- a/packages/utils/interactive-hooks/tsconfig.json +++ b/packages/utils/interactive-hooks/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@fluentui-react-native/scripts/tsconfig", + "extends": "@fluentui-react-native/scripts/tsconfig-strict", "compilerOptions": { "outDir": "lib", "rootDir": "src" diff --git a/yarn.lock b/yarn.lock index 045b2ee7eb8..8c723322e53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -339,7 +339,20 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.20.0, @babel/generator@npm:^7.25.0, @babel/generator@npm:^7.28.6, @babel/generator@npm:^7.29.0, @babel/generator@npm:^7.29.1, @babel/generator@npm:^7.7.2": +"@babel/generator@npm:^7.20.0, @babel/generator@npm:^7.25.0, @babel/generator@npm:^7.28.6, @babel/generator@npm:^7.7.2": + version: 7.28.6 + resolution: "@babel/generator@npm:7.28.6" + dependencies: + "@babel/parser": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" + jsesc: "npm:^3.0.2" + checksum: 10c0/162fa358484a9a18e8da1235d998f10ea77c63bab408c8d3e327d5833f120631a77ff022c5ed1d838ee00523f8bb75df1f08196d3657d0bca9f2cfeb8503cc12 + languageName: node + linkType: hard + +"@babel/generator@npm:^7.29.0, @babel/generator@npm:^7.29.1": version: 7.29.1 resolution: "@babel/generator@npm:7.29.1" dependencies: @@ -1954,7 +1967,17 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.24.7, @babel/types@npm:^7.25.2, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.5, @babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0, @babel/types@npm:^7.3.0, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.24.7, @babel/types@npm:^7.25.2, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.5, @babel/types@npm:^7.28.6, @babel/types@npm:^7.3.0, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": + version: 7.28.6 + resolution: "@babel/types@npm:7.28.6" + dependencies: + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.28.5" + checksum: 10c0/54a6a9813e48ef6f35aa73c03b3c1572cad7fa32b61b35dd07e4230bc77b559194519c8a4d8106a041a27cc7a94052579e238a30a32d5509aa4da4d6fd83d990 + languageName: node + linkType: hard + +"@babel/types@npm:^7.29.0": version: 7.29.0 resolution: "@babel/types@npm:7.29.0" dependencies: