Skip to content

Redux process of the Serlo editor

anbestCL edited this page Feb 6, 2023 · 1 revision

Redux process

What happens when we have a change in the editor?

We need to distinguish between temporary changes such as a new character in the text and final changes when the save button is pressed. For temporary changes we use a redux store, the final changes are sent to the database. The following describes the process related to the redux store.

Before delving into the redux logic, we need to explain the concept of StateType.

StateTypes

Plugins use a common API for their state, which provides helper functions to the plugin developer, which interact with the core correctly. The provided types are

  • scalar - represents a single value, (also string, boolean, and number exist for convenience)
  • serializedScalar - represents a value where the schema in the redux store and the database is different
  • child - represents another plugin or sub document
  • object - composes other StateTypes to an object
  • list - represents a collection of another StateType

For each StateType helper function, “getters” and “setters” are defined. For example, we consider the helper functions for a serializedScalar. This is a scalar equipped with a serializer which can serialize the state to be saved in the database or deserialize it to be used by the plugin.

export function serializedScalar<S, T>(
 initialState: T,
 serializer: Serializer<S, T>
): SerializedScalarStateType<S, T>

When we are editing in the editor, we might be typing a new article introduction. The content of a plugin is represented by a scalar and more specifically by serializedScalar. When a change happens in the content of a text plugin, this constitutes an AstChange (where Ast stands for Abstract Syntax Tree) and the editor state has to be updated:

if (isAstChange) {
       previousValue.current = newValue
      props.state.set({ value: newValue, selection: editor.selection })

The set function that is called is defined for each StateType, in our case the one of the SerializedScalar State Type.

init(state, onChange) {
     class SerializedScalarType {
       public get value(): T { # getter for the currently stored value
         return state
       }
       public set value(param: T) { # ??? scalar.value = 
         this.set(param)
       }
       public get() { # alternative synonymous API to get the currently stored value
         return state
       }
       public set(param: T | ((previousValue: T) => T)) { # expects a new value or an update function
         onChange((previousValue) => {
           if (typeof param === 'function') {
             const updater = param as (currentValue: T) => T
             return updater(previousValue)
           }
           return param
         })
       }
     }
     return new SerializedScalarType()

When a new character is entered, the set function is called to save the new content value. This triggers the onChange function defined by the SubDocumentEditor which “dispatches” (i.e. sends) the change to the redux store:

const onChange = (
     initial: StateUpdater<unknown>,
     executor?: StateExecutor<StateUpdater<unknown>>
   ) => {
     store.dispatch(
       change({
         id,
         state: {
           initial,
           executor,
         },
       })
     )
   }

initial refers to a synchronous state update while executor is an “await-like” asynchronous state update. In our example of a content change the initial updater will be used.

Store.dispatch warps the state, adds the scope (see the possible editor instantiations ) and dispatches the change action to the redux store (learn more about redux actions. This is the entry point to redux.

Within the redux store, the change action is first inputted to the changeSaga. Recall that sagas serve as a middleware to coordinate and trigger asynchronous actions (side effects). The pure actions are then resolved by the reducer. Now let’s have a closer look at the changeSaga:

The changeAction input looks like this:

{
  type: 'Change',
  payload: {
	id: 'FaQQEuUOqYW',
	state: {}
  },
  scope: 'main'
}

The first step in the saga is to retrieve the document on which the change happened using the id saved in the action’s payload and the action’s scope.

  const { id, state: stateHandler } = action.payload
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
  const document: SelectorReturnType<typeof getDocument> = yield select(
	scopeSelector(getDocument, action.scope),
	id
  )
  if (!document) return

In the next step, all actions that have been triggered are collected to be grouped together before being committed to the store. The call command returns a list of actions and the final state (value and selection).

  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
  const [actions, state]: [ReversibleAction[], unknown] = yield call(
	handleRecursiveInserts,
	action.scope,
	(helpers: StoreDeserializeHelpers) => {
  	return stateHandler.initial(document.state, helpers)
	}
  )

More specifically, handleRecursiveInserts is a saga that holds a list of documents (e.g. here "document" is a single "plugin state" which need to be inserted into the store. Consider the case when a box plugin is inserted into a rows plugin. Then we first need to add the box plugin which contains a text plugin as a child to represent the title and a rows plugin as its main content. The rows plugin has a single text plugin as a child. So before this plugin can be added we need to add 4 documents (= 4 plugins) to the store to process (pendingDocs) and create new documents

export function* handleRecursiveInserts(
  scope: string,
  act: (helpers: StoreDeserializeHelpers) => unknown,
  initialDocuments: { id: string; plugin: string; state?: unknown }[] = []
) {
  ...
  const pendingDocs: {
    id: string
    plugin: string
    state?: unknown
  }[] = initialDocuments
  const helpers: StoreDeserializeHelpers = {
    createDocument(doc) {
      pendingDocs.push(doc)
    },
  }

The createChange function generates two pure actions, one pure change action with the final state and the reverse action for undo/redo with the previous state before the change.

  function createChange(
	previousState: unknown,
	newState: unknown
  ): ReversibleAction<PureChangeAction, PureChangeAction> {
	return {
  	action: pureChange({ id, state: newState })(action.scope),
  	reverse: pureChange({ id, state: previousState })(action.scope),
	}
  }

The two actions are added to the list of actions to be processed.

  actions.push(createChange(document.state, state))

Commit groups the actions together and put instructs the middleware to schedule the dispatching of the grouped actions to the store.

  if (!stateHandler.executor) {
	yield put(commit(actions)(action.scope))

More precisely, commit calls the commitSaga defined for the store history. This creates a CommitAction of the form

{
  type: 'Commit',
  payload: [
    {
      action: {
        type: 'PureChange',
        payload: {...},
        scope: 'main'
      },
      reverse: {
        type: 'PureChange',
        payload: {...},
        scope: 'main'
      }
    }
  ],
  scope: 'main'
}

The commitSaga further calls the executeCommit saga to transform the CommitAction to a pureCommit that is dispatched to the store using put (see executeCommit saga for details). At this point the combine flag can be set to true to combine the actions so that they are reversed together with the previous actions when hitting Strg+Z(in this case the undoStack has to be recomputed).

Clone this wiki locally