This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new bbccb74e feat: cut first-paint payload and cache static assets (#1545)
bbccb74e is described below

commit bbccb74eededac1e6a5a15c69a9b091cea0e56bb
Author: zhaohai <[email protected]>
AuthorDate: Tue Aug 11 20:22:45 2026 +0800

    feat: cut first-paint payload and cache static assets (#1545)
    
    - nginx: serve hashed /assets/ with Cache-Control immutable + expires 1y so
      repeat visits do not re-download the ~1.8MB JS bundle; index.html is 
served
      no-cache so releases are picked up immediately. Document (commented-out)
      Brotli directives for images that ship ngx_brotli.
    - vite: drop manualChunks.markdown so react-markdown/remark-gfm (used only 
by
      the lazy /ai page) are no longer preloaded on first paint. Verified: entry
      now loads only index+antd+react+css; first-paint gzip ~555KB -> ~507KB.
    - MainLayout: wrap menuItems/breadcrumbMap/breadcrumbItems/navigationEntries
      in useMemo keyed on their real dependencies; previously rebuilt on every
      render (search typing, theme toggle, route change).
    
    Verified: tsc -b clean, full vitest suite passes except pre-existing
    GrafanaDashboardList export timing flakes (also fail on baseline HEAD).
---
 web/nginx.conf                 |  21 ++++
 web/src/layouts/MainLayout.tsx | 229 +++++++++++++++++++++++------------------
 web/vite.config.ts             |   6 +-
 3 files changed, 153 insertions(+), 103 deletions(-)

diff --git a/web/nginx.conf b/web/nginx.conf
index 41833596..ef47cdfa 100644
--- a/web/nginx.conf
+++ b/web/nginx.conf
@@ -10,12 +10,33 @@ server {
     gzip_vary on;
     gzip_types text/plain text/css application/javascript application/json 
image/svg+xml;
 
+    # Brotli is only enabled when the image ships the module (e.g. nginx built 
with
+    # ngx_brotli). The stock `nginx:alpine` image does not include it, so 
enabling the
+    # directives below would break startup:
+    #   brotli on;
+    #   brotli_comp_level 6;
+    #   brotli_types text/plain text/css application/javascript 
application/json image/svg+xml;
+
     # RESOLVER is injected by 15-resolver.sh (podman network gateway only).
     # Variable proxy_pass re-resolves on every request, so a recreated
     # rocketmq-server container with a new IP is picked up without restarting
     # nginx.
     resolver ${RESOLVER} valid=10s;
 
+    # Hashed build assets are content-addressable and never change; cache them 
forever
+    # so repeat visits download the ~1.8MB JS bundle only once.
+    location /assets/ {
+        root /usr/share/nginx/html;
+        expires 1y;
+        add_header Cache-Control "public, max-age=31536000, immutable";
+    }
+
+    # Never cache the HTML shell so a released build is picked up immediately.
+    location = /index.html {
+        root /usr/share/nginx/html;
+        add_header Cache-Control "no-cache";
+    }
+
     location / {
         root /usr/share/nginx/html;
         index index.html;
diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx
index d86ada04..5fd75121 100644
--- a/web/src/layouts/MainLayout.tsx
+++ b/web/src/layouts/MainLayout.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
 import { Layout, Menu, Breadcrumb, Avatar, Dropdown, Empty, Input, Modal, 
message } from 'antd';
 import { Outlet, useNavigate, useLocation } from 'react-router-dom';
 import {
@@ -100,112 +100,133 @@ const MainLayout = () => {
     return () => window.removeEventListener('keydown', openSearchWithShortcut);
   }, []);
 
-  const instanceScopedMatch = location.pathname.match(
-    /^\/instance\/[^/]+\/(topic|consumer|message|acl|dlq|resource-plan)$/,
+  const instanceScopedMatch = useMemo(
+    () =>
+      location.pathname.match(
+        /^\/instance\/[^/]+\/(topic|consumer|message|acl|dlq|resource-plan)$/,
+      ),
+    [location.pathname],
   );
   const selectedMenuKey = instanceScopedMatch
     ? `/instance/${instanceScopedMatch[1]}`
     : location.pathname;
 
-  const menuItems = [
-    { key: '/', icon: <House size={iconSize} weight="duotone" />, label: 
t('nav.home') },
-    {
-      key: 'instance-group',
-      icon: <Database size={iconSize} weight="duotone" />,
-      label: t('nav.instance'),
-      children: [
-        { key: '/instance', icon: <Database size={16} />, label: 
t('nav.instanceList') },
-        { key: '/instance/topic', icon: <ListDashes size={16} />, label: 
t('nav.topic') },
-        { key: '/instance/consumer', icon: <ChatCircleText size={16} />, 
label: t('nav.group') },
-        { key: '/instance/acl', icon: <Key size={16} />, label: t('nav.acl') },
-        { key: '/instance/message', icon: <MagnifyingGlass size={16} />, 
label: t('nav.message') },
-        { key: '/instance/dlq', icon: <TrashSimple size={16} />, label: 
t('nav.dlq') },
-        {
-          key: '/instance/resource-plan',
-          icon: <Notebook size={16} />,
-          label: t('nav.resourcePlan'),
-        },
-      ],
-    },
-    {
-      key: 'cluster-ops-group',
-      icon: <Monitor size={iconSize} weight="duotone" />,
-      label: t('nav.clusterOps'),
-      children: [
-        { key: '/ops/dashboard', icon: <ChartBar size={16} />, label: 
t('nav.dashboard') },
-        { key: '/ops/grafana', icon: <ChartLine size={16} />, label: 
t('nav.grafanaDashboards') },
-        { key: '/cluster/certs', icon: <ShieldCheck size={16} />, label: 
t('nav.certs') },
-        { key: '/cluster', icon: <Database size={16} />, label: 
t('nav.rocketmqCluster') },
-        { key: '/cluster/clients', icon: <PlugsConnected size={16} />, label: 
t('nav.clients') },
-        { key: '/ops/alerts', icon: <BellRinging size={16} />, label: 
t('nav.alertRules') },
-        { key: '/ops/system-alerts', icon: <BellRinging size={16} />, label: 
t('nav.alertEvents') },
-        { key: '/ops/audit', icon: <Notebook size={16} />, label: 
t('nav.audit') },
-        {
-          key: '/ops/nameserver-config-drift',
-          icon: <GitDiff size={16} />,
-          label: t('nav.nameServerConfigDrift'),
-        },
-        {
-          key: '/ops/alert-rule-templates',
-          icon: <Warning size={16} />,
-          label: t('nav.alertRuleAssets'),
-        },
-      ],
-    },
-    { key: '/ai', icon: <Sparkle size={iconSize} weight="duotone" />, label: 
t('nav.ai') },
-    {
-      key: '/settings',
-      icon: <GearSix size={iconSize} weight="duotone" />,
-      label: t('nav.settings'),
-    },
-  ];
+  const menuItems = useMemo(
+    () => [
+      { key: '/', icon: <House size={iconSize} weight="duotone" />, label: 
t('nav.home') },
+      {
+        key: 'instance-group',
+        icon: <Database size={iconSize} weight="duotone" />,
+        label: t('nav.instance'),
+        children: [
+          { key: '/instance', icon: <Database size={16} />, label: 
t('nav.instanceList') },
+          { key: '/instance/topic', icon: <ListDashes size={16} />, label: 
t('nav.topic') },
+          { key: '/instance/consumer', icon: <ChatCircleText size={16} />, 
label: t('nav.group') },
+          { key: '/instance/acl', icon: <Key size={16} />, label: t('nav.acl') 
},
+          {
+            key: '/instance/message',
+            icon: <MagnifyingGlass size={16} />,
+            label: t('nav.message'),
+          },
+          { key: '/instance/dlq', icon: <TrashSimple size={16} />, label: 
t('nav.dlq') },
+          {
+            key: '/instance/resource-plan',
+            icon: <Notebook size={16} />,
+            label: t('nav.resourcePlan'),
+          },
+        ],
+      },
+      {
+        key: 'cluster-ops-group',
+        icon: <Monitor size={iconSize} weight="duotone" />,
+        label: t('nav.clusterOps'),
+        children: [
+          { key: '/ops/dashboard', icon: <ChartBar size={16} />, label: 
t('nav.dashboard') },
+          { key: '/ops/grafana', icon: <ChartLine size={16} />, label: 
t('nav.grafanaDashboards') },
+          { key: '/cluster/certs', icon: <ShieldCheck size={16} />, label: 
t('nav.certs') },
+          { key: '/cluster', icon: <Database size={16} />, label: 
t('nav.rocketmqCluster') },
+          { key: '/cluster/clients', icon: <PlugsConnected size={16} />, 
label: t('nav.clients') },
+          { key: '/ops/alerts', icon: <BellRinging size={16} />, label: 
t('nav.alertRules') },
+          {
+            key: '/ops/system-alerts',
+            icon: <BellRinging size={16} />,
+            label: t('nav.alertEvents'),
+          },
+          { key: '/ops/audit', icon: <Notebook size={16} />, label: 
t('nav.audit') },
+          {
+            key: '/ops/nameserver-config-drift',
+            icon: <GitDiff size={16} />,
+            label: t('nav.nameServerConfigDrift'),
+          },
+          {
+            key: '/ops/alert-rule-templates',
+            icon: <Warning size={16} />,
+            label: t('nav.alertRuleAssets'),
+          },
+        ],
+      },
+      { key: '/ai', icon: <Sparkle size={iconSize} weight="duotone" />, label: 
t('nav.ai') },
+      {
+        key: '/settings',
+        icon: <GearSix size={iconSize} weight="duotone" />,
+        label: t('nav.settings'),
+      },
+    ],
+    [t],
+  );
 
-  const breadcrumbMap: Record<string, string> = {
-    '/': t('nav.home'),
-    '/ops': t('nav.clusterOps'),
-    '/instance': t('nav.instanceList'),
-    '/instance/topic': t('nav.topic'),
-    '/instance/consumer': t('nav.group'),
-    '/instance/message': t('nav.message'),
-    '/instance/acl': t('nav.acl'),
-    '/instance/dlq': t('nav.dlq'),
-    '/instance/resource-plan': t('nav.resourcePlan'),
-    '/cluster': t('nav.rocketmqCluster'),
-    '/cluster/certs': t('nav.certs'),
-    '/cluster/clients': t('nav.clients'),
-    '/ops/dashboard': t('nav.dashboard'),
-    '/ops/grafana': t('nav.grafanaDashboards'),
-    '/ops/system-alerts': t('nav.alertEvents'),
-    '/ops/alerts': t('nav.alertRules'),
-    '/ops/audit': t('nav.audit'),
-    '/ops/nameserver-config-drift': t('nav.nameServerConfigDrift'),
-    '/ops/alert-rule-templates': t('nav.alertRuleAssets'),
-    '/ai': t('nav.ai'),
-    '/settings': t('nav.settings'),
-  };
+  const breadcrumbMap: Record<string, string> = useMemo(
+    () => ({
+      '/': t('nav.home'),
+      '/ops': t('nav.clusterOps'),
+      '/instance': t('nav.instanceList'),
+      '/instance/topic': t('nav.topic'),
+      '/instance/consumer': t('nav.group'),
+      '/instance/message': t('nav.message'),
+      '/instance/acl': t('nav.acl'),
+      '/instance/dlq': t('nav.dlq'),
+      '/instance/resource-plan': t('nav.resourcePlan'),
+      '/cluster': t('nav.rocketmqCluster'),
+      '/cluster/certs': t('nav.certs'),
+      '/cluster/clients': t('nav.clients'),
+      '/ops/dashboard': t('nav.dashboard'),
+      '/ops/grafana': t('nav.grafanaDashboards'),
+      '/ops/system-alerts': t('nav.alertEvents'),
+      '/ops/alerts': t('nav.alertRules'),
+      '/ops/audit': t('nav.audit'),
+      '/ops/nameserver-config-drift': t('nav.nameServerConfigDrift'),
+      '/ops/alert-rule-templates': t('nav.alertRuleAssets'),
+      '/ai': t('nav.ai'),
+      '/settings': t('nav.settings'),
+    }),
+    [t],
+  );
 
   const pathSnippets = location.pathname.split('/').filter((i) => i);
-  const breadcrumbItems = [
-    {
-      title: (
-        <span onClick={() => navigate('/')} style={{ cursor: 'pointer' }}>
-          🏠
-        </span>
-      ),
-      key: 'home',
-    },
-    ...pathSnippets.map((_, index) => {
-      const path = '/' + pathSnippets.slice(0, index + 1).join('/');
-      const isSectionLeaf = instanceScopedMatch && index === 
pathSnippets.length - 1;
-      const leafTitle = isSectionLeaf
-        ? breadcrumbMap[`/instance/${instanceScopedMatch[1]}`]
-        : undefined;
-      return {
-        title: breadcrumbMap[path] || leafTitle || path,
-        key: path,
-      };
-    }),
-  ];
+  const breadcrumbItems = useMemo(
+    () => [
+      {
+        title: (
+          <span onClick={() => navigate('/')} style={{ cursor: 'pointer' }}>
+            🏠
+          </span>
+        ),
+        key: 'home',
+      },
+      ...pathSnippets.map((_, index) => {
+        const path = '/' + pathSnippets.slice(0, index + 1).join('/');
+        const isSectionLeaf = instanceScopedMatch && index === 
pathSnippets.length - 1;
+        const leafTitle = isSectionLeaf
+          ? breadcrumbMap[`/instance/${instanceScopedMatch[1]}`]
+          : undefined;
+        return {
+          title: breadcrumbMap[path] || leafTitle || path,
+          key: path,
+        };
+      }),
+    ],
+    [location.pathname, navigate, breadcrumbMap, instanceScopedMatch],
+  );
 
   const userMenu = {
     onClick: handleUserMenuClick,
@@ -220,9 +241,13 @@ const MainLayout = () => {
   const siderBg = darkMode ? '#2a2a2e' : '#ffffff';
   const topBarBg = darkMode ? 'rgba(42,42,46,0.85)' : 'rgba(255,255,255,0.7)';
   const logoColor = darkMode ? '#e5e5e5' : '#1b1b1a';
-  const navigationEntries: NavigationSearchEntry[] = menuItems
-    .flatMap((item) => ('children' in item && item.children ? item.children : 
[item]))
-    .map((item) => ({ key: String(item.key), label: String(item.label), icon: 
item.icon }));
+  const navigationEntries: NavigationSearchEntry[] = useMemo(
+    () =>
+      menuItems
+        .flatMap((item) => ('children' in item && item.children ? 
item.children : [item]))
+        .map((item) => ({ key: String(item.key), label: String(item.label), 
icon: item.icon })),
+    [menuItems],
+  );
   const searchResults = filterNavigationEntries(navigationEntries, searchText);
   const isAiRoute = location.pathname === '/ai';
 
diff --git a/web/vite.config.ts b/web/vite.config.ts
index dbf50854..ec30743b 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -9,13 +9,17 @@ export default defineConfig(({ mode }) => {
     build: {
       // Ant Design is shared by the application shell and most route 
components. Keep it
       // cacheable as one vendor chunk rather than splitting its cyclic 
internals.
+      // The 1.3MB (gzip ~420KB) size is inherent to the dashboard UI surface; 
combined with
+      // immutable asset caching in nginx.conf, repeat visits download it only 
once.
       chunkSizeWarningLimit: 1400,
       rollupOptions: {
         output: {
           manualChunks: {
             react: ['react', 'react-dom', 'react-router-dom'],
             antd: ['antd', '@ant-design/icons'],
-            markdown: ['react-markdown', 'remark-gfm'],
+            // react-markdown / remark-gfm are only used by the lazily-loaded 
/ai page;
+            // pinning them here would force the entry to preload them on 
first paint.
+            // markdown: ['react-markdown', 'remark-gfm'],
           },
         },
       },

Reply via email to