Reputation: 955
I have a monorepo Node solution with TS React apps, a Shared Components application, and my apps are built with Vite. This is the structure of my solution:
- root-app/
- node_modules/
- package.json
- ...
- shared-components/
- node_modules/
- package.json
- Components
- Context
- GrommetWrapper
- ...
- webapp/
- node_modules/
- package.json
- ...
I am using Vite to build my web apps, and I am encountering an issue where multiple instances of certain components, such as the app context and the Grommet theme, are being created. This is causing unexpected behavior and performance issues in my application.
Here is my EntryClient.tsx:
import ReactDOM from 'react-dom/client';
import { I18nextProvider, useSSR } from 'react-i18next';
import { BrowserRouter } from 'react-router-dom';
import i18n from './i18n';
import AppErrorBoundary from '../../Shared/Components/ErrorBoundary/ErrorBoundary';
import MainRouter from '../../Shared/Components/Router/MainRouter';
import AppContextProvider from '../../Shared/Components/Template/Context/AppContextProvider';
import GrommetWrapper from '../../Shared/Components/Template/GrommetWrapper/GrommetWrapper';
import { ICustomWindowWithData } from '../../Shared/Interfaces/ICustomWindow';
declare const window: ICustomWindowWithData;
// eslint-disable-next-line react-refresh/only-export-components
const App: React.FC = () => {
// @ts-expect-error this is a type error from the react-i18next library
useSSR(window.__INITIAL_DATA__.i18nextInstance.store, window.__INITIAL_DATA__.context.Language || 'pt');
return (
<AppContextProvider context={window.__INITIAL_DATA__.context}>
<I18nextProvider i18n={i18n}>
<BrowserRouter>
<GrommetWrapper>
<AppErrorBoundary>
<MainRouter />
</AppErrorBoundary>
</GrommetWrapper>
</BrowserRouter>
</I18nextProvider>
</AppContextProvider>
);
};
ReactDOM.hydrateRoot(document.getElementById('root')!, <App />);
This is my vite.config.ts:
import {
AliasOptions,
CommonServerOptions, ConfigEnv, UserConfig, defineConfig, loadEnv,
} from 'vite';
import react from '@vitejs/plugin-react';
import { cjsInterop } from 'vite-plugin-cjs-interop';
import fs from 'node:fs';
import path from 'node:path';
// https://vitejs.dev/config/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export default defineConfig(({ command, mode, isSsrBuild, isPreview }: ConfigEnv) => {
console.log('🚀 ~ defineConfig ~ command:', command);
console.log('🚀 ~ defineConfig ~ isPreview:', isPreview);
console.log('🚀 ~ defineConfig ~ isSsrBuild:', isSsrBuild);
const httpsSettings: CommonServerOptions['https'] = {
pfx: fs.readFileSync('./cert/certificate.pfx'),
passphrase: 'password',
};
const aliases: AliasOptions = (isSsrBuild || command === 'serve') ? {} : {
'@shared': path.resolve(__dirname, '../Shared'),
react: path.resolve(__dirname, '../../node_modules/react'),
'react-dom': path.resolve(__dirname, '../../node_modules/react-dom'),
'react-router-dom': path.resolve(__dirname, '../../node_modules/react-router-dom'),
'react-i18next': path.resolve(__dirname, '../../node_modules/react-i18next'),
'react-toastify': path.resolve(__dirname, '../../node_modules/react-toastify'),
'styled-components': path.resolve(__dirname, '../../node_modules/styled-components'),
grommet: path.resolve(__dirname, '../../node_modules/grommet'),
i18next: path.resolve(__dirname, '../../node_modules/i18next'),
};
console.log('🚀 ~ defineConfig ~ aliases:', aliases);
const env = loadEnv(mode, process.cwd(), '');
const buildOpts: UserConfig['build'] = {
minify: (JSON.stringify(env.NODE_ENV) === 'production'),
cssMinify: (JSON.stringify(env.NODE_ENV) === 'production'),
sourcemap: true,
rollupOptions: {
external: ['react', 'react-dom', 'react-router-dom', 'react-i18next', 'react-toastify', 'styled-components', 'grommet', 'i18next'],
output: [
{
format: 'cjs',
interop: 'compat',
},
{
format: 'esm',
interop: 'compat',
},
],
},
};
return {
build: buildOpts,
server: {
https: (JSON.stringify(env.NODE_ENV) === 'production') ? undefined : httpsSettings,
},
plugins: [
cjsInterop({
dependencies: [
'grommet-icons',
'styled-components',
'react-easy-crop',
'grommet'
],
}),
react({
babel: {
presets: ['@babel/preset-typescript'],
plugins: [
'@babel/plugin-transform-typescript',
[
'babel-plugin-styled-components',
{
ssr: true,
displayName: true,
fileName: false,
},
],
['@babel/plugin-transform-react-jsx', { runtime: 'automatic' }],
],
},
}),
],
ssr: {
noExternal: ['react-easy-crop', 'tslib'],
},
resolve: {
alias: aliases,
dedupe: ['grommet', '^@babel/', 'react', 'react-dom', 'react-router-dom', 'react-i18next', 'react-toastify', 'styled-components', 'i18next'],
},
};
});
My server.js is:
import https from 'https';
import fs from 'node:fs/promises';
import dotenv from 'dotenv';
import express from 'express';
import i18next from 'i18next';
import Backend from 'i18next-fs-backend';
import i18middleware from 'i18next-http-middleware';
import pem from 'pem';
import { initReactI18next } from 'react-i18next';
import initialContext from '../Shared/Components/Template/Context/initialContext.js';
dotenv.config();
console.log('server.js:14 - Env: ', process.env.NODE_ENV);
const apiUrl = process.env.VITE_API_URL;
// Constants
const port = process.env.PORT || 3000;
const base = process.env.BASE || '/';
const isProduction = process.env.NODE_ENV === 'production';
// Cached production assets
const templateHtml = isProduction ? await fs.readFile('./dist/client/index.html', 'utf-8') : '';
const ssrManifest = isProduction ? await fs.readFile('./dist/client/.vite/ssr-manifest.json', 'utf-8') : undefined;
// i18n
i18next
.use(i18middleware.LanguageDetector)
.use(initReactI18next)
.use(Backend)
.init({
initImmediate: false,
load: 'languageOnly',
lng: 'pt',
fallbackLng: 'pt',
preload: ['en', 'pt'],
ns: ['translation', 'footer', 'header'],
debug: false,
react: {
useSuspense: false,
},
backend: {
loadPath: 'public/locales/{{lng}}/{{ns}}.json',
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
}, (err, t) => {
if (err) return console.error(err);
})
;
// Create http server
const app = express();
// i18n middleware
app.use(
i18middleware.handle(i18next, {
ignoreRoutes: ['/locales', '/favicon.ico', '/robots.txt', '/src'],
removeLngFromUrl: false,
}),
);
// Add Vite or respective production middlewares
let vite;
if (!isProduction) {
const { createServer } = await import('vite');
vite = await createServer({
server: { middlewareMode: true },
appType: 'custom',
base,
});
app.use(vite.middlewares);
} else {
const compression = (await import('compression')).default;
const sirv = (await import('sirv')).default;
app.use(compression());
app.use(base, sirv('./dist/client', { extensions: [] }));
}
const getCookies = function (request) {
var cookies = {};
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
request.headers && request.headers.cookie && request.headers.cookie.split(';').forEach(function (cookie) {
var parts = cookie.match(/(.*?)=(.*)$/);
cookies[parts[1].trim()] = (parts[2] || '').trim();
});
return cookies;
};
const getInitialContext = async (authCookie, url, i18nextInstance) => {
// fetch api for identity and initial context
let identityResult;
try {
const identity = await fetch(
`${apiUrl}/api/Identity/GetInitialContext`,
{
credentials: 'include',
headers: {
Cookie: `Interdev=${authCookie || ''}`,
},
},
);
identityResult = await identity.json();
} catch (error) {
identityResult = initialContext;
console.log(`fetch error (${url}): `, error);
}
const initialContextToPass = identityResult.Success ? identityResult.Object : initialContext;
const dataToPass = {
context: initialContextToPass,
i18nextInstance: i18nextInstance,
};
return dataToPass;
};
const htmlToReturn = async (url, dataToPass) => {
let template;
let render;
if (!isProduction) {
// Always read fresh template in development
template = await fs.readFile('./index.html', 'utf-8');
template = await vite.transformIndexHtml(url, template);
render = (await vite.ssrLoadModule('./src/EntryServer.tsx')).render;
} else {
template = templateHtml;
render = (await import('./dist/server/EntryServer.js')). render;
}
const rendered = await render(url, ssrManifest, dataToPass.context);
const html = template
.replace('<!--app-head-->', rendered.head ?? '')
.replace('<!--app-html-->', rendered.html ?? '')
.replace('<!--app-styles-->', rendered.styleTags ?? '')
.replace('<!--initial-data-->', `<script>window.__INITIAL_DATA__ = ${JSON.stringify(dataToPass)}</script>`)
;
return html;
};
// Middleware logger
const middlewareLogger = (req, res, next) => {
const middlewares = app._router.stack
.filter(r => r.handle && r.handle.name) // Filter to get only named middlewares
.map(r => r.handle.name || 'anonymous middleware');
console.log('Registered Middlewares:', middlewares);
next();
};
// Use the middleware logger
app.use(middlewareLogger);
// Serve HTML
app.use('*', async (req, res) => {
try {
const url = req.originalUrl.replace(base, '/');
const authCookie = getCookies(req).Interdev;
const dataToPass = await getInitialContext(authCookie, url, req.i18n);
req.i18n.changeLanguage(dataToPass.context?.Language || 'pt');
const translation = req.t('footer:desenvolvidoPor');
console.log('🚀 ~ app.use ~ translation:', translation);
const html = await htmlToReturn(url, dataToPass);
res.status(200).set({ 'Content-Type': 'text/html' }).send(html);
} catch (e) {
vite?.ssrFixStacktrace(e);
console.log(e.stack);
res.status(500).end(e.stack);
}
});
// use pem to read pfx file on dev
if (process.env.NODE_ENV === 'development') {
const password = process.env.CERT_PWD || 'password';
pem.readPkcs12('./cert/certificate.pfx', { p12Password: password }, (err, cert) => {
if (err) {
console.error('Error reading PFX file:', err);
return;
}
const options = {
cert: cert.cert,
key: cert.key,
};
https.createServer(options, app).listen(5000, () => {
console.log('Express app running over HTTPS on port 5000');
});
});
}
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
I suspect that the issue might be related to how the components like context, translation and theme are being instantiated in each nested node app. I have tried several approaches but haven't been able to resolve the issue.
My questions are:
How can I ensure that only one instance of the app context and Grommet theme is created across the entire application?
Is there a best practice for structuring a monorepo with shared components and using Vite to avoid such issues?
Any other suggestions to diagnose and fix this problem?
Upvotes: 0
Views: 51