Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: Batch Update For Trait #664

Open
MrWindlike opened this issue Jan 3, 2023 · 1 comment
Open

Proposal: Batch Update For Trait #664

MrWindlike opened this issue Jan 3, 2023 · 1 comment
Labels
feature New feature or request runtime about meta-ui runtime

Comments

@MrWindlike
Copy link
Contributor

Currently, the same trait would be executed multiple times in a React update cycle. If there are side effects in the trait, some unexpected results may occur. So, this Issue discusses how to solve this problem.

Phenomenon

Let’s take a look the example of this problem. Here are a log trait, it just log the params value when they change.

import { Type } from '@sinclair/typebox';
import { implementRuntimeTrait } from '../../utils/buildKit';
import { CORE_VERSION } from '@sunmao-ui/shared';

export const LogTraitPropertiesSpec = Type.Object({
  param1: Type.Any({
    title: 'Param 1',
  }),
  param2: Type.Any({
    title: 'Param 2',
  }),
});

export default implementRuntimeTrait({
  version: CORE_VERSION,
  metadata: {
    name: 'Log',
    description: '',
    isDataSource: true,
  },
  spec: {
    properties: LogTraitPropertiesSpec,
    state: Type.Any(),
    methods: [],
  },
})(() => {
  return ({ param1, param2 }) => {
    console.log(`log: param1(${param1}), param2(${param2})`);

    return {
      props: {},
    };
  };
});

We pass the {{state.value}} and the {{!state.value}} as the values of the param1 and the param2 . Here is the full App schema:

{
    "kind":"Application",
    "version":"example/v1",
    "metadata":{
        "name":"sunmao application",
        "description":"sunmao empty application"
    },
    "spec":{
        "components":[
            {
                "id":"state",
                "type":"core/v1/dummy",
                "properties":{

                },
                "traits":[
                    {
                        "type":"core/v1/state",
                        "properties":{
                            "key":"value",
                            "initialValue":"value"
                        }
                    }
                ]
            },
            {
                "id":"button",
                "type":"chakra_ui/v1/button",
                "properties":{
                    "text":{
                        "raw":"Button",
                        "format":"plain"
                    },
                    "isLoading":false,
                    "colorScheme":"blue"
                },
                "traits":[
                    {
                        "type":"core/v1/event",
                        "properties":{
                            "handlers":[
                                {
                                    "type":"onClick",
                                    "componentId":"state",
                                    "method":{
                                        "name":"setValue",
                                        "parameters":{
                                            "key":"value",
                                            "value":""
                                        }
                                    },
                                    "wait":{
                                        "type":"debounce",
                                        "time":0
                                    },
                                    "disabled":false
                                }
                            ]
                        }
                    },
                    {
                        "type":"core/v1/Log",
                        "properties":{
                            "param1":"{{state.value}}",
                            "param2":"{{!state.value}}"
                        }
                    }
                ]
            }
        ]
    }
}

After that we click the button to change the state ‘s value once, and then check the logs:

Log.tsx:28 log: param1(), param2(false)
Log.tsx:28 log: param1(), param2(true)

As we can see, it logs twice when the dependent state only changes once. If traits implement the logic of side effects, they can cause unexpected errors, which should not be expected.

Solutions

Batch Execute For The Trait

We should probably wait until all of the Trait's properties have been updated in one React update cycle before executing the Trait. This avoids the problem of executing the Trait multiple times in one React update cycle.

// ImplWrapperMain.tsx
const [traitPropertiesMap, setTraitPropertiesMap] = useState({});

// batch update the properties of traits
useEffect(() => {
  const stops: ReturnType<typeof watch>[] = [];
  const properties: Array<RuntimeTraitSchema['properties']> = [];

  c.traits.forEach((t, i) => {
    const { result, stop } = stateManager.deepEvalAndWatch(
      t.properties,
      ({ result: property }: any) => {
				setTraitPropertiesMap({
					set(traitPropertiesMap, t.type, property);
        })
      },
      {
        slotKey,
        fallbackWhenError: () => undefined,
      }
    );

    stops.push(stop);
    properties.push(result);
  });
	
	setTraitPropertiesMap(c.traits.reduce((result, trait, i)=> {
    result[trait.type] = properties[i];

    return result;
  }, {}));
  
  return () => stops.forEach(s => s());
}, [...]);

// execute traits after the properties changed 
useEffect(()=> {
	c.traits.forEach((t, i) => {
	  const traitResult = executeTrait(t, property);
	  setTraitResults(oldResults => {
	    // assume traits number and order will not change
	    const newResults = [...oldResults];
	    newResults[i] = traitResult;
	    return newResults;
	  });
	});
}, [...]);

But the problem with this solution is that it doesn't execute the Trait until the next iteration of React, which is a potentially risky change from the original mechanism.

Batch Execute For Side Effects

Maybe we don't need to do a batch update on the whole Trait, but only on the side effects. For example, we provide a batch execution method batchExecute for traits to perform side effects.

// trait
const { batchExecute } = props.services;

function sideEffect() {
  ...
}

batchExecute(sideEffect);
// ImplWrapperMain.tsx
const [batchExecuteMap, setBatchExecuteMap] = useState({});

const batchExecute = useCallback((trait, fn) {
  setBatchExecuteQueue((map)=> {
    if (map[trait.type]) return map;

    const newBatchExecuteMap = set(map, trait.type, fn);

    return newBatchExecuteMap;
  });
}, [...]);

useEffect(()=> {
	batchExecuteMap.forEach(fn=> fn());
  setBatchExecuteMap({});
}, [batchExecuteMap]);

Add traitPropertiesDidUpdated

Or we can provide a traitPropertiesDidUpdated lifecycle function to traits to perform side effects.

// ImplWrapperMain.tsx
const [traitPropertiesDidUpdatedHandlers, setTraitPropertiesDidUpdatedHandlers] = useState([]);

// eval traits' properties then execute traits
useEffect(() => {
  const stops: ReturnType<typeof watch>[] = [];
  const properties: Array<RuntimeTraitSchema['properties']> = [];

  c.traits.forEach((t, i) => {
    const { result, stop } = stateManager.deepEvalAndWatch(
      t.properties,
      ({ result: property }: any) => {
        const traitResult = executeTrait(t, property);

        ...
				if (traitResult.traitPropertiesDidUpdated) {
					setTraitPropertiesDidUpdatedHandlers((handlers)=> {
            handlers = handlers.concat(traitResult.traitPropertiesDidUpdated);

						return handlers;
          })
        }
      },
      {
        slotKey,
        fallbackWhenError: () => undefined,
      }
    );
    stops.push(stop);
    properties.push(result);
  });
  ...
}, [...]);

useEffect(() => {
  const clearFunctions = traitPropertiesDidUpdatedHandlers.map(fn => fn());

	setTraitPropertiesDidUpdatedHandlers([]);

  return () => {
    clearFunctions?.forEach(func => func && func());
  };
}, [traitPropertiesDidUpdatedHandlers]);

Don’t Change The Code In Runtime

The final solution is that we don't deal with this issue at runtime, but instead ask users to write traits without executing logic that has side effects. If the users need to perform side effects, they can write components to do so.

Conclusion

I prefer the third option, which is to provide a new lifecycle function traitPropertiesDidUpdated to traits.

The reasons are as follows:

The first solution solves the problem, but it changes the original implementation mechanism and has the possibility of causing the original project to go wrong unexpectedly.

The second option does not break the original mechanism, but providing a single function to handle side effects may increase the user's understanding cost.

The third, more intuitive solution is to provide a new lifecycle function that executes when a Trait property changes, allowing users to do anything in it, including side effects that they don't want to repeat.

The last one requires users to modify their own code and is unfriendly because it doesn't address the limitations of traits.

@MrWindlike MrWindlike added feature New feature or request runtime about meta-ui runtime labels Jan 3, 2023
@tanbowensg
Copy link
Collaborator

tanbowensg commented Jan 9, 2023

I prefer the first way although it is the hardest way. This problem not only occurs in trait, but also in component. If a component has multiple expressions which use the same state, it also will render multiple times.
The root cause is that we watch the changes in expression level, not in component property level. If we fix it as so, it will be a big change, which need more attention to see the influence.

  deepEvalAndWatch<T extends Record<string, unknown> | any[] | string>(
    value: T,
    watcher: (params: { result: EvaledResult<T> }) => void,
    options: EvalOptions = {}
  ) {
    const stops: ReturnType<typeof watch>[] = [];

    // just eval
    const evaluated = this.deepEval(value, options) as T extends string
      ? unknown
      : PropsAfterEvaled<Exclude<T, string>>;

    const store = this.slotStore;
    options.scopeObject = {
      ...options.scopeObject,
      $slot: options.slotKey ? store[options.slotKey] : undefined,
    };
    // watch change
    if (value && typeof value === 'object') {
      let resultCache = evaluated as PropsAfterEvaled<Exclude<T, string>>;

      this.mapValuesDeep(value, ({ value, path }) => {
        const isDynamicExpression =
          typeof value === 'string' &&
          parseExpression(value).some(exp => typeof exp !== 'string');

        if (!isDynamicExpression) return;

        const stop = watch(
          () => {
            const result = this._maskedEval(value as string, options);

            return result;
          },
          newV => {
            resultCache = produce(resultCache, draft => {
              set(draft, path, newV);
            });
            watcher({ result: resultCache as EvaledResult<T> });
          }
        );
        stops.push(stop);
      });

The third way will not break current code, but it seems too temporary. I think we'd better check how big influence the first way will bring. If it is not as big as we think, we should go with it. If it is really very big, then we can go the third way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature New feature or request runtime about meta-ui runtime
Projects
None yet
Development

No branches or pull requests

2 participants