Merge branch 'develop' into publish

* develop:
  fix(StateManager): fix the can't deep eval the nest array issues
  feat(runtime): add style for ErrorBoundary
  feat(runtime): add ErrorBoundary to ImplWrapper
  refactor(runtime): add slot receiver to app services
  refactor(runtime): temporary hack the list component with the new slot system
  refactor(runtime): remove slot props from event trait
  perf(runtime): implement slot receiver to avoid of re-render when passing fallback elements
  perf(runtime): refactor the slot's props and fallback implementation
  fix(SpaceWidget): fix padding display misalignment
This commit is contained in:
Bowen Tan 2022-08-26 17:22:59 +08:00
commit 4f301b7db7
26 changed files with 392 additions and 89 deletions

View File

@ -19,6 +19,7 @@ import {
ModuleRenderer,
implementRuntimeComponent,
ImplWrapper,
formatSlotKey,
} from '@sunmao-ui/runtime';
import { Type, Static } from '@sinclair/typebox';
import { ResizableTitle } from './ResizableTitle';
@ -178,6 +179,7 @@ export const Table = implementRuntimeComponent({
customStyle,
services,
component,
slotsElements,
} = props;
const ref = useRef<TableInstance | null>(null);
@ -418,6 +420,14 @@ export const Table = implementRuntimeComponent({
id: `${component.id}_${childSchema.id}_${index}`,
};
/**
* FIXME: temporary hack
*/
slotsElements.content?.({
[LIST_ITEM_EXP]: record,
[LIST_ITEM_INDEX_EXP]: index,
});
colItem = (
<ImplWrapper
key={_childrenSchema.id}
@ -427,9 +437,9 @@ export const Table = implementRuntimeComponent({
childrenMap={{}}
isInModule
evalListItem
slotProps={{
[LIST_ITEM_EXP]: record,
[LIST_ITEM_INDEX_EXP]: index,
slotContext={{
renderSet: new Set(),
slotKey: formatSlotKey(_childrenSchema.id, 'td', `td_${index}`),
}}
/>
);

View File

@ -34,6 +34,7 @@ export const TableImpl = implementTable(
services,
app,
elementRef,
slotsElements,
}) => {
const [selectedItem, setSelectedItem] = useState<Record<string, any> | undefined>();
const [selectedItems, setSelectedItems] = useState<Array<Record<string, any>>>([]);
@ -196,6 +197,7 @@ export const TableImpl = implementTable(
column={column}
services={services}
app={app}
slotsElements={slotsElements}
/>
))}
</Tr>

View File

@ -5,7 +5,7 @@ import {
PropsBeforeEvaled,
} from '@sunmao-ui/core';
import { Static } from '@sinclair/typebox';
import { ColumnSpec, ColumnsPropertySpec } from './TableTypes';
import { ColumnSpec, ColumnsPropertySpec, ContentSlotPropsSpec } from './TableTypes';
import { Button, Link, Td, Text } from '@chakra-ui/react';
import {
LIST_ITEM_EXP,
@ -14,6 +14,8 @@ import {
UIServices,
ExpressionError,
ImplWrapper,
SlotsElements,
formatSlotKey,
} from '@sunmao-ui/runtime';
export const TableTd: React.FC<{
@ -25,9 +27,23 @@ export const TableTd: React.FC<{
services: UIServices;
component: RuntimeComponentSchema;
app: RuntimeApplication;
slotsElements: SlotsElements<{
content: {
slotProps: typeof ContentSlotPropsSpec;
};
}>;
}> = props => {
const { item, index, component, column, rawColumns, onClickItem, services, app } =
props;
const {
item,
index,
component,
column,
rawColumns,
onClickItem,
services,
app,
slotsElements,
} = props;
const evalOptions = {
evalListItem: true,
scopeObject: {
@ -118,6 +134,14 @@ export const TableTd: React.FC<{
id: `${component.id}_${childSchema.id}_${index}`,
};
/**
* FIXME: temporary hack
*/
slotsElements.content?.({
[LIST_ITEM_EXP]: item,
[LIST_ITEM_INDEX_EXP]: index,
});
content = (
<ImplWrapper
key={_childrenSchema.id}
@ -127,9 +151,9 @@ export const TableTd: React.FC<{
childrenMap={{}}
isInModule
evalListItem
slotProps={{
[LIST_ITEM_EXP]: item,
[LIST_ITEM_INDEX_EXP]: index,
slotContext={{
renderSet: new Set(),
slotKey: formatSlotKey(_childrenSchema.id, 'td', `td_${index}`),
}}
/>
);

View File

@ -1,5 +1,10 @@
import { Type } from '@sinclair/typebox';
import { ModuleRenderSpec, EventCallBackHandlerSpec } from '@sunmao-ui/shared';
import {
ModuleRenderSpec,
EventCallBackHandlerSpec,
LIST_ITEM_EXP,
LIST_ITEM_INDEX_EXP,
} from '@sunmao-ui/shared';
import { BASIC, APPEARANCE, BEHAVIOR } from '../constants/category';
export const MajorKeyPropertySpec = Type.String({
@ -105,3 +110,8 @@ export const TableStateSpec = Type.Object({
selectedItem: Type.Optional(Type.Object({})),
selectedItems: Type.Array(Type.Object({})),
});
export const ContentSlotPropsSpec = Type.Object({
[LIST_ITEM_EXP]: Type.Any(),
[LIST_ITEM_INDEX_EXP]: Type.Number(),
});

View File

@ -1,9 +1,5 @@
import { Type } from '@sinclair/typebox';
import {
implementRuntimeComponent,
LIST_ITEM_EXP,
LIST_ITEM_INDEX_EXP,
} from '@sunmao-ui/runtime';
import { implementRuntimeComponent } from '@sunmao-ui/runtime';
import {
ColumnsPropertySpec,
DataPropertySpec,
@ -12,6 +8,7 @@ import {
TableStateSpec,
TableSizePropertySpec,
IsMultiSelectPropertySpec,
ContentSlotPropsSpec,
} from './TableTypes';
const PropsSpec = Type.Object({
@ -62,10 +59,7 @@ export const implementTable = implementRuntimeComponent({
methods: {},
slots: {
content: {
slotProps: Type.Object({
[LIST_ITEM_EXP]: Type.Any(),
[LIST_ITEM_INDEX_EXP]: Type.Number(),
}),
slotProps: ContentSlotPropsSpec,
},
},
styleSlots: [],

View File

@ -101,7 +101,7 @@ export default implementRuntimeComponent({
${customStyle?.tabContent}
`}
>
{slotsElements?.content?.({ tabIndex: idx }, placeholder)}
{slotsElements?.content?.({ tabIndex: idx }, placeholder, `content_${idx}`)}
</TabPanel>
);
})}

View File

@ -43,7 +43,7 @@ const paddingCfg = [
},
{
direction: 'right',
colStart: 2,
colStart: 4,
rowStart: 3,
},
{
@ -53,7 +53,7 @@ const paddingCfg = [
},
{
direction: 'left',
colStart: 4,
colStart: 2,
rowStart: 3,
},
];

View File

@ -1,9 +1,15 @@
import { css } from '@emotion/css';
import React from 'react';
type Props = {
onError?: (error: Error | null) => void;
};
const ErrorStyle = css`
white-space: pre-line;
font-family: monospace;
color: red;
`;
class ErrorBoundary extends React.Component<Props, { error: Error | null }> {
constructor(props: Props) {
super(props);
@ -24,7 +30,7 @@ class ErrorBoundary extends React.Component<Props, { error: Error | null }> {
render() {
if (this.state.error) {
return <div style={{ whiteSpace: 'pre-line' }}>{this.state.error.stack}</div>;
return <div className={ErrorStyle}>{this.state.error.stack}</div>;
}
return this.props.children;

View File

@ -158,4 +158,23 @@ describe('evalExpression function', () => {
stateManager.store.text.value = 'bye';
});
});
it('can deep deep eval the nest array', () => {
const stateManager = new StateManager();
stateManager.mute = true;
stateManager.store.text = { value: 'hello' };
const result = stateManager.deepEval({
data: [
[
{
value: '{{text.value}}',
},
],
],
});
expect(result.data[0][0].value).toBe('hello');
});
});

View File

@ -71,7 +71,9 @@ export default implementRuntimeComponent({
</div>
<div className="panels">
{tabNames.map((n, idx) => (
<div key={n}>{slotsElements?.content?.({ tabIndex: idx })}</div>
<div key={n}>
{slotsElements?.content?.({ tabIndex: idx }, undefined, `content_${idx}`)}
</div>
))}
</div>
</div>

View File

@ -0,0 +1,90 @@
import { css } from '@emotion/css';
import React from 'react';
type Props = {
componentId: string;
onRef: (ele: HTMLElement) => void;
onError?: (error: Error | null) => void;
onRecoverFromError: () => void;
};
const TitleCss = css`
font-weight: bold;
font-size: 16px;
`;
const ButtonStyle = css`
background: #3182ce;
color: white;
padding: 4px 8px;
border-radius: 4px;
`;
const ErrorStyle = css`
white-space: pre-line;
font-family: monospace;
color: red;
`;
export default class ComponentErrorBoundary extends React.Component<
Props,
{ error: Error | null; rerenderFlag: number }
> {
ref = React.createRef<HTMLDivElement>();
constructor(props: Props) {
super(props);
this.state = { error: null, rerenderFlag: 1 };
}
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidMount() {
if (this.ref.current) {
this.props.onRef(this.ref.current);
}
this.props.onError?.(null);
}
componentDidCatch(error: Error) {
this.props.onError?.(error);
}
componentDidUpdate() {
if (this.ref.current) {
this.props.onRef(this.ref.current);
}
}
onRerender = () => {
this.setState({ error: null }, () => {
// update the ref after rerender
if (this.ref.current) {
this.props.onRef(this.ref.current);
} else {
this.props.onRecoverFromError();
}
});
};
render() {
if (this.state.error) {
return (
<div ref={this.ref}>
<div>
<p className={TitleCss}>
Error occurred in component: {this.props.componentId}.
</p>
<button className={ButtonStyle} onClick={this.onRerender}>
Click here to Rerender
</button>
</div>
<div className={ErrorStyle}>{this.state.error.stack}</div>
</div>
);
}
return this.props.children;
}
}

View File

@ -2,7 +2,6 @@ import React from 'react';
import { ImplWrapperProps } from '../../../types';
import { shallowCompare } from '@sunmao-ui/shared';
import { UnmountImplWrapper } from './UnmountImplWrapper';
import { isEqual } from 'lodash';
export const ImplWrapper = React.memo<ImplWrapperProps>(
UnmountImplWrapper,
@ -18,12 +17,6 @@ export const ImplWrapper = React.memo<ImplWrapperProps>(
} else if (prevChildren === nextChildren) {
isComponentEqual = true;
}
return (
isComponentEqual &&
prevComponent === nextComponent &&
// TODO: keep ImplWrapper memorized and get slot props from store
shallowCompare(prevProps.slotProps, nextProps.slotProps) &&
isEqual(prevProps.slotContext, nextProps.slotContext)
);
return isComponentEqual && prevComponent === nextComponent;
}
);

View File

@ -8,11 +8,13 @@ import { getSlotElements } from './hooks/useSlotChildren';
import { useGlobalHandlerMap } from './hooks/useGlobalHandlerMap';
import { useEleRef } from './hooks/useEleMap';
import { initStateAndMethod } from '../../../utils/initStateAndMethod';
import ComponentErrorBoundary from '../ComponentErrorBoundary';
export const ImplWrapperMain = React.forwardRef<HTMLDivElement, ImplWrapperProps>(
function ImplWrapperMain(props, ref) {
const { component: c, children, evalListItem, slotProps } = props;
const { component: c, children, evalListItem, slotContext } = props;
const { registry, stateManager } = props.services;
const slotKey = slotContext?.slotKey || '';
const Impl = registry.getComponent(c.parsedType.version, c.parsedType.name).impl;
@ -24,7 +26,7 @@ export const ImplWrapperMain = React.forwardRef<HTMLDivElement, ImplWrapperProps
initStateAndMethod(registry, stateManager, [c]);
}
const { eleRef, onRef } = useEleRef(props);
const { eleRef, onRef, onRecoverFromError } = useEleRef(props);
const { mergeState, subscribeMethods, executeTrait } = useRuntimeFunctions(props);
@ -36,7 +38,7 @@ export const ImplWrapperMain = React.forwardRef<HTMLDivElement, ImplWrapperProps
t,
stateManager.deepEval(t.properties, {
evalListItem,
scopeObject: { $slot: slotProps },
slotKey,
fallbackWhenError: () => undefined,
})
)
@ -67,7 +69,7 @@ export const ImplWrapperMain = React.forwardRef<HTMLDivElement, ImplWrapperProps
},
{
evalListItem,
scopeObject: { $slot: slotProps },
slotKey,
fallbackWhenError: () => undefined,
}
);
@ -78,7 +80,7 @@ export const ImplWrapperMain = React.forwardRef<HTMLDivElement, ImplWrapperProps
// because mergeState will be called during the first render of component, and state will change
setTraitResults(c.traits.map((trait, i) => executeTrait(trait, properties[i])));
return () => stops.forEach(s => s());
}, [c.id, c.traits, executeTrait, stateManager, slotProps, evalListItem]);
}, [c.id, c.traits, executeTrait, stateManager, evalListItem, slotKey]);
// reduce traitResults
const propsFromTraits: TraitResult<
@ -112,7 +114,7 @@ export const ImplWrapperMain = React.forwardRef<HTMLDivElement, ImplWrapperProps
stateManager.deepEval(c.properties, {
fallbackWhenError: () => undefined,
evalListItem,
scopeObject: { $slot: slotProps },
slotKey,
}),
propsFromTraits
);
@ -127,14 +129,14 @@ export const ImplWrapperMain = React.forwardRef<HTMLDivElement, ImplWrapperProps
{
evalListItem,
fallbackWhenError: () => undefined,
scopeObject: { $slot: slotProps },
slotKey,
}
);
// must keep this line, reason is the same as above
setEvaledComponentProperties({ ...result });
return stop;
}, [c.properties, stateManager, slotProps, evalListItem]);
}, [c.properties, stateManager, evalListItem, slotKey]);
useEffect(() => {
const clearFunctions = propsFromTraits?.componentDidMount?.map(e => e());
@ -167,7 +169,7 @@ export const ImplWrapperMain = React.forwardRef<HTMLDivElement, ImplWrapperProps
<Impl
ref={ref}
key={c.id}
{...omit(props, ['slotProps', 'slotContext'])}
{...omit(props, ['slotContext'])}
{...mergedProps}
slotsElements={slotElements}
mergeState={mergeState}
@ -178,10 +180,15 @@ export const ImplWrapperMain = React.forwardRef<HTMLDivElement, ImplWrapperProps
);
return (
<React.Fragment key={c.id}>
<ComponentErrorBoundary
key={c.id}
componentId={c.id}
onRef={onRef}
onRecoverFromError={onRecoverFromError}
>
{C}
{children}
</React.Fragment>
</ComponentErrorBoundary>
);
}
);

View File

@ -5,6 +5,7 @@ import { useRuntimeFunctions } from './hooks/useRuntimeFunctions';
import { initSingleComponentState } from '../../../utils/initStateAndMethod';
import { ImplWrapperProps, TraitResult } from '../../../types';
import { watch } from '../../..';
import { Receiver } from '../../../services/SlotReciver';
export const UnmountImplWrapper = React.forwardRef<HTMLDivElement, ImplWrapperProps>(
function UnmountImplWrapper(props, ref) {
@ -14,6 +15,8 @@ export const UnmountImplWrapper = React.forwardRef<HTMLDivElement, ImplWrapperPr
const renderCount = useRef(0);
renderCount.current++;
const slotKey = slotContext?.slotKey || '';
const unmountTraits = useMemo(
() =>
c.traits.filter(
@ -26,7 +29,7 @@ export const UnmountImplWrapper = React.forwardRef<HTMLDivElement, ImplWrapperPr
const results: TraitResult<ReadonlyArray<string>, ReadonlyArray<string>>[] =
unmountTraits.map(t => {
const properties = stateManager.deepEval(t.properties, {
scopeObject: { $slot: props.slotProps },
slotKey,
});
return executeTrait(t, properties);
});
@ -69,7 +72,7 @@ export const UnmountImplWrapper = React.forwardRef<HTMLDivElement, ImplWrapperPr
t.properties,
newValue => traitChangeCallback(t, newValue.result),
{
scopeObject: { $slot: props.slotProps },
slotKey,
}
);
traitChangeCallback(t, result);
@ -79,14 +82,7 @@ export const UnmountImplWrapper = React.forwardRef<HTMLDivElement, ImplWrapperPr
return () => {
stops.forEach(stop => stop());
};
}, [
c,
executeTrait,
unmountTraits,
stateManager,
traitChangeCallback,
props.slotProps,
]);
}, [c, executeTrait, unmountTraits, stateManager, traitChangeCallback, slotKey]);
// If a component is unmount, its state would be removed.
// So if it mount again, we should init its state again.
@ -97,7 +93,9 @@ export const UnmountImplWrapper = React.forwardRef<HTMLDivElement, ImplWrapperPr
if (isHidden && slotContext) {
slotContext.renderSet.delete(c.id);
if (slotContext.renderSet.size === 0) {
return <>{slotContext.fallback}</>;
return (
<Receiver slotKey={slotContext.slotKey} slotReceiver={services.slotReceiver} />
);
}
}
return !isHidden ? <ImplWrapperMain {...props} ref={ref} /> : null;

View File

@ -14,6 +14,15 @@ export function useEleRef(props: ImplWrapperProps) {
hooks?.didDomUpdate && hooks?.didDomUpdate();
};
// When component recover from error state, eleRef.current will not update automatically,
// so we need do it manually
const onRecoverFromError = () => {
if (eleRef.current && !isInModule) {
eleMap.set(c.id, eleRef.current);
}
hooks?.didDomUpdate && hooks?.didDomUpdate();
};
useEffect(() => {
// If a component is in module, it should not have mask, so we needn't set it
if (eleRef.current && !isInModule) {
@ -31,5 +40,6 @@ export function useEleRef(props: ImplWrapperProps) {
return {
eleRef,
onRef,
onRecoverFromError,
};
}

View File

@ -5,8 +5,9 @@ import { merge } from 'lodash';
import { HandlerMap } from '../../../../services/handler';
export function useRuntimeFunctions(props: ImplWrapperProps) {
const { component: c, services, slotProps, evalListItem } = props;
const { component: c, services, slotContext, evalListItem } = props;
const { stateManager, registry, globalHandlerMap } = services;
const slotKey = slotContext?.slotKey || '';
const mergeState = useCallback(
(partial: any) => {
@ -35,11 +36,11 @@ export function useRuntimeFunctions(props: ImplWrapperProps) {
mergeState,
subscribeMethods,
services,
slotProps,
slotKey,
evalListItem,
});
},
[c.id, evalListItem, mergeState, registry, services, slotProps, subscribeMethods]
[c.id, evalListItem, mergeState, registry, services, slotKey, subscribeMethods]
);
return {
mergeState,

View File

@ -2,34 +2,49 @@ import React from 'react';
import { SlotSpec } from '@sunmao-ui/core';
import { ImplWrapperProps, SlotsElements } from '../../../../types';
import { ImplWrapper } from '../ImplWrapper';
import { shallowCompare } from '@sunmao-ui/shared';
export function formatSlotKey(componentId: string, slot: string, key: string): string {
/**
* TODO: better naming strategy to avoid of conflicts
*/
return `${componentId}_${slot}${key ? `_${key}` : ''}`;
}
export function getSlotElements(
props: ImplWrapperProps & { children?: React.ReactNode }
): SlotsElements<Record<string, SlotSpec>> {
const { component: c, childrenMap } = props;
const { component: c, childrenMap, services } = props;
if (!childrenMap[c.id]) {
return {};
}
const slotElements: SlotsElements<Record<string, SlotSpec>> = {};
for (const slot in childrenMap[c.id]) {
const slotChildren = childrenMap[c.id][slot].map(child => {
return <ImplWrapper key={child.id} {...props} component={child} />;
});
slotElements[slot] = function getSlot(slotProps, slotFallback) {
slotElements[slot] = function getSlot(slotProps, slotFallback, key) {
const slotKey = formatSlotKey(c.id, slot, key!);
/**
* The shallow compare is just a heuristic optimization,
* feel free to improve it.
*/
if (!shallowCompare(services.stateManager.slotStore[slotKey], slotProps)) {
services.stateManager.slotStore[slotKey] = slotProps;
}
const slotContext = {
renderSet: new Set(childrenMap[c.id][slot].map(child => child.id)),
parentId: c.id,
slotProps: JSON.stringify(slotProps),
fallback: slotFallback,
slotKey,
};
const children = slotChildren.map(child =>
React.cloneElement(child, {
slotProps,
slotContext,
})
);
services.slotReceiver.emitter.emit(slotKey, slotFallback);
return children;
};
}

View File

@ -3,6 +3,7 @@ import { css } from '@emotion/css';
import { LIST_ITEM_EXP, LIST_ITEM_INDEX_EXP } from '../../constants';
import { implementRuntimeComponent } from '../../utils/buildKit';
import { ImplWrapper } from '../_internal/ImplWrapper';
import { formatSlotKey } from '../_internal/ImplWrapper/hooks/useSlotChildren';
const PropsSpec = Type.Object({
listData: Type.Array(Type.Record(Type.String(), Type.String()), {
@ -42,7 +43,7 @@ export default implementRuntimeComponent({
styleSlots: ['content'],
events: [],
},
})(({ listData, component, app, services, customStyle, elementRef }) => {
})(({ listData, component, app, services, customStyle, elementRef, slotsElements }) => {
if (!listData) {
return null;
}
@ -74,6 +75,18 @@ export default implementRuntimeComponent({
};
});
/**
* FIXME: temporary hack
*/
slotsElements.content?.(
{
[LIST_ITEM_EXP]: listItem,
[LIST_ITEM_INDEX_EXP]: i,
},
undefined,
`content_${i}`
);
const childrenEles = _childrenSchema.map(child => {
return (
<ImplWrapper
@ -84,9 +97,9 @@ export default implementRuntimeComponent({
childrenMap={{}}
isInModule
evalListItem
slotProps={{
[LIST_ITEM_EXP]: listItem,
[LIST_ITEM_INDEX_EXP]: i,
slotContext={{
renderSet: new Set(),
slotKey: formatSlotKey(component.id, 'content', `content_${i}`),
}}
/>
);

View File

@ -7,6 +7,7 @@ import { UtilMethodManager } from './services/UtilMethodManager';
import { AppHooks } from './types';
import { enableES5, setAutoFreeze } from 'immer';
import './style.css';
import { initSlotReceiver } from './services/SlotReciver';
// immer would make some errors when read the states, so we do these to avoid it temporarily
// ref: https://github.com/immerjs/immer/issues/916
@ -26,12 +27,14 @@ export function initSunmaoUI(props: SunmaoUIRuntimeProps = {}) {
const apiService = initApiService();
const utilMethodManager = new UtilMethodManager(apiService);
const eleMap = new Map<string, HTMLElement>();
const slotReceiver = initSlotReceiver();
const registry = initRegistry(
{
stateManager,
globalHandlerMap,
apiService,
eleMap,
slotReceiver,
},
utilMethodManager
);
@ -48,6 +51,7 @@ export function initSunmaoUI(props: SunmaoUIRuntimeProps = {}) {
globalHandlerMap,
apiService,
eleMap,
slotReceiver,
},
props.hooks,
props.isInEditor
@ -94,6 +98,7 @@ export {
StringUnion,
generateDefaultValueFromSpec,
} from '@sunmao-ui/shared';
export { formatSlotKey } from './components/_internal/ImplWrapper/hooks/useSlotChildren';
// TODO: check this export
export { watch } from './utils/watchReactivity';

View File

@ -0,0 +1,71 @@
/**
* The slot receiver is a magic hole of sunmao's runtime.
* In sunmao, we support pass props and fallback elements to a slot.
* But if we pass them as React component's props,
* it will cause re-render since most of them could not use a shallow equal checker.
*
* Also, in sunmao's runtime, we are not using a traditional React render mechanism.
* Instead, we keep most of the components not be rendered and only subscribe to related state updates.
*
* To continue with our design,
* we need a way to render slot's fallback elements without passing the elements as props.
* This is where the slot receiver comes.
* It contains a map and an event emitter, when a slot need render,
* it will attach the fallback elements to the map and send a signal via the emitter.
* When the Receiver component receive a signal, it will force render the fallback elements.
*/
import React, { useEffect, useState, useRef } from 'react';
import mitt from 'mitt';
export class SlotReceiver {
fallbacks: Partial<Record<string, React.ReactNode>> = {};
emitter = mitt<Record<string, React.ReactNode>>();
constructor() {
this.emitter.on('*', (slotKey: string, c: React.ReactNode) => {
// undefined means no fallback has been received yet
// null means the Receiver component start to hanle the events
if (this.fallbacks[slotKey] === undefined) {
this.fallbacks[slotKey] = c;
}
});
}
}
export function initSlotReceiver() {
return new SlotReceiver();
}
export const Receiver: React.FC<{ slotKey?: string; slotReceiver: SlotReceiver }> = ({
slotKey = '',
slotReceiver,
}) => {
const [, setForce] = useState(0);
const cRef = useRef<React.ReactNode>(slotReceiver.fallbacks[slotKey] || null);
useEffect(() => {
if (!slotKey) {
return;
}
const handler = (c: React.ReactNode) => {
if (slotReceiver.fallbacks[slotKey]) {
// release memory
slotReceiver.fallbacks[slotKey] = null;
}
cRef.current = c;
/**
* the event emitter fired during render process
* defer the setState to avoid React warning
*/
setTimeout(() => {
setForce(prev => prev + 1);
}, 0);
};
slotReceiver.emitter.on(slotKey, handler);
return () => {
slotReceiver.emitter.off(slotKey, handler);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slotKey]);
return <>{cRef.current}</>;
};

View File

@ -1,4 +1,4 @@
import { mapValues, isArray, isPlainObject, set } from 'lodash';
import { mapValues, isArray, set } from 'lodash';
import dayjs from 'dayjs';
import produce from 'immer';
import 'dayjs/locale/zh-cn';
@ -22,6 +22,7 @@ type EvalOptions = {
fallbackWhenError?: (exp: string) => any;
// when ignoreEvalError is true, the eval process will continue after error happens in nests expression.
ignoreEvalError?: boolean;
slotKey?: string;
};
type EvaledResult<T> = T extends string ? unknown : PropsAfterEvaled<Exclude<T, string>>;
@ -42,6 +43,7 @@ export type StateManagerInterface = InstanceType<typeof StateManager>;
export class StateManager {
store = reactive<Record<string, any>>({});
slotStore = reactive<Record<string, any>>({});
dependencies: Record<string, unknown>;
@ -141,11 +143,11 @@ export class StateManager {
return mapValues(obj, (val, key: string | number) => {
return isArray(val)
? val.map((innerVal, idx) => {
return isPlainObject(innerVal)
return innerVal && typeof innerVal === 'object'
? this.mapValuesDeep(innerVal, fn, path.concat(key, idx))
: fn({ value: innerVal, key, obj, path: path.concat(key, idx) });
})
: isPlainObject(val)
: val && typeof val === 'object'
? this.mapValuesDeep(val as unknown as T, fn, path.concat(key))
: fn({ value: val, key, obj, path: path.concat(key) });
}) as PropsAfterEvaled<T>;
@ -155,6 +157,20 @@ export class StateManager {
value: T,
options: EvalOptions = {}
): EvaledResult<T> {
const store = this.slotStore;
const redirector = new Proxy(
{},
{
get(_, prop) {
return options.slotKey ? store[options.slotKey][prop] : undefined;
},
}
);
options.scopeObject = {
...options.scopeObject,
$slot: redirector,
};
// just eval
if (typeof value !== 'string') {
return this.mapValuesDeep(value, ({ value }) => {
@ -180,6 +196,19 @@ export class StateManager {
? unknown
: PropsAfterEvaled<Exclude<T, string>>;
const store = this.slotStore;
const redirector = new Proxy(
{},
{
get(_, prop) {
return options.slotKey ? store[options.slotKey][prop] : undefined;
},
}
);
options.scopeObject = {
...options.scopeObject,
$slot: redirector,
};
// watch change
if (value && typeof value === 'object') {
let resultCache = evaluated as PropsAfterEvaled<Exclude<T, string>>;
@ -210,11 +239,13 @@ export class StateManager {
const stop = watch(
() => {
const result = this._maskedEval(value, options);
return result;
},
newV => {
watcher({ result: newV as EvaledResult<T> });
},
{
deep: true,
}
);
stops.push(stop);

View File

@ -21,14 +21,14 @@ export const generateCallback = (
rawHandlers: string | PropsBeforeEvaled<Static<typeof CallbackSpec>>,
index: number,
services: UIServices,
slotProps?: unknown,
slotKey: string,
evalListItem?: boolean
) => {
const { stateManager } = services;
const send = () => {
// Eval before sending event to assure the handler object is evaled from the latest state.
const evalOptions = {
scopeObject: { $slot: slotProps },
slotKey,
evalListItem,
};
const evaledHandlers = stateManager.deepEval(rawHandlers, evalOptions) as Static<
@ -73,7 +73,7 @@ export default implementRuntimeTrait({
state: {},
},
})(() => {
return ({ trait, handlers, services, slotProps, evalListItem }) => {
return ({ trait, handlers, services, evalListItem, slotKey }) => {
const callbackQueueMap: Record<string, Array<() => void>> = {};
const rawHandlers = trait.properties.handlers;
// setup current handlers
@ -84,14 +84,7 @@ export default implementRuntimeTrait({
callbackQueueMap[handler.type] = [];
}
callbackQueueMap[handler.type].push(
generateCallback(
handler,
rawHandlers,
Number(i),
services,
slotProps,
evalListItem
)
generateCallback(handler, rawHandlers, Number(i), services, slotKey, evalListItem)
);
}

View File

@ -85,6 +85,7 @@ export default implementRuntimeTrait({
subscribeMethods,
componentId,
disabled,
slotKey,
}) => {
const lazy = _lazy === undefined ? true : _lazy;
@ -157,7 +158,13 @@ export default implementRuntimeTrait({
const rawOnComplete = trait.properties.onComplete;
onComplete?.forEach((_, index) => {
generateCallback(onComplete[index], rawOnComplete, index, services)();
generateCallback(
onComplete[index],
rawOnComplete,
index,
services,
slotKey
)();
});
} else {
// TODO: Add FetchError class and remove console info
@ -174,7 +181,7 @@ export default implementRuntimeTrait({
const rawOnError = trait.properties.onError;
onError?.forEach((_, index) => {
generateCallback(onError[index], rawOnError, index, services)();
generateCallback(onError[index], rawOnError, index, services, slotKey)();
});
}
},
@ -193,7 +200,7 @@ export default implementRuntimeTrait({
const rawOnError = trait.properties.onError;
onError?.forEach((_, index) => {
generateCallback(onError[index], rawOnError, index, services)();
generateCallback(onError[index], rawOnError, index, services, slotKey)();
});
}
);

View File

@ -2,6 +2,7 @@ import { ApiService } from '../services/apiService';
import { GlobalHandlerMap } from '../services/handler';
import { RegistryInterface } from '../services/Registry';
import { StateManagerInterface } from '../services/StateManager';
import { SlotReceiver } from '../services/SlotReciver';
import { Application } from '@sunmao-ui/core';
export type UIServices = {
@ -10,6 +11,7 @@ export type UIServices = {
globalHandlerMap: GlobalHandlerMap;
apiService: ApiService;
eleMap: Map<string, HTMLElement>;
slotReceiver: SlotReceiver;
};
export type ComponentParamsFromApp = {

View File

@ -22,8 +22,7 @@ export type ImplWrapperProps<
isInModule: boolean;
app: RuntimeApplication;
evalListItem?: boolean;
slotProps?: unknown;
slotContext?: { renderSet: Set<string>; fallback?: React.ReactNode };
slotContext?: { renderSet: Set<string>; slotKey?: string };
} & ComponentParamsFromApp;
export type ComponentImplProps<
@ -77,7 +76,8 @@ type MergeState<T> = (partialState: Partial<T>) => void;
export type SlotsElements<U extends Record<string, SlotSpec>> = {
[K in keyof U]?: (
props: Static<U[K]['slotProps']>,
fallback?: React.ReactNode
fallback?: React.ReactNode,
key?: string
) => React.ReactNode;
};

View File

@ -26,7 +26,7 @@ export type TraitImpl<TProperties = any> = (
componentId: string;
services: UIServices;
evalListItem?: boolean;
slotProps?: unknown;
slotKey: string;
}
) => TraitResult<ReadonlyArray<string>, ReadonlyArray<string>>;