Skip to content

Commit 96fec15

Browse files
committed
Fix edge cases around saved wrapper props and error handling
Changed to useLayoutEffect to ensure that the lastWrapperProps ref is written to synchronously when a render is committed. However, because React warns about this in SSR, added a check to fall back to plain useEffect under Node so we don't print warnings. Also added logic to ensure that if an error is thrown during a mapState function, but the component is unmounted before it can render, that the error will still be thrown. This shouldn't happen given our top-down subscriptions, but this will help surface the problem in our tests if we ever break the top-down behavior.
1 parent efeadb5 commit 96fec15

File tree

1 file changed

+32
-5
lines changed

1 file changed

+32
-5
lines changed

src/components/connectAdvanced.js

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import React, {
44
useContext,
55
useMemo,
66
useEffect,
7+
useLayoutEffect,
78
useRef,
89
useReducer
910
} from 'react'
@@ -31,6 +32,14 @@ function storeStateUpdatesReducer(state, action) {
3132

3233
const initStateUpdates = () => [null, 0]
3334

35+
// React currently throws a warning when using useLayoutEffect on the server.
36+
// To get around it, we can conditionally useEffect on the server (no-op) and
37+
// useLayoutEffect in the browser. We need useLayoutEffect because we want
38+
// `connect` to perform sync updates to a ref to save the latest props after
39+
// a render is actually committed to the DOM.
40+
const useIsomorphicLayoutEffect =
41+
typeof window !== 'undefined' ? useLayoutEffect : useEffect
42+
3443
export default function connectAdvanced(
3544
/*
3645
selectorFactory is a func that is responsible for returning the selector function used to
@@ -266,8 +275,10 @@ export default function connectAdvanced(
266275
return childPropsSelector(store.getState(), wrapperProps)
267276
}, [store, previousStateUpdateResult, wrapperProps])
268277

269-
// Every time we do re-render:
270-
useEffect(() => {
278+
// We need this to execute synchronously every time we re-render. However, React warns
279+
// about useLayoutEffect in SSR, so we try to detect environment and fall back to
280+
// just useEffect instead to avoid the warning, since neither will run anyway.
281+
useIsomorphicLayoutEffect(() => {
271282
// We want to capture the wrapper props and child props we used for later comparisons
272283
lastWrapperProps.current = wrapperProps
273284
lastChildProps.current = actualChildProps
@@ -284,7 +295,9 @@ export default function connectAdvanced(
284295
// If we're not subscribed to the store, nothing to do here
285296
if (!shouldHandleStateChanges) return
286297

298+
// Capture values for checking if and when this component unmounts
287299
let didUnsubscribe = false
300+
let lastThrownError = null
288301

289302
// We'll run this callback every time a store subscription update propagates to this component
290303
const checkForUpdates = () => {
@@ -306,6 +319,11 @@ export default function connectAdvanced(
306319
)
307320
} catch (e) {
308321
error = e
322+
lastThrownError = e
323+
}
324+
325+
if (!error) {
326+
lastThrownError = null
309327
}
310328

311329
// If the child props haven't changed, nothing to do here - cascade the subscription update
@@ -330,17 +348,26 @@ export default function connectAdvanced(
330348
}
331349
}
332350

333-
// Pull data from the store after first render in case the store has
334-
// changed since we began.
335-
351+
// Actually subscribe to the nearest connected ancestor (or store)
336352
subscription.onStateChange = checkForUpdates
337353
subscription.trySubscribe()
338354

355+
// Pull data from the store after first render in case the store has
356+
// changed since we began.
339357
checkForUpdates()
340358

341359
const unsubscribeWrapper = () => {
342360
didUnsubscribe = true
343361
subscription.tryUnsubscribe()
362+
363+
if (lastThrownError) {
364+
// It's possible that we caught an error due to a bad mapState function, but the
365+
// parent re-rendered without this component and we're about to unmount.
366+
// This shouldn't happen as long as we do top-down subscriptions correctly, but
367+
// if we ever do those wrong, this throw will surface the error in our tests.
368+
// In that case, throw the error from here so it doesn't get lost.
369+
throw lastThrownError
370+
}
344371
}
345372

346373
return unsubscribeWrapper

0 commit comments

Comments
 (0)