68 | > & {
69 | preload(props?: P): void;
70 | };
71 |
72 | type Module =
73 | | {
74 | default?: P;
75 | [x: string]: any;
76 | }
77 | | {
78 | exports?: P;
79 | [x: string]: any;
80 | };
81 |
82 | type Options
= Partial<
83 | {
84 | /**
85 | * Lets you specify the export from the module you want to be your component
86 | * if it's not `default` in ES6 or `module.exports` in ES5.
87 | * It can be a string corresponding to the export key, or a function that's
88 | * passed the entire module and returns the export that will become the component.
89 | */
90 | key: keyof Export | ((module: Export) => ComponentType
);
91 |
92 | /**
93 | * Allows you to specify a maximum amount of time before the error component
94 | * is displayed. The default is 15 seconds.
95 | */
96 | timeout: number;
97 |
98 | /**
99 | * `minDelay` is essentially the minimum amount of time the loading component
100 | * will always show for. It's good for enforcing silky smooth animations, such as
101 | * during a 500ms sliding transition. It insures the re-render won't happen
102 | * until the animation is complete. It's often a good idea to set this to something
103 | * like 300ms even if you don't have a transition, just so the loading spinner
104 | * shows for an appropriate amount of time without jank.
105 | */
106 | minDelay: number;
107 |
108 | /**
109 | * `alwaysDelay` is a boolean you can set to true (default: `false`) to guarantee the
110 | * `minDelay` is always used (i.e. even when components cached from previous imports
111 | * and therefore synchronously and instantly required). This can be useful for
112 | * guaranteeing animations operate as you want without having to wire up other
113 | * components to perform the task.
114 | * _Note: this only applies to the client when
115 | * your `UniversalComponent` uses dynamic expressions to switch between multiple
116 | * components._
117 | *
118 | * default: `false`
119 | */
120 | alwaysDelay: boolean;
121 |
122 | /**
123 | * When set to `false` allows you to keep showing the current component when the
124 | * loading component would otherwise show during transitions from one component to
125 | * the next.
126 | */
127 | loadingTransition: boolean;
128 |
129 | /**
130 | * `ignoreBabelRename` is by default set to false which allows the plugin to attempt
131 | * and name the dynamically imported chunk (replacing / with -).
132 | * In more advanced scenarios where more granular control is required over the webpack chunk name,
133 | * you should set this to true in addition to providing a function to chunkName to control chunk naming.
134 | */
135 | ignoreBabelRename: boolean;
136 |
137 | testBabelPlugin: boolean;
138 |
139 | resolve: string | number | ((props: P) => number | string);
140 |
141 | path: string | ((props: P) => string);
142 |
143 | chunkName: string | ((props: P) => string);
144 |
145 | alwaysUpdate: boolean;
146 |
147 | id: string;
148 |
149 | /**
150 | * A callback called if async imports fail.
151 | * It does not apply to sync requires.
152 | */
153 | onError(
154 | error: Error,
155 | options: { isServer: boolean }
156 | ): void;
157 |
158 | /**
159 | * A callback function that receives the entire module.
160 | * It allows you to export and put to use things other than your
161 | * default component export, like reducers, sagas, etc.
162 | *
163 | * `onLoad` is fired directly before the component is rendered so you can setup
164 | * any reducers/etc it depends on. Unlike the `onAfter` prop, this option to the
165 | * `universal` HOC is only fired the first time the module is received. Also
166 | * note: it will fire on the server, so do if (!isServer) if you have to.
167 | * But also keep in mind you will need to do things like replace reducers on
168 | * both the server + client for the imported component that uses new reducers
169 | * to render identically in both places.
170 | */
171 | onLoad(
172 | module: Export,
173 | options: { isSync: boolean; isServer: boolean }
174 | ): void;
175 |
176 | /**
177 | * The component class or function corresponding to your stateless component
178 | * that displays while the primary import is loading.
179 | * While testing out this package, you can leave it out as a simple default one is used.
180 | */
181 | loading:
182 | | ((p: P) => JSX.Element | ComponentType
)
183 | | (JSX.Element | ComponentType
);
184 |
185 | /**
186 | * The component that displays if there are any errors that occur during
187 | * your aynschronous import. While testing out this package,
188 | * you can leave it out as a simple default one is used.
189 | */
190 | error:
191 | | ((p: P) => JSX.Element | ComponentType
)
192 | | (JSX.Element | ComponentType
);
193 |
194 | render: (
195 | props: P,
196 | module: Export | undefined,
197 | isLoading: boolean,
198 | error: Error | undefined
199 | ) => JSX.Element
200 | }
201 | >;
202 |
203 | export default function universal<
204 | P,
205 | C extends ComponentType
= ComponentType
,
206 | Export extends Module = Module
207 | >(
208 | loadSpec:
209 | | PromiseLike
210 | | ((props: P) => PromiseLike)
211 | | {
212 | load(props: P): PromiseLike;
213 | },
214 | options?: Options
215 | ): UniversalComponent
;
216 | }
217 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | // @flow
2 | import React from 'react'
3 | import PropTypes from 'prop-types'
4 | import hoist from 'hoist-non-react-statics'
5 | import req from './requireUniversalModule'
6 | import type {
7 | Config,
8 | ConfigFunc,
9 | ComponentOptions,
10 | RequireAsync,
11 | State,
12 | Props,
13 | Context
14 | } from './flowTypes'
15 | import ReportContext from './context'
16 |
17 | import {
18 | DefaultLoading,
19 | DefaultError,
20 | createDefaultRender,
21 | isServer
22 | } from './utils'
23 | import { __update } from './helpers'
24 |
25 | export { CHUNK_NAMES, MODULE_IDS } from './requireUniversalModule'
26 | export { default as ReportChunks } from './report-chunks'
27 |
28 | let hasBabelPlugin = false
29 |
30 | const isHMR = () =>
31 | // $FlowIgnore
32 | module.hot && (module.hot.data || module.hot.status() === 'apply')
33 |
34 | export const setHasBabelPlugin = () => {
35 | hasBabelPlugin = true
36 | }
37 |
38 | export default function universal(
39 | asyncModule: Config | ConfigFunc,
40 | opts: ComponentOptions = {}
41 | ) {
42 | const {
43 | render: userRender,
44 | loading: Loading = DefaultLoading,
45 | error: Err = DefaultError,
46 | minDelay = 0,
47 | alwaysDelay = false,
48 | testBabelPlugin = false,
49 | loadingTransition = true,
50 | ...options
51 | } = opts
52 |
53 | const renderFunc = userRender || createDefaultRender(Loading, Err)
54 |
55 | const isDynamic = hasBabelPlugin || testBabelPlugin
56 | options.isDynamic = isDynamic
57 | options.usesBabelPlugin = hasBabelPlugin
58 | options.modCache = {}
59 | options.promCache = {}
60 |
61 | return class UniversalComponent extends React.Component {
62 | /* eslint-disable react/sort-comp */
63 | _initialized: boolean
64 | _asyncOnly: boolean
65 |
66 | state: State
67 | props: Props
68 | /* eslint-enable react/sort-comp */
69 |
70 | static contextType = ReportContext
71 |
72 | static preload(props: Props) {
73 | props = props || {}
74 | const { requireAsync, requireSync } = req(asyncModule, options, props)
75 | let mod
76 |
77 | try {
78 | mod = requireSync(props)
79 | }
80 | catch (error) {
81 | return Promise.reject(error)
82 | }
83 |
84 | return Promise.resolve()
85 | .then(() => {
86 | if (mod) return mod
87 | return requireAsync(props)
88 | })
89 | .then(mod => {
90 | hoist(UniversalComponent, mod, {
91 | preload: true,
92 | preloadWeak: true
93 | })
94 | return mod
95 | })
96 | }
97 |
98 | static preloadWeak(props: Props) {
99 | props = props || {}
100 | const { requireSync } = req(asyncModule, options, props)
101 |
102 | const mod = requireSync(props)
103 | if (mod) {
104 | hoist(UniversalComponent, mod, {
105 | preload: true,
106 | preloadWeak: true
107 | })
108 | }
109 |
110 | return mod
111 | }
112 |
113 | requireAsyncInner(
114 | requireAsync: RequireAsync,
115 | props: Props,
116 | state: State,
117 | isMount?: boolean
118 | ) {
119 | if (!state.mod && loadingTransition) {
120 | this.update({ mod: null, props }) // display `loading` during componentWillReceiveProps
121 | }
122 |
123 | const time = new Date()
124 |
125 | requireAsync(props)
126 | .then((mod: ?any) => {
127 | const state = { mod, props }
128 |
129 | const timeLapsed = new Date() - time
130 | if (timeLapsed < minDelay) {
131 | const extraDelay = minDelay - timeLapsed
132 | return setTimeout(() => this.update(state, isMount), extraDelay)
133 | }
134 |
135 | this.update(state, isMount)
136 | })
137 | .catch(error => this.update({ error, props }))
138 | }
139 |
140 | update = (
141 | state: State,
142 | isMount?: boolean = false,
143 | isSync?: boolean = false,
144 | isServer?: boolean = false
145 | ) => {
146 | if (!this._initialized) return
147 | if (!state.error) state.error = null
148 |
149 | this.handleAfter(state, isMount, isSync, isServer)
150 | }
151 |
152 | handleBefore(
153 | isMount: boolean,
154 | isSync: boolean,
155 | isServer?: boolean = false
156 | ) {
157 | if (this.props.onBefore) {
158 | const { onBefore } = this.props
159 | const info = { isMount, isSync, isServer }
160 | onBefore(info)
161 | }
162 | }
163 |
164 | handleAfter(
165 | state: State,
166 | isMount: boolean,
167 | isSync: boolean,
168 | isServer: boolean
169 | ) {
170 | const { mod, error } = state
171 |
172 | if (mod && !error) {
173 | hoist(UniversalComponent, mod, {
174 | preload: true,
175 | preloadWeak: true
176 | })
177 |
178 | if (this.props.onAfter) {
179 | const { onAfter } = this.props
180 | const info = { isMount, isSync, isServer }
181 | onAfter(info, mod)
182 | }
183 | }
184 | else if (error && this.props.onError) {
185 | this.props.onError(error)
186 | }
187 |
188 | this.setState(state)
189 | }
190 | // $FlowFixMe
191 | init(props) {
192 | const { addModule, requireSync, requireAsync, asyncOnly } = req(
193 | asyncModule,
194 | options,
195 | props
196 | )
197 |
198 | let mod
199 |
200 | try {
201 | mod = requireSync(props)
202 | }
203 | catch (error) {
204 | return __update(props, { error, props }, this._initialized)
205 | }
206 |
207 | this._asyncOnly = asyncOnly
208 | const chunkName = addModule(props) // record the module for SSR flushing :)
209 | if (this.context && this.context.report) {
210 | this.context.report(chunkName)
211 | }
212 |
213 | if (mod || isServer) {
214 | this.handleBefore(true, true, isServer)
215 | return __update(
216 | props,
217 | { asyncOnly, props, mod },
218 | this._initialized,
219 | true,
220 | true,
221 | isServer
222 | )
223 | }
224 |
225 | this.handleBefore(true, false)
226 | this.requireAsyncInner(
227 | requireAsync,
228 | props,
229 | { props, asyncOnly, mod },
230 | true
231 | )
232 | return { mod, asyncOnly, props }
233 | }
234 |
235 | constructor(props: Props, context: Context) {
236 | super(props, context)
237 | this.state = this.init(this.props)
238 | // $FlowFixMe
239 | this.state.error = null
240 | }
241 |
242 | static getDerivedStateFromProps(nextProps, currentState) {
243 | const { requireSync, shouldUpdate } = req(
244 | asyncModule,
245 | options,
246 | nextProps,
247 | currentState.props
248 | )
249 | if (isHMR() && shouldUpdate(currentState.props, nextProps)) {
250 | const mod = requireSync(nextProps)
251 | return { ...currentState, mod }
252 | }
253 | return null
254 | }
255 |
256 | componentDidMount() {
257 | this._initialized = true
258 | }
259 |
260 | componentDidUpdate(prevProps: Props) {
261 | if (isDynamic || this._asyncOnly) {
262 | const { requireSync, requireAsync, shouldUpdate } = req(
263 | asyncModule,
264 | options,
265 | this.props,
266 | prevProps
267 | )
268 |
269 | if (shouldUpdate(this.props, prevProps)) {
270 | let mod
271 |
272 | try {
273 | mod = requireSync(this.props)
274 | }
275 | catch (error) {
276 | return this.update({ error })
277 | }
278 |
279 | this.handleBefore(false, !!mod)
280 |
281 | if (!mod) {
282 | return this.requireAsyncInner(requireAsync, this.props, { mod })
283 | }
284 |
285 | const state = { mod }
286 |
287 | if (alwaysDelay) {
288 | if (loadingTransition) this.update({ mod: null }) // display `loading` during componentWillReceiveProps
289 | setTimeout(() => this.update(state, false, true), minDelay)
290 | return
291 | }
292 |
293 | this.update(state, false, true)
294 | }
295 | }
296 | }
297 |
298 | componentWillUnmount() {
299 | this._initialized = false
300 | }
301 |
302 | render() {
303 | const { isLoading, error: userError, ...props } = this.props
304 | const { mod, error } = this.state
305 | return renderFunc(props, mod, isLoading, userError || error)
306 | }
307 | }
308 | }
309 |
--------------------------------------------------------------------------------
/__tests__/__snapshots__/index.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`advanced Component.preload: static preload method pre-fetches chunk 1`] = `
4 |
5 | Loading...
6 |
7 | `;
8 |
9 | exports[`advanced Component.preload: static preload method pre-fetches chunk 2`] = `
10 |
11 | MyComponent
12 | {}
13 |
14 | `;
15 |
16 | exports[`advanced Component.preload: static preload method pre-fetches chunk 3`] = `
17 |
18 | MyComponent
19 | {}
20 |
21 | `;
22 |
23 | exports[`advanced babel-plugin 1`] = `
24 |
25 | Loading...
26 |
27 | `;
28 |
29 | exports[`advanced babel-plugin 2`] = `
30 |
31 | MyComponent
32 | {}
33 |
34 | `;
35 |
36 | exports[`advanced componentWillReceiveProps: changes component (dynamic require) (no babel plugin) 1`] = `
37 |
38 | Loading...
39 |
40 | `;
41 |
42 | exports[`advanced componentWillReceiveProps: changes component (dynamic require) (no babel plugin) 2`] = `
43 |
44 | MyComponent
45 | {"page":"MyComponent"}
46 |
47 | `;
48 |
49 | exports[`advanced componentWillReceiveProps: changes component (dynamic require) (no babel plugin) 3`] = `
50 |
51 | Loading...
52 |
53 | `;
54 |
55 | exports[`advanced componentWillReceiveProps: changes component (dynamic require) (no babel plugin) 4`] = `
56 |
57 | MyComponent
58 | {"page":"MyComponent2"}
59 |
60 | `;
61 |
62 | exports[`advanced componentWillReceiveProps: changes component (dynamic require) 1`] = `
63 |
64 | Loading...
65 |
66 | `;
67 |
68 | exports[`advanced componentWillReceiveProps: changes component (dynamic require) 2`] = `
69 |
70 | MyComponent
71 | {"page":"MyComponent"}
72 |
73 | `;
74 |
75 | exports[`advanced componentWillReceiveProps: changes component (dynamic require) 3`] = `
76 |
77 | Loading...
78 |
79 | `;
80 |
81 | exports[`advanced componentWillReceiveProps: changes component (dynamic require) 4`] = `
82 |
83 | MyComponent
84 | {"page":"MyComponent2"}
85 |
86 | `;
87 |
88 | exports[`advanced dynamic requires (async) 1`] = `
89 |
90 | MyComponent
91 | {"page":"MyComponent"}
92 |
93 | `;
94 |
95 | exports[`advanced promise passed directly 1`] = `
96 |
97 | Loading...
98 |
99 | `;
100 |
101 | exports[`advanced promise passed directly 2`] = `
102 |
103 | MyComponent
104 | {}
105 |
106 | `;
107 |
108 | exports[`async lifecycle component unmounted: setState not called 1`] = `
109 |
110 | Loading...
111 |
112 | `;
113 |
114 | exports[`async lifecycle error 1`] = `
115 |
116 | Loading...
117 |
118 | `;
119 |
120 | exports[`async lifecycle error 2`] = `
121 |
122 | Loading...
123 |
124 | `;
125 |
126 | exports[`async lifecycle error 3`] = `
127 |
128 | Error:
129 | test error
130 |
131 | `;
132 |
133 | exports[`async lifecycle loading 1`] = `
134 |
135 | Loading...
136 |
137 | `;
138 |
139 | exports[`async lifecycle loading 2`] = `
140 |
141 | Loading...
142 |
143 | `;
144 |
145 | exports[`async lifecycle loading 3`] = `
146 |
147 | MyComponent
148 | {}
149 |
150 | `;
151 |
152 | exports[`async lifecycle loading 4`] = `
153 |
154 | MyComponent
155 | {}
156 |
157 | `;
158 |
159 | exports[`async lifecycle timeout error 1`] = `
160 |
161 | Loading...
162 |
163 | `;
164 |
165 | exports[`async lifecycle timeout error 2`] = `
166 |
167 | Error:
168 | timeout exceeded
169 |
170 | `;
171 |
172 | exports[`other options key (function): resolves export to function return 1`] = `
173 |
174 | Loading...
175 |
176 | `;
177 |
178 | exports[`other options key (function): resolves export to function return 2`] = `
179 |
180 | Loading...
181 |
182 | `;
183 |
184 | exports[`other options key (function): resolves export to function return 3`] = `
185 |
186 | MyComponent
187 | {}
188 |
189 | `;
190 |
191 | exports[`other options key (string): resolves export to value of key 1`] = `
192 |
193 | Loading...
194 |
195 | `;
196 |
197 | exports[`other options key (string): resolves export to value of key 2`] = `
198 |
199 | Loading...
200 |
201 | `;
202 |
203 | exports[`other options key (string): resolves export to value of key 3`] = `
204 |
205 | MyComponent
206 | {}
207 |
208 | `;
209 |
210 | exports[`other options minDelay: loads for duration of minDelay even if component ready 1`] = `
211 |
212 | Loading...
213 |
214 | `;
215 |
216 | exports[`other options minDelay: loads for duration of minDelay even if component ready 2`] = `
217 |
218 | Loading...
219 |
220 | `;
221 |
222 | exports[`other options minDelay: loads for duration of minDelay even if component ready 3`] = `
223 |
224 | MyComponent
225 | {}
226 |
227 | `;
228 |
229 | exports[`other options onLoad (async): is called and passed an es6 module 1`] = `
230 |
231 | MyComponent
232 | {"foo":"bar"}
233 |
234 | `;
235 |
236 | exports[`other options onLoad (async): is called and passed entire module 1`] = `
237 |
238 | MyComponent
239 | {}
240 |
241 | `;
242 |
243 | exports[`props: all components receive props - also displays error component 1`] = `
244 |
245 | Error!
246 | {"error":{}}
247 |
248 | `;
249 |
250 | exports[`props: all components receive props - also displays error component 2`] = `
251 |
252 | Error!
253 | {"error":{}}
254 |
255 | `;
256 |
257 | exports[`props: all components receive props - also displays loading component 1`] = `
258 |
259 | Loading...
260 |
261 | `;
262 |
263 | exports[`props: all components receive props - also displays loading component 2`] = `
264 |
265 | Loading...
266 |
267 | `;
268 |
269 | exports[`props: all components receive props arguments/props passed to asyncComponent function for data-fetching 1`] = `
270 |
271 | Loading...
272 |
273 | `;
274 |
275 | exports[`props: all components receive props arguments/props passed to asyncComponent function for data-fetching 2`] = `
276 |
279 | foo
280 |
281 | `;
282 |
283 | exports[`props: all components receive props components passed as elements: error 1`] = `
284 |
285 | Loading...
286 |
287 | `;
288 |
289 | exports[`props: all components receive props components passed as elements: error 2`] = `
290 |
291 | Loading...
292 |
293 | `;
294 |
295 | exports[`props: all components receive props components passed as elements: error 3`] = `
296 |
297 | Error!
298 | {"prop":"foo","error":{}}
299 |
300 | `;
301 |
302 | exports[`props: all components receive props components passed as elements: error 4`] = `
303 |
304 | Loading...
305 |
306 | `;
307 |
308 | exports[`props: all components receive props components passed as elements: loading 1`] = `
309 |
310 | Loading...
311 | {"prop":"foo"}
312 |
313 | `;
314 |
315 | exports[`props: all components receive props components passed as elements: loading 2`] = `
316 |
317 | Loading...
318 | {"prop":"foo"}
319 |
320 | `;
321 |
322 | exports[`props: all components receive props components passed as elements: loading 3`] = `
323 |
324 | MyComponent
325 | {"prop":"foo"}
326 |
327 | `;
328 |
329 | exports[`props: all components receive props components passed as elements: loading 4`] = `
330 |
331 | MyComponent
332 | {"prop":"bar"}
333 |
334 | `;
335 |
336 | exports[`props: all components receive props custom error component 1`] = `
337 |
338 | Loading...
339 |
340 | `;
341 |
342 | exports[`props: all components receive props custom error component 2`] = `
343 |
344 | Loading...
345 |
346 | `;
347 |
348 | exports[`props: all components receive props custom error component 3`] = `
349 |
350 | Error!
351 | {"prop":"foo","error":{}}
352 |
353 | `;
354 |
355 | exports[`props: all components receive props custom error component 4`] = `
356 |
357 | Loading...
358 |
359 | `;
360 |
361 | exports[`props: all components receive props custom loading component 1`] = `
362 |
363 | Loading...
364 | {"prop":"foo"}
365 |
366 | `;
367 |
368 | exports[`props: all components receive props custom loading component 2`] = `
369 |
370 | Loading...
371 | {"prop":"foo"}
372 |
373 | `;
374 |
375 | exports[`props: all components receive props custom loading component 3`] = `
376 |
377 | MyComponent
378 | {"prop":"foo"}
379 |
380 | `;
381 |
382 | exports[`props: all components receive props custom loading component 4`] = `
383 |
384 | MyComponent
385 | {"prop":"bar"}
386 |
387 | `;
388 |
389 | exports[`server-side rendering es5: module.exports resolved 1`] = `
390 |
391 | fixture-ES5
392 |
393 | `;
394 |
395 | exports[`server-side rendering es6: default export automatically resolved 1`] = `
396 |
397 | fixture1
398 |
399 | `;
400 |
--------------------------------------------------------------------------------
/__tests__/requireUniversalModule.js:
--------------------------------------------------------------------------------
1 | // @noflow
2 | import path from 'path'
3 | import { createPath, waitFor, normalizePath } from '../__test-helpers__'
4 |
5 | import req, {
6 | flushModuleIds,
7 | flushChunkNames,
8 | clearChunks
9 | } from '../src/requireUniversalModule'
10 |
11 | const requireModule = (asyncImport, options, props) =>
12 | req(asyncImport, { ...options, modCache: {}, promCache: {} }, props)
13 |
14 | describe('requireSync: tries to require module synchronously on both the server and client', () => {
15 | it('babel', () => {
16 | const modulePath = createPath('es6')
17 | const { requireSync } = requireModule(undefined, { path: modulePath })
18 | const mod = requireSync()
19 |
20 | const defaultExport = require(modulePath).default
21 | expect(mod).toEqual(defaultExport)
22 | })
23 |
24 | it('babel: path option as function', () => {
25 | const modulePath = createPath('es6')
26 | const { requireSync } = requireModule(undefined, { path: () => modulePath })
27 | const mod = requireSync()
28 |
29 | const defaultExport = require(modulePath).default
30 | expect(mod).toEqual(defaultExport)
31 | })
32 |
33 | it('webpack', () => {
34 | global.__webpack_require__ = path => __webpack_modules__[path]
35 | const modulePath = createPath('es6')
36 |
37 | global.__webpack_modules__ = {
38 | [modulePath]: require(modulePath)
39 | }
40 |
41 | const options = { resolve: () => modulePath }
42 | const { requireSync } = requireModule(undefined, options)
43 | const mod = requireSync()
44 |
45 | const defaultExport = require(modulePath).default
46 | expect(mod).toEqual(defaultExport)
47 |
48 | delete global.__webpack_require__
49 | delete global.__webpack_modules__
50 | })
51 |
52 | it('webpack: resolve option as string', () => {
53 | global.__webpack_require__ = path => __webpack_modules__[path]
54 | const modulePath = createPath('es6.js')
55 |
56 | global.__webpack_modules__ = {
57 | [modulePath]: require(modulePath)
58 | }
59 |
60 | const { requireSync } = requireModule(undefined, { resolve: modulePath })
61 | const mod = requireSync()
62 |
63 | const defaultExport = require(modulePath).default
64 | expect(mod).toEqual(defaultExport)
65 |
66 | delete global.__webpack_require__
67 | delete global.__webpack_modules__
68 | })
69 |
70 | it('webpack: when mod is undefined, requireSync used instead after all chunks evaluated at render time', () => {
71 | global.__webpack_require__ = path => __webpack_modules__[path]
72 | const modulePath = createPath('es6')
73 |
74 | // main.js chunk is evaluated, but 0.js comes after
75 | global.__webpack_modules__ = {}
76 |
77 | const { requireSync } = requireModule(undefined, {
78 | resolve: () => modulePath
79 | })
80 | const mod = requireSync()
81 |
82 | expect(mod).toEqual(undefined)
83 |
84 | // 0.js chunk is evaluated, and now the module exists
85 | global.__webpack_modules__ = {
86 | [modulePath]: require(modulePath)
87 | }
88 |
89 | // requireSync is used, for example, at render time after all chunks are evaluated
90 | const modAttempt2 = requireSync()
91 | const defaultExport = require(modulePath).default
92 | expect(modAttempt2).toEqual(defaultExport)
93 |
94 | delete global.__webpack_require__
95 | delete global.__webpack_modules__
96 | })
97 |
98 | it('es5 resolution', () => {
99 | const { requireSync } = requireModule(undefined, {
100 | path: path.join(__dirname, '../__fixtures__/es5')
101 | })
102 | const mod = requireSync()
103 |
104 | const defaultExport = require('../__fixtures__/es5')
105 | expect(mod).toEqual(defaultExport)
106 | })
107 |
108 | it('babel: dynamic require', () => {
109 | const modulePath = ({ page }) => createPath(page)
110 | const props = { page: 'es6' }
111 | const options = { path: modulePath }
112 | const { requireSync } = requireModule(null, options, props)
113 | const mod = requireSync(props)
114 |
115 | const defaultExport = require(createPath('es6')).default
116 | expect(mod).toEqual(defaultExport)
117 | })
118 |
119 | it('webpack: dynamic require', () => {
120 | global.__webpack_require__ = path => __webpack_modules__[path]
121 | const modulePath = ({ page }) => createPath(page)
122 |
123 | global.__webpack_modules__ = {
124 | [createPath('es6')]: require(createPath('es6'))
125 | }
126 |
127 | const props = { page: 'es6' }
128 | const options = { resolve: modulePath }
129 | const { requireSync } = requireModule(undefined, options, props)
130 | const mod = requireSync(props)
131 |
132 | const defaultExport = require(createPath('es6')).default
133 | expect(mod).toEqual(defaultExport)
134 |
135 | delete global.__webpack_require__
136 | delete global.__webpack_modules__
137 | })
138 | })
139 |
140 | describe('requireAsync: requires module asynchronously on the client, returning a promise', () => {
141 | it('asyncImport as function: () => import()', async () => {
142 | const { requireAsync } = requireModule(() => Promise.resolve('hurray'))
143 |
144 | const res = await requireAsync()
145 | expect(res).toEqual('hurray')
146 | })
147 |
148 | it('asyncImport as promise: import()', async () => {
149 | const { requireAsync } = requireModule(Promise.resolve('hurray'))
150 |
151 | const res = await requireAsync()
152 | expect(res).toEqual('hurray')
153 | })
154 |
155 | it('asyncImport as function using callback for require.ensure: (props, { resolve }) => resolve(module)', async () => {
156 | const { requireAsync } = requireModule((props, { resolve }) =>
157 | resolve('hurray')
158 | )
159 |
160 | const res = await requireAsync()
161 | expect(res).toEqual('hurray')
162 | })
163 |
164 | it('asyncImport as function using callback for require.ensure: (props, { reject }) => reject(error)', async () => {
165 | const { requireAsync } = requireModule((props, { reject }) =>
166 | reject(new Error('ah'))
167 | )
168 |
169 | try {
170 | await requireAsync()
171 | }
172 | catch (error) {
173 | expect(error.message).toEqual('ah')
174 | }
175 | })
176 |
177 | it('asyncImport as function with props: props => import()', async () => {
178 | const { requireAsync } = requireModule(props => Promise.resolve(props.foo))
179 | const res = await requireAsync({ foo: 123 })
180 | expect(res).toEqual(123)
181 | })
182 |
183 | it('asyncImport as function with props: (props, { resolve }) => cb()', async () => {
184 | const asyncImport = (props, { resolve }) => resolve(props.foo)
185 | const { requireAsync } = requireModule(asyncImport)
186 | const res = await requireAsync({ foo: 123 })
187 | expect(res).toEqual(123)
188 | })
189 |
190 | it('return Promise.resolve(mod) if module already synchronously required', async () => {
191 | const modulePath = createPath('es6')
192 | const options = { path: modulePath }
193 | const { requireSync, requireAsync } = requireModule(undefined, options)
194 | const mod = requireSync()
195 |
196 | expect(mod).toBeDefined()
197 |
198 | const prom = requireAsync()
199 | expect(prom.then).toBeDefined()
200 |
201 | const modAgain = await requireAsync()
202 | expect(modAgain).toEqual('hello')
203 | })
204 |
205 | it('export not found rejects', async () => {
206 | const { requireAsync } = requireModule(() => Promise.resolve('hurray'), {
207 | key: 'dog'
208 | })
209 |
210 | try {
211 | await requireAsync()
212 | }
213 | catch (error) {
214 | expect(error.message).toEqual('export not found')
215 | }
216 | })
217 |
218 | it('rejected promise', async () => {
219 | const { requireAsync } = requireModule(Promise.reject(new Error('ah')))
220 |
221 | try {
222 | await requireAsync()
223 | }
224 | catch (error) {
225 | expect(error.message).toEqual('ah')
226 | }
227 | })
228 |
229 | it('rejected promise calls onError', async () => {
230 | const error = new Error('ah')
231 | const onError = jest.fn()
232 | const opts = { onError }
233 | const { requireAsync } = requireModule(Promise.reject(error), opts)
234 |
235 | try {
236 | await requireAsync()
237 | }
238 | catch (error) {
239 | expect(error.message).toEqual('ah')
240 | }
241 |
242 | expect(onError).toBeCalledWith(error, { isServer: false })
243 | })
244 | })
245 |
246 | describe('addModule: add moduleId and chunkName for SSR flushing', () => {
247 | it('babel', () => {
248 | clearChunks()
249 |
250 | const moduleEs6 = createPath('es6')
251 | const moduleEs5 = createPath('es5')
252 |
253 | let universal = requireModule(undefined, {
254 | path: moduleEs6,
255 | chunkName: 'es6'
256 | })
257 | universal.addModule()
258 |
259 | universal = requireModule(undefined, { path: moduleEs5, chunkName: 'es5' })
260 | universal.addModule()
261 |
262 | const paths = flushModuleIds().map(normalizePath)
263 | const chunkNames = flushChunkNames()
264 |
265 | expect(paths).toEqual(['/es6', '/es5'])
266 | expect(chunkNames).toEqual(['es6', 'es5'])
267 | })
268 |
269 | it('webpack', () => {
270 | global.__webpack_require__ = path => __webpack_modules__[path]
271 |
272 | const moduleEs6 = createPath('es6')
273 | const moduleEs5 = createPath('es5')
274 |
275 | // modules stored by paths instead of IDs (replicates babel implementation)
276 | global.__webpack_modules__ = {
277 | [moduleEs6]: require(moduleEs6),
278 | [moduleEs5]: require(moduleEs5)
279 | }
280 |
281 | clearChunks()
282 |
283 | let universal = requireModule(undefined, {
284 | resolve: () => moduleEs6,
285 | chunkName: 'es6'
286 | })
287 | universal.addModule()
288 |
289 | universal = requireModule(undefined, {
290 | resolve: () => moduleEs5,
291 | chunkName: 'es5'
292 | })
293 | universal.addModule()
294 |
295 | const paths = flushModuleIds().map(normalizePath)
296 | const chunkNames = flushChunkNames()
297 |
298 | expect(paths).toEqual(['/es6', '/es5'])
299 | expect(chunkNames).toEqual(['es6', 'es5'])
300 |
301 | delete global.__webpack_require__
302 | delete global.__webpack_modules__
303 | })
304 | })
305 |
306 | describe('other options', () => {
307 | it('key (string): resolve export to value of key', () => {
308 | const modulePath = createPath('es6')
309 | const { requireSync } = requireModule(undefined, {
310 | path: modulePath,
311 | key: 'foo'
312 | })
313 | const mod = requireSync()
314 |
315 | const defaultExport = require(modulePath).foo
316 | expect(mod).toEqual(defaultExport)
317 | })
318 |
319 | it('key (function): resolves export to function return', () => {
320 | const modulePath = createPath('es6')
321 | const { requireSync } = requireModule(undefined, {
322 | path: modulePath,
323 | key: module => module.foo
324 | })
325 | const mod = requireSync()
326 |
327 | const defaultExport = require(modulePath).foo
328 | expect(mod).toEqual(defaultExport)
329 | })
330 |
331 | it('key (null): resolves export to be entire module', () => {
332 | const { requireSync } = requireModule(undefined, {
333 | path: path.join(__dirname, '../__fixtures__/es6'),
334 | key: null
335 | })
336 | const mod = requireSync()
337 |
338 | const defaultExport = require('../__fixtures__/es6')
339 | expect(mod).toEqual(defaultExport)
340 | })
341 |
342 | it('timeout: throws if loading time is longer than timeout', async () => {
343 | const asyncImport = waitFor(20).then('hurray')
344 | const { requireAsync } = requireModule(asyncImport, { timeout: 10 })
345 |
346 | try {
347 | await requireAsync()
348 | }
349 | catch (error) {
350 | expect(error.message).toEqual('timeout exceeded')
351 | }
352 | })
353 |
354 | it('onLoad (async): is called and passed entire module', async () => {
355 | const onLoad = jest.fn()
356 | const mod = { __esModule: true, default: 'foo' }
357 | const asyncImport = Promise.resolve(mod)
358 | const { requireAsync } = requireModule(() => asyncImport, {
359 | onLoad,
360 | key: 'default'
361 | })
362 |
363 | const props = { foo: 'bar' }
364 | await requireAsync(props)
365 |
366 | const info = { isServer: false, isSync: false }
367 | expect(onLoad).toBeCalledWith(mod, info, props)
368 | expect(onLoad).not.toBeCalledWith('foo', info, props)
369 | })
370 |
371 | it('onLoad (sync): is called and passed entire module', async () => {
372 | const onLoad = jest.fn()
373 | const mod = { __esModule: true, default: 'foo' }
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, {
382 | onLoad,
383 | resolve: () => 'id',
384 | key: 'default'
385 | })
386 |
387 | const props = { foo: 'bar' }
388 | requireSync(props)
389 |
390 | const info = { isServer: false, isSync: true }
391 | expect(onLoad).toBeCalledWith(mod, info, props)
392 | expect(onLoad).not.toBeCalledWith('foo', info, props)
393 |
394 | delete global.__webpack_require__
395 | delete global.__webpack_modules__
396 | })
397 | })
398 |
--------------------------------------------------------------------------------
/__tests__/index.js:
--------------------------------------------------------------------------------
1 | // @noflow
2 | import path from 'path'
3 | import React from 'react'
4 | import renderer from 'react-test-renderer'
5 |
6 | import universal from '../src'
7 | import { flushModuleIds, flushChunkNames } from '../src/requireUniversalModule'
8 |
9 | import {
10 | createApp,
11 | createDynamicApp,
12 | createPath,
13 | createBablePluginApp,
14 | createDynamicBablePluginApp
15 | } from '../__test-helpers__/createApp'
16 |
17 | import {
18 | normalizePath,
19 | waitFor,
20 | Loading,
21 | Err,
22 | MyComponent,
23 | MyComponent2,
24 | createComponent,
25 | createDynamicComponent,
26 | createBablePluginComponent,
27 | createDynamicBablePluginComponent,
28 | dynamicBabelNodeComponent,
29 | createDynamicComponentAndOptions
30 | } from '../__test-helpers__'
31 |
32 | describe('async lifecycle', () => {
33 | it('loading', async () => {
34 | const asyncComponent = createComponent(400, MyComponent)
35 | const Component = universal(asyncComponent)
36 |
37 | const component1 = renderer.create()
38 | expect(component1.toJSON()).toMatchSnapshot() // initial
39 |
40 | await waitFor(200)
41 | expect(component1.toJSON()).toMatchSnapshot() // loading
42 |
43 | await waitFor(200)
44 | expect(component1.toJSON()).toMatchSnapshot() // loaded
45 |
46 | const component2 = renderer.create()
47 | expect(component2.toJSON()).toMatchSnapshot() // re-loaded
48 | })
49 |
50 | it('error', async () => {
51 | const asyncComponent = createComponent(40, null)
52 | const Component = universal(asyncComponent)
53 |
54 | const component = renderer.create()
55 | expect(component.toJSON()).toMatchSnapshot() // initial
56 |
57 | await waitFor(20)
58 | expect(component.toJSON()).toMatchSnapshot() // loading
59 |
60 | await waitFor(20)
61 | expect(component.toJSON()).toMatchSnapshot() // errored
62 | })
63 |
64 | it('timeout error', async () => {
65 | const asyncComponent = createComponent(40, null)
66 | const Component = universal(asyncComponent, {
67 | timeout: 10
68 | })
69 |
70 | const component = renderer.create()
71 | expect(component.toJSON()).toMatchSnapshot() // initial
72 |
73 | await waitFor(20)
74 |
75 | expect(component.toJSON()).toMatchSnapshot() // error
76 | })
77 |
78 | it('component unmounted: setState not called', async () => {
79 | const asyncComponent = createComponent(10, MyComponent)
80 | const Component = universal(asyncComponent)
81 |
82 | let instance
83 | const component = renderer.create( (instance = i)} />)
84 |
85 | instance.componentWillUnmount()
86 | await waitFor(20)
87 |
88 | // component will still be in loading state because setState is NOT called
89 | // since its unmounted. In reality, it won't be rendered anymore.
90 | expect(component.toJSON()).toMatchSnapshot() /*? component.toJSON() */
91 | })
92 | })
93 |
94 | describe('props: all components receive props', () => {
95 | it('custom loading component', async () => {
96 | const asyncComponent = createComponent(40, MyComponent)
97 | const Component = universal(asyncComponent, {
98 | loading: Loading
99 | })
100 |
101 | const component1 = renderer.create()
102 | expect(component1.toJSON()).toMatchSnapshot() // initial
103 |
104 | await waitFor(20)
105 | expect(component1.toJSON()).toMatchSnapshot() // loading
106 |
107 | await waitFor(20)
108 | expect(component1.toJSON()).toMatchSnapshot() // loaded
109 |
110 | const component2 = renderer.create()
111 | expect(component2.toJSON()).toMatchSnapshot() // re-loaded
112 | })
113 |
114 | it('custom error component', async () => {
115 | const asyncComponent = createComponent(40, null)
116 | const Component = universal(asyncComponent, {
117 | error: Err
118 | })
119 |
120 | const component1 = renderer.create()
121 | expect(component1.toJSON()).toMatchSnapshot() // initial
122 |
123 | await waitFor(20)
124 | expect(component1.toJSON()).toMatchSnapshot() // loading
125 |
126 | await waitFor(20)
127 | expect(component1.toJSON()).toMatchSnapshot() // Error!
128 |
129 | const component2 = renderer.create()
130 | expect(component2.toJSON()).toMatchSnapshot() // loading again..
131 | })
132 |
133 | it(' - also displays loading component', async () => {
134 | const asyncComponent = createComponent(40, MyComponent)
135 | const Component = universal(asyncComponent)
136 |
137 | const component1 = renderer.create()
138 | expect(component1.toJSON()).toMatchSnapshot() // initial
139 |
140 | await waitFor(50)
141 | expect(component1.toJSON()).toMatchSnapshot() // loading even though async component is available
142 | })
143 |
144 | it(' - also displays error component', async () => {
145 | const asyncComponent = createComponent(40, MyComponent)
146 | const Component = universal(asyncComponent, { error: Err })
147 |
148 | const component1 = renderer.create()
149 | expect(component1.toJSON()).toMatchSnapshot() // initial
150 |
151 | await waitFor(50)
152 | expect(component1.toJSON()).toMatchSnapshot() // error even though async component is available
153 | })
154 |
155 | it('components passed as elements: loading', async () => {
156 | const asyncComponent = createComponent(40, )
157 | const Component = universal(asyncComponent, {
158 | loading:
159 | })
160 |
161 | const component1 = renderer.create()
162 | expect(component1.toJSON()).toMatchSnapshot() // initial
163 |
164 | await waitFor(20)
165 | expect(component1.toJSON()).toMatchSnapshot() // loading
166 |
167 | await waitFor(20)
168 | expect(component1.toJSON()).toMatchSnapshot() // loaded
169 |
170 | const component2 = renderer.create()
171 | expect(component2.toJSON()).toMatchSnapshot() // reload
172 | })
173 |
174 | it('components passed as elements: error', async () => {
175 | const asyncComponent = createComponent(40, null)
176 | const Component = universal(asyncComponent, {
177 | error:
178 | })
179 |
180 | const component1 = renderer.create()
181 | expect(component1.toJSON()).toMatchSnapshot() // initial
182 |
183 | await waitFor(20)
184 | expect(component1.toJSON()).toMatchSnapshot() // loading
185 |
186 | await waitFor(20)
187 | expect(component1.toJSON()).toMatchSnapshot() // Error!
188 |
189 | const component2 = renderer.create()
190 | expect(component2.toJSON()).toMatchSnapshot() // loading again...
191 | })
192 |
193 | it('arguments/props passed to asyncComponent function for data-fetching', async () => {
194 | const asyncComponent = async (props, cb) => {
195 | // this is what you would actually be doing here:
196 | // const data = await fetch(`/path?key=${props.prop}`)
197 | // const value = await data.json()
198 |
199 | const value = props.prop
200 | const component = await Promise.resolve({value}
)
201 | return component
202 | }
203 | const Component = universal(asyncComponent, {
204 | key: null
205 | })
206 |
207 | const component1 = renderer.create()
208 | expect(component1.toJSON()).toMatchSnapshot() // initial
209 |
210 | await waitFor(10)
211 | expect(component1.toJSON()).toMatchSnapshot() // loaded
212 | })
213 | })
214 |
215 | describe('server-side rendering', () => {
216 | it('es6: default export automatically resolved', async () => {
217 | const asyncComponent = createComponent(40, null)
218 | const Component = universal(asyncComponent, {
219 | path: path.join(__dirname, '../__fixtures__/component')
220 | })
221 |
222 | const component = renderer.create()
223 |
224 | expect(component.toJSON()).toMatchSnapshot() // serverside
225 | })
226 |
227 | it('es5: module.exports resolved', async () => {
228 | const asyncComponent = createComponent(40, null)
229 | const Component = universal(asyncComponent, {
230 | path: path.join(__dirname, '../__fixtures__/component.es5')
231 | })
232 |
233 | const component = renderer.create()
234 |
235 | expect(component.toJSON()).toMatchSnapshot() // serverside
236 | })
237 | })
238 |
239 | describe('other options', () => {
240 | it('key (string): resolves export to value of key', async () => {
241 | const asyncComponent = createComponent(20, { fooKey: MyComponent })
242 | const Component = universal(asyncComponent, {
243 | key: 'fooKey'
244 | })
245 |
246 | const component = renderer.create()
247 | expect(component.toJSON()).toMatchSnapshot() // initial
248 |
249 | await waitFor(5)
250 | expect(component.toJSON()).toMatchSnapshot() // loading
251 |
252 | await waitFor(30)
253 | expect(component.toJSON()).toMatchSnapshot() // success
254 | })
255 |
256 | it('key (function): resolves export to function return', async () => {
257 | const asyncComponent = createComponent(20, { fooKey: MyComponent })
258 | const Component = universal(asyncComponent, {
259 | key: module => module.fooKey
260 | })
261 |
262 | const component = renderer.create()
263 | expect(component.toJSON()).toMatchSnapshot() // initial
264 |
265 | await waitFor(5)
266 | expect(component.toJSON()).toMatchSnapshot() // loading
267 |
268 | await waitFor(20)
269 | expect(component.toJSON()).toMatchSnapshot() // success
270 | })
271 |
272 | it('onLoad (async): is called and passed an es6 module', async () => {
273 | const onLoad = jest.fn()
274 | const mod = { __esModule: true, default: MyComponent }
275 | const asyncComponent = createComponent(40, mod)
276 | const Component = universal(asyncComponent, { onLoad })
277 |
278 | const component = renderer.create()
279 |
280 | await waitFor(50)
281 | const info = { isServer: false, isSync: false }
282 | const props = { foo: 'bar' }
283 | expect(onLoad).toBeCalledWith(mod, info, props)
284 |
285 | expect(component.toJSON()).toMatchSnapshot() // success
286 | })
287 |
288 | it('onLoad (async): is called and passed entire module', async () => {
289 | const onLoad = jest.fn()
290 | const mod = { __esModule: true, foo: MyComponent }
291 | const asyncComponent = createComponent(40, mod)
292 | const Component = universal(asyncComponent, {
293 | onLoad,
294 | key: 'foo'
295 | })
296 |
297 | const component = renderer.create()
298 |
299 | await waitFor(50)
300 | const info = { isServer: false, isSync: false }
301 | expect(onLoad).toBeCalledWith(mod, info, {})
302 |
303 | expect(component.toJSON()).toMatchSnapshot() // success
304 | })
305 |
306 | it('onLoad (sync): is called and passed entire module', async () => {
307 | const onLoad = jest.fn()
308 | const asyncComponent = createComponent(40)
309 | const Component = universal(asyncComponent, {
310 | onLoad,
311 | key: 'default',
312 | path: path.join(__dirname, '..', '__fixtures__', 'component')
313 | })
314 |
315 | renderer.create()
316 |
317 | expect(onLoad).toBeCalledWith(
318 | require('../__fixtures__/component'),
319 | {
320 | isServer: false,
321 | isSync: true
322 | },
323 | {}
324 | )
325 | })
326 |
327 | it('minDelay: loads for duration of minDelay even if component ready', async () => {
328 | const asyncComponent = createComponent(40, MyComponent)
329 | const Component = universal(asyncComponent, {
330 | minDelay: 60
331 | })
332 |
333 | const component = renderer.create()
334 | expect(component.toJSON()).toMatchSnapshot() // initial
335 |
336 | await waitFor(45)
337 | expect(component.toJSON()).toMatchSnapshot() // still loading
338 |
339 | await waitFor(30)
340 | expect(component.toJSON()).toMatchSnapshot() // loaded
341 | })
342 | })
343 |
344 | describe('SSR flushing: flushModuleIds() + flushChunkNames()', () => {
345 | it('babel', async () => {
346 | const App = createApp()
347 |
348 | flushModuleIds() // insure sets are empty:
349 | flushChunkNames()
350 |
351 | renderer.create()
352 | let paths = flushModuleIds().map(normalizePath)
353 | let chunkNames = flushChunkNames()
354 |
355 | expect(paths).toEqual(['/component', '/component2'])
356 | expect(chunkNames).toEqual(['component', 'component2'])
357 |
358 | renderer.create()
359 | paths = flushModuleIds().map(normalizePath)
360 | chunkNames = flushChunkNames()
361 |
362 | expect(paths).toEqual(['/component', '/component3'])
363 | expect(chunkNames).toEqual(['component', 'component3'])
364 | })
365 |
366 | it('babel (babel-plugin)', async () => {
367 | const App = createBablePluginApp()
368 |
369 | flushModuleIds() // insure sets are empty:
370 | flushChunkNames()
371 |
372 | renderer.create()
373 | let paths = flushModuleIds().map(normalizePath)
374 | let chunkNames = flushChunkNames().map(normalizePath)
375 |
376 | expect(paths).toEqual(['/component', '/component2'])
377 | expect(chunkNames).toEqual(['/component', '/component2'])
378 |
379 | renderer.create()
380 | paths = flushModuleIds().map(normalizePath)
381 | chunkNames = flushChunkNames().map(normalizePath)
382 |
383 | expect(paths).toEqual(['/component', '/component3'])
384 | expect(chunkNames).toEqual(['/component', '/component3'])
385 | })
386 |
387 | it('webpack', async () => {
388 | global.__webpack_require__ = path => __webpack_modules__[path]
389 |
390 | // modules stored by paths instead of IDs (replicates babel implementation)
391 | global.__webpack_modules__ = {
392 | [createPath('component')]: require(createPath('component')),
393 | [createPath('component2')]: require(createPath('component2')),
394 | [createPath('component3')]: require(createPath('component3'))
395 | }
396 |
397 | const App = createApp(true)
398 |
399 | flushModuleIds() // insure sets are empty:
400 | flushChunkNames()
401 |
402 | renderer.create()
403 | let paths = flushModuleIds().map(normalizePath)
404 | let chunkNames = flushChunkNames()
405 |
406 | expect(paths).toEqual(['/component', '/component2'])
407 | expect(chunkNames).toEqual(['component', 'component2'])
408 |
409 | renderer.create()
410 | paths = flushModuleIds().map(normalizePath)
411 | chunkNames = flushChunkNames()
412 |
413 | expect(paths).toEqual(['/component', '/component3'])
414 | expect(chunkNames).toEqual(['component', 'component3'])
415 |
416 | delete global.__webpack_require__
417 | delete global.__webpack_modules__
418 | })
419 |
420 | it('webpack (babel-plugin)', async () => {
421 | global.__webpack_require__ = path => __webpack_modules__[path]
422 |
423 | // modules stored by paths instead of IDs (replicates babel implementation)
424 | global.__webpack_modules__ = {
425 | [createPath('component')]: require(createPath('component')),
426 | [createPath('component2')]: require(createPath('component2')),
427 | [createPath('component3')]: require(createPath('component3'))
428 | }
429 |
430 | const App = createBablePluginApp(true)
431 |
432 | flushModuleIds() // insure sets are empty:
433 | flushChunkNames()
434 |
435 | renderer.create()
436 | let paths = flushModuleIds().map(normalizePath)
437 | let chunkNames = flushChunkNames().map(normalizePath)
438 |
439 | expect(paths).toEqual(['/component', '/component2'])
440 | expect(chunkNames).toEqual(['/component', '/component2'])
441 |
442 | renderer.create()
443 | paths = flushModuleIds().map(normalizePath)
444 | chunkNames = flushChunkNames().map(normalizePath)
445 |
446 | expect(paths).toEqual(['/component', '/component3'])
447 | expect(chunkNames).toEqual(['/component', '/component3'])
448 |
449 | delete global.__webpack_require__
450 | delete global.__webpack_modules__
451 | })
452 |
453 | it('babel: dynamic require', async () => {
454 | const App = createDynamicApp()
455 |
456 | flushModuleIds() // insure sets are empty:
457 | flushChunkNames()
458 |
459 | renderer.create()
460 | let paths = flushModuleIds().map(normalizePath)
461 | let chunkNames = flushChunkNames()
462 |
463 | expect(paths).toEqual(['/component', '/component2'])
464 | expect(chunkNames).toEqual(['component', 'component2'])
465 |
466 | renderer.create()
467 | paths = flushModuleIds().map(normalizePath)
468 | chunkNames = flushChunkNames()
469 |
470 | expect(paths).toEqual(['/component', '/component3'])
471 | expect(chunkNames).toEqual(['component', 'component3'])
472 | })
473 |
474 | it('webpack: dynamic require', async () => {
475 | global.__webpack_require__ = path => __webpack_modules__[path]
476 |
477 | // modules stored by paths instead of IDs (replicates babel implementation)
478 | global.__webpack_modules__ = {
479 | [createPath('component')]: require(createPath('component')),
480 | [createPath('component2')]: require(createPath('component2')),
481 | [createPath('component3')]: require(createPath('component3'))
482 | }
483 |
484 | const App = createDynamicApp(true)
485 |
486 | flushModuleIds() // insure sets are empty:
487 | flushChunkNames()
488 |
489 | renderer.create()
490 | let paths = flushModuleIds().map(normalizePath)
491 | let chunkNames = flushChunkNames()
492 |
493 | expect(paths).toEqual(['/component', '/component2'])
494 | expect(chunkNames).toEqual(['component', 'component2'])
495 |
496 | renderer.create()
497 | paths = flushModuleIds().map(normalizePath)
498 | chunkNames = flushChunkNames()
499 |
500 | expect(paths).toEqual(['/component', '/component3'])
501 | expect(chunkNames).toEqual(['component', 'component3'])
502 |
503 | delete global.__webpack_require__
504 | delete global.__webpack_modules__
505 | })
506 |
507 | it('babel: dynamic require (babel-plugin)', async () => {
508 | const App = createDynamicBablePluginApp()
509 |
510 | flushModuleIds() // insure sets are empty:
511 | flushChunkNames()
512 |
513 | renderer.create()
514 | let paths = flushModuleIds().map(normalizePath)
515 | let chunkNames = flushChunkNames()
516 |
517 | expect(paths).toEqual(['/component', '/component2'])
518 | expect(chunkNames).toEqual(['component', 'component2'])
519 |
520 | renderer.create()
521 | paths = flushModuleIds().map(normalizePath)
522 | chunkNames = flushChunkNames()
523 |
524 | expect(paths).toEqual(['/component', '/component3'])
525 | expect(chunkNames).toEqual(['component', 'component3'])
526 | })
527 |
528 | it('webpack: dynamic require (babel-plugin)', async () => {
529 | global.__webpack_require__ = path => __webpack_modules__[path]
530 |
531 | // modules stored by paths instead of IDs (replicates babel implementation)
532 | global.__webpack_modules__ = {
533 | [createPath('component')]: require(createPath('component')),
534 | [createPath('component2')]: require(createPath('component2')),
535 | [createPath('component3')]: require(createPath('component3'))
536 | }
537 |
538 | const App = createDynamicBablePluginApp(true)
539 |
540 | flushModuleIds() // insure sets are empty:
541 | flushChunkNames()
542 |
543 | renderer.create()
544 | let paths = flushModuleIds().map(normalizePath)
545 | let chunkNames = flushChunkNames()
546 |
547 | expect(paths).toEqual(['/component', '/component2'])
548 | expect(chunkNames).toEqual(['component', 'component2'])
549 |
550 | renderer.create()
551 | paths = flushModuleIds().map(normalizePath)
552 | chunkNames = flushChunkNames()
553 |
554 | expect(paths).toEqual(['/component', '/component3'])
555 | expect(chunkNames).toEqual(['component', 'component3'])
556 |
557 | delete global.__webpack_require__
558 | delete global.__webpack_modules__
559 | })
560 | })
561 |
562 | describe('advanced', () => {
563 | it('dynamic requires (async)', async () => {
564 | const components = { MyComponent }
565 | const asyncComponent = createDynamicComponent(0, components)
566 | const Component = universal(asyncComponent)
567 |
568 | const component = renderer.create()
569 | await waitFor(5)
570 |
571 | expect(component.toJSON()).toMatchSnapshot() // success
572 | })
573 |
574 | it('Component.preload: static preload method pre-fetches chunk', async () => {
575 | const components = { MyComponent }
576 | const asyncComponent = createDynamicComponent(40, components)
577 | const Component = universal(asyncComponent)
578 |
579 | Component.preload({ page: 'MyComponent' })
580 | await waitFor(20)
581 |
582 | const component1 = renderer.create()
583 |
584 | expect(component1.toJSON()).toMatchSnapshot() // still loading...
585 |
586 | // without the preload, it still would be loading
587 | await waitFor(22)
588 | expect(component1.toJSON()).toMatchSnapshot() // success
589 |
590 | const component2 = renderer.create()
591 | expect(component2.toJSON()).toMatchSnapshot() // success
592 | })
593 |
594 | it('Component.preload: static preload method hoists non-react statics', async () => {
595 | // define a simple component with static properties
596 | const FooComponent = props => (
597 | FooComponent {JSON.stringify(props)}
598 | )
599 | FooComponent.propTypes = {}
600 | FooComponent.nonReactStatic = { foo: 'bar' }
601 | // prepare that component to be universally loaded
602 | const components = { FooComponent }
603 | const asyncComponent = createDynamicComponent(40, components)
604 | const Component = universal(asyncComponent)
605 | // wait for preload to finish
606 | await Component.preload({ page: 'FooComponent' })
607 | // assert desired static is available
608 | expect(Component).not.toHaveProperty('propTypes')
609 | expect(Component).toHaveProperty('nonReactStatic')
610 | expect(Component.nonReactStatic).toBe(FooComponent.nonReactStatic)
611 | })
612 |
613 | it('Component.preload: static preload method on node', async () => {
614 | const onLoad = jest.fn()
615 | const onErr = jest.fn()
616 | const opts = { testBabelPlugin: true }
617 |
618 | const Component = universal(dynamicBabelNodeComponent, opts)
619 | await Component.preload({ page: 'component' }).then(onLoad, onErr)
620 |
621 | expect(onErr).not.toHaveBeenCalled()
622 |
623 | const targetComponent = require(createPath('component')).default
624 | expect(onLoad).toBeCalledWith(targetComponent)
625 | })
626 |
627 | it('promise passed directly', async () => {
628 | const asyncComponent = createComponent(0, MyComponent, new Error('ah'))
629 |
630 | const options = {
631 | chunkName: ({ page }) => page,
632 | error: ({ message }) => {message}
633 | }
634 |
635 | const Component = universal(asyncComponent(), options)
636 |
637 | const component = renderer.create()
638 | expect(component.toJSON()).toMatchSnapshot() // loading...
639 |
640 | await waitFor(2)
641 | expect(component.toJSON()).toMatchSnapshot() // loaded
642 | })
643 |
644 | it('babel-plugin', async () => {
645 | const asyncComponent = createBablePluginComponent(
646 | 0,
647 | MyComponent,
648 | new Error('ah'),
649 | 'MyComponent'
650 | )
651 | const options = {
652 | testBabelPlugin: true,
653 | chunkName: ({ page }) => page
654 | }
655 |
656 | const Component = universal(asyncComponent, options)
657 |
658 | const component = renderer.create()
659 | expect(component.toJSON()).toMatchSnapshot() // loading...
660 |
661 | await waitFor(2)
662 | expect(component.toJSON()).toMatchSnapshot() // loaded
663 | })
664 |
665 | it('componentWillReceiveProps: changes component (dynamic require)', async () => {
666 | const components = { MyComponent, MyComponent2 }
667 | const asyncComponent = createDynamicBablePluginComponent(0, components)
668 | const options = {
669 | testBabelPlugin: true,
670 | chunkName: ({ page }) => page
671 | }
672 |
673 | const Component = universal(asyncComponent, options)
674 |
675 | class Container extends React.Component {
676 | render() {
677 | const page = (this.state && this.state.page) || 'MyComponent'
678 | return
679 | }
680 | }
681 |
682 | let instance
683 | const component = renderer.create( (instance = i)} />)
684 | expect(component.toJSON()).toMatchSnapshot() // loading...
685 |
686 | await waitFor(2)
687 | expect(component.toJSON()).toMatchSnapshot() // loaded
688 |
689 | instance.setState({ page: 'MyComponent2' })
690 |
691 | expect(component.toJSON()).toMatchSnapshot() // loading...
692 | await waitFor(2)
693 |
694 | expect(component.toJSON()).toMatchSnapshot() // loaded
695 | })
696 |
697 | it('componentWillReceiveProps: changes component (dynamic require) (no babel plugin)', async () => {
698 | const components = { MyComponent, MyComponent2 }
699 | const { load, options } = createDynamicComponentAndOptions(0, components)
700 |
701 | const Component = universal(load, options)
702 |
703 | class Container extends React.Component {
704 | render() {
705 | const page = (this.state && this.state.page) || 'MyComponent'
706 | return
707 | }
708 | }
709 |
710 | let instance
711 | const component = renderer.create( (instance = i)} />)
712 | expect(component.toJSON()).toMatchSnapshot() // loading...
713 |
714 | await waitFor(2)
715 | expect(component.toJSON()).toMatchSnapshot() // loaded
716 |
717 | instance.setState({ page: 'MyComponent2' })
718 |
719 | expect(component.toJSON()).toMatchSnapshot() // loading...
720 | await waitFor(2)
721 |
722 | expect(component.toJSON()).toMatchSnapshot() // loaded
723 | })
724 | })
725 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | # React Universal Component
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | 🍾🍾🍾 GIT CLONE 3.0 LOCAL DEMO 🚀🚀🚀
45 |
46 |
47 | - [React Universal Component](#react-universal-component)
48 | * [Intro](#intro)
49 | * [What makes Universal Rendering so painful](#what-makes-universal-rendering-so-painful)
50 | * [Installation](#installation)
51 | * [Other Packages You Will Need or Want](#other-packages-you-will-need-or-want)
52 | * [API and Options](#api-and-options)
53 | * [Flushing for SSR](#flushing-for-ssr)
54 | * [Preload](#preload)
55 | * [Static Hoisting](#static-hoisting)
56 | * [Props API](#props-api)
57 | * [Custom Rendering](#custom-rendering)
58 | * [Usage with CSS-in-JS libraries](#usage-with-css-in-js-libraries)
59 | * [Usage with two-stage rendering](#usage-with-two-stage-rendering)
60 | * [Universal Demo](#universal-demo)
61 | * [Contributing](#contributing)
62 | * [Tests](#tests)
63 | * [More from FaceySpacey](#more-from-faceyspacey-in-reactlandia)
64 |
65 |
66 | ## Intro
67 |
68 | For "power users" the traditional SPA is dead. If you're not universally rendering on the server, you're at risk of choking search engine visibility. As it stands, SEO and client-side rendering are not a match for SSR. Even though many search engines claim better SPA indexing, there are many caveats. **Server-side rendering matters: [JavaScript & SEO Backfire – A Hulu.com Case Study](https://www.elephate.com/blog/javascript-seo-backfire-hulu-com-case-study/)**
69 |
70 |
71 | The real problem has been **simultaneous SSR + Splitting**. If you've ever attempted such, *you know*. Here is a one-of-a-kind solution that brings it all together.
72 |
73 | *This is the final universal component for React you'll ever need, and it looks like this:*
74 |
75 | ```js
76 | import universal from 'react-universal-component'
77 |
78 | const UniversalComponent = universal(props => import(`./${props.page}`))
79 |
80 | export default () =>
81 |
82 |
83 |
84 | ```
85 |
86 | It's made possible by our [PR to webpack](https://github.com/webpack/webpack/pull/5235) which built support for ```require.resolveWeak(`'./${page}`)```. Before it couldn't be dynamic--i.e. it supported one module, not a folder of modules.
87 |
88 | Dont forget to check out [webpack-flush-chunks](https://github.com/faceyspacey/webpack-flush-chunks) - it provides the server with info about the render so it can deliver the right assets from the start. No additional async imports on first load. [It reduces time to interactive](https://developers.google.com/web/tools/lighthouse/audits/time-to-interactive)
89 |
90 | > DEFINITION: "Universal Rendering" is *simultaneous* SSR + Splitting, not trading one for the other.
91 |
92 |
93 | Read more about Chunk Flushing
94 |
95 | Gone are the days of holding onto file hashes, manual loading conventions, and elaborate lookups on the server or client. You can frictionlessly support multiple components in one HoC as if imports weren't static. This seemingly small thing--we predict--will lead to universal rendering finally becoming commonplace. It's what a universal component for React is supposed to be.
96 |
97 | [webpack-flush-chunks](https://github.com/faceyspacey/webpack-flush-chunks) brings it together server-side Ultimately that's the real foundation here and the most challenging part. Packages in the past like *React Loadable* did not address this aspect. They excelled at the SPA. In terms of universal rendering, but stumbled on provisioning whats required beyond the scope of knowing the module IDs that were rendered. There are a few extras to take into account.
98 |
99 | **Webpack Flush Chunks** ensures you serve all the chunks rendered on the server to the client in style. To be clear, it's been impossible until now. This is the first general solution to do it, and still the only one. You *must* use it in combination with React Universal Component to fulfill the universal code splitting dream.
100 |
101 |
102 |
103 | ## Installation
104 |
105 | ```yarn add react-universal-component```
106 |
107 | *.babelrc:*
108 | ```js
109 | {
110 | "plugins": ["universal-import"]
111 | }
112 | ```
113 |
114 | > For Typescript or environments without Babel, just copy what [babel-plugin-universal-import](https://github.com/faceyspacey/babel-plugin-universal-import) does.
115 |
116 |
117 | **Reactlandia Articles:**
118 |
119 | - **[code-cracked-for-ssr-plus-splitting-in-reactlandia](https://medium.com/@faceyspacey/code-cracked-for-code-splitting-ssr-in-reactlandia-react-loadable-webpack-flush-chunks-and-1a6b0112a8b8)** 🚀
120 |
121 | - **[announcing-react-universal-component-2-and-babel-plugin-universal-import](https://medium.com/faceyspacey/announcing-react-universal-component-2-0-babel-plugin-universal-import-5702d59ec1f4)** 🚀🚀🚀
122 |
123 | - [how-to-use-webpack-magic-comments-with-react-universal-component](https://medium.com/@faceyspacey/how-to-use-webpacks-new-magic-comment-feature-with-react-universal-component-ssr-a38fd3e296a)
124 |
125 | - [webpack-import-will-soon-fetch-js-and-css-heres-how-you-do-it-today](https://medium.com/faceyspacey/webpacks-import-will-soon-fetch-js-css-here-s-how-you-do-it-today-4eb5b4929852)
126 |
127 | ## Other Packages You Will Need or Want
128 |
129 | To be clear, you can get started with just the simple `HoC` shown at the top of the page, but to accomplish universal rendering, you will need to follow the directions in the *webpack-flush-chunks* package:
130 |
131 | - **[webpack-flush-chunks](https://github.com/faceyspacey/webpack-flush-chunks)**
132 |
133 | And if you want CSS chunks *(which we highly recommend)*, you will need:
134 | - [extract-css-chunks-webpack-plugin](https://github.com/faceyspacey/extract-css-chunks-webpack-plugin)
135 |
136 |
137 | ## API and Options
138 |
139 |
140 | ```js
141 | universal(asyncComponent, options)
142 | ```
143 |
144 | **asyncComponent:**
145 | - ```props => import(`./${props.page}`)```
146 | - `import('./Foo')` *// doesn't need to be wrapped in a function when using the babel plugin!*
147 | - `(props, cb) => require.ensure([], require => cb(null, require('./Foo')))`
148 |
149 | The first argument can be a function that returns a promise, a promise itself, or a function that takes a node-style callback. The most powerful and popular is a function that takes props as an argument.
150 |
151 | **Options (all are optional):**
152 |
153 | - `loading`: LoadingComponent, -- *default: a simple one is provided for you*
154 | - `error`: ErrorComponent, -- *default: a simple one is provided for you*
155 | - `key`: `'foo'` || `module => module.foo` -- *default: `default` export in ES6 and `module.exports` in ES5*
156 | - `timeout`: `15000` -- *default*
157 | - `onError`: `(error, { isServer }) => handleError(error, isServer)
158 | - `onLoad`: `(module, { isSync, isServer }, props) => do(module, isSync, isServer, props)`
159 | - `minDelay`: `0` -- *default*
160 | - `alwaysDelay`: `false` -- *default*
161 | - `loadingTransition`: `true` -- *default*
162 | - `ignoreBabelRename`: `false` -- *default*
163 |
164 | - `render`: `(props, module, isLoading, error) => ` -- *default*: the default rendering logic is roughly equivalent to the following.
165 | ```js
166 | render: (props, Mod, isLoading, error) => {
167 | if (isLoading) return
168 | else if (error) return
169 | else if (Mod) return
170 | return
171 | }
172 | ```
173 |
174 | **In Depth:**
175 | > All components can be classes/functions or elements (e.g: `Loading` or ``)
176 |
177 | - `loading` is the component class or function corresponding to your stateless component that displays while the primary import is loading. While testing out this package, you can leave it out as a simple default one is used.
178 |
179 | - `error` similarly is the component that displays if there are any errors that occur during your aynschronous import. While testing out this package, you can leave it out as a simple default one is used.
180 |
181 | - `key` lets you specify the export from the module you want to be your component if it's not `default` in ES6 or `module.exports` in ES5. It can be a string corresponding to the export key, or a function that's passed the entire module and returns the export that will become the component.
182 |
183 | - `timeout` allows you to specify a maximum amount of time before the `error` component is displayed. The default is 15 seconds.
184 |
185 |
186 | - `onError` is a callback called if async imports fail. It does not apply to sync requires.
187 |
188 | - `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:
189 | ```js
190 | onLoad: (module, info, props) => {
191 | props.store.replaceReducer({ ...otherReducers, foo: module.fooReducer })
192 |
193 | // if a route triggered component change, new reducers needs to reflect it
194 | props.store.dispatch({ type: 'INIT_ACTION_FOR_ROUTE', payload: { param: props.param } })
195 | }
196 | ````
197 | **As you can see we have thought of everything you might need to really do code-splitting right (we have real apps that use this stuff).** `onLoad` is fired directly before the component is rendered so you can setup any reducers/etc it depends on. Unlike the `onAfter` prop, this *option* to the `universal` *HOC* is 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. But also keep in mind you will need to do things like replace reducers on both the server + client for the imported component that uses new reducers to render identically in both places.
198 |
199 | - `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.
200 |
201 | - `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.*
202 |
203 | - `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.
204 | - `ignoreBabelRename` is by default set to `false` which allows the plugin to attempt and name the dynamically imported chunk (replacing `/` with `-`). In more advanced scenarios where more granular control is required over the webpack chunk name, you should set this to `true` in addition to providing a function to `chunkName` to control chunk naming.
205 |
206 | - `render` overrides the default rendering logic. This option enables some interesting and useful usage of this library. Please refer to the [Custom Rendering](#custom-rendering) section.
207 |
208 | ## What makes Universal Rendering so painful
209 |
210 | One wouldn't expect it to be. Sadly the SSR part of react hasn't been as innovative as the CSR side.
211 |
212 | If you didn't know how much of a pain in the ass *universal rendering* has been, check this quote from the **React Router** docs:
213 |
214 | 
215 |
216 | ## Flushing for SSR
217 |
218 | Below is the most important thing on this page. It's a quick example of the connection between this package and [webpack-flush-chunks](https://github.com/faceyspacey/webpack-flush-chunks):
219 |
220 | ```js
221 | import { clearChunks, flushChunkNames } from 'react-universal-component/server'
222 | import flushChunks from 'webpack-flush-chunks'
223 | import ReactDOM from 'react-dom/server'
224 |
225 | export default function serverRender(req, res) => {
226 | clearChunks()
227 | const app = ReactDOM.renderToString()
228 | const { js, styles, cssHash } = flushChunks(webpackStats, {
229 | chunkNames: flushChunkNames()
230 | })
231 |
232 | res.send(`
233 |
234 |
235 |
236 | ${styles}
237 |
238 |
239 | ${app}
240 | ${cssHash}
241 | ${js}
242 |
243 |
244 | `)
245 | ```
246 |
247 | > NOTE: this requires that the bundling and rendering happen within the same context. The module, react-universal-component/server holds a global cache of all the universal components that are rendered and makes them available via `flushChunkNames`
248 |
249 | If you build step and your render step are separate (i.e. using a static site generator like `react-static`) we can use a Provider type component to locate the components that should be included on the client. This is not the recommended use of locating chunk names and only should be used when absolutely necessary. It uses React's context functionality to pass the `report` function to react-universal-component.
250 |
251 | ```js
252 | import { ReportChunks } from 'react-universal-component'
253 | import flushChunks from 'webpack-flush-chunks'
254 | import ReactDOM from 'react-dom/server'
255 |
256 | function renderToHtml () => {
257 | let chunkNames = []
258 | const appHtml =
259 | ReactDOM.renderToString(
260 | chunkNames.push(chunkName)}>
261 |
262 | ,
263 | ),
264 | )
265 |
266 | const { scripts } = flushChunks(webpackStats, {
267 | chunkNames,
268 | })
269 |
270 | return appHtml
271 | }
272 | ```
273 |
274 |
275 | ## Preload
276 |
277 | You can preload the async component if there's a likelihood it will show soon:
278 |
279 | ```js
280 | import universal from 'react-universal-component'
281 |
282 | const UniversalComponent = universal(import('./Foo'))
283 |
284 | export default class MyComponent extends React.Component {
285 | componentWillMount() {
286 | UniversalComponent.preload()
287 | }
288 |
289 | render() {
290 | return {this.props.visible && }
291 | }
292 | }
293 | ```
294 |
295 | ## Static Hoisting
296 |
297 | If your imported component has static methods like this:
298 |
299 | ```js
300 | export default class MyComponent extends React.Component {
301 | static doSomething() {}
302 | render() {}
303 | }
304 | ```
305 |
306 | Then this will work:
307 |
308 | ```js
309 | const MyUniversalComponent = universal(import('./MyComponent'))
310 |
311 | // render it
312 |
313 |
314 | // call this only after you're sure it has loaded
315 | MyUniversalComponent.doSomething()
316 |
317 | // If you are not sure if the component has loaded or rendered, call preloadWeak().
318 | // This will attempt to hoist and return the inner component,
319 | // but only if it can be loaded synchronously, otherwise null will be returned.
320 | const InnerComponent = MyUniversalComponent.preloadWeak()
321 | if (InnerComponent) {
322 | InnerComponent.doSomething()
323 | }
324 | ```
325 | > NOTE: for imports using dynamic expressions, conflicting methods will be overwritten by the current component
326 |
327 | > NOTE: preloadWeak() will not cause network requests, which means that if the component has not loaded, it will return null. Use it only when you need to retrieve and hoist the wrapped component before rendering. Calling preloadWeak() on your server will ensure that all statics are hoisted properly.
328 |
329 | ## Props API
330 |
331 | - `isLoading: boolean`
332 | - `error: new Error`
333 | - `onBefore`: `({ isMount, isSync, isServer }) => doSomething(isMount, isSync, isServer)`
334 | - `onAfter`: `({ isMount, isSync, isServer }, Component) => doSomething(Component, isMount, etc)`
335 | - `onError`: `error => handleError(error)`
336 |
337 | ### `isLoading` + `error`:
338 | 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).
339 |
340 | Here's an example using Apollo:
341 |
342 | ```js
343 | const UniversalUser = universal(import('./User'))
344 |
345 | const User = ({ loading, error, user }) =>
346 |
347 |
348 |
349 |
350 | export default graphql(gql`
351 | query CurrentUser {
352 | user {
353 | id
354 | name
355 | }
356 | }
357 | `, {
358 | props: ({ ownProps, data: { loading, error, user } }) => ({
359 | loading,
360 | error,
361 | user,
362 | }),
363 | })(User)
364 | ```
365 | > 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**.
366 |
367 | ### `onBefore` + `onAfter`:
368 |
369 | `onBefore/After` are callbacks called before and after the wrapped component loads/changes on both `componentWillMount` and `componentWillReceiveProps`. This enables you to display `loading` indicators elsewhere in the UI.
370 |
371 | If the component is already cached or you're on the server, they will both be called ***back to back synchronously***. They're both still called in this case for consistency. And they're both called before re-render to trigger the least amount of renders. Each receives an `info` object, giving you full flexibility in terms of deciding what to do. Here are the keys on it:
372 |
373 | - `isMount` *(whether the component just mounted)*
374 | - `isSync` *(whether the imported component is already available from previous usage and required synchronsouly)*
375 | - `isServer` *(very rarely will you want to do stuff on the server; note: server will always be sync)*
376 |
377 | `onAfter` is also passed a second argument containing the imported `Component`, which you can use to do things like call its static methods.
378 |
379 |
380 | ```js
381 | const UniversalComponent = universal(props => import(`./${props.page}`))
382 |
383 | const MyComponent = ({ dispatch, isLoading }) =>
384 |
385 | {isLoading &&
loading...
}
386 |
387 |
!isSync && dispatch({ type: 'LOADING', true })}
390 | onAfter={({ isSync }, Component) => !isSync && dispatch({ type: 'LOADING', false })}
391 | />
392 |
393 | ```
394 |
395 | > Keep in mind if you call `setState` within these callbacks and they are called during `componentWillMount`, the `state` change will have no effect for that render. This is because the component is already in the middle of being rendered within the parent on which `this.setState` will be called. You can use *Redux* to call `dispatch` and that will affect child components. However, it's best to use this primarily for setting up and tearing down loading state on the client, and nothing more. If you chose to use them on the server, make sure the client renders the same thing on first load or you will have checksum mismatches. One good thing is that if you use `setState`, it in fact won't cause checksum mismatches since it won't be called on the server or the first render on the client. It will be called on an instant subsequent render on the client and helpfully display errors where it counts. The same won't apply with `dispatch` which can affect children components, and therefore could lead to rendering different things on each side.
396 |
397 | ### `onError`
398 |
399 | `onError` is similar to the `onError` static option, except it operates at the component level. Therefore you can bind to `this` of the parent component and call `this.setState()` or `this.props.dispatch()`. Again, it's use case is for when you want to show error information elsewhere in the UI besides just the place that the universal component would otherwise render.
400 |
401 | **The reality is just having the `` as the only placeholder where you can show loading and error information is very limiting and not good enough for real apps. Hence these props.**
402 |
403 | ## Custom Rendering
404 |
405 | This library supports custom rendering so that you can define rendering logic that best suits your own need. This feature has also enabled some interesting and useful usage of this library.
406 |
407 | For example, in some static site generation setup, data are loaded as JavaScript modules, instead of being fetched from an API. In this case, the async component that you are loading is not a React component, but an JavaScript object. To better illustrate this use case, suppose that there are some data modules `pageData1.js`, `pageData2.js`, ... in the `src/data` folder. Each of them corresponds to a page.
408 | ```js
409 | // src/data/pageDataX.js
410 | export default { title: 'foo', content: 'bar' }
411 | ```
412 |
413 | All of the `pageDataX.js` files will be rendered with the `` component below. You wouldn't want to create `Page1.jsx`, `Page2.jsx`, ... just to support the async loading of each data item. Instead, you can just define a single `Page` component as follows.
414 | ```js
415 | const Page = ({ title, content }) => {title}
{content}
416 | ```
417 |
418 | And define your custom rendering logic.
419 | ```js
420 | // src/components/AsyncPage.jsx
421 | import universal from 'react-universal-component'
422 |
423 | const AsyncPage = universal(props => import(`../data/${props.pageDataId}`), {
424 | render: (props, mod) =>
425 | })
426 |
427 | export default AsyncPage
428 | ```
429 |
430 | Now, with a `pageId` props provided by your router, or whatever data sources, you can load your data synchronsouly on the server side (and on the client side for the initial render), and asynchronously on the client side for the subsequent render.
431 | ```js
432 | // Usage
433 | const BlogPost = (props) =>
434 |
435 |
436 |
437 | ```
438 |
439 | ## Usage with CSS-in-JS libraries
440 |
441 | flushChunkNames relies on renderToString's synchronous execution to keep track of dynamic chunks. This is the same strategy used by CSS-in-JS frameworks to extract critical CSS for first render.
442 |
443 | To use these together, simply wrap the CSS library's callback with `clearChunks()` and `flushChunkNames()`:
444 |
445 | ### Example with Aphrodite
446 |
447 | ```js
448 | import { StyleSheetServer } from 'aphrodite'
449 | import { clearChunks, flushChunkNames } from "react-universal-component/server"
450 | import ReactDOM from 'react-dom/server'
451 |
452 | clearChunks()
453 | // similar for emotion, aphodite, glamor, glamorous
454 | const { html, css} = StyleSheetServer.renderStatic(() => {
455 | return ReactDOM.renderToString(app)
456 | })
457 | const chunkNames = flushChunkNames()
458 |
459 | // res.send template
460 | ```
461 |
462 | Just like CSS-in-JS libraries, this library is not compatible with asynchronous renderToString replacements, such as react-dom-stream. Using the two together will give unpredictable results!
463 |
464 | ## Usage with two-stage rendering
465 |
466 | Some data-fetching libraries require an additional step which walks the render tree (react-apollo, isomorphic-relay, react-tree-walker). These are compatible, as long as chunks are cleared after the collection step.
467 |
468 | ### Example with react-apollo and Aphrodite
469 |
470 | ```js
471 | import { getDataFromTree } from "react-apollo"
472 | import { StyleSheetServer } from 'aphrodite'
473 | import { clearChunks, flushChunkNames } from "react-universal-component/server"
474 | import ReactDOM from 'react-dom/server'
475 |
476 | const app = (
477 |
478 |
479 |
480 | )
481 |
482 | // If clearChunks() is run here, getDataFromTree() can cause chunks to leak between requests.
483 | getDataFromTree(app).then(() => {
484 | const initialState = client.cache.extract()
485 |
486 | // This is safe.
487 | clearChunks()
488 | const { html, css} = StyleSheetServer.renderStatic(() => {
489 | return ReactDOM.renderToString(app)
490 | })
491 | const chunkNames = flushChunkNames()
492 |
493 | // res.send template
494 | })
495 | ```
496 |
497 | ## Universal Demo
498 | 🍾🍾🍾 **[faceyspacey/universal-demo](https://github.com/faceyspacey/universal-demo)** 🚀🚀🚀
499 |
500 | ```bash
501 | git clone https://github.com/faceyspacey/universal-demo.git
502 | cd universal-demo
503 | yarn
504 | yarn start
505 | ```
506 |
507 | ## Contributing
508 |
509 | We use [commitizen](https://github.com/commitizen/cz-cli), so run `npm run cm` to make commits. A command-line form will appear, requiring you answer a few questions to automatically produce a nicely formatted commit. Releases, semantic version numbers, tags, changelogs and publishing to NPM will automatically be handled based on these commits thanks to [semantic-release](https://github.com/semantic-release/semantic-release). Be good.
510 |
511 |
512 | ## Tests
513 |
514 | Reviewing a module's tests are a great way to get familiar with it. It's direct insight into the capabilities of the given module (if the tests are thorough). What's even better is a screenshot of the tests neatly organized and grouped (you know the whole "a picture says a thousand words" thing).
515 |
516 | Below is a screenshot of this module's tests running in [Wallaby](https://wallabyjs.com) *("An Integrated Continuous Testing Tool for JavaScript")* which everyone in the React community should be using. It's fantastic and has taken my entire workflow to the next level. It re-runs your tests on every change along with comprehensive logging, bi-directional linking to your IDE, in-line code coverage indicators, **and even snapshot comparisons + updates for Jest!** I requestsed that feature by the way :). It's basically a substitute for live-coding that inspires you to test along your journey.
517 |
518 | 
519 | 
520 |
521 | ## More from FaceySpacey in Reactlandia
522 | - [redux-first-router](https://github.com/faceyspacey/redux-first-router). It's made to work perfectly with *Universal*. Together they comprise our *"frameworkless"* Redux-based approach to what Next.js does (splitting, SSR, prefetching, routing).
523 |
--------------------------------------------------------------------------------