diff --git a/packages/react-components/react-jsx-runtime/etc/react-jsx-runtime.api.md b/packages/react-components/react-jsx-runtime/etc/react-jsx-runtime.api.md index a03816582a4a7..9ec8f93f3f9b8 100644 --- a/packages/react-components/react-jsx-runtime/etc/react-jsx-runtime.api.md +++ b/packages/react-components/react-jsx-runtime/etc/react-jsx-runtime.api.md @@ -7,10 +7,10 @@ import { Fragment } from 'react'; import * as React_2 from 'react'; -export { Fragment } +// @public (undocumented) +export function createElement

(type: React_2.ElementType

, props?: P | null, ...children: React_2.ReactNode[]): React_2.ReactElement

| null; -// @public -export function jsx

(type: React_2.ElementType

, props?: P | null, ...children: React_2.ReactNode[]): React_2.ReactElement

| null; +export { Fragment } // (No @packageDocumentation comment for this package) diff --git a/packages/react-components/react-jsx-runtime/src/createElement.test.tsx b/packages/react-components/react-jsx-runtime/src/createElement.test.tsx new file mode 100644 index 0000000000000..ee4e3221aa772 --- /dev/null +++ b/packages/react-components/react-jsx-runtime/src/createElement.test.tsx @@ -0,0 +1,168 @@ +/* eslint-disable jsdoc/check-tag-names */ +/** @jsxRuntime classic */ +/** @jsxFrag Fragment */ +/** @jsx createElement */ +/* eslint-enable jsdoc/check-tag-names */ + +import { render } from '@testing-library/react'; +import { ComponentProps, ComponentState, Slot, getSlotsNext, resolveShorthand } from '@fluentui/react-utilities'; +import { createElement } from './createElement'; + +describe('createElement', () => { + describe('general behavior tests', () => { + it('handles a string', () => { + const result = render(

Hello world
); + + expect(result.container.firstChild).toMatchInlineSnapshot(` +
+ Hello world +
+ `); + }); + + it('handles an array', () => { + const result = render( +
+ {Array.from({ length: 3 }, (_, i) => ( +
{i}
+ ))} +
, + ); + + expect(result.container.firstChild).toMatchInlineSnapshot(` +
+
+ 0 +
+
+ 1 +
+
+ 2 +
+
+ `); + }); + + it('handles an array of children', () => { + const result = render( +
+
1
+
2
+
, + ); + + expect(result.container.firstChild).toMatchInlineSnapshot(` +
+
+ 1 +
+
+ 2 +
+
+ `); + }); + }); + + describe('custom behavior tests', () => { + it('keeps children from "defaultProps" in a render callback', () => { + type TestComponentSlots = { slot: Slot<'div'> }; + type TestComponentState = ComponentState; + type TestComponentProps = ComponentProps>; + + const TestComponent = (props: TestComponentProps) => { + const state: TestComponentState = { + components: { slot: 'div' }, + + slot: resolveShorthand(props.slot, { + defaultProps: { children: 'Default Children', id: 'slot' }, + }), + }; + const { slots, slotProps } = getSlotsNext(state); + + return ; + }; + + const children = jest.fn().mockImplementation((Component, props) => ( +
+ +
+ )); + const result = render(); + + expect(children).toHaveBeenCalledTimes(1); + expect(children).toHaveBeenCalledWith('div', { children: 'Default Children', id: 'slot' }); + + expect(result.container.firstChild).toMatchInlineSnapshot(` +
+
+ Default Children +
+
+ `); + }); + + it('keeps children from a render template in a render callback', () => { + type TestComponentSlots = { outer: Slot<'div'>; inner: Slot<'div'> }; + type TestComponentState = ComponentState; + type TestComponentProps = ComponentProps>; + + const TestComponent = (props: TestComponentProps) => { + const state: TestComponentState = { + components: { inner: 'div', outer: 'div' }, + + inner: resolveShorthand(props.inner, { defaultProps: { id: 'inner' } }), + outer: resolveShorthand(props.outer, { defaultProps: { id: 'outer' } }), + }; + const { slots, slotProps } = getSlotsNext(state); + + return ( + + + + ); + }; + + const children = jest.fn().mockImplementation((Component, props) => ( +
+ +
+ )); + const result = render(); + + expect(children).toHaveBeenCalledTimes(1); + expect(children.mock.calls[0][0]).toBe('div'); + expect(children.mock.calls[0][1].id).toBe('outer'); + expect(children.mock.calls[0][1].children).toMatchInlineSnapshot(` + +
+ Inner children +
+
+ `); + + expect(result.container.firstChild).toMatchInlineSnapshot(` +
+
+
+ Inner children +
+
+
+ `); + }); + }); +}); diff --git a/packages/react-components/react-jsx-runtime/src/createElement.ts b/packages/react-components/react-jsx-runtime/src/createElement.ts new file mode 100644 index 0000000000000..a891075365f04 --- /dev/null +++ b/packages/react-components/react-jsx-runtime/src/createElement.ts @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { SlotRenderFunction, UnknownSlotProps, SLOT_RENDER_FUNCTION_SYMBOL } from '@fluentui/react-utilities'; + +type WithMetadata = Props & { + [SLOT_RENDER_FUNCTION_SYMBOL]: SlotRenderFunction; +}; + +export function createElement

( + type: React.ElementType

, + props?: P | null, + ...children: React.ReactNode[] +): React.ReactElement

| null { + if (!isSlotComponent(props)) { + return React.createElement(type, props, ...children); + } + + const result = normalizeRenderFunction(props, children); + return React.createElement( + React.Fragment, + {}, + result.renderFunction(type, { ...result.props, children: result.children }), + ) as React.ReactElement

; +} + +function normalizeRenderFunction( + propsWithMetadata: WithMetadata, + overrideChildren?: React.ReactNode[], +): { + props: Props; + children: React.ReactNode; + renderFunction: SlotRenderFunction; +} { + const { [SLOT_RENDER_FUNCTION_SYMBOL]: renderFunction, children: externalChildren, ...props } = propsWithMetadata; + + const children: React.ReactNode = + Array.isArray(overrideChildren) && overrideChildren.length > 0 + ? React.createElement(React.Fragment, {}, ...overrideChildren) + : externalChildren; + + return { + children, + renderFunction, + props: props as UnknownSlotProps as Props, + }; +} + +export function isSlotComponent(props?: Props | null): props is WithMetadata { + return Boolean(props?.hasOwnProperty(SLOT_RENDER_FUNCTION_SYMBOL)); +} diff --git a/packages/react-components/react-jsx-runtime/src/index.ts b/packages/react-components/react-jsx-runtime/src/index.ts index 388451abd3502..0276e539054a3 100644 --- a/packages/react-components/react-jsx-runtime/src/index.ts +++ b/packages/react-components/react-jsx-runtime/src/index.ts @@ -1,2 +1,2 @@ -export { jsx } from './jsx'; +export { createElement } from './createElement'; export { Fragment } from 'react'; diff --git a/packages/react-components/react-jsx-runtime/src/jsx-dev-runtime.ts b/packages/react-components/react-jsx-runtime/src/jsx-dev-runtime.ts deleted file mode 100644 index 85b668ae011b3..0000000000000 --- a/packages/react-components/react-jsx-runtime/src/jsx-dev-runtime.ts +++ /dev/null @@ -1,18 +0,0 @@ -/// - -import * as React from 'react'; -import { jsx as createElement, extractChildrenFromProps } from './jsx'; - -export function jsxDEV

( - type: React.ElementType

, - props?: P | null, - key?: string, - isStaticChildren?: boolean, - source?: unknown, - self?: unknown, -): React.ReactElement

| null { - // extractChildrenFromProps is required since jsxDev signature differs from React.createElement signature - return createElement(type, props, ...extractChildrenFromProps(props)); -} - -export { Fragment } from 'react/jsx-dev-runtime'; diff --git a/packages/react-components/react-jsx-runtime/src/jsx-runtime.ts b/packages/react-components/react-jsx-runtime/src/jsx-runtime.ts deleted file mode 100644 index bae0a1772ecdd..0000000000000 --- a/packages/react-components/react-jsx-runtime/src/jsx-runtime.ts +++ /dev/null @@ -1,24 +0,0 @@ -/// - -import * as React from 'react'; -import { jsx as createElement, extractChildrenFromProps } from './jsx'; - -export function jsx

( - type: React.ElementType

, - props?: P | null, - key?: string, -): React.ReactElement

| null { - // extractChildrenFromProps is required since jsx signature differs from React.createElement signature - return createElement(type, props, ...extractChildrenFromProps(props)); -} - -export function jsxs

( - type: React.ElementType

, - props?: P | null, - key?: string, -): React.ReactElement

| null { - // extractChildrenFromProps is required since jsxs signature differs from React.createElement signature - return createElement(type, props, ...extractChildrenFromProps(props)); -} - -export { Fragment } from 'react/jsx-runtime'; diff --git a/packages/react-components/react-jsx-runtime/src/jsx.test.tsx b/packages/react-components/react-jsx-runtime/src/jsx.test.tsx deleted file mode 100644 index f99decf9e4863..0000000000000 --- a/packages/react-components/react-jsx-runtime/src/jsx.test.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/* eslint-disable jsdoc/check-tag-names */ -/** @jsxRuntime classic */ -/** @jsxFrag Fragment */ -/** @jsx jsx */ -/* eslint-enable jsdoc/check-tag-names */ - -import { - ComponentProps, - ComponentState, - Slot, - getSlots, - getSlotsNext, - resolveShorthand, -} from '@fluentui/react-utilities'; -import { jsx, Fragment } from './index'; -import { render } from '@testing-library/react'; - -type TestComponentSlots = { slot: Slot<'div'> }; -type TestComponentState = ComponentState; -type TestComponentProps = ComponentProps> & { - getSlots: typeof getSlots | typeof getSlotsNext; -}; - -const TestComponent = (props: TestComponentProps) => { - const state: TestComponentState = { - components: { - slot: 'div', - }, - slot: resolveShorthand( - props.slot ?? { - children: (C, p) => ( - <> -

before
- -
after
- - ), - }, - { - required: true, - defaultProps: { - children:
this is internal children
, - }, - }, - ), - }; - const { slots, slotProps } = props.getSlots(state); - return ; -}; - -describe('jsx', () => { - it('should lose internal children while using getSlots', () => { - const result = render(); - expect(result.container).toMatchInlineSnapshot(` -
-
- before -
-
-
- after -
-
- `); - }); - it('should keep internal children while using getSlotsNext', () => { - const result = render(); - expect(result.container).toMatchInlineSnapshot(` -
-
- before -
-
-
- this is internal children -
-
-
- after -
-
- `); - }); -}); diff --git a/packages/react-components/react-jsx-runtime/src/jsx.ts b/packages/react-components/react-jsx-runtime/src/jsx.ts deleted file mode 100644 index 47811383c2e7b..0000000000000 --- a/packages/react-components/react-jsx-runtime/src/jsx.ts +++ /dev/null @@ -1,89 +0,0 @@ -import * as React from 'react'; -import { - SlotRenderFunction, - UnknownSlotProps, - SLOT_EXTERNAL_CHILDREN_SYMBOL, - SLOT_INTERNAL_CHILDREN_SYMBOL, -} from '@fluentui/react-utilities'; - -type WithMetadata = Props & { - [SLOT_EXTERNAL_CHILDREN_SYMBOL]: React.ReactNode | SlotRenderFunction; - [SLOT_INTERNAL_CHILDREN_SYMBOL]: React.ReactNode | SlotRenderFunction; -}; - -/** - * Equivalent to React.createElement but supporting v9 slot API - */ -export function jsx

( - type: React.ElementType

, - props?: P | null, - ...children: React.ReactNode[] -): React.ReactElement

| null { - return isSlotComponent(props) - ? jsxFromSlotComponent(type, props, ...children) - : React.createElement(type, props, ...children); -} - -function jsxFromSlotComponent( - type: React.ElementType, - propsWithMetadata: WithMetadata, - ...overrideChildren: React.ReactNode[] -): React.ReactElement | null { - const { children, renderFn, props } = normalizeChildren(propsWithMetadata, overrideChildren); - if (renderFn) { - return React.createElement(React.Fragment, {}, renderFn(type, { ...props, children })) as React.ReactElement; - } - - return React.createElement(type, { ...props, children } as Props); -} - -export function extractChildrenFromProps>( - props?: Props | null, -): React.ReactNode[] { - if (!props) { - return []; - } - return Array.isArray(props.children) ? props.children : [props.children]; -} - -function normalizeChildren( - propsWithMetadata: WithMetadata, - overrideChildren: React.ReactNode[] = [], -): { - props: Props; - children: React.ReactNode; - renderFn?: SlotRenderFunction; -} { - const { - [SLOT_EXTERNAL_CHILDREN_SYMBOL]: slotExternalChildren, - [SLOT_INTERNAL_CHILDREN_SYMBOL]: slotInternalChildren, - ...props - } = propsWithMetadata; - const isRenderFn = typeof slotExternalChildren === 'function'; - const notNormalizedChildren: React.ReactNode | React.ReactNode[] = - (overrideChildren.length > 0 ? overrideChildren : undefined) ?? - (isRenderFn ? slotInternalChildren : slotExternalChildren) ?? - slotInternalChildren ?? - propsWithMetadata.children; - const normalizedChildren = Array.isArray(notNormalizedChildren) - ? React.createElement(React.Fragment, {}, ...notNormalizedChildren) - : notNormalizedChildren; - const renderFn = isRenderFn ? (slotExternalChildren as SlotRenderFunction) : undefined; - if (isRenderFn) { - const { children: _, ...propsWithoutChildren } = props as UnknownSlotProps; - return { - children: normalizedChildren, - renderFn, - props: propsWithoutChildren as Props, - }; - } - return { - children: normalizedChildren, - renderFn, - props: props as UnknownSlotProps as Props, - }; -} - -export function isSlotComponent(props?: Props | null): props is WithMetadata { - return Boolean(props?.hasOwnProperty(SLOT_INTERNAL_CHILDREN_SYMBOL)); -} diff --git a/packages/react-components/react-jsx-runtime/src/types.d.ts b/packages/react-components/react-jsx-runtime/src/types.d.ts deleted file mode 100644 index 922fbea70b0a0..0000000000000 --- a/packages/react-components/react-jsx-runtime/src/types.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -declare module 'react/jsx-runtime' { - import type * as React from 'react'; - - export const Fragment: symbol; - - export function jsx

( - type: React.ElementType

, - props?: P | null, - key?: string, - ): React.ReactElement

| null; - - export function jsxs

( - type: React.ElementType

, - props?: P | null, - key?: string, - ): React.ReactElement

| null; -} -declare module 'react/jsx-dev-runtime' { - import type * as React from 'react'; - - export const Fragment: symbol; - - export function jsxDEV

( - type: React.ElementType

, - props?: P | null, - key?: string, - isStaticChildren?: boolean, - source?: unknown, - self?: unknown, - ): React.ReactElement

| null; -} diff --git a/packages/react-components/react-utilities/etc/react-utilities.api.md b/packages/react-components/react-utilities/etc/react-utilities.api.md index 985a481231f31..a03ffd720af38 100644 --- a/packages/react-components/react-utilities/etc/react-utilities.api.md +++ b/packages/react-components/react-utilities/etc/react-utilities.api.md @@ -150,10 +150,7 @@ export type Slot = { diff --git a/packages/react-components/react-utilities/src/compose/constants.ts b/packages/react-components/react-utilities/src/compose/constants.ts index 55e6c0a70eae3..f5c1593f9203a 100644 --- a/packages/react-components/react-utilities/src/compose/constants.ts +++ b/packages/react-components/react-utilities/src/compose/constants.ts @@ -1,10 +1,5 @@ /** * @internal - * internal symbol used to keep defaultProps.children available + * Internal reference for the render function */ -export const SLOT_INTERNAL_CHILDREN_SYMBOL = Symbol('fui.slotInternalChildren'); -/** - * @internal - * internal symbol used to keep slot.children available - */ -export const SLOT_EXTERNAL_CHILDREN_SYMBOL = Symbol('fui.slotExternalChildren'); +export const SLOT_RENDER_FUNCTION_SYMBOL = Symbol('fui.slotRenderFunction'); diff --git a/packages/react-components/react-utilities/src/compose/getSlots.ts b/packages/react-components/react-utilities/src/compose/getSlots.ts index 561b7af095746..2799542d26308 100644 --- a/packages/react-components/react-utilities/src/compose/getSlots.ts +++ b/packages/react-components/react-utilities/src/compose/getSlots.ts @@ -10,7 +10,6 @@ import type { UnionToIntersection, UnknownSlotProps, } from './types'; -import { SLOT_EXTERNAL_CHILDREN_SYMBOL, SLOT_INTERNAL_CHILDREN_SYMBOL } from './constants'; export type Slots = { [K in keyof S]: ExtractSlotProps extends AsIntrinsicElement @@ -76,10 +75,6 @@ function getSlot( return [null, undefined as R[K]]; } - // Symbols must be deleted to ensure new custom pragma won't recognize this as the new version element - delete (props as { [SLOT_EXTERNAL_CHILDREN_SYMBOL]: unknown })[SLOT_EXTERNAL_CHILDREN_SYMBOL]; - delete (props as { [SLOT_INTERNAL_CHILDREN_SYMBOL]: unknown })[SLOT_INTERNAL_CHILDREN_SYMBOL]; - const { children, as: asProp, ...rest } = props; const slot = ( diff --git a/packages/react-components/react-utilities/src/compose/resolveShorthand.test.tsx b/packages/react-components/react-utilities/src/compose/resolveShorthand.test.tsx index aa41fb44b50a5..4d564af249623 100644 --- a/packages/react-components/react-utilities/src/compose/resolveShorthand.test.tsx +++ b/packages/react-components/react-utilities/src/compose/resolveShorthand.test.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { resolveShorthand } from './resolveShorthand'; import type { Slot } from './types'; -import { SLOT_EXTERNAL_CHILDREN_SYMBOL, SLOT_INTERNAL_CHILDREN_SYMBOL } from './constants'; type TestProps = { slotA?: Slot<'div'>; @@ -18,8 +17,6 @@ describe('resolveShorthand', () => { expect(resolvedProps).toEqual({ children: 'hello', - [SLOT_EXTERNAL_CHILDREN_SYMBOL]: 'hello', - [SLOT_INTERNAL_CHILDREN_SYMBOL]: undefined, }); }); @@ -29,8 +26,6 @@ describe('resolveShorthand', () => { expect(resolvedProps).toEqual({ children:

hello
, - [SLOT_EXTERNAL_CHILDREN_SYMBOL]:
hello
, - [SLOT_INTERNAL_CHILDREN_SYMBOL]: undefined, }); }); @@ -40,8 +35,6 @@ describe('resolveShorthand', () => { expect(resolvedProps).toEqual({ children: 42, - [SLOT_EXTERNAL_CHILDREN_SYMBOL]: 42, - [SLOT_INTERNAL_CHILDREN_SYMBOL]: undefined, }); }); diff --git a/packages/react-components/react-utilities/src/compose/resolveShorthand.ts b/packages/react-components/react-utilities/src/compose/resolveShorthand.ts index 64ada767e217f..76b109240de9f 100644 --- a/packages/react-components/react-utilities/src/compose/resolveShorthand.ts +++ b/packages/react-components/react-utilities/src/compose/resolveShorthand.ts @@ -1,6 +1,6 @@ import { isValidElement } from 'react'; -import type { SlotShorthandValue, UnknownSlotProps } from './types'; -import { SLOT_EXTERNAL_CHILDREN_SYMBOL, SLOT_INTERNAL_CHILDREN_SYMBOL } from './constants'; +import type { SlotRenderFunction, SlotShorthandValue, UnknownSlotProps } from './types'; +import { SLOT_RENDER_FUNCTION_SYMBOL } from './constants'; export type ResolveShorthandOptions = Required extends true ? { required: true; defaultProps?: Props } @@ -25,7 +25,9 @@ export const resolveShorthand: ResolveShorthandFunction = (value, options) => { return undefined; } - let resolvedShorthand = {} as UnknownSlotProps; + let resolvedShorthand: UnknownSlotProps & { + [SLOT_RENDER_FUNCTION_SYMBOL]?: SlotRenderFunction; + } = {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any if (typeof value === 'string' || typeof value === 'number' || Array.isArray(value) || isValidElement(value)) { @@ -34,10 +36,15 @@ export const resolveShorthand: ResolveShorthandFunction = (value, options) => { resolvedShorthand = value; } - return { + resolvedShorthand = { ...defaultProps, ...resolvedShorthand, - [SLOT_EXTERNAL_CHILDREN_SYMBOL]: resolvedShorthand.children, - [SLOT_INTERNAL_CHILDREN_SYMBOL]: defaultProps?.children, }; + + if (typeof resolvedShorthand.children === 'function') { + resolvedShorthand[SLOT_RENDER_FUNCTION_SYMBOL] = resolvedShorthand.children as SlotRenderFunction; + resolvedShorthand.children = defaultProps?.children; + } + + return resolvedShorthand; }; diff --git a/packages/react-components/react-utilities/src/index.ts b/packages/react-components/react-utilities/src/index.ts index a95032278b593..c2d0023253e0b 100644 --- a/packages/react-components/react-utilities/src/index.ts +++ b/packages/react-components/react-utilities/src/index.ts @@ -3,8 +3,7 @@ export { getSlotsNext, resolveShorthand, isResolvedShorthand, - SLOT_EXTERNAL_CHILDREN_SYMBOL, - SLOT_INTERNAL_CHILDREN_SYMBOL, + SLOT_RENDER_FUNCTION_SYMBOL, } from './compose/index'; export type { ExtractSlotProps, diff --git a/tsconfig.base.json b/tsconfig.base.json index ba88b1270f996..b994a894ae86f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -48,7 +48,6 @@ "@fluentui/react-infobutton": ["packages/react-components/react-infobutton/src/index.ts"], "@fluentui/react-input": ["packages/react-components/react-input/src/index.ts"], "@fluentui/react-jsx-runtime": ["packages/react-components/react-jsx-runtime/src/index.ts"], - "@fluentui/react-jsx-runtime/jsx-runtime": ["packages/react-components/react-jsx-runtime/src/jsx-runtime.ts"], "@fluentui/react-label": ["packages/react-components/react-label/src/index.ts"], "@fluentui/react-link": ["packages/react-components/react-link/src/index.ts"], "@fluentui/react-menu": ["packages/react-components/react-menu/src/index.ts"],