Skip to content

Commit

Permalink
feat: implements custom JSX pragma
Browse files Browse the repository at this point in the history
  • Loading branch information
bsunderhus committed Apr 6, 2023
1 parent dea94a8 commit eb275e8
Show file tree
Hide file tree
Showing 19 changed files with 536 additions and 15 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "feat: implements custom JSX pragma",
"packageName": "@fluentui/react-jsx-runtime",
"email": "bernardo.sunderhus@gmail.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: ensure compatibility with custom JSX pragma",
"packageName": "@fluentui/react-utilities",
"email": "bernardo.sunderhus@gmail.com",
"dependentChangeType": "patch"
}
2 changes: 1 addition & 1 deletion packages/react-components/react-jsx-runtime/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"name": "@fluentui/react-jsx-runtime",
"version": "9.0.0-alpha.0",
"private": true,
"description": "React components for building web experiences",
"main": "lib-commonjs/index.js",
"module": "lib/index.js",
Expand Down Expand Up @@ -30,6 +29,7 @@
"@fluentui/scripts-tasks": "*"
},
"dependencies": {
"@fluentui/react-utilities": "^9.7.3",
"@swc/helpers": "^0.4.14"
},
"peerDependencies": {
Expand Down
4 changes: 2 additions & 2 deletions packages/react-components/react-jsx-runtime/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
// TODO: replace with real exports
export {};
export { jsx } from './jsx';
export { Fragment } from 'react';
18 changes: 18 additions & 0 deletions packages/react-components/react-jsx-runtime/src/jsx-dev-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/// <reference path="types.d.ts" />

import * as React from 'react';
import { jsx as createElement, extractChildrenFromProps } from './jsx';

export function jsxDEV<P extends {}>(
type: React.ElementType<P>,
props?: P | null,
key?: string,
isStaticChildren?: boolean,
source?: unknown,
self?: unknown,
): React.ReactElement<P> | 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';
24 changes: 24 additions & 0 deletions packages/react-components/react-jsx-runtime/src/jsx-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/// <reference path="types.d.ts" />

import * as React from 'react';
import { jsx as createElement, extractChildrenFromProps } from './jsx';

export function jsx<P extends {}>(
type: React.ElementType<P>,
props?: P | null,
key?: string,
): React.ReactElement<P> | null {
// extractChildrenFromProps is required since jsx signature differs from React.createElement signature
return createElement(type, props, ...extractChildrenFromProps(props));
}

export function jsxs<P extends {}>(
type: React.ElementType<P>,
props?: P | null,
key?: string,
): React.ReactElement<P> | null {
// extractChildrenFromProps is required since jsxs signature differs from React.createElement signature
return createElement(type, props, ...extractChildrenFromProps(props));
}

export { Fragment } from 'react/jsx-runtime';
84 changes: 84 additions & 0 deletions packages/react-components/react-jsx-runtime/src/jsx.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/* 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<TestComponentSlots>;
type TestComponentProps = ComponentProps<Partial<TestComponentSlots>> & {
getSlots: typeof getSlots;
};

const TestComponent = (props: TestComponentProps) => {
const state: TestComponentState = {
components: {
slot: 'div',
},
slot: resolveShorthand(
props.slot ?? {
children: (C, p) => (
<>
<div>before</div>
<C {...p} />
<div>after</div>
</>
),
},
{
required: true,
defaultProps: {
children: <div>this is internal children</div>,
},
},
),
};
const { slots, slotProps } = props.getSlots<TestComponentSlots>(state);
return <slots.slot {...slotProps.slot} />;
};

describe('jsx', () => {
it('should lose internal children while using getSlots', () => {
const result = render(<TestComponent getSlots={getSlots} />);
expect(result.container).toMatchInlineSnapshot(`
<div>
<div>
before
</div>
<div />
<div>
after
</div>
</div>
`);
});
it('should keep internal children while using getSlotsNext', () => {
const result = render(<TestComponent getSlots={getSlotsNext} />);
expect(result.container).toMatchInlineSnapshot(`
<div>
<div>
before
</div>
<div>
<div>
this is internal children
</div>
</div>
<div>
after
</div>
</div>
`);
});
});
89 changes: 89 additions & 0 deletions packages/react-components/react-jsx-runtime/src/jsx.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import * as React from 'react';
import {
SlotRenderFunction,
UnknownSlotProps,
SLOT_EXTERNAL_CHILDREN_SYMBOL,
SLOT_INTERNAL_CHILDREN_SYMBOL,
} from '@fluentui/react-utilities';

type WithMetadata<Props extends {}> = Props & {
[SLOT_EXTERNAL_CHILDREN_SYMBOL]: React.ReactNode | SlotRenderFunction<Props>;
[SLOT_INTERNAL_CHILDREN_SYMBOL]: React.ReactNode | SlotRenderFunction<Props>;
};

/**
* Equivalent to React.createElement but supporting v9 slot API
*/
export function jsx<P extends {}>(
type: React.ElementType<P>,
props?: P | null,
...children: React.ReactNode[]
): React.ReactElement<P> | null {
return isSlotComponent(props)
? jsxFromSlotComponent(type, props, ...children)
: React.createElement(type, props, ...children);
}

function jsxFromSlotComponent<Props extends UnknownSlotProps>(
type: React.ElementType<Props>,
propsWithMetadata: WithMetadata<Props>,
...overrideChildren: React.ReactNode[]
): React.ReactElement<Props> | null {
const { children, renderFn, props } = normalizeChildren(propsWithMetadata, overrideChildren);
if (renderFn) {
return React.createElement(React.Fragment, {}, renderFn(type, { ...props, children })) as React.ReactElement<Props>;
}

return React.createElement<Props>(type, { ...props, children } as Props);
}

export function extractChildrenFromProps<Props extends React.PropsWithChildren<{}>>(
props?: Props | null,
): React.ReactNode[] {
if (!props) {
return [];
}
return Array.isArray(props.children) ? props.children : [props.children];
}

function normalizeChildren<Props extends UnknownSlotProps>(
propsWithMetadata: WithMetadata<Props>,
overrideChildren: React.ReactNode[] = [],
): {
props: Props;
children: React.ReactNode;
renderFn?: SlotRenderFunction<Props>;
} {
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<Props>) : 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 extends {}>(props?: Props | null): props is WithMetadata<Props> {
return Boolean(props?.hasOwnProperty(SLOT_INTERNAL_CHILDREN_SYMBOL));
}
31 changes: 31 additions & 0 deletions packages/react-components/react-jsx-runtime/src/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
declare module 'react/jsx-runtime' {
import type * as React from 'react';

export const Fragment: symbol;

export function jsx<P extends {}>(
type: React.ElementType<P>,
props?: P | null,
key?: string,
): React.ReactElement<P> | null;

export function jsxs<P extends {}>(
type: React.ElementType<P>,
props?: P | null,
key?: string,
): React.ReactElement<P> | null;
}
declare module 'react/jsx-dev-runtime' {
import type * as React from 'react';

export const Fragment: symbol;

export function jsxDEV<P extends {}>(
type: React.ElementType<P>,
props?: P | null,
key?: string,
isStaticChildren?: boolean,
source?: unknown,
self?: unknown,
): React.ReactElement<P> | null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ export function getSlots<R extends SlotPropsRecord>(state: ComponentState<R>): {
slotProps: ObjectSlotProps<R>;
};

// @public
export function getSlotsNext<R extends SlotPropsRecord>(state: ComponentState<R>): {
slots: Slots<R>;
slotProps: ObjectSlotProps<R>;
};

// @internal
export function getTriggerChild<TriggerChildProps>(children: TriggerProps<TriggerChildProps>['children']): (React_2.ReactElement<Partial<TriggerChildProps>> & {
ref?: React_2.Ref<any>;
Expand Down Expand Up @@ -143,6 +149,12 @@ export type Slot<Type extends keyof JSX.IntrinsicElements | React_2.ComponentTyp
} & WithSlotRenderFunction<IntrinsicElementProps<As>>;
}[AlternateAs] | null : 'Error: First parameter to Slot must not be not a union of types. See documentation of Slot type.';

// @internal
export const SLOT_EXTERNAL_CHILDREN_SYMBOL: unique symbol;

// @internal
export const SLOT_INTERNAL_CHILDREN_SYMBOL: unique symbol;

// @public
export type SlotClassNames<Slots> = {
[SlotName in keyof Slots]-?: string;
Expand Down Expand Up @@ -175,6 +187,11 @@ export type TriggerProps<TriggerChildProps = unknown> = {
children?: React_2.ReactElement | ((props: TriggerChildProps) => React_2.ReactElement | null) | null;
};

// @public
export type UnknownSlotProps = Pick<React_2.HTMLAttributes<HTMLElement>, 'children' | 'className' | 'style'> & {
as?: keyof JSX.IntrinsicElements;
};

// @internal
export const useControllableState: <State>(options: UseControllableStateOptions<State>) => [State, React_2.Dispatch<React_2.SetStateAction<State>>];

Expand Down
10 changes: 10 additions & 0 deletions packages/react-components/react-utilities/src/compose/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* @internal
* internal symbol used to keep defaultProps.children available
*/
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');
20 changes: 14 additions & 6 deletions packages/react-components/react-utilities/src/compose/getSlots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import type {
SlotPropsRecord,
SlotRenderFunction,
UnionToIntersection,
UnknownSlotProps,
} from './types';
import { SLOT_EXTERNAL_CHILDREN_SYMBOL, SLOT_INTERNAL_CHILDREN_SYMBOL } from './constants';

export type Slots<S extends SlotPropsRecord> = {
[K in keyof S]: ExtractSlotProps<S[K]> extends AsIntrinsicElement<infer As>
Expand All @@ -19,7 +21,7 @@ export type Slots<S extends SlotPropsRecord> = {
: React.ElementType<ExtractSlotProps<S[K]>>;
};

type ObjectSlotProps<S extends SlotPropsRecord> = {
export type ObjectSlotProps<S extends SlotPropsRecord> = {
[K in keyof S]-?: ExtractSlotProps<S[K]> extends AsIntrinsicElement<infer As>
? // For intrinsic element types, return the intersection of all possible
// element's props, to be compatible with the As type returned by Slots<>
Expand Down Expand Up @@ -68,10 +70,17 @@ function getSlot<R extends SlotPropsRecord, K extends keyof R>(
state: ComponentState<R>,
slotName: K,
): readonly [React.ElementType<R[K]> | null, R[K]] {
if (state[slotName] === undefined) {
const props = state[slotName];

if (props === undefined) {
return [null, undefined as R[K]];
}
const { children, as: asProp, ...rest } = state[slotName]!;

// 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 = (
state.components?.[slotName] === undefined || typeof state.components[slotName] === 'string'
Expand All @@ -89,8 +98,7 @@ function getSlot<R extends SlotPropsRecord, K extends keyof R>(
];
}

const shouldOmitAsProp = typeof slot === 'string' && state[slotName]?.as;
const slotProps = (shouldOmitAsProp ? omit(state[slotName]!, ['as']) : state[slotName]) as R[K];

const shouldOmitAsProp = typeof slot === 'string' && asProp;
const slotProps = (shouldOmitAsProp ? omit(props, ['as']) : (props as UnknownSlotProps)) as R[K];
return [slot, slotProps];
}
Loading

0 comments on commit eb275e8

Please sign in to comment.