Skip to content

feat(lightspeed): replace Context-based drawer state with global store#3697

Closed
its-mitesh-kumar wants to merge 2 commits into
redhat-developer:mainfrom
its-mitesh-kumar:feat/lightspeed-global-drawer-store
Closed

feat(lightspeed): replace Context-based drawer state with global store#3697
its-mitesh-kumar wants to merge 2 commits into
redhat-developer:mainfrom
its-mitesh-kumar:feat/lightspeed-global-drawer-store

Conversation

@its-mitesh-kumar

@its-mitesh-kumar its-mitesh-kumar commented Jul 7, 2026

Copy link
Copy Markdown
Member

Description

Remove legacy (OFS) component re-exports from the main ./ entry point — they are now exclusively available at the ./legacy subpath. Refactors the drawer state management from a globalThis React Context singleton to a proper global store using @backstage/version-bridge + useSyncExternalStore, eliminating the Provider dependency for NFS consumers and resolving cross-MF-boundary state sharing issues cleanly.

Key Changes

  • Legacy exports removed from ./ entry point (breaking) — consumers must use ./legacy
  • New lightspeedDrawerStore.ts provides a singleton store via @backstage/version-bridge
  • New useLightspeedDrawer hook replaces useLightspeedDrawerContext using useSyncExternalStore
  • LightspeedDrawerProvider simplified to a router-bridge shell (no Context.Provider)
  • Updated API report, README migration guide, and changeset (major bump)

Fixed

Architecture: globalThis → version-bridge Global Store

What globalThis was solving

In Module Federation (NFS), each dynamically loaded plugin remote executes in its own JavaScript module scope. When the Lightspeed plugin's code is split across multiple remotes (main plugin, FAB module, Translations module), each remote runs its own copy of LightspeedDrawerContext.tsx. This means createContext() is called multiple times, producing duplicate React Context objects:

  • Remote A (FAB): createContext()Context_A
  • Remote B (Drawer): createContext()Context_B

The FAB component uses Context_A to read state, but LightspeedDrawerProvider provides values on Context_B. They never see each other → "must be used within a LightspeedDrawerProvider" error.

The globalThis fix (PR #3513) solved this by storing the Context in a well-known global key:

const CONTEXT_KEY = '__lightspeed_drawer_context__' as keyof typeof globalThis;

function getOrCreateContext() {
  const existing = (globalThis as any)[CONTEXT_KEY];
  if (existing) return existing;
  const ctx = createContext<LightspeedDrawerContextType | undefined>(undefined);
  (globalThis as any)[CONTEXT_KEY] = ctx;
  return ctx;
}

export const LightspeedDrawerContext = getOrCreateContext();

This ensured all remotes shared the same Context object, so Provider and consumers could communicate.

Problems with globalThis approach

  1. Still requires Provider nesting — Even with a shared Context, a <LightspeedDrawerProvider> must wrap all consumers in the React tree. In NFS, the FAB mounts via AppRootWrapperBlueprint and the drawer content via AppDrawerContentBlueprint — they're siblings, not parent-child. Ensuring the Provider wraps both depends on extension load order, which is fragile.

  2. Race condition risk — If any remote calls getOrCreateContext() before the "canonical" one, it wins. The Context is created without any guarantee about which remote established it first.

  3. No version safety — If two different plugin versions are loaded (e.g., during a rolling upgrade), globalThis['__lightspeed_drawer_context__'] silently collides with no type safety or version negotiation.

  4. Invisible to React DevTools — A raw globalThis key doesn't appear in React's component tree or DevTools Context inspector, making debugging harder.

  5. Not aligned with Backstage conventions — Backstage has a purpose-built utility (@backstage/version-bridge) for exactly this problem, but it wasn't being used.

How version-bridge + useSyncExternalStore solves it

The new approach eliminates React Context entirely for state management:

// store/lightspeedDrawerStore.ts
import { getOrCreateGlobalSingleton } from '@backstage/version-bridge';

export const lightspeedDrawerStore = getOrCreateGlobalSingleton(
  'rhdh-lightspeed-drawer',
  createLightspeedDrawerStore,
);

// hooks/useLightspeedDrawer.ts
import { useSyncExternalStore } from 'react';

export function useLightspeedDrawer() {
  const snapshot = useSyncExternalStore(
    lightspeedDrawerStore.subscribe,
    lightspeedDrawerStore.getSnapshot,
    getServerSnapshot,
  );
  return { isChatbotActive: snapshot.isOpen, toggleChatbot: lightspeedDrawerStore.toggle, ... };
}

Why this is better:

Aspect globalThis Context version-bridge + useSyncExternalStore
Provider needed Yes — must wrap all consumers No — works from anywhere in the tree
Version conflicts Silent collision on same key version-bridge handles multiple versions gracefully
Concurrent mode Context may tear during concurrent renders useSyncExternalStore guarantees tear-free reads
Extension ordering Provider must mount before consumers Irrelevant — store exists before any React renders
Testability Tests need Provider wrapper Tests call store directly, no wrapper needed
Backstage alignment Ad-hoc pattern Uses the official recommended utility

How the data flows:

┌─────────────────────────────────────────────────────────┐
│  @backstage/version-bridge singleton                     │
│  getOrCreateGlobalSingleton('rhdh-lightspeed-drawer')   │
│                                                          │
│  ┌──────────────────────────────────────────────────┐   │
│  │  lightspeedDrawerStore                            │   │
│  │  • state: { isOpen, displayMode, ... }           │   │
│  │  • listeners: Set<() => void>                    │   │
│  │  • handlers: (registered by router-bridge shell) │   │
│  └──────────────────────────────────────────────────┘   │
└──────────────┬──────────────────────┬───────────────────┘
               │                      │
    subscribe + getSnapshot    subscribe + getSnapshot
               │                      │
    ┌──────────▼──────────┐  ┌───────▼─────────────┐
    │  FAB Remote          │  │  Drawer Remote       │
    │  useLightspeedDrawer │  │  useLightspeedDrawer │
    │  → reads isOpen      │  │  → reads displayMode │
    │  → calls toggle()    │  │  → renders chat UI   │
    └─────────────────────┘  └─────────────────────┘

No Provider in between. No Context. No ordering dependency. Just a shared store that any component in any remote can subscribe to.

Test it on RHDH-Local

Prerequisites

  • In .env of RHDH-local
APP_CONFIG_app_packageName=app-next
ENABLE_STANDARD_MODULE_FEDERATION=true
  • app-defaults plugin (provides app-root-wrapper:app/drawer)

1. Build the Lightspeed dynamic plugin

cd workspaces/lightspeed/plugins/lightspeed
yarn build
npx @red-hat-developer-hub/cli@latest plugin export
cp -r dist-dynamic /path/to/rhdh-local/local-plugins/red-hat-developer-hub-backstage-plugin-lightspeed

2. Configure dynamic plugins

In dynamic-plugins.override.yaml:

includes:
  - dynamic-plugins.default.yaml
  - /dynamic-plugins-root/dynamic-plugins.extensions.yaml

plugins:
  # Disable OCI lightspeed frontend
  - package: 'oci://registry.access.redhat.com/rhdh/red-hat-developer-hub-backstage-plugin-lightspeed:{{inherit}}'
    disabled: true
  # Enable local build
  - package: ./local-plugins/red-hat-developer-hub-backstage-plugin-lightspeed
    disabled: false
  # Enable app-defaults (provides drawer shell)
  - package: ./local-plugins/red-hat-developer-hub-backstage-plugin-app-defaults
    disabled: false
  # Keep backend from OCI
  - package: 'oci://registry.access.redhat.com/rhdh/red-hat-developer-hub-backstage-plugin-lightspeed-backend:{{inherit}}'
    disabled: false

3. Configure NFS extensions

In configs/app-config/app-config.local.yaml, use either option:

# Option A: Auto-discover all extensions (simplest — recommended)
app:
  packages: all

# Option B: Explicit list (if you need to limit what's enabled)
app:
  extensions:
    - app-root-wrapper:app/drawer
    - app-root-wrapper:app/lightspeed-fab
    - translation:app/lightspeed-translations

Note: With the global store refactor, extension ordering no longer matters — the drawer state no longer relies on React Context nesting.

4. Start RHDH-local

cd /path/to/rhdh-local
podman compose down
podman compose up -d

5. Verify

  • FAB button appears on all pages
  • Click FAB → drawer opens in "docked to window" mode
  • Toggle to overlay mode → modal opens correctly
  • Chat history, new conversation, model selector all functional
  • No "must be used within a Provider" errors in console

✔️ Checklist

  • A changeset describing the change and affected packages. (more info)
  • Added or Updated documentation
  • Tests for new functionality and regression tests for bug fixes
  • Screenshots attached (for UI changes)

Remove legacy (OFS) re-exports from the main ./ entry point — they are
now exclusively available at ./legacy. Refactor drawer state management
from a globalThis React Context singleton to a proper global store using
@backstage/version-bridge + useSyncExternalStore, eliminating the
Provider dependency for NFS consumers.

BREAKING CHANGE: Legacy component imports must now use the ./legacy subpath.

Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@rhdh-gh-app

rhdh-gh-app Bot commented Jul 7, 2026

Copy link
Copy Markdown

Important

This PR includes changes that affect public-facing API. Please ensure you are adding/updating documentation for new features or behavior.

Changed Packages

Package Name Package Path Changeset Bump Current Version
app-legacy workspaces/lightspeed/packages/app-legacy none v0.0.28
backend workspaces/lightspeed/packages/backend none v0.0.59
@red-hat-developer-hub/backstage-plugin-lightspeed workspaces/lightspeed/plugins/lightspeed major v2.9.1

@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.71429% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 54.34%. Comparing base (5bbd694) to head (e37320b).
⚠️ Report is 2 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3697      +/-   ##
==========================================
+ Coverage   54.31%   54.34%   +0.02%     
==========================================
  Files        2349     2349              
  Lines       89701    89753      +52     
  Branches    25105    25121      +16     
==========================================
+ Hits        48723    48778      +55     
+ Misses      40748    40745       -3     
  Partials      230      230              
Flag Coverage Δ *Carryforward flag
adoption-insights 83.70% <ø> (ø) Carriedforward from d2d9924
ai-integrations 68.35% <ø> (ø) Carriedforward from d2d9924
app-defaults 69.79% <ø> (ø) Carriedforward from d2d9924
augment 46.39% <ø> (ø) Carriedforward from d2d9924
boost 74.68% <ø> (ø) Carriedforward from d2d9924
bulk-import 72.46% <ø> (ø) Carriedforward from d2d9924
cost-management 14.10% <ø> (ø) Carriedforward from d2d9924
dcm 61.81% <ø> (ø) Carriedforward from d2d9924
extensions 61.53% <ø> (ø) Carriedforward from d2d9924
global-floating-action-button 71.18% <ø> (ø) Carriedforward from d2d9924
global-header 59.71% <ø> (ø) Carriedforward from d2d9924
homepage 50.30% <ø> (ø) Carriedforward from d2d9924
install-dynamic-plugins 56.77% <ø> (ø) Carriedforward from d2d9924
konflux 91.49% <ø> (ø) Carriedforward from d2d9924
lightspeed 69.15% <95.71%> (+0.34%) ⬆️
mcp-integrations 85.46% <ø> (ø) Carriedforward from d2d9924
orchestrator 39.89% <ø> (ø) Carriedforward from d2d9924
quickstart 65.63% <ø> (ø) Carriedforward from d2d9924
sandbox 79.56% <ø> (ø) Carriedforward from d2d9924
scorecard 82.67% <ø> (ø) Carriedforward from d2d9924
theme 61.26% <ø> (ø) Carriedforward from d2d9924
translations 7.25% <ø> (ø) Carriedforward from d2d9924
x2a 78.68% <ø> (ø) Carriedforward from d2d9924

*This pull request uses carry forward flags. Click here to find out more.


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 5bbd694...e37320b. Read the comment docs.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…provisional

The synchronous store update via useSyncExternalStore triggers a
re-render before local conversationId state updates, causing the
provisional-thread detection effect to re-disable the New Chat button
after onComplete already enabled it. Add an else branch to reset
newChatCreated=false once the thread has a real ID.

Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@sonarqubecloud

sonarqubecloud Bot commented Jul 7, 2026

Copy link
Copy Markdown

@its-mitesh-kumar

Copy link
Copy Markdown
Member Author

Closing this in favor of #3708.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant