tree: 99aa65c2f628c03f092a804402b9efcbdddd310a [path history] [tgz]
  1. ducks/
  2. file_key.ts
  3. for_tests.ts
  4. README.md
  5. state.ts
  6. store.ts
ui/file_manager/file_manager/state/README.md

Files app state management

This folder contains the data model and the APIs to manage the state for the whole Files app.

Deps

Allowed: This folder can make use of standard Web APIs and private APIs.

Disallowed: This folder shouldn't depend on the UI layer, like widgets/.

Debug

Run fileManager.store_.setDebug(true) in the DevTools console to enable store debugging, which will show verbose logs for each action dispatched and state change, it will also show each selector emitted.

Run fileManager.store_.setDebug(false) to turn it off.

Store

The Store is central place to host the whole app state and to subscribe/listen to updates to the state.

The Store is a singleton shared throughout the app.

The Store class in store.ts is merely enforcing the data type specific to Files app. The store implementation itself is in lib/base_store.ts.

The store requires an initial state and a collection of slices to be constructed.

Slice

Slices are instances of the class Slice, each representing an entry directly under the root state object. For example, if the store's state shape is:

{
  cat: CatData;
  dog: DogData;
}

Then one should expect this store to be constructed with a cat and a dog slice, each focused on its own slice of the store.

Slices are instantiated with the shape of the store and their own shape, specified through templates. Additionally, they take a name (which should match the key for its entry under the root state), which is mostly used to automatically prefix their actions for debugging. For example, a slice might be instantiated as such:

new Slice<State, State["search"]>("search");

Each slice holds a collection of reducers related to the shape of the state they own.

When reducers are added to slices, they return action factories that can be used for:

  1. Dispatching the action to the store; and
  2. Registering reducers in other slices with the same action type.

Ducks

Ducks is a pattern of organizing parts of the store used by Files app.

The idea behind ducks is relatively simple: collocate actions, action factories, reducers, selectors, and action producers as much as possible according to the slice of the store they are more closely related to.

In practice, this translates into creating one file per Slice instance and grouping all those files under a ducks/ directory.

Action

An Action is an event that gets captured by one or more reducers registered in the store and contains the necessary information to update the app's state.

At its core, an Action is just a plain object, with a type property to uniquely identify the type of that Action. We enforce the shape of actions by creating action factories through the use of Slice::addReducer().

For example, an Action to change the current directory might look like:

{
  type: 'CHANGE_DIRECTORY',
  payload: {
    newDirectory: '-- URL to the directory --',
  },
}

Naming actions

When naming actions, it is a good idea to think of them as events rather than commands or setters. Remember that multiple reducers can intercept and handle the same action. Naming actions as events makes them more scalable as we're not tying a specific behavior to their name.

Further reading:

https://redux.js.org/style-guide/#model-actions-as-events-not-setters

Action factory

Action factories are callable objects returned by Slice::addReducer().

Action factories can be used to dispatch actions of the type specified by Slice::addReducer, e.g.:

store.dispatch(updateDeviceConnectionState());

They can also be used to register reducers for the same action type in other slices, e.g.:

In ducks/device.ts:

export const updateDeviceConnectionState = slice.addReducer(
  'set-connection-state',
  updateDeviceConnectionStateReducer
);

function updateDeviceConnectionStateReducer(
  currentState: State,
  payload: {
    connection: chrome.fileManagerPrivate.DeviceConnectionState;
  }
): State {
  // ...
}

In ducks/volume.ts:

slice.addReducer(
  updateDeviceConnectionState.type,
  updateDeviceConnectionStateReducer
);

function updateDeviceConnectionStateReducer(
  currentState: State,
  payload: GetActionFactoryPayload<typeof updateDeviceConnectionState>
): State {
  // ...
}

Raw actions vs action factories

Avoid creating actions directly and instead use action factories to ensure your dispatched actions are always correctly typed.

Reducers

Reducers are functions that take the current state and an Action and perform the actual change to the current state, returning a new state (never mutating the state object it receives).

Actions Producer

Actions Producers (AP) are async generator functions, that call asynchronous APIs and yield Actions to the Store.

Each AP should be wrapped in a concurrency model to guarantee that the execution is still valid in-between each async API call.

See more details in the internal design doc: http://go/files-app-store-async-concurrency

Selectors

Selectors are functions that efficiently notify parts of the app about specific changes in the state tree. In other words, they “select” a part of the state tree and provide non-redundant updates about changes to that part.

Both the store and slices come with default selectors that are available immediately after they are instantiated. The store's default selector (the root selector) can be used to get updates on changes to the root state object, and the slice default selectors on changes to the respective slice of the store they represent.

Combining selectors

Selectors can be combined using the combineXSelectors() functions, which work by taking a group of existing selectors and a “select” function that combines their outputs into a new output of interest. For example:

const newSelector = combine2Selectors(
      (state, numVisitors) => state.something + numVisitors,
      store.selector, numVisitorsSlice.selector);

Here, 2 in combine2Selectors refers to the number of input selectors used to create the new selector. Notice that the number of input selectors and the number of arguments passed to the “select” function should be the same.

General usage

To subscribe to new values emitted by a selector, call:

selector.subscribe(callback);

To get the last value emitted by a selector, call:

selector.get();

Usage with Lit elements

Selectors can be used in Lit components using the selector's createController() helper function. The created controller takes a reference to the component which is then used to automatically re-render the component whenever the selector emits a new value. It also becomes responsible for automatically unsubscribing from the selector when the component is destroyed. For example:

  @customElement('xf-test')
  class XfTest extends XfBase {
    testCtrl = testSelector.createController(this);

    override render() {
      return html`
      <div id="test">${this.testCtrl.value}</div>
    `;
    }
  }

State

The interface State in ../state/state.js describes the shape and types of the Files app state.

At runtime, the state is just a plain Object the interface is used throughout the code to guarantee that all the parts are managing the plain Object with correct types.

Adding new State

Every part of the State should be typed. While we still support Closure type check we maintain the type definition using @typedef in ../state/state.js which is understood by both Closure and TS compilers.

NOTE: To define a property as optional you should use the markup (!TypeName|undefined), because that's closest we can get for Closure and TS to understand the property as optional, TS still enforces the property to be explicitly assigned undefined.

The global State type is named State. You can add a new property to State or any of its sub-properties.

In this hypothetical example let's create a new feature to allow the user to save their UI settings (sort order and preferred view (list/grid)).

/** @enum {string} */
export const SortOrder = {
  ASC: 'ASCENDING',
  DESC: 'DESCENDING',
};

/** @enum {string} */
export const ColumnName = {
  NAME: 'NAME',
  SIZE: 'SIZE',
  DATE: 'DATE',
  TYPE: 'TYPE',
};

/**
 * @typedef {{
 *   order: !SortOrder,
 *   column: !ColumnName,
 * }}
 */
export let Sorting;

/**
 * Types of the view for the right-hand side of the Files app.
 * @enum {string }
 */
export const ViewType = {
  LIST: 'LIST-VIEW',
  GRID: 'GRID-VIEW',
}

/**
 * User's UI Settings.
 * @typedef {{
 *   sort: !Sorting,
 *   detailView: !ViewType,
 * }}
 */
export let UiSettings;

/**
 * @typedef {{
 *   ...
 *   uiSettings: !UiSettings,
 * }}
 */
export let State;

Adding new Slice

Actions are create by registering a new reducer to a slice via Slice::addReducer(). This guarantees that all actions are assigned to at least one reducer.

Let's continue the example of the UI Settings and create a slice with actions and reducers to update the uiSettings state:

const slice = new Slice<State, State['uiSettings']>('uiSettings');
export {slice as uiSettingsSlice};

export const changeSortingOrder = slice.addReducer('change-sort-order', (state: State, payload: {
    order: SortOrder,
    column: ColumnName,
  }) => ({
    ...state,
    uiSettings: {
      ...uiSettings,
      sort: {
        order: payload.order,
        column: payload.column,
      },
    },
  }));

export const switchDetailView = slice.addReducer('switch-detail-view', (state: State) => ({
    ...state,
    uiSettings: {
      ...uiSettings,
      detailView: state.uiSettings.detailView === ViewType.LIST ? ViewType.GRID :
                                                                  ViewType.LIST,
    },
  }));

In ./store.ts add the default empty state for the new data.

export function getEmptyState(): State {
  return {
    ...
    uiSettings: {
      sort: {
        order: SortOrder.ASC,
        column: ColumnName.NAME,
      },
      detailView: ViewType.LIST,
    },
  };
}

Register the new slice ui_settings.ts in ../file_names.gni.

ts_files = [
   ...
  "file_manager/state/ducks/ui_settings.ts",
]

Use the new actions in a container.

For example a hypothetical “toolbar_container.ts” handling the “switch-view” button and the “sort-button”.

import {switchDetailView} from '../state/ducks/ui_settings.js';

class ToolbarContainer {
  onSwitchButtonClick_(event) {
    this.store_.dispatch(switchDetailView());
  }

  onSortButtonClick_(event) {
    // Figure out the sorting order from the event.
    const sortOrder = event.target.dataset.order === 'descending' ?
        SortOrder.DESC :
        SortOrder.ASC;

    this.store_.dispatch(changeSortOrder(order: sortOrder));
  }
}

Add new Actions Producer

Continuing with the UI Settings state example, let's change the feature to persist this data to localStorage.

For Actions Producer (AP), we have to always think on the concurrency model that the feature requires.

For UI Settings we have 2 actions:

  1. Switch view: Which depends on the previous state to flip to a new state. So the Queue/Serialize model is a good fit.
  2. Change Sort Order: The Keep Latest is a good fit, because intermediary sort order aren't relevant, only the last option made by the user matters.

The State changes slightly from before, we add a status attribute, its type is the enum PropStatus.

In file ../state/state.js:

/**
 * @typedef {{
 *   order: !SortOrder,
 *   column: !ColumnName,
 *   status: !PropStatus,
 * }}
 */
export let Sorting;

/**
 * Types of the view for the right-hand side of the Files app.
 * @enum {string }
 */
export const ViewType = {
  LIST: 'LIST-VIEW',
  GRID: 'GRID-VIEW',
}

/**
 * @typedef {{
 *   view: !ViewType,
 *   status: !PropStatus,
 * }}
 */
export let DetailView;

/**
 * User's UI Settings.
 * @typedef {{
 *   sort: !Sorting,
 *   detailView: !DetailView,
 * }}
 */
export let UiSettings;

The action and the reducer changes to account for the new status.

export const changeSortingOrder = slice.addReducer('change-sort-order', (state: State, payload: {
    order: SortOrder,
    column: ColumnName,
    status: PropStatus,
  }) => ({
    ...state,
    uiSettings: {
      ...uiSettings,
      sort: {
        status: payload.status,
        order: payload.order,
        column: payload.column,
      },
    },
  }));

export const switchDetailView = slice.addReducer('switch-detail-view', (state: State: payload: {
    view: ViewType,
    status: PropStatus,
  }) => ({
    ...state,
    uiSettings: {
      ...uiSettings,
      detailView: {
        status: payload.status,
        view: payload.view,
      },
    },
  }));

For the Actions Producer, we create a new file: ./actions_producers/ui_settings.ts.

const VIEW_KEY = 'ui-settings-view-key';
const SORTING_KEY = 'ui-settings-sorting-key';

export async function*
    switchDetailViewProducer(): ActionsProducerGen {
  const state = getStore().getState();
  const viewType = state.uiSettings.detailView.view === ViewType.LIST ?
      ViewType.GRID :
      ViewType.LIST;

  yield switchDetailView(viewType, PropStatus.STARTED);

  await storage.local.setAsync({[VIEW_KEY]: viewType});

  yield switchDetailView(viewType, PropStatus.SUCCESS);
}

export const switchDetailView = serialize(switchDetailViewProducer);

let changeSortingOrderProducer = async function*(order: SortOrder, column: ColumnName):
        ActionsProducerGen {
  yield changeSortingOrder(order, column, PropStatus.STARTED);

  await storage.local.setAsync({[SORTING_KEY]: {order, column}});

  yield changeSortingOrder(order, column, PropStatus.SUCCESS);
}

changeSortingOrderProducer = keepLatest(changeSortingOrderProducer);

export changeSortingOrderProducer;

In the container side, we dispatch the Actions Producer in the same way we dispatch Actions.

We only use the AP wrapped by the concurrency model.

class ToolbarContainer {
  onSwitchButtonClick_(event) {
    this.store_.dispatch(switchDetailView());
  }

  onSortButtonClick_(event) {
    // Figure out the sorting order from the event.
    const sortOrder = event.target.dataset.order === 'descending' ?
        SortOrder.DESC :
        SortOrder.ASC;

    this.store_.dispatch(changeSortingOrder(order: sortOrder));
  }
}

Add a new selector

Continuing with our previous example, let's add a selector by combining the default selector provided to us by the slice we created.

The new selector indicates that either sort.order or detailView.view has changed:

const sortingOrder = combine1Selector(
      (uiSettings) => uiSettings.sort.order, uiSettingsSlice.selector);

const detailViewType = combine1Selector(
      (uiSettings) => uiSettings.detailView.view, uiSettingsSlice.selector);

// Will always emit a new object, but will only be triggered when either one of
// its parents (sortingOrder, detailViewType) have emitted a new value.
const sortingFinished = combine2Selectors(
      (order, detailView) => ({order, detailView}), sortingOrder, detailViewType);