Skip to content
This repository has been archived by the owner on Jul 15, 2021. It is now read-only.

Refactor Context usage for performance #26

Merged
merged 5 commits into from
Jul 10, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

### v0.0.8 (2019/7/10)

- Performance improvements when mounting and unmounting large numbers of Focusable components.

### v0.0.7 (2019/7/10)

- New `Focusable` props: `onBlur` and `onFocus`
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@xdproto/focus",
"version": "0.0.7",
"version": "0.0.8",
"description": "A React library for managing focus in TV apps",
"main": "dist/index.js",
"module": "es/index.js",
Expand Down
50 changes: 13 additions & 37 deletions src/focus-root.js
Original file line number Diff line number Diff line change
@@ -1,57 +1,33 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import FocusContext from './focus-context';
import createFocusTree from './focus-tree/create-focus-tree';
import focusLrud from './focus-lrud/focus-lrud';

function getFocusState(focusTreeRef) {
return {
...focusTreeRef.current.getState(),
focusTree: focusTreeRef.current,
createNode: focusTreeRef.current.createNode,
updateNode: focusTreeRef.current.updateNode,
destroyNode: focusTreeRef.current.destroyNode,
setFocus: focusTreeRef.current.setFocus,
};
}

export default function FocusRoot({ children, orientation, wrapping }) {
const focusTreeRef = useRef();

if (!focusTreeRef.current) {
focusTreeRef.current = createFocusTree({
orientation,
wrapping,
});
}

const [focusState, setFocusState] = useState(() =>
getFocusState(focusTreeRef)
);
const [providerValue] = useState(() => {
return {
focusTree: createFocusTree({
orientation,
wrapping,
}),
currentNodeId: 'root',
};
});

useEffect(() => {
// At this point in time, the focusOnMount component will have updated the focus tree. But –
// we haven't subscribed, so our mirrored state is stale. We manually sync it.
setFocusState(getFocusState(focusTreeRef));

// Now that we've manually synced the state, we can set up an automatic subscription to keep the state in order
// going forward.
const unsubscribe = focusTreeRef.current.subscribe(() =>
setFocusState(getFocusState(focusTreeRef))
);

const lrud = focusLrud(focusTreeRef.current);
const lrud = focusLrud(providerValue.focusTree);
lrud.subscribe();

return () => {
unsubscribe();
lrud.unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return React.createElement(
FocusContext.Provider,
{ value: focusState },
{ value: providerValue },
children
);
}
Expand Down
79 changes: 60 additions & 19 deletions src/focusable.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,23 @@ import FocusContext from './focus-context';

let uniqueValue = 0;

// Presently, the only impact a focus node has on the DOM is related to:
//
// - isFocused
// - isFocusedExact
// - disabled
//
// through the form of the class names. Therefore, we only update the state
// when one of these attributes change.
function checkIfUpdateIsNecessary(one = {}, two = {}) {
const focusChanged = Boolean(one.isFocused) !== Boolean(two.isFocused);
const focusExactChanged =
Boolean(one.isFocusedExact) !== Boolean(two.isFocusedExact);
const disabledChanged = Boolean(one.disabled) !== Boolean(two.disabled);

return focusChanged || focusExactChanged || disabledChanged;
}

export function Focusable(
{
className = '',
Expand Down Expand Up @@ -63,14 +80,16 @@ export function Focusable(
}

const parentProviderValue = useContext(FocusContext);
const [providerValue, setProviderValue] = useState(() => {
const [providerValue] = useState(() => {
return {
...parentProviderValue,
focusTree: parentProviderValue.focusTree,
currentNodeId: idRef.current,
};
});

const { nodes, createNode, destroyNode, updateNode } = parentProviderValue;
const focusTree = parentProviderValue.focusTree;
const { nodes } = focusTree.getState();
const { destroyNode, createNode, updateNode } = focusTree;

const possibleNode = nodes[idRef.current];
const hasNode = Boolean(possibleNode);
Expand All @@ -81,18 +100,24 @@ export function Focusable(
hasNodeRef.current = hasNode;
}, [hasNode]);

// TODO: verify that renders are as expected. Is it rendering too much?
useEffect(() => {
setProviderValue({
...parentProviderValue,
currentNodeId: idRef.current,
});
}, [parentProviderValue]);

const parentId = parentProviderValue.currentNodeId;

useImperativeHandle(ref, () => nodeRef.current);

// This node needs to be updated
const [node, setNode] = useState(() => {
return (
nodes[idRef.current] || {
id: idRef.current,
isFocused: false,
isFocusedExact: false,
children: null,
disabled: false,
activeChildIndex: null,
}
);
});

const createdRef = useRef(false);

if (!createdRef.current) {
Expand Down Expand Up @@ -125,14 +150,30 @@ export function Focusable(
});
}

const node = nodes[idRef.current] || {
id: idRef.current,
isFocused: false,
isFocusedExact: false,
children: null,
disabled: false,
activeChildIndex: null,
};
const focusNodeRef = useRef(node);
useEffect(() => {
focusNodeRef.current = node;
}, [node]);

useEffect(() => {
const unsubscribe = focusTree.subscribe(() => {
const state = focusTree.getState();
const newNode = state.nodes[idRef.current] || focusNodeRef.current;

if (checkIfUpdateIsNecessary(newNode, focusNodeRef.current)) {
// This ref is updated whenever `setNode` resolves, but there can be a delay
// between when that occurs. For that reason, we manually update it here to
// ensure that subsequent calls are using the _actual_ up-to-date node.
focusNodeRef.current = newNode;
setNode(newNode);
}
});

return () => {
unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const { isFocused, isFocusedExact } = node;

Expand Down
11 changes: 11 additions & 0 deletions src/hooks/use-current-ref.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useEffect, useRef } from 'react';

export default function useCurrentRef(value) {
const currentRef = useRef(value);

useEffect(() => {
currentRef.current = value;
}, [value]);

return currentRef;
}
58 changes: 52 additions & 6 deletions src/use-is-focused.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,61 @@
import { useState, useContext, useEffect } from 'react';
import FocusContext from './focus-context';
import useCurrentRef from './hooks/use-current-ref';

function calculateFocus({ focusId, exact, focusTree }) {
const currentState = focusTree.getState();
const node = currentState.nodes[focusId] || {};
const focused = exact ? node.isFocusedExact : node.isFocused;

return Boolean(focused);
}

export default function useIsFocused(focusId, { exact = false } = {}) {
const [isFocused, setIsFocused] = useState(false);
const { nodes } = useContext(FocusContext);
const { focusTree } = useContext(FocusContext);

const exactRef = useCurrentRef(exact);

const [isFocused, setIsFocused] = useState(() => {
return calculateFocus({
focusId,
exact: exactRef.current,
focusTree,
});
});

useEffect(() => {
const node = nodes[focusId] || {};
const focusState = exact ? node.isFocusedExact : node.isFocused;
setIsFocused(Boolean(focusState));
}, [setIsFocused, nodes, exact, focusId]);
const focus = calculateFocus({
focusId,
focusTree,
exact: exactRef.current,
});

let currentFocus = focus;

// This check is here in the event that the mounting of the component affects the focus state
// (a useEffect called before this hook could have changed focus before the subscribe below is called)
if (isFocused !== currentFocus) {
setIsFocused(currentFocus);
}

const unsubscribe = focusTree.subscribe(() => {
const focus = calculateFocus({
focusId,
exact: exactRef.current,
focusTree,
});

if (focus !== currentFocus) {
currentFocus = focus;
setIsFocused(focus);
}
});

return () => {
unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return isFocused;
}
5 changes: 2 additions & 3 deletions src/use-set-focus.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { useContext } from 'react';
import FocusContext from './focus-context';

export default function useSetFocus() {
const { setFocus } = useContext(FocusContext);

return setFocus;
const { focusTree } = useContext(FocusContext);
return focusTree.setFocus;
}