Skip to content

Commit b2870a6

Browse files
committed
fix: add delay safety nets
1 parent 150370d commit b2870a6

File tree

1 file changed

+40
-46
lines changed

1 file changed

+40
-46
lines changed

site/src/components/Spinner/Spinner.tsx

Lines changed: 40 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ type SpinnerProps = Readonly<
5858
}
5959
>;
6060

61-
const leavesIterable = Array.from({ length: SPINNER_LEAF_COUNT }, (_, i) => i);
61+
const leafIndices = Array.from({ length: SPINNER_LEAF_COUNT }, (_, i) => i);
6262
const animationSettings: CSSProperties = isChromatic()
6363
? {}
6464
: {
@@ -76,12 +76,44 @@ export const Spinner: FC<SpinnerProps> = ({
7676
unmountedWhileLoading = false,
7777
...delegatedProps
7878
}) => {
79+
// Disallow negative timeout values and fractional values, but also round
80+
// the delay down if it's small enough that it would effectively be
81+
// immediate from a user perspective
82+
let safeDelay = Math.trunc(spinnerStartDelayMs);
83+
if (safeDelay < 100) {
84+
safeDelay = 0;
85+
}
86+
87+
// Doing a bunch of mid-render state syncs to minimize risks of
88+
// contradictory states during re-renders. It's ugly, but it's what the
89+
// React team officially recommends. Be very careful with this logic; React
90+
// only bails out of redundant state updates if they happen outside of a
91+
// render. Inside a render, if you keep calling a state dispatch, you will
92+
// get an infinite render loop, no matter what the state value is.
93+
const [delayDone, setDelayDone] = useState(safeDelay === 0);
94+
if (delayDone && !loading) {
95+
setDelayDone(false);
96+
}
97+
if (!delayDone && loading && safeDelay === 0) {
98+
setDelayDone(true);
99+
}
100+
useEffect(() => {
101+
if (safeDelay === 0) {
102+
return;
103+
}
104+
105+
const delayId = window.setTimeout(() => {
106+
setDelayDone(true);
107+
}, safeDelay);
108+
return () => window.clearTimeout(delayId);
109+
}, [safeDelay]);
110+
79111
/**
80-
* @todo Figure out if this conditional logic causes a component to lose
81-
* state. I would hope not, since the children prop is the same in both
112+
* @todo Figure out if this conditional logic can ever cause a component to
113+
* lose state. I would hope not, since the children prop is the same in both
82114
* cases, but I need to test this out
83115
*/
84-
const showSpinner = useShowSpinner(loading, spinnerStartDelayMs);
116+
const showSpinner = delayDone && loading;
85117
if (!showSpinner) {
86118
return children;
87119
}
@@ -98,19 +130,19 @@ export const Spinner: FC<SpinnerProps> = ({
98130
className={cn(className, spinnerVariants({ size }))}
99131
>
100132
<title>Loading&hellip;</title>
101-
{leavesIterable.map((leafIndex) => (
133+
{leafIndices.map((index) => (
102134
<rect
103-
key={leafIndex}
135+
key={index}
104136
x="10.9"
105137
y="2"
106138
width="2"
107139
height="5.5"
108140
rx="1"
109141
style={{
110142
...animationSettings,
111-
transform: `rotate(${leafIndex * (360 / SPINNER_LEAF_COUNT)}deg)`,
143+
transform: `rotate(${index * (360 / SPINNER_LEAF_COUNT)}deg)`,
112144
transformOrigin: "center",
113-
animationDelay: `${-leafIndex * 0.1}s`,
145+
animationDelay: `${-index * 0.1}s`,
114146
}}
115147
/>
116148
))}
@@ -125,41 +157,3 @@ export const Spinner: FC<SpinnerProps> = ({
125157
</>
126158
);
127159
};
128-
129-
// Not a big fan of one-time custom hooks, but it helps insulate the main
130-
// component from the chaos of handling all these state syncs, when the ultimate
131-
// result is a simple boolean. A lot of browsers will be able to inline the
132-
// function definition in some cases anyway
133-
function useShowSpinner(
134-
loading: boolean,
135-
spinnerStartDelayMs: number,
136-
): boolean {
137-
// Doing a bunch of mid-render state syncs to minimize risks of
138-
// contradictory states during re-renders. It's ugly, but it's what the
139-
// React team officially recommends. Be very careful with this logic; React
140-
// only bails out of redundant state updates if they happen outside of a
141-
// render. Inside a render, if you keep calling a state dispatch, you will
142-
// get an infinite render loop, no matter what the state value is.
143-
const [spinnerActive, setSpinnerActive] = useState(spinnerStartDelayMs === 0);
144-
const unmountImmediatelyOnRerender = spinnerActive && !loading;
145-
if (unmountImmediatelyOnRerender) {
146-
setSpinnerActive(false);
147-
}
148-
const mountImmediatelyOnRerender =
149-
!spinnerActive && loading && spinnerStartDelayMs === 0;
150-
if (mountImmediatelyOnRerender) {
151-
setSpinnerActive(true);
152-
}
153-
useEffect(() => {
154-
if (spinnerStartDelayMs === 0) {
155-
return;
156-
}
157-
158-
const delayId = window.setTimeout(() => {
159-
setSpinnerActive(true);
160-
}, spinnerStartDelayMs);
161-
return () => window.clearTimeout(delayId);
162-
}, [spinnerStartDelayMs]);
163-
164-
return spinnerActive && loading;
165-
}

0 commit comments

Comments
 (0)