1 | import {
|
2 | Tapable,
|
3 | AsyncSeriesHook,
|
4 | AsyncParallelBailHook,
|
5 | SyncHook,
|
6 | } from 'tapable';
|
7 | import _ from 'lodash';
|
8 | import React from 'react';
|
9 | import { renderRoutes } from 'react-router-config';
|
10 | import { Router } from 'react-router';
|
11 | import { HashRouter } from 'react-router-dom';
|
12 | import { createBrowserHistory } from 'history';
|
13 | import { render, hydrate } from 'react-dom';
|
14 | import RouteHandler from '../router/handler';
|
15 | import ErrorBoundary from '../components/ErrorBoundary';
|
16 | import { generateMeta } from '../utils/seo';
|
17 | import possibleStandardNames from '../utils/reactPossibleStandardNames';
|
18 | import PreloadDataManager from '../utils/preloadDataManager';
|
19 |
|
20 | const possibleHtmlNames = _.invert(possibleStandardNames);
|
21 | const getPossibleHtmlName = key => possibleHtmlNames[key] || key;
|
22 |
|
23 | export default class ClientHandler extends Tapable {
|
24 | historyUnlistener = null;
|
25 |
|
26 | routeHandler = null;
|
27 |
|
28 | constructor(options) {
|
29 | super();
|
30 | this.addPlugin = this.addPlugin.bind(this);
|
31 | this.manageHistoryChange = this.manageHistoryChange.bind(this);
|
32 | this.preloadManager = new PreloadDataManager();
|
33 |
|
34 | window.PAW_HISTORY = window.PAW_HISTORY || createBrowserHistory({
|
35 | basename: options.env.appRootUrl,
|
36 | });
|
37 | this.history = window.PAW_HISTORY;
|
38 | this.historyUnlistener = this.history.listen(this.manageHistoryChange);
|
39 |
|
40 | this.hooks = {
|
41 | locationChange: new AsyncParallelBailHook(['location', 'action']),
|
42 | beforeLoadData: new AsyncSeriesHook(['setParams', 'getParams']),
|
43 | beforeRender: new AsyncSeriesHook(['Application']),
|
44 | renderRoutes: new AsyncSeriesHook(['AppRoutes']),
|
45 | renderComplete: new SyncHook(),
|
46 | };
|
47 | this.options = options;
|
48 | this.manageServiceWorker();
|
49 | }
|
50 |
|
51 | manageHistoryChange(location, action) {
|
52 | this.hooks.locationChange.callAsync(location, action, () => null);
|
53 | if (this.routeHandler) {
|
54 | this.updatePageMeta(location).catch((e) => {
|
55 |
|
56 | console.log(e);
|
57 | });
|
58 | }
|
59 | }
|
60 |
|
61 | async updatePageMeta(location) {
|
62 | const routes = this.routeHandler.getRoutes();
|
63 | const currentRoutes = RouteHandler.matchRoutes(routes, location.pathname.replace(this.options.env.appRootUrl, ''));
|
64 | const promises = [];
|
65 |
|
66 | let seoData = {};
|
67 | const pwaSchema = this.routeHandler.getPwaSchema();
|
68 | const seoSchema = this.routeHandler.getDefaultSeoSchema();
|
69 |
|
70 | currentRoutes.forEach((r) => {
|
71 | if (r.route && r.route.component && r.route.component.preload) {
|
72 | promises.push(r.route.component.preload(undefined, {
|
73 | route: r.route,
|
74 | match: r.match,
|
75 | ...this.preloadManager.getParams(),
|
76 | }));
|
77 | }
|
78 | });
|
79 |
|
80 | await Promise.all(promises).then(() => {
|
81 | currentRoutes.forEach((r) => {
|
82 | let routeSeo = {};
|
83 | if (r.route.getRouteSeo) {
|
84 | routeSeo = r.route.getRouteSeo();
|
85 | }
|
86 | seoData = _.assignIn(seoData, r.route.seo, routeSeo);
|
87 | });
|
88 |
|
89 | const metaTags = generateMeta(seoData, {
|
90 | baseUrl: window.location.origin,
|
91 | url: window.location.href,
|
92 | seoSchema,
|
93 | pwaSchema,
|
94 | });
|
95 |
|
96 | metaTags.forEach((meta) => {
|
97 | let metaSearchStr = 'meta';
|
98 | let firstMetaSearchStr = '';
|
99 | const htmlMeta = {};
|
100 |
|
101 | if (meta.name === 'title') {
|
102 | document.title = meta.content;
|
103 | }
|
104 |
|
105 | Object.keys(meta).forEach((key) => {
|
106 | htmlMeta[getPossibleHtmlName(key)] = meta[key];
|
107 | if (!firstMetaSearchStr) {
|
108 | firstMetaSearchStr = `meta[${getPossibleHtmlName(key)}=${JSON.stringify(meta[key])}]`;
|
109 | }
|
110 | metaSearchStr += `[${getPossibleHtmlName(key)}=${JSON.stringify(meta[key])}]`;
|
111 | });
|
112 |
|
113 | const alreadyExists = document.querySelector(metaSearchStr);
|
114 | if (!alreadyExists) {
|
115 | const previousExists = document.querySelector(firstMetaSearchStr);
|
116 | if (previousExists) {
|
117 | previousExists.remove();
|
118 | }
|
119 |
|
120 | const metaElement = document.createElement('meta');
|
121 | Object.keys(htmlMeta).forEach((htmlMetaKey) => {
|
122 | metaElement.setAttribute(htmlMetaKey, htmlMeta[htmlMetaKey]);
|
123 | });
|
124 | document.getElementsByTagName('head')[0].appendChild(metaElement);
|
125 | }
|
126 | });
|
127 | });
|
128 | }
|
129 |
|
130 | manageServiceWorker() {
|
131 | if (this.options.env.serviceWorker) {
|
132 | this.hooks.renderComplete.tap('AddServiceWorker', (err) => {
|
133 | if (err) return;
|
134 | if ('serviceWorker' in navigator) {
|
135 | navigator.serviceWorker.register(`${this.options.env.appRootUrl}/sw.js`).catch(() => null);
|
136 | }
|
137 | });
|
138 | } else {
|
139 |
|
140 | this.hooks.renderComplete.tap('RemoveServiceWorker', (err) => {
|
141 | if (err) return;
|
142 | if ('serviceWorker' in navigator) {
|
143 | navigator.serviceWorker.getRegistrations().then((registrations) => {
|
144 | registrations.forEach(registration => registration.unregister());
|
145 | });
|
146 | }
|
147 | });
|
148 | }
|
149 | }
|
150 |
|
151 | addPlugin(plugin) {
|
152 | try {
|
153 | if (plugin.hooks && Object.keys(plugin.hooks).length) {
|
154 | _.each(plugin.hooks, (hookValue, hookName) => {
|
155 | this.hooks[hookName] = hookValue;
|
156 | });
|
157 | }
|
158 | } catch (ex) {
|
159 |
|
160 | console.log(ex);
|
161 | }
|
162 | if (plugin.apply) {
|
163 | plugin.apply(this);
|
164 | }
|
165 | }
|
166 |
|
167 | async run({ routeHandler }) {
|
168 | this.routeHandler = routeHandler;
|
169 | const { env } = this.options;
|
170 | const root = _.get(env, 'clientRootElementId', 'app');
|
171 |
|
172 | if (!document.getElementById(root)) {
|
173 |
|
174 | console.warn(`#${root} element not found in html. thus cannot proceed further`);
|
175 | return false;
|
176 | }
|
177 | const domRootReference = document.getElementById(root);
|
178 | const renderer = env.serverSideRender && !env.singlePageApplication ? hydrate : render;
|
179 |
|
180 | const routes = routeHandler.getRoutes();
|
181 |
|
182 | const currentPageRoutes = RouteHandler.matchRoutes(routes, window.location.pathname.replace(this.options.env.appRootUrl, ''));
|
183 |
|
184 | const promises = [];
|
185 |
|
186 | if (window.PAW_PRELOADED_DATA) {
|
187 | const preloadedData = JSON.parse(atob(window.PAW_PRELOADED_DATA));
|
188 |
|
189 |
|
190 | await new Promise(r => this
|
191 | .hooks
|
192 | .beforeLoadData
|
193 | .callAsync(this.preloadManager.setParams, this.preloadManager.getParams, r));
|
194 |
|
195 | currentPageRoutes.forEach((r, i) => {
|
196 | if (
|
197 | (typeof preloadedData[i] !== 'undefined')
|
198 | && r.route && r.route.component && r.route.component.preload
|
199 | ) {
|
200 | promises.push(r.route.component.preload(preloadedData[i], {
|
201 | route: r.route,
|
202 | match: r.match,
|
203 | ...this.preloadManager.getParams(),
|
204 | }));
|
205 | }
|
206 | });
|
207 | }
|
208 |
|
209 | const AppRouter = (this.options.env.singlePageApplication && this.options.env.hashedRoutes)
|
210 | ? HashRouter : Router;
|
211 |
|
212 | let RouterParams = {
|
213 | history: this.history,
|
214 | };
|
215 | if (this.options.env.singlePageApplication) {
|
216 | RouterParams = {};
|
217 | }
|
218 |
|
219 | const AppRoutes = {
|
220 | renderedRoutes: renderRoutes(routes),
|
221 | setRenderedRoutes: (r) => {
|
222 | AppRoutes.renderedRoutes = r;
|
223 | },
|
224 | getRenderedRoutes: () => AppRoutes.renderedRoutes,
|
225 | };
|
226 |
|
227 | await Promise.all(promises).catch();
|
228 |
|
229 | await (new Promise(r => this.hooks.renderRoutes.callAsync({
|
230 | setRenderedRoutes: AppRoutes.setRenderedRoutes,
|
231 | getRenderedRoutes: AppRoutes.getRenderedRoutes,
|
232 | }, r)));
|
233 |
|
234 |
|
235 |
|
236 |
|
237 |
|
238 | const children = (
|
239 | <AppRouter basename={env.appRootUrl} {...RouterParams}>
|
240 | {AppRoutes.renderedRoutes}
|
241 | </AppRouter>
|
242 | );
|
243 | const Application = {
|
244 | children,
|
245 | currentRoutes: currentPageRoutes.slice(0),
|
246 | routes: routes.slice(0),
|
247 | };
|
248 |
|
249 | return new Promise((resolve) => {
|
250 | this.hooks.beforeRender.callAsync(Application, async () => {
|
251 |
|
252 | renderer(
|
253 | <ErrorBoundary ErrorComponent={routeHandler.getErrorComponent()}>
|
254 | {Application.children}
|
255 | </ErrorBoundary>,
|
256 | domRootReference,
|
257 |
|
258 | () => {
|
259 | window.PAW_PRELOADED_DATA = null;
|
260 | delete window.PAW_PRELOADED_DATA;
|
261 | if (document.getElementById('__pawjs_preloaded')) {
|
262 | document.getElementById('__pawjs_preloaded').remove();
|
263 | }
|
264 | this.hooks.renderComplete.call();
|
265 | resolve();
|
266 | },
|
267 | );
|
268 | });
|
269 | });
|
270 | }
|
271 | }
|