Skip to content

perf(ui): lazy load explore and shared page components#29183

Open
shah-harshit wants to merge 1 commit into
feat/dashboard-lcp-parentfrom
feat/dashboard-lcp-07-common-pages
Open

perf(ui): lazy load explore and shared page components#29183
shah-harshit wants to merge 1 commit into
feat/dashboard-lcp-parentfrom
feat/dashboard-lcp-07-common-pages

Conversation

@shah-harshit

@shah-harshit shah-harshit commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Applies lazy-load cleanup across explore, generic widgets, and shared page components that are not needed in the initial dashboard shell.
  • Split from the dashboard LCP optimization work so it can be reviewed independently.

Testing

  • Not run; tests will be fixed separately for this PR.

Ref: https://github.com/open-metadata/openmetadata-collate/issues/4230


Summary by Gitar

  • Performance optimizations:
    • Lazy-loaded KnowledgePages, ContainerChildren, and ContainerDataModel using withSuspenseFallback.
    • Converted useMemo to useEffect in GenericWidget to dynamically import customizeGlossaryTermPageClassBase on demand.
  • Codebase cleanup:
    • Refactored multiple imports to use import type for interface/enum definitions to improve bundle tree-shaking.
    • Updated several utility imports to EntityDisplayPureUtils and TaskNavigationUtils following directory restructuring.
    • Added missing styles.css import for react-awesome-query-builder in AdvanceSearchProvider.

This will update automatically on new commits.

Greptile Summary

This PR applies lazy-loading across 35 files in the explore, widget, and shared page layers, converting static imports to lazy() + withSuspenseFallback to defer non-critical component bundles and reduce the initial page load weight. It also relocates the @react-awesome-query-builder CSS import from the global entry stylesheet to the three components that actually consume it, and migrates several utility references from EntityDisplayUtils to the new EntityDisplayPureUtils module.

  • Lazy-loading pattern: ~40 components across widget utilities, topic details, domain experts, and customization widgets are converted to withSuspenseFallback(lazy(...)), following the project's established HOC pattern consistently in almost all cases.
  • CSS code-split: @react-awesome-query-builder/antd/css/styles.css is removed from styles/index.ts and co-located in AdvanceSearchProvider, QueryBuilderWidget, and QueryBuilderWidgetV1 so it is only loaded when the query builder mounts.
  • Utility refactoring: Several functions (getEntityDeleteMessage, getEntityMissingError, getCountBadge, requiredField, getTaskDetailPath) are re-imported from their new pure-utility counterparts, with one edge case in EditConnectionFormPage where getServiceLogo correctly stays in EntityDisplayUtils.

Confidence Score: 4/5

Largely mechanical and safe; the changes follow the established withSuspenseFallback(lazy(...)) pattern consistently, with a few small inconsistencies worth addressing before the pattern is extended further.

The PR is a well-scoped refactor with no data-path changes. The few issues found are stylistic or type-safety concerns: one lazy component (EntityLineageTab) skips withSuspenseFallback in favour of an inline Suspense that is functionally equivalent today but easy to break in a future refactor; two components lose their specific prop types via ComponentType<Record<string, unknown>> casts; and a dynamic import in GenericWidget has no error handler. None of these affect production behaviour today.

TopicDetails.component.tsx (inconsistent Suspense strategy for EntityLineageTab) and GenericWidgetUtils.tsx (broad prop-type casts on PropertyValue and TagButton) are the two files worth a second look before this pattern is replicated elsewhere.

Important Files Changed

Filename Overview
openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.tsx Heavy lazy-loading refactor; all components converted except EntityLineageTab, which relies on a manually placed Suspense wrapper at its call site instead of withSuspenseFallback like every other component in this file.
openmetadata-ui/src/main/resources/ui/src/utils/GenericWidget/GenericWidgetUtils.tsx Converts many static imports to lazy; PropertyValue and TagButton are cast to ComponentType<Record<string, unknown>>, dropping their actual prop types and silencing future TypeScript errors at call sites.
openmetadata-ui/src/main/resources/ui/src/components/Customization/GenericWidget/GenericWidget.tsx Converts useMemo (side-effect misuse) to useEffect with dynamic import — correct improvement — but the import .then() has no .catch(), silently swallowing load failures.
openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainExpertsWidget/DomainExpertWidget.tsx Lazifies EditIconButton, PlusIconButton, and UserSelectableList; the two icon buttons use separate lazy() calls for the same source file, creating independent loading states that may render two simultaneous spinners.
openmetadata-ui/src/main/resources/ui/src/styles/index.ts Removes @react-awesome-query-builder/antd/css/styles.css from the global entry; the import is now co-located in the three components that actually use the query builder.
openmetadata-ui/src/main/resources/ui/src/components/DataAssets/CommonWidgets/CommonWidgets.tsx Lazifies 9 components; OwnerLabelV2 and ReviewerLabelV2 drop their explicit generic type argument after wrapping, but no runtime change is expected since the components are used without explicit props.
openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.component.tsx Adds co-located CSS import for @react-awesome-query-builder/antd so the stylesheet only loads when the advanced search provider mounts.
openmetadata-ui/src/main/resources/ui/src/components/Customization/CustomizeTabWidget/CustomizeTabWidget.tsx Converts four static component imports (AddDetailsPageWidgetModal, EmptyWidgetPlaceholder, LeftPanelContainer, GenericWidget) to lazy with withSuspenseFallback; straightforward and correct.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Initial Page Load] -->|"styles/index.ts (entry)"| B[Core Styles + Fonts]
    A --> C[App Shell Components]

    C --> D{User navigates to...}

    D -->|"Explore / Search"| E["AdvanceSearchProvider\n+ QB CSS (lazy chunk)"]
    E --> F["QueryBuilderWidget (lazy)"]
    E --> G["QueryBuilderWidgetV1 (lazy)"]

    D -->|"Topic Detail Page"| H["TopicDetails\n(lazy components)"]
    H --> H1["ActivityFeedTab (lazy)"]
    H --> H2["GenericProvider (lazy)"]
    H --> H3["DataAssetsHeader (lazy)"]
    H --> H4["EntityLineageTab (lazy + inline Suspense)"]

    D -->|"Customize Page"| I["CustomizablePage\n(lazy components)"]
    I --> I1["CustomizeGlossaryTermDetailPage (lazy)"]
    I --> I2["CustomizeTabWidget → GenericWidget (lazy)"]
    I2 --> I3["WIDGET_COMPONENTS map\n(all lazy via GenericWidgetUtils)"]

    D -->|"Domain / Data Products"| J["CommonWidgets\n(9 lazy components)"]
    J --> J1["DescriptionV1, TierWidget, CertificationWidget..."]
    J --> J2["DomainExpertWidget → EditIconButton + PlusIconButton (lazy)"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[Initial Page Load] -->|"styles/index.ts (entry)"| B[Core Styles + Fonts]
    A --> C[App Shell Components]

    C --> D{User navigates to...}

    D -->|"Explore / Search"| E["AdvanceSearchProvider\n+ QB CSS (lazy chunk)"]
    E --> F["QueryBuilderWidget (lazy)"]
    E --> G["QueryBuilderWidgetV1 (lazy)"]

    D -->|"Topic Detail Page"| H["TopicDetails\n(lazy components)"]
    H --> H1["ActivityFeedTab (lazy)"]
    H --> H2["GenericProvider (lazy)"]
    H --> H3["DataAssetsHeader (lazy)"]
    H --> H4["EntityLineageTab (lazy + inline Suspense)"]

    D -->|"Customize Page"| I["CustomizablePage\n(lazy components)"]
    I --> I1["CustomizeGlossaryTermDetailPage (lazy)"]
    I --> I2["CustomizeTabWidget → GenericWidget (lazy)"]
    I2 --> I3["WIDGET_COMPONENTS map\n(all lazy via GenericWidgetUtils)"]

    D -->|"Domain / Data Products"| J["CommonWidgets\n(9 lazy components)"]
    J --> J1["DescriptionV1, TierWidget, CertificationWidget..."]
    J --> J2["DomainExpertWidget → EditIconButton + PlusIconButton (lazy)"]
Loading

Comments Outside Diff (1)

  1. openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainExpertsWidget/DomainExpertWidget.tsx, line 424-438 (link)

    P2 Two independent lazy() chunks for the same source module

    EditIconButton and PlusIconButton are imported from the same file via two separate lazy() calls. Each call creates its own LazyExoticComponent instance with its own loading state, so when both buttons are visible simultaneously, two independent <Suspense> fallback spinners appear while the single underlying module loads. The module itself is only fetched once (browsers cache the resolved promise), but the double-spinner flash can look jarring.

    A single lazy() call that returns both exports would share one loading state and eliminate the double-render.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (1): Last reviewed commit: "perf(ui): lazy load explore and shared p..." | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

@shah-harshit shah-harshit requested a review from a team as a code owner June 18, 2026 10:32
@shah-harshit shah-harshit added UI UI specific issues safe to test Add this label to run secure Github workflows on PRs skip-pr-checks Bypass PR metadata validation check labels Jun 18, 2026
@shah-harshit shah-harshit self-assigned this Jun 18, 2026
Comment on lines 101 to +104
import {
getEntityDeleteMessage,
getEntityMissingError,
} from '../../utils/EntityDisplayUtils';
} from '../../utils/EntityDisplayPureUtils';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 Bug: Imports reference non-existent EntityDisplayPureUtils module

These files were changed to import from ../utils/EntityDisplayPureUtils (e.g. getEntityDeleteMessage, getEntityMissingError, getCountBadge), but no such module exists in this branch. The only file present is utils/EntityDisplayUtils.tsx, which is where these functions are actually defined (confirmed: getCountBadge, getEntityMissingError, getEntityDeleteMessage all live there). As a result the module resolution will fail and the build/typecheck will break. This rename appears to belong to a sibling PR that was not included in this split. Either restore the original EntityDisplayUtils import path, or include the renamed module file in this PR.

Point imports back to the existing EntityDisplayUtils module.:

// Revert path until EntityDisplayPureUtils is introduced
import {
  getEntityDeleteMessage,
  getEntityMissingError,
} from '../../utils/EntityDisplayUtils';
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

Comment on lines +41 to +54
useEffect(() => {
if (
props.isEditView &&
props.widgetKey.startsWith(GlossaryTermDetailPageWidgetKeys.TERMS_TABLE)
) {
setGlossaryChildTerms(
customizeGlossaryTermPageClassBase.getGlossaryChildTerms()
);
import(
'../../../utils/CustomizeGlossaryTerm/CustomizeGlossaryTermBaseClass'
).then(({ default: customizeGlossaryTermPageClassBase }) => {
setGlossaryChildTerms(
customizeGlossaryTermPageClassBase.getGlossaryChildTerms()
);
});
}
}, [props.widgetKey, props.isEditView]);
}, [props.widgetKey, props.isEditView, setGlossaryChildTerms]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Bug: Dynamic import in useEffect can set state after unmount

The effect now performs a dynamic import(...).then(...) and calls setGlossaryChildTerms(...) in the resolved callback. If the component unmounts before the dynamic import resolves, the state setter runs on an unmounted component (harmless warning in React 17, generally a no-op in React 18, but still an unguarded async-after-unmount pattern). Consider guarding with a cancellation flag in the effect cleanup to avoid the late state update.

Guard the post-import state update with a cancellation flag.:

useEffect(() => {
  let cancelled = false;
  if (
    props.isEditView &&
    props.widgetKey.startsWith(GlossaryTermDetailPageWidgetKeys.TERMS_TABLE)
  ) {
    import('../../../utils/CustomizeGlossaryTerm/CustomizeGlossaryTermBaseClass')
      .then(({ default: customizeGlossaryTermPageClassBase }) => {
        if (!cancelled) {
          setGlossaryChildTerms(customizeGlossaryTermPageClassBase.getGlossaryChildTerms());
        }
      });
  }
  return () => { cancelled = true; };
}, [props.widgetKey, props.isEditView, setGlossaryChildTerms]);
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

@gitar-bot

gitar-bot Bot commented Jun 18, 2026

Copy link
Copy Markdown
Code Review 🚫 Blocked 0 resolved / 2 findings

Implements lazy-loading for core dashboard components and refactors imports to improve bundle tree-shaking, but is blocked by broken module references in EntityDisplayPureUtils and potential state update leaks in dynamic imports.

🚨 Bug: Imports reference non-existent EntityDisplayPureUtils module

📄 openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx:101-104 📄 openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagsPage.tsx:56-59 📄 openmetadata-ui/src/main/resources/ui/src/utils/DeleteWidget/DeleteWidgetClassBase.ts:14-15

These files were changed to import from ../utils/EntityDisplayPureUtils (e.g. getEntityDeleteMessage, getEntityMissingError, getCountBadge), but no such module exists in this branch. The only file present is utils/EntityDisplayUtils.tsx, which is where these functions are actually defined (confirmed: getCountBadge, getEntityMissingError, getEntityDeleteMessage all live there). As a result the module resolution will fail and the build/typecheck will break. This rename appears to belong to a sibling PR that was not included in this split. Either restore the original EntityDisplayUtils import path, or include the renamed module file in this PR.

Point imports back to the existing EntityDisplayUtils module.
// Revert path until EntityDisplayPureUtils is introduced
import {
  getEntityDeleteMessage,
  getEntityMissingError,
} from '../../utils/EntityDisplayUtils';
💡 Bug: Dynamic import in useEffect can set state after unmount

📄 openmetadata-ui/src/main/resources/ui/src/components/Customization/GenericWidget/GenericWidget.tsx:41-54

The effect now performs a dynamic import(...).then(...) and calls setGlossaryChildTerms(...) in the resolved callback. If the component unmounts before the dynamic import resolves, the state setter runs on an unmounted component (harmless warning in React 17, generally a no-op in React 18, but still an unguarded async-after-unmount pattern). Consider guarding with a cancellation flag in the effect cleanup to avoid the late state update.

Guard the post-import state update with a cancellation flag.
useEffect(() => {
  let cancelled = false;
  if (
    props.isEditView &&
    props.widgetKey.startsWith(GlossaryTermDetailPageWidgetKeys.TERMS_TABLE)
  ) {
    import('../../../utils/CustomizeGlossaryTerm/CustomizeGlossaryTermBaseClass')
      .then(({ default: customizeGlossaryTermPageClassBase }) => {
        if (!cancelled) {
          setGlossaryChildTerms(customizeGlossaryTermPageClassBase.getGlossaryChildTerms());
        }
      });
  }
  return () => { cancelled = true; };
}, [props.widgetKey, props.isEditView, setGlossaryChildTerms]);
🤖 Prompt for agents
Code Review: Implements lazy-loading for core dashboard components and refactors imports to improve bundle tree-shaking, but is blocked by broken module references in EntityDisplayPureUtils and potential state update leaks in dynamic imports.

1. 🚨 Bug: Imports reference non-existent EntityDisplayPureUtils module
   Files: openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx:101-104, openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagsPage.tsx:56-59, openmetadata-ui/src/main/resources/ui/src/utils/DeleteWidget/DeleteWidgetClassBase.ts:14-15

   These files were changed to import from `../utils/EntityDisplayPureUtils` (e.g. `getEntityDeleteMessage`, `getEntityMissingError`, `getCountBadge`), but no such module exists in this branch. The only file present is `utils/EntityDisplayUtils.tsx`, which is where these functions are actually defined (confirmed: `getCountBadge`, `getEntityMissingError`, `getEntityDeleteMessage` all live there). As a result the module resolution will fail and the build/typecheck will break. This rename appears to belong to a sibling PR that was not included in this split. Either restore the original `EntityDisplayUtils` import path, or include the renamed module file in this PR.

   Fix (Point imports back to the existing EntityDisplayUtils module.):
   // Revert path until EntityDisplayPureUtils is introduced
   import {
     getEntityDeleteMessage,
     getEntityMissingError,
   } from '../../utils/EntityDisplayUtils';

2. 💡 Bug: Dynamic import in useEffect can set state after unmount
   Files: openmetadata-ui/src/main/resources/ui/src/components/Customization/GenericWidget/GenericWidget.tsx:41-54

   The effect now performs a dynamic `import(...).then(...)` and calls `setGlossaryChildTerms(...)` in the resolved callback. If the component unmounts before the dynamic import resolves, the state setter runs on an unmounted component (harmless warning in React 17, generally a no-op in React 18, but still an unguarded async-after-unmount pattern). Consider guarding with a cancellation flag in the effect cleanup to avoid the late state update.

   Fix (Guard the post-import state update with a cancellation flag.):
   useEffect(() => {
     let cancelled = false;
     if (
       props.isEditView &&
       props.widgetKey.startsWith(GlossaryTermDetailPageWidgetKeys.TERMS_TABLE)
     ) {
       import('../../../utils/CustomizeGlossaryTerm/CustomizeGlossaryTermBaseClass')
         .then(({ default: customizeGlossaryTermPageClassBase }) => {
           if (!cancelled) {
             setGlossaryChildTerms(customizeGlossaryTermPageClassBase.getGlossaryChildTerms());
           }
         });
     }
     return () => { cancelled = true; };
   }, [props.widgetKey, props.isEditView, setGlossaryChildTerms]);

Options

Display: compact → Showing less information.

Comment with these commands to change:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

Comment on lines +135 to +139
const EntityLineageTab = lazy(() =>
import('../../Lineage/EntityLineageTab/EntityLineageTab').then((module) => ({
default: module.EntityLineageTab,
}))
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 EntityLineageTab is the only lazy component in this file without withSuspenseFallback

Every other lazily loaded component in this file (13 total) is wrapped with withSuspenseFallback, which co-locates the <Suspense> boundary with the component definition. EntityLineageTab skips this and instead relies on an inline <Suspense> wrapper at its single call site (line 444). If the component is ever moved, reused, or that wrapper is refactored away, it will suspend without a fallback, causing the nearest React error boundary — or a blank screen — to handle it rather than a graceful loader.

Consider wrapping EntityLineageTab consistently with withSuspenseFallback as done for all peers in this file.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +36 to +42
const PropertyValue = withSuspenseFallback(
lazy(() =>
import('../../components/common/CustomPropertyTable/PropertyValue').then(
(m) => ({ default: m.PropertyValue })
)
)
) as ComponentType<Record<string, unknown>>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Broad ComponentType<Record<string, unknown>> cast silences prop-type errors

Both PropertyValue and TagButton are cast to ComponentType<Record<string, unknown>>, which tells TypeScript to accept any object as valid props. Their actual interfaces likely enforce required props (e.g., PropertyValue almost certainly requires a value prop). Every call site that passes incorrect or missing props will now compile cleanly instead of failing, meaning regressions in widget rendering would only surface at runtime.

Consider using ComponentType<PropertyValueProps> / ComponentType<TagButtonProps> (importing the real prop interfaces as type-only imports) so TypeScript continues to enforce correct usage.

Comment on lines +46 to +52
import(
'../../../utils/CustomizeGlossaryTerm/CustomizeGlossaryTermBaseClass'
).then(({ default: customizeGlossaryTermPageClassBase }) => {
setGlossaryChildTerms(
customizeGlossaryTermPageClassBase.getGlossaryChildTerms()
);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The dynamic import inside this useEffect has no .catch() handler. If CustomizeGlossaryTermBaseClass fails to load (e.g., network error or chunk hash mismatch after a deploy), setGlossaryChildTerms is never called and the glossary terms widget silently shows no dummy data in edit/preview mode with no user feedback.

Suggested change
import(
'../../../utils/CustomizeGlossaryTerm/CustomizeGlossaryTermBaseClass'
).then(({ default: customizeGlossaryTermPageClassBase }) => {
setGlossaryChildTerms(
customizeGlossaryTermPageClassBase.getGlossaryChildTerms()
);
});
import(
'../../../utils/CustomizeGlossaryTerm/CustomizeGlossaryTermBaseClass'
)
.then(({ default: customizeGlossaryTermPageClassBase }) => {
setGlossaryChildTerms(
customizeGlossaryTermPageClassBase.getGlossaryChildTerms()
);
})
.catch(() => {
// no-op: edit/preview dummy data won't render, page stays functional
});

@shah-harshit shah-harshit changed the base branch from main to feat/dashboard-lcp-parent June 18, 2026 11:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

safe to test Add this label to run secure Github workflows on PRs skip-pr-checks Bypass PR metadata validation check UI UI specific issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant