Skip to content

Commit 4176dc9

Browse files
committed
feat($callbacks): add component level callbacks (onBefore, onAfter) and options: (loadingTransition,
1 parent 36dac1c commit 4176dc9

File tree

10 files changed

+213
-49
lines changed

10 files changed

+213
-49
lines changed

README.md

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<a href="https://gitter.im/Reactlandia/Lobby" target="_blank">
2-
<img alt="Edit Redux-First Router Demo" src="https://s3-us-west-1.amazonaws.com/cdn.reactlandia.com/reactlandia-chat.png">
2+
<img alt="Reactlandia Chat" src="https://s3-us-west-1.amazonaws.com/cdn.reactlandia.com/reactlandia-chat.png">
33
</a>
44

55
<a href="https://codesandbox.io/s/github/faceyspacey/redux-first-router-codesandbox/tree/master/?module=r1oVP5YEUZ" target="_blank">
@@ -135,9 +135,11 @@ The first argument can be a function that returns a promise, a promise itself, o
135135
- `error`: ErrorComponent, -- *default: a simple one is provided for you*
136136
- `key`: `'foo'` || `module => module.foo` -- *default: `default` export in ES6 and `module.exports` in ES5*
137137
- `timeout`: `15000` -- *default*
138-
- `onLoad`: `module => doSomething(module)
139-
- `onError`: `error => handleError(error)
138+
- `onError`: `(error, { isServer }) => handleError(error, isServer)
139+
- `onLoad`: `(module, { isSync, isServer }) => doSomething(module, isSync, isServer)`
140140
- `minDelay`: `0` -- *default*
141+
- `alwaysDelay`: `false` -- *default*
142+
- `loadingTransition`: `true` -- *default*
141143

142144

143145
**In Depth:**
@@ -151,11 +153,16 @@ The first argument can be a function that returns a promise, a promise itself, o
151153

152154
- `timeout` allows you to specify a maximum amount of time before the `error` component is displayed. The default is 15 seconds.
153155

154-
- `onLoad` is a callback function that receives the *entire* module. It allows you to export and put to use things other than your `default` component export, like reducers, sagas, etc. E.g: `onLoad: module => store.replaceReducer({ ...otherReducers, foo: module.fooReducer })`.
155156

156157
- `onError` is a callback called if async imports fail. It does not apply to sync requires.
157158

158-
- `minDelay` is essentially the minimum amount of time the loading component will always show for. It's good for enforcing silky smooth animations, such as during a 500ms sliding transition. It insures the re-render won't happen until the animation is complete. It's often a good idea to set this to something like 300ms even if you don't have a transition, just so the loading spinner shows for an appropriate amount of time without jank.
159+
- `onLoad` is a callback function that receives the *entire* module. It allows you to export and put to use things other than your `default` component export, like reducers, sagas, etc. E.g: `onLoad: module => store.replaceReducer({ ...otherReducers, foo: module.fooReducer })`. It's fired directly before the component is rendered so you can setup any reducers/etc it depends on. Unlike `onAfterChange`, it's only fired the first time the module is received. *Also note*: it will fire on the server, so do `if (!isServer)` if you have to.
160+
161+
- `minDelay` is essentially the minimum amount of time the `loading` component will always show for. It's good for enforcing silky smooth animations, such as during a 500ms sliding transition. It insures the re-render won't happen until the animation is complete. It's often a good idea to set this to something like 300ms even if you don't have a transition, just so the loading spinner shows for an appropriate amount of time without jank.
162+
163+
- `alwaysDelay` is a boolean you can set to true (*default: false*) to guarantee the `minDelay` is always used (i.e. even when components cached from previous imports and therefore synchronously and instantly required). This can be useful for guaranteeing animations operate as you want without having to wire up other components to perform the task. *Note: this only applies to the client when your `UniversalComponent` uses dynamic expressions to switch between multiple components.*
164+
165+
- `loadingTransition` when set to `false` allows you to keep showing the current component when the `loading` component would otherwise show during transitions from one component to the next.
159166

160167

161168
## Flushing for SSR
@@ -210,9 +217,38 @@ export default class MyComponent extends React.Component {
210217
}
211218
```
212219
220+
## Static Hoisting
221+
222+
If your imported component has static methods like this:
223+
224+
```js
225+
export default class MyComponent extends React.Component {
226+
static doSomething() {}
227+
render() {}
228+
}
229+
```
230+
231+
Then this will work:
232+
233+
```js
234+
const MyUniversalComponent = universal(import('./MyComponent'))
235+
236+
// render it
237+
<MyUniversalComponent />
238+
239+
// call this only after you're sure it has loaded
240+
MyUniversalComponent.doSomething()
241+
```
242+
> NOTE: for imports using dynamic expressions, conflicting methods will be overwritten by the current component
213243
214244
## Props API
215245
246+
- `isLoading: boolean`
247+
- `error: new Error`
248+
- `onBefore`: `({ isMount, isSync, isServer }) => doSomething(isMount, isSync, isServer)`
249+
- `onAfter`: `({ isMount, isSync, isServer }, Component) => doSomething(Component, isMount, etc)`
250+
251+
### `isLoading` + `error`:
216252
You can pass `isLoading` and `error` props to the resulting component returned from the `universal` HoC. This has the convenient benefit of allowing you to continue to show the ***same*** `loading` component (or trigger the ***same*** `error` component) that is shown while your async component loads *AND* while any data-fetching may be occuring in a parent HoC. That means less jank from unnecessary re-renders, and less work (DRY).
217253
218254
Here's an example using Apollo:
@@ -242,6 +278,38 @@ export default graphql(gql`
242278
```
243279
> If it's not clear, the ***same*** `loading` component will show while both async aspects load, without flinching/re-rendering. And perhaps more importantly **they will be run in parallel**.
244280
281+
### `onBefore` + `onAfter`:
282+
283+
`onBefore/After` are callbacks called before and after the wrapped component changes. It's also called on `componentWillMount` on both the client and server. If you chose to use it on the server, make sure the client renders the same thing on first load or you will have checksum mismatches.
284+
285+
It's primary use case is for triggering *loading* state **outside** of the component *on the client during child component transitions*. You can use its `info` argument and keys like `info.isSync` to determine what you want to do. Here's an example:
286+
287+
```js
288+
const UniversalComponent = univesal(props => import(`./props.page`))
289+
290+
const MyComponent = ({ dispatch, isLoading }) =>
291+
<div>
292+
{isLoading && <div>loading...</div>}
293+
294+
<UniversalComponent
295+
page={props.page}
296+
onBefore={({ isSync }) => !isSync && dispatch({ type: 'LOADING', true })}
297+
onAfter={({ isSync }) => !isSync && dispatch({ type: 'LOADING', false })}
298+
/>
299+
</div>
300+
```
301+
302+
Each callback is passed an `info` argument containing these keys:
303+
304+
- `isMount` *(whether the component just mounted)*
305+
- `isSync` *(whether the imported component is already available from previous usage and required synchronsouly)*
306+
- `isServer` *(very rarely will you want to do stuff on the server; note: server will always be sync)*
307+
308+
`onAfter` is also passed a second argument containing the imported `Component`, which you can use to do things like call its static methods.
309+
310+
> NOTE: `onBefore` and `onAfter` will fire synchronously a millisecond a part after the first time `Component` loads (and on the server). Your options are to only trigger loading state when `!info.isSync` as in the above example, or you can use the `info` argument with `minDelay` + `alwaysDelay` to insure the 2 callbacks always fire, e.g., *300ms* a part. The latter can be helpful to produce consistent glitch-free animations. A consistent `300ms` or even `500ms` wait doesn't hurt user-experience--what does is unpredictability, glitchy animations and large bundles 😀
311+
312+
245313
246314
## Universal Demo
247315
🍾🍾🍾 **[faceyspacey/universal-demo](https://github.com/faceyspacey/universal-demo)** 🚀🚀🚀

__tests__/index.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ describe('other options', () => {
276276
const component = renderer.create(<Component />)
277277

278278
await waitFor(50)
279-
expect(onLoad).toBeCalledWith(mod)
279+
expect(onLoad).toBeCalledWith(mod, { isServer: false, isSync: false })
280280

281281
expect(component.toJSON()).toMatchSnapshot() // success
282282
})
@@ -293,7 +293,7 @@ describe('other options', () => {
293293
const component = renderer.create(<Component />)
294294

295295
await waitFor(50)
296-
expect(onLoad).toBeCalledWith(mod)
296+
expect(onLoad).toBeCalledWith(mod, { isServer: false, isSync: false })
297297

298298
expect(component.toJSON()).toMatchSnapshot() // success
299299
})
@@ -309,7 +309,10 @@ describe('other options', () => {
309309

310310
renderer.create(<Component />)
311311

312-
expect(onLoad).toBeCalledWith(require('../__fixtures__/component'))
312+
expect(onLoad).toBeCalledWith(require('../__fixtures__/component'), {
313+
isServer: false,
314+
isSync: true
315+
})
313316
})
314317

315318
it('minDelay: loads for duration of minDelay even if component ready', async () => {

__tests__/requireUniversalModule.js

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ describe('requireAsync: requires module asynchronously on the client, returning
238238
expect(error.message).toEqual('ah')
239239
}
240240

241-
expect(onError).toBeCalledWith(error)
241+
expect(onError).toBeCalledWith(error, { isServer: false })
242242
})
243243
})
244244

@@ -364,22 +364,32 @@ describe('other options', () => {
364364

365365
await requireAsync()
366366

367-
expect(onLoad).toBeCalledWith(mod)
368-
expect(onLoad).not.toBeCalledWith('foo')
367+
expect(onLoad).toBeCalledWith(mod, { isServer: false, isSync: false })
368+
expect(onLoad).not.toBeCalledWith('foo', { isServer: false, isSync: false })
369369
})
370370

371371
it('onLoad (sync): is called and passed entire module', async () => {
372372
const onLoad = jest.fn()
373373
const mod = { __esModule: true, default: 'foo' }
374-
const asyncImport = Promise.resolve(mod)
375-
const { requireAsync } = requireModule(() => asyncImport, {
374+
const asyncImport = () => {
375+
throw new Error('ah')
376+
}
377+
378+
global.__webpack_modules__ = { id: mod }
379+
global.__webpack_require__ = id => __webpack_modules__[id]
380+
381+
const { requireSync } = requireModule(asyncImport, {
376382
onLoad,
383+
resolve: () => 'id',
377384
key: 'default'
378385
})
379386

380-
await requireAsync()
387+
requireSync()
388+
389+
expect(onLoad).toBeCalledWith(mod, { isServer: false, isSync: true })
390+
expect(onLoad).not.toBeCalledWith('foo', { isServer: false, isSync: true })
381391

382-
expect(onLoad).toBeCalledWith(mod)
383-
expect(onLoad).not.toBeCalledWith('foo')
392+
delete global.__webpack_require__
393+
delete global.__webpack_modules__
384394
})
385395
})

__tests__/utils.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ test('resolveExport: finds export and calls onLoad', () => {
6161
const mod = { foo: 'bar' }
6262
const exp = resolveExport(mod, 'foo', onLoad)
6363
expect(exp).toEqual('bar')
64-
expect(onLoad).toBeCalledWith(mod)
64+
expect(onLoad).toBeCalledWith(mod, { isServer: false, isSync: false })
6565
// todo: test caching
6666
})
6767

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,5 +71,8 @@
7171
"repository": {
7272
"type": "git",
7373
"url": "https://github.com/faceyspacey/react-universal-component.git"
74+
},
75+
"dependencies": {
76+
"hoist-non-react-statics": "^2.2.1"
7477
}
7578
}

src/flowTypes.js

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export type ComponentOptions = {
4444
loading?: LoadingCompponent,
4545
error?: ErrorComponent,
4646
minDelay?: number,
47+
alwaysDelay?: boolean,
48+
loadingTransition?: boolean,
4749
testBabelPlugin?: boolean,
4850

4951
// options for requireAsyncModule:
@@ -65,8 +67,11 @@ export type ResolveImport = (module: ?any) => void
6567
export type RejectImport = (error: Object) => void
6668
export type Id = string
6769
export type Key = string | null | ((module: ?(Object | Function)) => any)
68-
export type OnLoad = (module: ?(Object | Function)) => void
69-
export type OnError = (error: Object) => void
70+
export type OnLoad = (
71+
module: ?(Object | Function),
72+
info: { isServer: boolean }
73+
) => void
74+
export type OnError = (error: Object, info: { isServer: boolean }) => void
7075

7176
export type RequireAsync = (props: Object) => Promise<?any>
7277
export type RequireSync = (props: Object) => ?any
@@ -83,10 +88,17 @@ export type Tools = {
8388
export type Ids = Array<string>
8489

8590
// RUC
91+
export type State = { error?: any, Component?: ?any }
92+
93+
type Info = { isMount: boolean, isSync: boolean, isServer: boolean }
94+
type OnBeforeChange = Info => void
95+
type OnAfterChange = (Info, any) => void
8696

8797
export type Props = {
8898
error?: ?any,
89-
isLoading?: ?boolean
99+
isLoading?: ?boolean,
100+
onBeforeChange?: OnBeforeChange,
101+
onAfterChange?: OnAfterChange
90102
}
91103

92104
export type GenericComponent<Props> =

0 commit comments

Comments
 (0)