diff --git a/frontend/src/components/DynamicPageRenderer.tsx b/frontend/src/components/DynamicPageRenderer.tsx index 42a3ae8..c99f06c 100644 --- a/frontend/src/components/DynamicPageRenderer.tsx +++ b/frontend/src/components/DynamicPageRenderer.tsx @@ -13,6 +13,7 @@ import { defaultPageService } from '../services/defaultPageService'; import { usePageState } from '../contexts/PageStateContext'; import { useModuleState } from '../contexts/ModuleStateContext'; import { pageContextService } from '../services/PageContextService'; +import { clearPageCache } from '../utils/clearPageCache'; // Declare global interface for backward compatibility declare global { @@ -96,22 +97,50 @@ function useUnifiedPageLoader(options: { // Helper function to convert a layout item const convertLayoutItem = (item: any) => { - const moduleId = ('moduleUniqueId' in item) ? item.moduleUniqueId : item.i; - let pluginId = ('pluginId' in item) ? item.pluginId : - (legacyPage.modules?.[moduleId]?.pluginId); + // The layout item key is a unique instance id; use it to look up the module definition + const layoutKey = ('moduleUniqueId' in item) ? item.moduleUniqueId : item.i; + const moduleDef = legacyPage.modules?.[layoutKey]; - // If no direct pluginId, try to extract from moduleId - if (!pluginId && moduleId) { - pluginId = extractPluginIdFromModuleId(moduleId); - // console.log(`[DynamicPageRenderer] Extracted pluginId '${pluginId}' from moduleId '${moduleId}'`); - } + // Check if item has args property (new format from database) + const hasArgs = item.args && typeof item.args === 'object'; - // Skip items without a valid pluginId to prevent infinite loops + // Prefer pluginId from item directly, then from args, then from module definition + let pluginId = item.pluginId || (hasArgs ? item.pluginId : undefined) || moduleDef?.pluginId; + if (!pluginId && layoutKey) { + pluginId = extractPluginIdFromModuleId(layoutKey) || undefined as any; + } if (!pluginId || pluginId === 'unknown') { - console.warn(`[DynamicPageRenderer] Skipping layout item with missing pluginId:`, { item, moduleId, pluginId }); + console.warn(`[DynamicPageRenderer] Skipping layout item with missing pluginId:`, { item, layoutKey, pluginId }); return null; } + // Extract moduleId from args if available, otherwise use module definition or layout key + let baseModuleId; + if (hasArgs && item.args.moduleId) { + baseModuleId = item.args.moduleId; + console.log('[DynamicPageRenderer] Using moduleId from args:', baseModuleId); + } else { + baseModuleId = moduleDef?.moduleId || moduleDef?.moduleName || layoutKey; + console.log('[DynamicPageRenderer] Using moduleId from moduleDef or layoutKey:', baseModuleId); + } + + // Merge config from module definition and args + // IMPORTANT: Include moduleId in config for LayoutEngine to use + const config = { + ...(moduleDef?.config || {}), + ...(hasArgs ? item.args : {}), + moduleId: baseModuleId // Ensure moduleId is in config + }; + + console.log('[DynamicPageRenderer] Converted layout item:', { + itemId: item.i, + pluginId, + moduleId: baseModuleId, + hasArgs, + args: item.args, + config + }); + return { i: item.i, x: item.x, @@ -125,9 +154,9 @@ function useUnifiedPageLoader(options: { static: ('static' in item) ? item.static : false, isDraggable: ('isDraggable' in item) ? item.isDraggable !== false : true, isResizable: ('isResizable' in item) ? item.isResizable !== false : true, - moduleId, + moduleId: baseModuleId, pluginId, - config: legacyPage.modules?.[moduleId]?.config || {}, + config, }; }; @@ -145,9 +174,11 @@ function useUnifiedPageLoader(options: { // Convert modules to unified format const modules: ModuleConfig[] = []; if (legacyPage.modules) { - Object.entries(legacyPage.modules).forEach(([moduleId, moduleDefinition]) => { + Object.entries(legacyPage.modules).forEach(([layoutKey, moduleDefinition]) => { + // Prefer the plugin's declared module id or name for unified ids + const unifiedModuleId = moduleDefinition.moduleId || moduleDefinition.moduleName || layoutKey; modules.push({ - id: moduleId, + id: unifiedModuleId, pluginId: moduleDefinition.pluginId, type: 'component', ...moduleDefinition.config, @@ -187,6 +218,13 @@ function useUnifiedPageLoader(options: { const cacheKey = getCacheKey(); + // TEMPORARY: Clear cache for AI Chat page to ensure fresh data + if (route === 'ai-chat-1756310855' || location.pathname.includes('ai-chat')) { + clearPageCache('0c8f4dc670a4409c87030c3000779e14'); + clearPageCache('ai-chat'); + console.log('[DynamicPageRenderer] Cleared cache for AI Chat page'); + } + // Check cache first const cachedPage = getCachedPage(cacheKey); if (cachedPage) { diff --git a/frontend/src/components/DynamicPluginRenderer.tsx b/frontend/src/components/DynamicPluginRenderer.tsx index 7398769..0dbfe55 100644 --- a/frontend/src/components/DynamicPluginRenderer.tsx +++ b/frontend/src/components/DynamicPluginRenderer.tsx @@ -50,13 +50,7 @@ export class DynamicPluginRenderer extends React.Component< const Component = module.component; - console.log(`[DynamicPluginRenderer] Rendering component for ${module.name}:`, { - hasComponent: !!Component, - componentType: typeof Component, - componentName: Component?.name, - isFunction: typeof Component === 'function', - moduleProps: Object.keys(module.props || {}) - }); + if (!Component) { console.error(`[DynamicPluginRenderer] No component found for module ${module.name}`); diff --git a/frontend/src/components/PluginModuleRenderer.tsx b/frontend/src/components/PluginModuleRenderer.tsx index 1571372..4ee2f76 100644 --- a/frontend/src/components/PluginModuleRenderer.tsx +++ b/frontend/src/components/PluginModuleRenderer.tsx @@ -766,14 +766,7 @@ export const PluginModuleRenderer: React.FC = ({ ); } - console.log(`[PluginModuleRenderer] Rendering module for ${pluginId}:`, { - moduleId: module.id, - moduleName: module.name, - hasComponent: !!module.component, - componentType: typeof module.component, - componentName: module.component?.name, - propsKeys: Object.keys(module.props || {}) - }); + return ( diff --git a/frontend/src/components/dashboard/Header.tsx b/frontend/src/components/dashboard/Header.tsx index 4f7f00d..c5a3655 100644 --- a/frontend/src/components/dashboard/Header.tsx +++ b/frontend/src/components/dashboard/Header.tsx @@ -40,28 +40,23 @@ const Header = ({ onToggleSidebar, rightContent, sidebarOpen }: HeaderProps) => const [pageTitle, setPageTitle] = useState(''); const [isStudioPage, setIsStudioPage] = useState(false); - // Determine if the current URL is a studio page - const isCurrentPathStudioPage = location.pathname.startsWith('/plugin-studio') || - location.pathname.startsWith('/pages/'); + // Determine if the current URL is a page that should show title + // This includes studio pages, regular pages, and any page that sets a title + const shouldShowPageTitle = location.pathname.startsWith('/plugin-studio') || + location.pathname.startsWith('/pages/') || + location.pathname.startsWith('/page/') || + // Also show for any page that has set a title + Boolean(window.currentPageTitle); // Effect to reset state when location changes useEffect(() => { - if (!isCurrentPathStudioPage) { - setPageTitle(''); - setIsStudioPage(false); - window.currentPageTitle = undefined; - window.isStudioPage = false; - console.log('Header - Reset state because not on a studio page'); - } - }, [location.pathname, isCurrentPathStudioPage]); + // Reset the page title when navigating away from pages + // But don't immediately clear it - let the new page set its title + console.log('Header - Location changed to:', location.pathname); + }, [location.pathname]); // Effect to check the global variables periodically useEffect(() => { - // Only check for updates if we're on a studio page - if (!isCurrentPathStudioPage) { - return; - } - // Initial check if (window.currentPageTitle) { setPageTitle(window.currentPageTitle); @@ -73,14 +68,20 @@ const Header = ({ onToggleSidebar, rightContent, sidebarOpen }: HeaderProps) => console.log('Header - Initial global variables:', { currentPageTitle: window.currentPageTitle, isStudioPage: window.isStudioPage, - isCurrentPathStudioPage + pathname: location.pathname }); // Set up an interval to check for changes const intervalId = setInterval(() => { - if (window.currentPageTitle && window.currentPageTitle !== pageTitle) { - console.log('Header - Updating page title from global:', window.currentPageTitle); - setPageTitle(window.currentPageTitle); + // Update page title if it has changed + if (window.currentPageTitle !== pageTitle) { + if (window.currentPageTitle) { + console.log('Header - Updating page title from global:', window.currentPageTitle); + setPageTitle(window.currentPageTitle); + } else { + // Clear the title if it's been unset + setPageTitle(''); + } } if (window.isStudioPage !== undefined && window.isStudioPage !== isStudioPage) { @@ -91,7 +92,7 @@ const Header = ({ onToggleSidebar, rightContent, sidebarOpen }: HeaderProps) => // Clean up the interval when the component unmounts return () => clearInterval(intervalId); - }, [pageTitle, isStudioPage, isCurrentPathStudioPage]); + }, [pageTitle, isStudioPage, location.pathname]); const handleMenu = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); @@ -156,8 +157,8 @@ const Header = ({ onToggleSidebar, rightContent, sidebarOpen }: HeaderProps) => {sidebarOpen ? : } - {/* Display page title for studio pages */} - {isStudioPage && isCurrentPathStudioPage ? ( + {/* Display page title when available */} + {pageTitle ? ( alignItems: 'center', overflow: 'hidden', textOverflow: 'ellipsis', - whiteSpace: 'nowrap' + whiteSpace: 'nowrap', + maxWidth: '400px' }} > - {pageTitle || 'No Title Available'} + {pageTitle} ) : null} diff --git a/frontend/src/features/plugin-studio/components/PluginStudioLayoutUnified.tsx b/frontend/src/features/plugin-studio/components/PluginStudioLayoutUnified.tsx index 2802f91..e4c1bcf 100644 --- a/frontend/src/features/plugin-studio/components/PluginStudioLayoutUnified.tsx +++ b/frontend/src/features/plugin-studio/components/PluginStudioLayoutUnified.tsx @@ -40,26 +40,47 @@ export const PluginStudioLayoutUnified: React.FC = () => { pageManagementOpen, setPageManagementOpen, routeManagementOpen, - setRouteManagementOpen + setRouteManagementOpen, + flushLayoutChanges // Phase 3: Get flush method from context } = usePluginStudio(); // Wrapper function to match the adapter's expected signature - const handleSave = async (pageId: string): Promise => { - console.log('[PluginStudioLayoutUnified] handleSave called with pageId:', pageId); + // QUICK MITIGATION: Accept options parameter with layoutOverride + const handleSave = async (pageId: string, options?: { layoutOverride?: any }): Promise => { + console.log('[PluginStudioLayoutUnified] handleSave called with pageId:', pageId, 'options:', options); try { - await savePage(pageId); + await savePage(pageId, options); console.log('[PluginStudioLayoutUnified] Save completed successfully'); } catch (error) { console.error('[PluginStudioLayoutUnified] Save failed:', error); throw error; } }; + + // Phase 3: Use the actual flush method from the layout hook + const handleLayoutChangeFlush = async (): Promise => { + console.log('[PluginStudioLayoutUnified] Layout change flush requested'); + if (flushLayoutChanges) { + await flushLayoutChanges(); + console.log('[PluginStudioLayoutUnified] Layout changes flushed'); + } else { + // Fallback: Wait a bit to allow debounced updates to complete + console.log('[PluginStudioLayoutUnified] No flush method available, using timeout fallback'); + await new Promise(resolve => setTimeout(resolve, 100)); + } + }; // Migration control state - const [useUnifiedRenderer, setUseUnifiedRenderer] = useState( - import.meta.env.MODE === 'development' // Enable by default in development - ); + // Default: unified if VITE_USE_UNIFIED_RENDERER=true, otherwise dev default is unified, prod default is legacy + const [useUnifiedRenderer, setUseUnifiedRenderer] = useState(() => { + const flag = (import.meta as any).env?.VITE_USE_UNIFIED_RENDERER; + if (typeof flag === 'string') return flag === 'true'; + return import.meta.env.MODE === 'development'; + }); const [unifiedError, setUnifiedError] = useState(null); + + // Get dev mode features - MUST be called before any conditional returns + const { features: devModeFeatures } = usePluginStudioDevMode(); // Handle unified renderer errors and fallback to legacy const handleUnifiedError = (error: Error) => { @@ -103,37 +124,32 @@ export const PluginStudioLayoutUnified: React.FC = () => { {/* Migration Control Panel (Development Only) */} - {import.meta.env.MODE === 'development' && ( + {import.meta.env.MODE === 'development' && devModeFeatures.rendererSwitch && ( - {(() => { - const { features } = usePluginStudioDevMode(); - return features.rendererSwitch && ( - { - setUseUnifiedRenderer(e.target.checked); - setUnifiedError(null); - }} - size="small" - /> - } - label={ - - - Unified Renderer - - - } + { + setUseUnifiedRenderer(e.target.checked); + setUnifiedError(null); + }} + size="small" /> - ); - })()} + } + label={ + + + Unified Renderer + + + } + /> {unifiedError && ( { page={currentPage} layouts={layouts} onLayoutChange={handleLayoutChange} + onLayoutChangeFlush={handleLayoutChangeFlush} onSave={handleSave} previewMode={previewMode} selectedItem={selectedItem} @@ -181,26 +198,23 @@ export const PluginStudioLayoutUnified: React.FC = () => { {/* Renderer Status Indicator (Plugin Studio Dev Mode Only) */} - {import.meta.env.MODE === 'development' && (() => { - const { features } = usePluginStudioDevMode(); - return features.unifiedIndicator && ( - - {useUnifiedRenderer ? 'UNIFIED' : 'LEGACY'} - - ); - })()} + {import.meta.env.MODE === 'development' && devModeFeatures.unifiedIndicator && ( + + {useUnifiedRenderer ? 'UNIFIED' : 'LEGACY'} + + )} {/* Dialogs - These remain unchanged */} @@ -227,4 +241,4 @@ export const PluginStudioLayoutUnified: React.FC = () => { ); }; -export default PluginStudioLayoutUnified; \ No newline at end of file +export default PluginStudioLayoutUnified; diff --git a/frontend/src/features/plugin-studio/components/canvas/PluginCanvas.tsx b/frontend/src/features/plugin-studio/components/canvas/PluginCanvas.tsx index 5d13216..a6df1c1 100644 --- a/frontend/src/features/plugin-studio/components/canvas/PluginCanvas.tsx +++ b/frontend/src/features/plugin-studio/components/canvas/PluginCanvas.tsx @@ -5,6 +5,7 @@ import { GridToolbar } from '../grid-toolbar'; import { GridContainer } from './GridContainer'; import { DropZone } from './DropZone'; import { usePluginStudio, useViewMode } from '../../hooks'; +import { LayoutCommitBadge } from '../../../unified-dynamic-page-renderer/components/LayoutCommitBadge'; /** * Component that renders the grid layout where plugins are placed @@ -103,6 +104,9 @@ export const PluginCanvas: React.FC = () => { Failed to save page. Please try again. + + {/* Phase 1: Add layout commit badge for debugging - moved to bottom-left to avoid grid overlap */} + ); -}; \ No newline at end of file +}; diff --git a/frontend/src/features/plugin-studio/components/dialogs/PageManagementDialogAdapter.tsx b/frontend/src/features/plugin-studio/components/dialogs/PageManagementDialogAdapter.tsx index 7376a03..d5e97a4 100644 --- a/frontend/src/features/plugin-studio/components/dialogs/PageManagementDialogAdapter.tsx +++ b/frontend/src/features/plugin-studio/components/dialogs/PageManagementDialogAdapter.tsx @@ -25,8 +25,7 @@ export const PageManagementDialog: React.FC = updatePage } = usePluginStudio(); - console.log('🔧 PageManagementDialogAdapter rendered:', { open, hasCurrentPage: !!currentPage }); - console.log('🔧 PageManagementDialogAdapter context:', { currentPageId: currentPage?.id, currentPageName: currentPage?.name }); + // Only render the dialog if we have a current page if (!currentPage) { diff --git a/frontend/src/features/plugin-studio/context/PluginStudioContext.tsx b/frontend/src/features/plugin-studio/context/PluginStudioContext.tsx index 6d32b9b..4f9cb35 100644 --- a/frontend/src/features/plugin-studio/context/PluginStudioContext.tsx +++ b/frontend/src/features/plugin-studio/context/PluginStudioContext.tsx @@ -12,7 +12,10 @@ export interface PluginStudioContextType { createPage: (pageName: string) => Promise; deletePage: (pageId: string) => Promise; renamePage: (pageId: string, newName: string) => Promise; - savePage: (pageId: string) => Promise; + savePage: (pageId: string, options?: { + layoutOverride?: any; + awaitCommit?: boolean; + }) => Promise; publishPage: (pageId: string, publish: boolean) => Promise; backupPage: (pageId: string) => Promise; restorePage: (pageId: string) => Promise; @@ -24,6 +27,7 @@ export interface PluginStudioContextType { removeItem: (id: string) => void; handleResizeStart: () => void; handleResizeStop: () => void; + flushLayoutChanges?: () => Promise; // Phase 3: Add flush method // Plugin state availablePlugins: DynamicPluginConfig[]; diff --git a/frontend/src/features/plugin-studio/context/PluginStudioProvider.tsx b/frontend/src/features/plugin-studio/context/PluginStudioProvider.tsx index f81da8d..d0a246c 100644 --- a/frontend/src/features/plugin-studio/context/PluginStudioProvider.tsx +++ b/frontend/src/features/plugin-studio/context/PluginStudioProvider.tsx @@ -51,7 +51,8 @@ export const PluginStudioProvider: React.FC<{ children: React.ReactNode }> = ({ handleLayoutChange, removeItem, handleResizeStart, - handleResizeStop + handleResizeStop, + flush: flushLayoutChanges // Phase 3: Get flush method from useLayout } = useLayout(currentPage, getModuleById); // View mode state const { @@ -94,6 +95,7 @@ export const PluginStudioProvider: React.FC<{ children: React.ReactNode }> = ({ removeItem, handleResizeStart, handleResizeStop, + flushLayoutChanges, // Phase 3: Add flush method to context // Plugin state availablePlugins, @@ -127,7 +129,7 @@ export const PluginStudioProvider: React.FC<{ children: React.ReactNode }> = ({ savePage, publishPage, backupPage, restorePage, updatePage, // Layout state - layouts, handleLayoutChange, removeItem, handleResizeStart, handleResizeStop, + layouts, handleLayoutChange, removeItem, handleResizeStart, handleResizeStop, flushLayoutChanges, // Plugin state availablePlugins, diff --git a/frontend/src/features/plugin-studio/hooks/layout/useLayout.ts b/frontend/src/features/plugin-studio/hooks/layout/useLayout.ts index d57d50d..595ab88 100644 --- a/frontend/src/features/plugin-studio/hooks/layout/useLayout.ts +++ b/frontend/src/features/plugin-studio/hooks/layout/useLayout.ts @@ -13,6 +13,9 @@ export const useLayout = ( ) => { const [layouts, setLayouts] = useState(initialPage?.layouts || null); + // Phase 1: Add debug mode flag + const isDebugMode = import.meta.env.VITE_LAYOUT_DEBUG === 'true'; + // Track performance metrics const performanceMetricsRef = useRef<{ lastUpdate: number }>({ lastUpdate: 0 }); @@ -27,12 +30,12 @@ export const useLayout = ( } currentPageIdRef.current = initialPage?.id || null; - console.log('[useLayout] Page changed to:', initialPage?.id); + if (initialPage?.layouts) { // Create a deep copy of the layouts to ensure we're not sharing references const layoutsCopy = JSON.parse(JSON.stringify(initialPage.layouts)); - console.log('[useLayout] Setting layouts from new page:', JSON.stringify(layoutsCopy)); + setLayouts(layoutsCopy); // Reset the last processed layout when page changes lastProcessedLayoutRef.current = JSON.stringify(layoutsCopy); @@ -59,7 +62,7 @@ export const useLayout = ( const resizeEndTimeoutRef = useRef(null); const processLayoutChange = useCallback((layout: any[], newLayouts: Layouts) => { - console.log('[useLayout] Processing layout change'); + // Track performance metrics performanceMetricsRef.current.lastUpdate = Date.now(); @@ -129,8 +132,31 @@ export const useLayout = ( } }, [initialPage]); - const handleLayoutChange = useCallback((layout: any[], newLayouts: Layouts) => { - console.log('[useLayout] Processing layout change with enhanced debouncing'); + const handleLayoutChange = useCallback((layout: any[], newLayouts: Layouts, metadata?: { version?: number; hash?: string; origin?: any }) => { + // Phase 1: Log layout change event + if (isDebugMode && metadata) { + const version = metadata.version || 0; + const hash = metadata.hash || ''; + console.log(`[useLayout] Apply v${version} hash:${hash}`, { + origin: metadata.origin, + timestamp: Date.now() + }); + } + + // RECODE V2 BLOCK: Log item dimensions when applying layout changes + if (metadata?.origin?.source === 'user-resize') { + const desktopItems = newLayouts?.desktop || []; + console.log('[RECODE_V2_BLOCK] useLayout apply - resize dimensions', { + source: metadata.origin.source, + version: metadata.version, + hash: metadata.hash, + desktopItemDimensions: desktopItems.map((item: any) => ({ + id: item.i, + dimensions: { w: item.w, h: item.h, x: item.x, y: item.y } + })), + timestamp: Date.now() + }); + } // Create a stable hash of the new layouts for comparison const newLayoutsHash = JSON.stringify(newLayouts); @@ -140,7 +166,7 @@ export const useLayout = ( const isImmediateDuplicate = lastProcessedLayoutRef.current === newLayoutsHash && timeSinceLastUpdate < 200; if (isImmediateDuplicate) { - console.log('[useLayout] Immediate duplicate layout detected, skipping'); + return; } @@ -163,7 +189,7 @@ export const useLayout = ( // Final duplicate check before processing if (pendingHash === lastProcessedLayoutRef.current) { - console.log('[useLayout] Skipping duplicate in timeout'); + pendingLayoutRef.current = null; layoutUpdateTimeoutRef.current = null; return; @@ -177,7 +203,7 @@ export const useLayout = ( } layoutUpdateTimeoutRef.current = null; }, debounceTime); - }, [processLayoutChange]); + }, [processLayoutChange, isDebugMode]); // Cleanup timeouts on unmount useEffect(() => { @@ -552,6 +578,42 @@ export const useLayout = ( }, 200); }, []); + /** + * Phase 3: Flush pending layout changes + * Returns a promise that resolves when all pending layout changes have been processed + */ + const flush = useCallback((): Promise => { + return new Promise((resolve) => { + // If there's a pending layout update, wait for it to complete + if (layoutUpdateTimeoutRef.current) { + // Clear the existing timeout + clearTimeout(layoutUpdateTimeoutRef.current); + + // Process the pending layout immediately if there is one + if (pendingLayoutRef.current) { + const { layout: pendingLayout, newLayouts: pendingNewLayouts } = pendingLayoutRef.current; + const pendingHash = JSON.stringify(pendingNewLayouts); + + // Only process if it's different from the last processed layout + if (pendingHash !== lastProcessedLayoutRef.current) { + lastProcessedLayoutRef.current = pendingHash; + processLayoutChange(pendingLayout, pendingNewLayouts); + } + + pendingLayoutRef.current = null; + } + + layoutUpdateTimeoutRef.current = null; + + // Wait a bit to ensure the change has propagated + setTimeout(resolve, 50); + } else { + // No pending changes, resolve immediately + resolve(); + } + }); + }, [processLayoutChange]); + return { layouts, setLayouts, @@ -561,6 +623,7 @@ export const useLayout = ( addItem, updateItem, handleResizeStart, - handleResizeStop + handleResizeStop, + flush // Phase 3: Expose flush method }; }; \ No newline at end of file diff --git a/frontend/src/features/plugin-studio/hooks/page/usePages.ts b/frontend/src/features/plugin-studio/hooks/page/usePages.ts index 8cc81a3..6a5f25b 100644 --- a/frontend/src/features/plugin-studio/hooks/page/usePages.ts +++ b/frontend/src/features/plugin-studio/hooks/page/usePages.ts @@ -15,6 +15,9 @@ export const usePages = () => { const [error, setError] = useState(null); // Get the clearCache function from PageStateContext const { clearCache } = usePageState(); + + // Phase 1: Add debug mode flag + const isDebugMode = import.meta.env.VITE_LAYOUT_DEBUG === 'true'; // Remove hasPendingChanges state since we're not using it anymore /** @@ -281,9 +284,16 @@ export const usePages = () => { /** * Save a page * @param pageId The ID of the page to save + * @param options Optional save options including layoutOverride * @returns The saved page or null if saving failed */ - const savePage = useCallback(async (pageId: string): Promise => { + const savePage = useCallback(async ( + pageId: string, + options?: { + layoutOverride?: any; + awaitCommit?: boolean; + } + ): Promise => { try { setIsLoading(true); setError(null); @@ -304,24 +314,36 @@ export const usePages = () => { // Create a unique route by adding a timestamp const uniquePageSlug = `${pageSlug}-${Date.now()}`; + // Phase 5: Use layoutOverride if provided for local pages too + const layoutsForNewPage = options?.layoutOverride || currentPage.layouts; + // Create a new page with the current content const newPage = await pageService.createPage({ name: newPageName, route: uniquePageSlug, description: currentPage.description || '', content: { - layouts: currentPage.layouts, + layouts: layoutsForNewPage, modules: currentPage.modules } }); // Transform the page to match the frontend expected format + // Phase 5: Ensure we use the exact layouts that were saved const transformedPage = { ...newPage, - layouts: currentPage.layouts, + layouts: layoutsForNewPage, modules: currentPage.modules ? normalizeObjectKeys(currentPage.modules) : {} }; + // Phase 5: Ensure content.layouts also matches what we saved + if (transformedPage.content) { + transformedPage.content = { + ...transformedPage.content, + layouts: JSON.parse(JSON.stringify(layoutsForNewPage)) + }; + } + // Update the local state setPages(prev => [...prev, transformedPage]); setCurrentPage(transformedPage); @@ -335,12 +357,33 @@ export const usePages = () => { return transformedPage; } else { // Normal case - update existing page + // Phase 5: Use layoutOverride if provided (from committed snapshot), otherwise use currentPage.layouts + const layoutsToSave = options?.layoutOverride || currentPage.layouts || {}; + // Create deep clones to avoid reference issues const content = { - layouts: JSON.parse(JSON.stringify(currentPage.layouts || {})), + layouts: JSON.parse(JSON.stringify(layoutsToSave)), modules: JSON.parse(JSON.stringify(currentPage.modules || {})) }; + // Phase 5: Enhanced logging for save serialization tracking + if (isDebugMode) { + const layoutStr = JSON.stringify(content.layouts); + let hash = 0; + for (let i = 0; i < layoutStr.length; i++) { + const char = layoutStr.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + const layoutHash = Math.abs(hash).toString(16).padStart(8, '0'); + const source = options?.layoutOverride ? 'layoutOverride (committed snapshot)' : 'currentPage.layouts'; + console.log(`[savePage] Phase 5 - Serialize from ${source} hash:${layoutHash}`, { + pageId, + timestamp: Date.now(), + awaitCommit: options?.awaitCommit !== false + }); + } + console.log('savePage - Saving content to backend:', content); // Ensure configOverrides in layout items are preserved @@ -366,34 +409,43 @@ export const usePages = () => { console.log('savePage - API response:', updatedPage); // Transform the page to match the frontend expected format - // Ensure both content.layouts and layouts are synchronized + // Phase 5: Ensure we use the exact layouts we serialized, creating a single source of truth const transformedPage = { ...updatedPage, + // Use the exact layouts we just saved (from committed snapshot or override) layouts: JSON.parse(JSON.stringify(content.layouts)), modules: content.modules ? normalizeObjectKeys(content.modules) : {} }; - // Ensure the layouts property is synchronized with content.layouts - if (transformedPage.content && transformedPage.content.layouts) { - transformedPage.layouts = JSON.parse(JSON.stringify(transformedPage.content.layouts)); + // Phase 5: Sync both currentPage.layouts and content.layouts to the same committed snapshot + // This ensures consistency across all references to the page's layout state + if (transformedPage.content) { + transformedPage.content = { + ...transformedPage.content, + layouts: JSON.parse(JSON.stringify(content.layouts)) + }; } - console.log('Saving page with layouts:', JSON.stringify(transformedPage.layouts)); - console.log('Saving page with content.layouts:', JSON.stringify(transformedPage.content?.layouts)); + if (isDebugMode) { + console.log('[savePage] Phase 5 - Syncing page state to committed snapshot'); + console.log(' currentPage.layouts:', JSON.stringify(transformedPage.layouts)); + console.log(' content.layouts:', JSON.stringify(transformedPage.content?.layouts)); + } - // Update the local state + // Update the local state with the synchronized snapshot setPages(prev => prev.map(p => p.id === pageId ? transformedPage : p) ); - // Update the current page + // Update the current page to the synchronized state setCurrentPage(transformedPage); // No need to set pending changes flag - // Clear the page cache to ensure fresh data is loaded next time - console.log('Clearing page cache after saving existing page'); - clearCache(); + // QUICK MITIGATION: Don't clear cache immediately after save + // This was causing the page to reload and reset the layout state + // clearCache() should only be called when explicitly needed + console.log('Save complete - not clearing cache to preserve layout state'); return transformedPage; } @@ -404,7 +456,7 @@ export const usePages = () => { } finally { setIsLoading(false); } - }, [currentPage, clearCache]); + }, [currentPage, isDebugMode]); // savePageImmediately function removed since we're always saving immediately now diff --git a/frontend/src/features/unified-dynamic-page-renderer/adapters/LegacyModuleAdapter.tsx b/frontend/src/features/unified-dynamic-page-renderer/adapters/LegacyModuleAdapter.tsx index 7624029..2b760ab 100644 --- a/frontend/src/features/unified-dynamic-page-renderer/adapters/LegacyModuleAdapter.tsx +++ b/frontend/src/features/unified-dynamic-page-renderer/adapters/LegacyModuleAdapter.tsx @@ -118,7 +118,7 @@ const arePropsEqual = (prevProps: LegacyModuleAdapterProps, nextProps: LegacyMod for (const key of prevKeys) { if (prevProps.moduleProps[key] !== nextProps.moduleProps[key]) { if (process.env.NODE_ENV === 'development') { - console.log(`[LegacyModuleAdapter] MEMO COMPARISON FAILED - moduleProps.${key} changed for ${moduleKey}`); + } return false; } @@ -155,7 +155,7 @@ const arePropsEqual = (prevProps: LegacyModuleAdapterProps, nextProps: LegacyMod } if (process.env.NODE_ENV === 'development') { - console.log(`[LegacyModuleAdapter] MEMO COMPARISON SUCCESS - Props are equal for ${moduleKey}, preventing re-render`); + } return true; }; @@ -180,25 +180,7 @@ export const LegacyModuleAdapter: React.FC = React.mem const renderCountRef = useRef(0); renderCountRef.current++; - if (process.env.NODE_ENV === 'development') { - console.log(`[LegacyModuleAdapter] RENDER #${renderCountRef.current} - Props received for ${pluginId}/${moduleId || moduleName}:`, { - pluginId, - moduleId, - moduleName, - useUnifiedRenderer, - fallbackStrategy, - mode, - enableMigrationWarnings, - performanceMonitoring, - lazyLoading, - priority, - modulePropsKeys: Object.keys(moduleProps || {}), - modulePropsLength: Object.keys(moduleProps || {}).length - }); - - // Track what's causing re-renders - console.log(`[LegacyModuleAdapter] RENDER #${renderCountRef.current} - Stack trace:`, new Error().stack); - } + // State management const [shouldUseUnified, setShouldUseUnified] = useState( diff --git a/frontend/src/features/unified-dynamic-page-renderer/adapters/PluginStudioAdapter.tsx b/frontend/src/features/unified-dynamic-page-renderer/adapters/PluginStudioAdapter.tsx index 0b03166..c7f64f3 100644 --- a/frontend/src/features/unified-dynamic-page-renderer/adapters/PluginStudioAdapter.tsx +++ b/frontend/src/features/unified-dynamic-page-renderer/adapters/PluginStudioAdapter.tsx @@ -1,11 +1,13 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Box, useTheme } from '@mui/material'; +import { LayoutCommitBadge } from '../components/LayoutCommitBadge'; import { UnifiedPageRenderer } from '../components/UnifiedPageRenderer'; import { ResponsiveContainer } from '../components/ResponsiveContainer'; import { LayoutEngine } from '../components/LayoutEngine'; import { PageProvider } from '../contexts/PageContext'; import { RenderMode, PageData, ResponsiveLayouts, LayoutItem, ModuleConfig } from '../types'; import { usePluginStudioDevMode } from '../../../hooks/usePluginStudioDevMode'; +import { generateLayoutHash } from '../utils/layoutChangeManager'; // Import Plugin Studio types and components import { @@ -26,9 +28,10 @@ export interface PluginStudioAdapterProps { // Studio functionality onLayoutChange?: (layout: any[], newLayouts: PluginStudioLayouts) => void; + onLayoutChangeFlush?: () => Promise; // Quick Mitigation: Add flush callback onPageLoad?: (page: PageData) => void; onError?: (error: Error) => void; - onSave?: (pageId: string) => Promise; // Add save callback + onSave?: (pageId: string, options?: { layoutOverride?: PluginStudioLayouts }) => Promise; // Quick Mitigation: Add options parameter // UI state previewMode?: boolean; @@ -56,6 +59,7 @@ export const PluginStudioAdapter: React.FC = ({ page, layouts, onLayoutChange, + onLayoutChangeFlush, onPageLoad, onError, onSave, @@ -71,6 +75,11 @@ export const PluginStudioAdapter: React.FC = ({ }) => { // Get Material-UI theme for dark mode support const theme = useTheme(); + // Get dev mode features - MUST be called before any conditional returns + const { features: devModeFeatures } = usePluginStudioDevMode(); + + // Phase 1: Add debug mode flag + const isDebugMode = import.meta.env.VITE_LAYOUT_DEBUG === 'true'; // State for converted data const [convertedPageData, setConvertedPageData] = useState(null); const [conversionError, setConversionError] = useState(null); @@ -78,6 +87,9 @@ export const PluginStudioAdapter: React.FC = ({ // Safeguard to prevent infinite loops during module updates const isUpdatingModulesRef = useRef(false); + + // Quick Mitigation: Add ref to store unified layout state + const unifiedLayoutStateRef = useRef(null); // Performance tracking const [performanceMetrics, setPerformanceMetrics] = useState<{ @@ -132,27 +144,20 @@ export const PluginStudioAdapter: React.FC = ({ ): LayoutItem => { // Extract module definition if available const moduleDefinition = moduleDefinitions?.[item.i]; - - // Extract pluginId from the item ID if not directly available - // Plugin Studio item IDs are typically in format: "PluginId_moduleId_timestamp" - let pluginId = item.pluginId; - if (!pluginId && item.i) { - const parts = item.i.split('_'); - if (parts.length >= 1) { - pluginId = parts[0]; // First part is usually the plugin ID - } - } - - // Also try to get pluginId from module definition - if (!pluginId && moduleDefinition?.pluginId) { - pluginId = moduleDefinition.pluginId; - } - - // Fallback to a safe default if still no pluginId found - if (!pluginId) { - console.warn('[PluginStudioAdapter] No pluginId found for item:', item.i, 'item:', item); - pluginId = 'unknown'; - } + // Prefer pluginId from module definition or original item + const originalPluginId = item?.args?._originalItem?.pluginId; + const extractComposite = (s?: string): string => { + if (!s) return ''; + const parts = String(s).split('_'); + if (parts.length >= 2) return `${parts[0]}_${parts[1]}`; + return parts[0] || ''; + }; + let pluginId: string = moduleDefinition?.pluginId + || originalPluginId + || (item.pluginId && item.pluginId !== 'unknown' ? item.pluginId : '') + || extractComposite(item.i) + || extractComposite(item.moduleId) + || 'unknown'; return { i: item.i, @@ -162,7 +167,7 @@ export const PluginStudioAdapter: React.FC = ({ h: item.h, minW: item.minW, minH: item.minH, - moduleId: item.i, + moduleId: item.i, // keep PS identity for module map lookups pluginId: pluginId, config: { ...item.args, @@ -254,7 +259,7 @@ export const PluginStudioAdapter: React.FC = ({ conversionTime, lastUpdate: Date.now() })); - console.log(`[PluginStudioAdapter] Page conversion took ${conversionTime.toFixed(2)}ms`); + } return pageData; @@ -269,19 +274,31 @@ export const PluginStudioAdapter: React.FC = ({ * Following the same pattern as the working legacy GridContainer */ const handleUnifiedLayoutChange = useCallback(( - unifiedLayouts: ResponsiveLayouts + unifiedLayouts: ResponsiveLayouts, + metadata?: { version?: number; hash?: string; origin?: any } ) => { if (!onLayoutChange) return; try { - // Optimized: Only update converted page data if layouts actually changed + // Phase 1: Log conversion event + const hash = metadata?.hash || generateLayoutHash(unifiedLayouts); + const version = metadata?.version || 0; + + if (isDebugMode) { + console.log(`[PluginStudioAdapter] Convert v${version} hash:${hash}`, { + origin: metadata?.origin, + timestamp: Date.now() + }); + } + // Phase 5: Always update converted page data with new layouts to ensure saves use latest state + // The UnifiedLayoutState already handles deduplication, so we should trust its updates setConvertedPageData(prev => { if (!prev) return prev; - // Check if layouts are actually different - const layoutsChanged = JSON.stringify(prev.layouts) !== JSON.stringify(unifiedLayouts); - if (!layoutsChanged) { - return prev; // No change, return same reference + // Always update layouts when we receive a change event + // This ensures convertedPageData stays in sync with the actual layout state + if (isDebugMode) { + console.log('[PluginStudioAdapter] Updating convertedPageData with new layouts'); } return { @@ -343,19 +360,39 @@ export const PluginStudioAdapter: React.FC = ({ }; // Convert using original layouts to preserve properties + // Plugin Studio has no 'wide' breakpoint; map unified 'wide' into 'desktop' when needed + const desktopUnified = (unifiedLayouts.desktop && unifiedLayouts.desktop.length > 0) + ? unifiedLayouts.desktop + : (unifiedLayouts.wide || []); + const pluginStudioLayouts: PluginStudioLayouts = { - desktop: convertUnifiedToPluginStudio(unifiedLayouts.desktop, layouts?.desktop), + desktop: convertUnifiedToPluginStudio(desktopUnified, layouts?.desktop), tablet: convertUnifiedToPluginStudio(unifiedLayouts.tablet, layouts?.tablet), mobile: convertUnifiedToPluginStudio(unifiedLayouts.mobile, layouts?.mobile) }; // Call onLayoutChange immediately to persist changes - onLayoutChange(unifiedLayouts.desktop, pluginStudioLayouts); + // Pass the layout for the active PS breakpoint (desktop), using 'wide' as fallback + onLayoutChange(desktopUnified, pluginStudioLayouts); + + // Phase 1: Log successful conversion + if (isDebugMode) { + // Generate hash using JSON stringify for Plugin Studio layouts + const layoutStr = JSON.stringify(pluginStudioLayouts); + let hash = 0; + for (let i = 0; i < layoutStr.length; i++) { + const char = layoutStr.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + const convertedHash = Math.abs(hash).toString(16).padStart(8, '0'); + console.log(`[PluginStudioAdapter] Conversion complete hash:${convertedHash}`); + } } catch (error) { console.error('[PluginStudioAdapter] Failed to convert layout changes:', error); onError?.(error as Error); } - }, [onLayoutChange, layouts, onError]); + }, [onLayoutChange, layouts, onError, isDebugMode]); /** * Handle unified page load events @@ -453,53 +490,93 @@ export const PluginStudioAdapter: React.FC = ({ return previewMode ? RenderMode.PREVIEW : RenderMode.STUDIO; }, [previewMode]); - // Show loading state during conversion - if (isConverting) { - return ( - -
Converting Plugin Studio data...
-
- ); - } - - // Show error state if conversion failed - if (conversionError || !convertedPageData) { - return ( - -
Failed to convert Plugin Studio data
- {conversionError && ( -
- {conversionError.message} -
- )} -
- ); - } + // Phase 3: Add method to get committed Plugin Studio layouts + const getCommittedPluginStudioLayouts = useCallback((): PluginStudioLayouts | null => { + // Get committed layouts from unified state if available + const committedLayouts = unifiedLayoutStateRef.current?.getCommittedLayouts?.(); + if (!committedLayouts) { + console.warn('[PluginStudioAdapter] No committed layouts available from unified state'); + return null; + } + + // Convert unified layouts to Plugin Studio format + const convertUnifiedToPluginStudio = (items: LayoutItem[] = []): (PluginStudioGridItem | any)[] => { + return items.map(item => { + // Try to restore original item properties + const originalItem = item.config?._originalItem; + + return { + ...originalItem, + i: item.i, + x: item.x, + y: item.y, + w: item.w, + h: item.h, + minW: item.minW, + minH: item.minH, + pluginId: item.pluginId, + args: { + ...originalItem?.args, + ...item.config, + // Remove internal properties + _pluginStudioItem: undefined, + _originalItem: undefined + } + }; + }); + }; + + return { + desktop: convertUnifiedToPluginStudio(committedLayouts.desktop), + tablet: convertUnifiedToPluginStudio(committedLayouts.tablet), + mobile: convertUnifiedToPluginStudio(committedLayouts.mobile) + }; + }, []); - // Handle save functionality for the GridToolbar - const handleSave = async (pageId?: string) => { + // Handle save functionality for the GridToolbar - MUST be defined before conditional returns + const handleSave = useCallback(async (pageId?: string) => { if (!page || !convertedPageData) { console.error('[PluginStudioAdapter] Cannot save - missing page or convertedPageData'); return; } try { - console.log('[PluginStudioAdapter] Starting save operation for page:', pageId || page.id); - console.log('[PluginStudioAdapter] Current convertedPageData layouts:', convertedPageData.layouts); + console.log('[PluginStudioAdapter] Starting save operation for page:', pageId || page?.id); + + // RECODE V2 BLOCK: Get committed layouts directly from unified state for accurate dimensions + let layoutsToSave = convertedPageData.layouts; + + // Try to get the committed layouts from unified state + const committedLayouts = getCommittedPluginStudioLayouts(); + if (committedLayouts) { + layoutsToSave = committedLayouts as any; // Type cast to match expected format + console.log('[RECODE_V2_BLOCK] Using committed layouts from unified state for save'); + } else { + console.log('[RECODE_V2_BLOCK] Fallback to convertedPageData layouts for save'); + } + + // RECODE V2 BLOCK: Log item dimensions at save time + const desktopItems = layoutsToSave?.desktop || []; + console.log('[RECODE_V2_BLOCK] PluginStudioAdapter save - item dimensions', { + pageId: pageId || page?.id, + source: committedLayouts ? 'committed' : 'convertedPageData', + desktopItemDimensions: desktopItems.map((item: any) => ({ + id: item.i, + dimensions: { w: item.w, h: item.h, x: item.x, y: item.y } + })), + timestamp: Date.now() + }); + + // Phase 5: Generate hash for save tracing + const layoutStr = JSON.stringify(layoutsToSave); + let hash = 0; + for (let i = 0; i < layoutStr.length; i++) { + const char = layoutStr.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + const saveHash = Math.abs(hash).toString(16).padStart(8, '0'); + console.log(`[PluginStudioAdapter] Save serialization - hash:${saveHash}`); // Convert the current unified layouts back to Plugin Studio format for saving const convertUnifiedToPluginStudio = (items: LayoutItem[] = []): (PluginStudioGridItem | any)[] => { @@ -528,24 +605,45 @@ export const PluginStudioAdapter: React.FC = ({ }); }; + // Plugin Studio has no 'wide' breakpoint; map unified 'wide' into 'desktop' when needed + const desktopSaveSource = (layoutsToSave.desktop && layoutsToSave.desktop.length > 0) + ? layoutsToSave.desktop + : (layoutsToSave.wide || []); + const pluginStudioLayouts: PluginStudioLayouts = { - desktop: convertUnifiedToPluginStudio(convertedPageData.layouts.desktop), - tablet: convertUnifiedToPluginStudio(convertedPageData.layouts.tablet), - mobile: convertUnifiedToPluginStudio(convertedPageData.layouts.mobile) + desktop: convertUnifiedToPluginStudio(desktopSaveSource), + tablet: convertUnifiedToPluginStudio(layoutsToSave.tablet), + mobile: convertUnifiedToPluginStudio(layoutsToSave.mobile) }; console.log('[PluginStudioAdapter] Converted layouts for save:', pluginStudioLayouts); - // 🔧 FIX: Call onLayoutChange to update the Plugin Studio state + // Phase 5: The layouts are already committed through handleUnifiedLayoutChange + // No need to flush here as the convertedPageData is already up-to-date + console.log('[PluginStudioAdapter] Layouts already committed and synced'); + + // Update Plugin Studio state with the committed layouts if (onLayoutChange) { - console.log('[PluginStudioAdapter] Calling onLayoutChange to update state'); - onLayoutChange(convertedPageData.layouts.desktop, pluginStudioLayouts); + console.log('[PluginStudioAdapter] Calling onLayoutChange to sync state before save'); + onLayoutChange(desktopSaveSource, pluginStudioLayouts); + } + + // Wait for layout changes to flush through the Plugin Studio system + // This ensures the Plugin Studio state is fully updated before saving + if (onLayoutChangeFlush) { + console.log('[PluginStudioAdapter] Awaiting onLayoutChangeFlush to ensure state sync'); + await onLayoutChangeFlush(); + } else { + // Fallback: Wait a bit to allow debounced updates to complete + console.log('[PluginStudioAdapter] No onLayoutChangeFlush available, using timeout fallback'); + await new Promise(resolve => setTimeout(resolve, 200)); } - // 🔧 FIX: Call onSave to actually save to backend + // Pass the layoutOverride to savePage + // This ensures we save exactly what we converted, bypassing any stale state if (onSave) { - console.log('[PluginStudioAdapter] Calling onSave to persist to backend'); - await onSave(pageId || page.id); + console.log('[PluginStudioAdapter] Calling onSave with layoutOverride'); + await onSave(pageId || page!.id, { layoutOverride: pluginStudioLayouts }); console.log('[PluginStudioAdapter] Backend save completed'); } else { console.error('[PluginStudioAdapter] onSave callback is missing - cannot save to backend!'); @@ -556,7 +654,44 @@ export const PluginStudioAdapter: React.FC = ({ console.error('[PluginStudioAdapter] Save failed:', error); onError?.(error as Error); } - }; + }, [page, convertedPageData, onLayoutChange, onSave, onError, onLayoutChangeFlush]); + + // Show loading state during conversion + if (isConverting) { + return ( + +
Converting Plugin Studio data...
+
+ ); + } + + // Show error state if conversion failed + if (conversionError || !convertedPageData) { + return ( + +
Failed to convert Plugin Studio data
+ {conversionError && ( +
+ {conversionError.message} +
+ )} +
+ ); + } return (
= ({ border-color: ${theme.palette.mode === 'dark' ? '#666666' : '#c0c0c0'} !important; } - /* Selected module styling */ + /* Selected module styling - keep subtle to avoid "mode switch" look */ .unified-page-renderer .react-grid-item.selected, .unified-page-renderer .react-grid-item.layout-item--selected { - border: 2px solid ${theme.palette.primary.main} !important; - box-shadow: 0 4px 16px ${theme.palette.primary.main}4D !important; + border: 1px solid ${theme.palette.divider} !important; + box-shadow: ${theme.palette.mode === 'dark' + ? '0 2px 4px rgba(0, 0, 0, 0.3)' + : '0 2px 4px rgba(0, 0, 0, 0.1)'} !important; } /* Resize handles - only show when selected */ @@ -715,13 +852,15 @@ export const PluginStudioAdapter: React.FC = ({ display: none !important; } - /* Maintain selection state during drag and resize operations */ + /* Maintain subtle selection during drag and resize operations */ .layout-engine-container--dragging .react-grid-item.selected, .layout-engine-container--resizing .react-grid-item.selected, .layout-engine-container--dragging .react-grid-item.layout-item--selected, .layout-engine-container--resizing .react-grid-item.layout-item--selected { - border: 2px solid ${theme.palette.primary.main} !important; - box-shadow: 0 4px 16px ${theme.palette.primary.main}4D !important; + border: 1px solid ${theme.palette.divider} !important; + box-shadow: ${theme.palette.mode === 'dark' + ? '0 2px 6px rgba(0, 0, 0, 0.35)' + : '0 2px 6px rgba(0, 0, 0, 0.12)'} !important; } /* Enhanced visual feedback during operations */ @@ -815,6 +954,9 @@ export const PluginStudioAdapter: React.FC = ({ mode={renderMode} allowUnpublished={true} responsive={true} + // Plugin Studio editing: disable container queries to avoid accidental + // breakpoint flips on first interaction due to container reflow. + containerQueries={false} lazyLoading={true} onPageLoad={handleUnifiedPageLoad} onLayoutChange={handleUnifiedLayoutChange} @@ -826,31 +968,33 @@ export const PluginStudioAdapter: React.FC = ({
{/* Performance overlay in development */} - {performanceMonitoring && import.meta.env.MODE === 'development' && (() => { - const { features } = usePluginStudioDevMode(); - return features.debugPanels && ( -
-
Plugin Studio Adapter
-
Conversion: {performanceMetrics.conversionTime?.toFixed(2)}ms
-
Render: {performanceMetrics.renderTime?.toFixed(2)}ms
-
Mode: {renderMode}
-
Items: {convertedPageData.layouts.desktop.length}
-
- ); - })()} + {performanceMonitoring && import.meta.env.MODE === 'development' && devModeFeatures.debugPanels && ( +
+
Plugin Studio Adapter
+
Conversion: {performanceMetrics.conversionTime?.toFixed(2)}ms
+
Render: {performanceMetrics.renderTime?.toFixed(2)}ms
+
Mode: {renderMode}
+
Items: {convertedPageData.layouts.desktop.length}
+
+ )} + + {/* Dev: Layout commit status badge for unified path as well */} + {import.meta.env.VITE_LAYOUT_DEBUG === 'true' && ( + + )} ); }; -export default PluginStudioAdapter; \ No newline at end of file +export default PluginStudioAdapter; diff --git a/frontend/src/features/unified-dynamic-page-renderer/components/LayoutCommitBadge.tsx b/frontend/src/features/unified-dynamic-page-renderer/components/LayoutCommitBadge.tsx new file mode 100644 index 0000000..56a622e --- /dev/null +++ b/frontend/src/features/unified-dynamic-page-renderer/components/LayoutCommitBadge.tsx @@ -0,0 +1,153 @@ +/** + * LayoutCommitBadge - Dev-only component for displaying layout commit status + * Part of Phase 1: Instrumentation & Verification + */ + +import React, { useState, useEffect } from 'react'; +import { Box, Typography, Chip, Paper } from '@mui/material'; +import { getLayoutCommitTracker } from '../utils/layoutCommitTracker'; +import { useUnifiedLayoutState } from '../hooks/useUnifiedLayoutState'; + +export interface LayoutCommitBadgeProps { + position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; +} + +export const LayoutCommitBadge: React.FC = ({ + position = 'bottom-right' +}) => { + const [commitInfo, setCommitInfo] = useState<{ + version: number; + hash: string; + timestamp: number; + hasPending: boolean; + } | null>(null); + + const [timeSinceCommit, setTimeSinceCommit] = useState(''); + + // Only render in debug mode + const isDebugMode = import.meta.env.VITE_LAYOUT_DEBUG === 'true'; + + // Get the layout commit tracker + const tracker = getLayoutCommitTracker(); + + // Update commit info periodically + useEffect(() => { + if (!isDebugMode) return; + + const updateInfo = () => { + const lastCommit = tracker.getLastCommit(); + const hasPending = tracker.hasPendingCommits(); + + if (lastCommit) { + setCommitInfo({ + version: lastCommit.version, + hash: lastCommit.hash, + timestamp: lastCommit.timestamp, + hasPending + }); + + // Calculate time since commit + const elapsed = Date.now() - lastCommit.timestamp; + if (elapsed < 1000) { + setTimeSinceCommit('just now'); + } else if (elapsed < 60000) { + setTimeSinceCommit(`${Math.floor(elapsed / 1000)}s ago`); + } else if (elapsed < 3600000) { + setTimeSinceCommit(`${Math.floor(elapsed / 60000)}m ago`); + } else { + setTimeSinceCommit(`${Math.floor(elapsed / 3600000)}h ago`); + } + } + }; + + // Update immediately + updateInfo(); + + // Update every second + const interval = setInterval(updateInfo, 1000); + + return () => clearInterval(interval); + }, [isDebugMode, tracker]); + + // Don't render if not in debug mode + if (!isDebugMode) { + return null; + } + + // Position styles + const positionStyles = { + 'top-left': { top: 16, left: 16 }, + 'top-right': { top: 16, right: 16 }, + 'bottom-left': { bottom: 16, left: 16 }, + 'bottom-right': { bottom: 16, right: 16 } + }; + + return ( + + + Layout Commit Status + + + {commitInfo ? ( + + + + v{commitInfo.version} + + + #{commitInfo.hash.substring(0, 6)} + + + + + + + {timeSinceCommit} + + + + ) : ( + + No commits yet + + )} + + ); +}; + +// Export a hook to use the badge programmatically +export const useLayoutCommitBadge = () => { + const [isVisible, setIsVisible] = useState(false); + + const show = () => setIsVisible(true); + const hide = () => setIsVisible(false); + const toggle = () => setIsVisible(prev => !prev); + + return { + isVisible, + show, + hide, + toggle + }; +}; \ No newline at end of file diff --git a/frontend/src/features/unified-dynamic-page-renderer/components/LayoutEngine.tsx b/frontend/src/features/unified-dynamic-page-renderer/components/LayoutEngine.tsx index 61326f9..5e2963e 100644 --- a/frontend/src/features/unified-dynamic-page-renderer/components/LayoutEngine.tsx +++ b/frontend/src/features/unified-dynamic-page-renderer/components/LayoutEngine.tsx @@ -1,13 +1,15 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Responsive, WidthProvider } from 'react-grid-layout'; +import { useTheme, Box } from '@mui/material'; import { RenderMode, ResponsiveLayouts, LayoutItem, ModuleConfig } from '../types'; import { ModuleRenderer } from './ModuleRenderer'; import { LegacyModuleAdapter } from '../adapters/LegacyModuleAdapter'; import { useBreakpoint } from '../hooks/useBreakpoint'; import { GridItemControls } from '../../plugin-studio/components/canvas/GridItemControls'; import { useUnifiedLayoutState } from '../hooks/useUnifiedLayoutState'; -import { LayoutChangeOrigin } from '../utils/layoutChangeManager'; +import { LayoutChangeOrigin, generateLayoutHash } from '../utils/layoutChangeManager'; import { useControlVisibility } from '../../../hooks/useControlVisibility'; +import { getLayoutCommitTracker } from '../utils/layoutCommitTracker'; const ResponsiveGridLayout = WidthProvider(Responsive); @@ -46,26 +48,99 @@ export const LayoutEngine: React.FC = React.memo(({ onItemSelect, onItemConfig, }) => { + const theme = useTheme(); + // Debug: Track component re-renders const layoutEngineRenderCount = useRef(0); layoutEngineRenderCount.current++; - if (process.env.NODE_ENV === 'development') { - console.log(`[LayoutEngine] COMPONENT RENDER #${layoutEngineRenderCount.current}`, { - layoutsKeys: Object.keys(layouts), - modulesLength: modules.length, - mode, - lazyLoading, - preloadPluginsLength: preloadPlugins.length, - }); - } + // ========== PHASE A: VERSIONED DOUBLE-BUFFER CONTROLLER ========== + // A1: Add Feature Flag Support + const ENABLE_LAYOUT_CONTROLLER_V2 = import.meta.env.VITE_LAYOUT_CONTROLLER_V2 === 'true' || false; + + // Phase 1: Add debug mode flag + const isDebugMode = import.meta.env.VITE_LAYOUT_DEBUG === 'true'; + + // A2: Implement Controller State + // Double-buffer refs for layout state + const workingLayoutsRef = useRef(null); + const canonicalLayoutsRef = useRef(null); + + // Controller state machine + type ControllerState = 'idle' | 'resizing' | 'dragging' | 'grace' | 'commit'; + const controllerStateRef = useRef('idle'); + + // Version tracking for stale update prevention + const lastVersionRef = useRef(0); + + // Force update function for controller state changes + const [, forceUpdate] = useState({}); + const triggerUpdate = useCallback(() => forceUpdate({}), []); + + // A5: Add Development Logging + const logControllerState = useCallback((action: string, data?: any) => { + if (import.meta.env.MODE === 'development' && ENABLE_LAYOUT_CONTROLLER_V2) { + console.log(`[LayoutController V2] ${action}`, { + state: controllerStateRef.current, + version: lastVersionRef.current, + workingLayouts: !!workingLayoutsRef.current, + canonicalLayouts: !!canonicalLayoutsRef.current, + timestamp: performance.now().toFixed(2), + ...data + }); + } + }, [ENABLE_LAYOUT_CONTROLLER_V2]); + + // ========== PHASE C1: Complete State Machine Transitions ========== + // State transition function with validation and logging + const transitionToState = useCallback((newState: ControllerState, data?: any) => { + if (!ENABLE_LAYOUT_CONTROLLER_V2) return; + + const oldState = controllerStateRef.current; + + // Validate state transitions + const validTransitions: Record = { + 'idle': ['resizing', 'dragging'], + 'resizing': ['grace', 'idle'], // Can abort to idle + 'dragging': ['grace', 'idle'], // Can abort to idle + 'grace': ['commit', 'idle'], // Can abort to idle + 'commit': ['idle'] + }; + + if (!validTransitions[oldState].includes(newState)) { + logControllerState('INVALID_STATE_TRANSITION', { + from: oldState, + to: newState, + allowed: validTransitions[oldState], + ...data + }); + return; + } + + controllerStateRef.current = newState; + logControllerState(`STATE_TRANSITION: ${oldState} -> ${newState}`, data); + + // Trigger re-render to update displayedLayouts + triggerUpdate(); + }, [ENABLE_LAYOUT_CONTROLLER_V2, logControllerState, triggerUpdate]); + // ========== END PHASE C1 ========== + + // Log controller initialization + useEffect(() => { + if (ENABLE_LAYOUT_CONTROLLER_V2) { + logControllerState('CONTROLLER_INITIALIZED', { + featureFlag: ENABLE_LAYOUT_CONTROLLER_V2, + environment: import.meta.env.MODE + }); + } + }, [ENABLE_LAYOUT_CONTROLLER_V2, logControllerState]); + // ========== END PHASE A CONTROLLER ========== // Use unified layout state management with stable reference const unifiedLayoutState = useUnifiedLayoutState({ initialLayouts: layouts, debounceMs: 200, // Increase debounce to prevent rapid updates onLayoutPersist: (persistedLayouts, origin) => { - console.log(`[LayoutEngine] Persisting layout change from ${origin.source}`); onLayoutChange?.(persistedLayouts); }, onError: (error) => { @@ -100,11 +175,267 @@ export const LayoutEngine: React.FC = React.memo(({ return stableLayoutsRef.current; }, [unifiedLayoutState.layouts]); + // A3: Implement Display Logic - Controller decides what layouts to display + const displayedLayouts = useMemo(() => { + if (!ENABLE_LAYOUT_CONTROLLER_V2) { + return currentLayouts; // Fallback to existing logic + } + + const state = controllerStateRef.current; + + // During operations and grace period, show working buffer + if (state === 'resizing' || state === 'dragging' || state === 'grace') { + logControllerState('DISPLAY_WORKING_BUFFER', { state }); + return workingLayoutsRef.current || currentLayouts; + } + + // When idle or committed, always prefer currentLayouts to ensure fresh data + // The buffers are just for operation management, not for caching page data + logControllerState('DISPLAY_CANONICAL_BUFFER', { + state, + usingCurrentLayouts: true + }); + return currentLayouts; + }, [currentLayouts, ENABLE_LAYOUT_CONTROLLER_V2, logControllerState]); + + // Initialize buffers when layouts change externally + useEffect(() => { + if (ENABLE_LAYOUT_CONTROLLER_V2) { + // Only update buffers when idle to avoid disrupting ongoing operations + if (controllerStateRef.current === 'idle') { + // Always sync buffers with current layouts when idle + // This ensures page changes are reflected immediately + // RECODE V2 BLOCK: Ensure ultrawide field exists + const layoutsWithUltrawide = { + ...currentLayouts, + ultrawide: currentLayouts.ultrawide || [] + }; + canonicalLayoutsRef.current = layoutsWithUltrawide; + workingLayoutsRef.current = layoutsWithUltrawide; + logControllerState('BUFFERS_SYNCED', { + source: 'external_update', + state: controllerStateRef.current, + itemCount: currentLayouts?.desktop?.length || 0, + hasUltrawide: !!layoutsWithUltrawide.ultrawide + }); + } else { + // Log that we're skipping update due to ongoing operation + logControllerState('BUFFER_SYNC_SKIPPED', { + reason: 'operation_in_progress', + state: controllerStateRef.current + }); + } + } + }, [currentLayouts, ENABLE_LAYOUT_CONTROLLER_V2, logControllerState]); + + // ========== PHASE C2: Single Debounced Commit Process ========== + // Track operation IDs for proper state management (moved up for use in commitLayoutChanges) + const currentOperationId = useRef(null); + + // Debounced commit timer ref + const commitTimerRef = useRef(null); + + // Unified commit function for all operations + const commitLayoutChanges = useCallback((finalLayout?: any, finalAllLayouts?: any, breakpoint?: string) => { + if (!ENABLE_LAYOUT_CONTROLLER_V2) { + logControllerState('COMMIT_SKIPPED', { + reason: 'Controller disabled' + }); + return; + } + + const version = lastVersionRef.current; + let layouts: ResponsiveLayouts; + + // RECODE V2 BLOCK: Use finalLayout if provided (from resize/drag stop) for accurate dimensions + if (finalLayout && finalAllLayouts) { + // Convert the final layout data to ResponsiveLayouts format + const convertedLayouts: ResponsiveLayouts = { + mobile: [], + tablet: [], + desktop: [], + wide: [], + ultrawide: [] + }; + // Normalize breakpoint keys from either grid (xs/sm/lg/xl/xxl) or semantic names + const toOurBreakpoint = (bp: string): keyof ResponsiveLayouts | undefined => { + const map: Record = { + xs: 'mobile', sm: 'tablet', lg: 'desktop', xl: 'wide', xxl: 'ultrawide', + mobile: 'mobile', tablet: 'tablet', desktop: 'desktop', wide: 'wide', ultrawide: 'ultrawide' + }; + return map[bp]; + }; + + // Helper: normalize an RGL layout array to include moduleId/pluginId + const normalizeItems = (items: any[] = []): LayoutItem[] => { + return (items || []).map((it: any) => { + const id = it?.i ?? ''; + let pluginId = it?.pluginId; + if (!pluginId && typeof id === 'string' && id.includes('_')) { + pluginId = id.split('_')[0]; + } + return { + i: id, + x: it?.x ?? 0, + y: it?.y ?? 0, + w: it?.w ?? 2, + h: it?.h ?? 2, + moduleId: it?.moduleId || id, + pluginId: pluginId || 'unknown', + minW: it?.minW, + minH: it?.minH, + isDraggable: it?.isDraggable ?? true, + isResizable: it?.isResizable ?? true, + static: it?.static ?? false, + config: it?.config + } as LayoutItem; + }); + }; + + // Use the current breakpoint's layout from finalLayout (has the latest changes) + // Use the breakpoint parameter passed in + if (finalLayout && breakpoint) { + const ourBreakpoint = toOurBreakpoint(breakpoint); + if (ourBreakpoint) { + convertedLayouts[ourBreakpoint] = normalizeItems(finalLayout as any[]); + + console.log('[RECODE_V2_BLOCK] commitLayoutChanges - using finalLayout for commit', { + breakpoint: ourBreakpoint, + itemDimensions: finalLayout.map((item: any) => ({ + id: item.i, + dimensions: { w: item.w, h: item.h, x: item.x, y: item.y } + })), + version, + timestamp: Date.now() + }); + } + } + + // Fill in other breakpoints from finalAllLayouts + Object.entries(finalAllLayouts).forEach(([gridBreakpoint, gridLayout]: [string, any]) => { + const ourBreakpoint = toOurBreakpoint(gridBreakpoint); + if (ourBreakpoint && Array.isArray(gridLayout)) { + // Only use allLayouts if we haven't already set this breakpoint from the current layout + if (toOurBreakpoint(gridBreakpoint) !== toOurBreakpoint(breakpoint || '') || !finalLayout) { + convertedLayouts[ourBreakpoint] = normalizeItems(gridLayout as any[]); + } + } + }); + + layouts = convertedLayouts; + // Also update the working buffer with the final layouts + workingLayoutsRef.current = convertedLayouts; + } else if (workingLayoutsRef.current) { + // Fallback to working buffer if no final layout provided + layouts = workingLayoutsRef.current; + console.log('[RECODE_V2_BLOCK] commitLayoutChanges - using workingLayoutsRef', { + version, + hasLayouts: !!workingLayoutsRef.current, + breakpoints: Object.keys(workingLayoutsRef.current || {}), + itemCounts: Object.entries(workingLayoutsRef.current || {}).map(([bp, items]) => ({ + breakpoint: bp, + count: (items as any[])?.length || 0 + })), + timestamp: Date.now() + }); + } else { + console.error('[RECODE_V2_BLOCK] COMMIT SKIPPED - No layouts available!', { + hasWorkingBuffer: !!workingLayoutsRef.current, + hasCanonicalBuffer: !!canonicalLayoutsRef.current, + version + }); + logControllerState('COMMIT_SKIPPED', { + reason: 'No layouts available' + }); + return; + } + + const hash = generateLayoutHash(layouts); + + // RECODE V2 BLOCK: Enhanced commit logging + console.log('[RECODE_V2_BLOCK] COMMIT - About to persist layouts', { + version, + hash, + hasLayouts: !!layouts, + breakpoints: Object.keys(layouts || {}), + itemCounts: Object.entries(layouts || {}).map(([bp, items]) => ({ + breakpoint: bp, + count: (items as any[])?.length || 0, + items: (items as any[])?.map((item: any) => ({ + id: item.i, + dimensions: { w: item.w, h: item.h, x: item.x, y: item.y } + })) + })) + }); + + // Phase 1: Log commit event + if (isDebugMode) { + console.log(`[LayoutEngine] Commit v${version} hash:${hash}`, { + source: controllerStateRef.current === 'resizing' ? 'user-resize' : 'user-drag', + timestamp: Date.now() + }); + } + + logControllerState('COMMIT_START', { + version, + hash, + layoutsPresent: !!layouts, + currentState: controllerStateRef.current + }); + + // Transition to commit state + transitionToState('commit', { version }); + + // Create origin with version + const origin: LayoutChangeOrigin = { + source: controllerStateRef.current === 'resizing' ? 'user-resize' : 'user-drag', + version, + timestamp: Date.now(), + operationId: currentOperationId.current || `commit-${Date.now()}` + }; + + // Persist the changes + unifiedLayoutState.updateLayouts(layouts, origin); + + // Update canonical buffer + canonicalLayoutsRef.current = JSON.parse(JSON.stringify(layouts)); + + // Transition back to idle after a short delay + setTimeout(() => { + transitionToState('idle', { version, source: 'commit_complete' }); + logControllerState('COMMIT_COMPLETE', { version, hash }); + + // Phase 1: Log commit completion + if (isDebugMode) { + console.log(`[LayoutEngine] Commit complete v${version} hash:${hash}`); + } + }, 50); + }, [ENABLE_LAYOUT_CONTROLLER_V2, unifiedLayoutState, logControllerState, transitionToState, isDebugMode]); + + // Schedule a debounced commit + const scheduleCommit = useCallback((delayMs: number = 150, finalLayout?: any, finalAllLayouts?: any, breakpoint?: string) => { + // Clear any existing timer + if (commitTimerRef.current) { + clearTimeout(commitTimerRef.current); + } + + // Schedule new commit + commitTimerRef.current = setTimeout(() => { + commitLayoutChanges(finalLayout, finalAllLayouts, breakpoint); + commitTimerRef.current = null; + }, delayMs); + + logControllerState('COMMIT_SCHEDULED', { delayMs }); + }, [commitLayoutChanges, logControllerState]); + // ========== END PHASE C2 ========== + // Local UI state const [selectedItem, setSelectedItem] = useState(null); const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); const [isDragOver, setIsDragOver] = useState(false); + // Stabilize module identities to avoid transient incomplete IDs during operations + const stableIdentityRef = useRef>(new Map()); const { currentBreakpoint } = useBreakpoint(); @@ -150,14 +481,22 @@ export const LayoutEngine: React.FC = React.memo(({ } }, [showControls]); - // Track operation IDs for proper state management - const currentOperationId = useRef(null); + // Track page ID for proper state management const pageIdRef = useRef(pageId); // Operation deduplication tracking const processedOperations = useRef>(new Set()); const operationCleanupTimers = useRef>(new Map()); + // Bounce detection tracking + const previousPositionsRef = useRef>(new Map()); + + // Track intended positions (the position user actually wanted) + const intendedPositionsRef = useRef>(new Map()); + + // Track bounce suppression window + const bounceSuppressionRef = useRef>(new Map()); + // Debug logging for bounce detection const debugLog = useCallback((message: string, data?: any) => { const timestamp = performance.now(); @@ -167,13 +506,29 @@ export const LayoutEngine: React.FC = React.memo(({ // Handle external layout changes (from props) useEffect(() => { - // Reset layouts when page changes - if (pageId !== pageIdRef.current) { - console.log('[LayoutEngine] Page changed, resetting layouts'); + // Phase 5: More careful page change detection to avoid resetting after save + // Only reset if the pageId actually changed (not just the reference) + const pageIdChanged = pageId !== pageIdRef.current && pageId !== undefined && pageIdRef.current !== undefined; + + if (pageIdChanged) { + console.log('[LayoutEngine] Page ID changed, resetting layouts', { + oldPageId: pageIdRef.current, + newPageId: pageId + }); unifiedLayoutState.resetLayouts(layouts); pageIdRef.current = pageId; + + // Clear bounce detection tracking when page changes + previousPositionsRef.current.clear(); + intendedPositionsRef.current.clear(); + debugLog('🔄 Page changed - cleared bounce detection tracking', { newPageId: pageId }); return; } + + // Update pageId ref if it was undefined before + if (pageIdRef.current === undefined && pageId !== undefined) { + pageIdRef.current = pageId; + } // Skip if layouts are semantically identical if (unifiedLayoutState.compareWithCurrent(layouts)) { @@ -197,7 +552,7 @@ export const LayoutEngine: React.FC = React.memo(({ }); }, [layouts, pageId, isDragging, isResizing, unifiedLayoutState]); - // Handle layout change - convert from react-grid-layout format to our format + // Handle layout change - Enhanced with controller V2 const handleLayoutChange = useCallback((layout: any[], allLayouts: any) => { const operationId = currentOperationId.current; @@ -209,6 +564,250 @@ export const LayoutEngine: React.FC = React.memo(({ allLayoutsKeys: Object.keys(allLayouts || {}), layoutData: layout?.map(item => ({ i: item.i, x: item.x, y: item.y, w: item.w, h: item.h })) }); + + // RECODE V2 BLOCK: Capture layout during resize BEFORE controller checks + // Store the layout data immediately if we're resizing + if (isResizing && layout && layout.length > 0 && currentBreakpoint) { + const breakpointMap: Record = { + xs: 'mobile', + sm: 'tablet', + lg: 'desktop', + xl: 'wide', + xxl: 'ultrawide' + }; + + const ourBreakpoint = breakpointMap[currentBreakpoint]; + if (ourBreakpoint && workingLayoutsRef.current) { + // Ensure the working buffer has the structure + if (!workingLayoutsRef.current[ourBreakpoint]) { + workingLayoutsRef.current[ourBreakpoint] = []; + } + workingLayoutsRef.current[ourBreakpoint] = layout as LayoutItem[]; + + console.log('[RECODE_V2_BLOCK] IMMEDIATE resize capture in handleLayoutChange', { + operationId, + breakpoint: ourBreakpoint, + itemCount: layout.length, + items: layout.map((item: any) => ({ + id: item.i, + dimensions: { w: item.w, h: item.h, x: item.x, y: item.y } + })) + }); + } + } + + // Controller V2: Handle layout changes based on controller state + if (ENABLE_LAYOUT_CONTROLLER_V2) { + const state = controllerStateRef.current; + + // During resize/drag operations, update working buffer only + if (state === 'resizing' || state === 'dragging') { + // Convert to ResponsiveLayouts format + const convertedLayouts: ResponsiveLayouts = { + mobile: [], + tablet: [], + desktop: [], + wide: [], + ultrawide: [] + }; + + const breakpointMap: Record = { + xs: 'mobile', + sm: 'tablet', + lg: 'desktop', + xl: 'wide', + xxl: 'ultrawide' + }; + + // RECODE V2 BLOCK: Use the current layout for the active breakpoint during resize + // This ensures we capture the actual resized dimensions + if (layout && layout.length > 0 && currentBreakpoint) { + const ourBreakpoint = breakpointMap[currentBreakpoint]; + if (ourBreakpoint) { + convertedLayouts[ourBreakpoint] = layout as LayoutItem[]; + console.log('[RECODE_V2_BLOCK] Working buffer update with resize dimensions', { + state, + breakpoint: ourBreakpoint, + itemCount: layout.length, + items: layout.map((item: any) => ({ + id: item.i, + dimensions: { w: item.w, h: item.h, x: item.x, y: item.y } + })) + }); + } + } else { + console.warn('[RECODE_V2_BLOCK] No layout data during resize!', { + state, + hasLayout: !!layout, + layoutLength: layout?.length, + currentBreakpoint, + allLayoutsKeys: Object.keys(allLayouts || {}) + }); + } + + // Fill in other breakpoints from allLayouts + Object.entries(allLayouts).forEach(([gridBreakpoint, gridLayout]: [string, any]) => { + const ourBreakpoint = breakpointMap[gridBreakpoint]; + if (ourBreakpoint && Array.isArray(gridLayout)) { + // Only use allLayouts if we haven't already set this breakpoint from the current layout + if (gridBreakpoint !== currentBreakpoint || !layout) { + convertedLayouts[ourBreakpoint] = gridLayout as LayoutItem[]; + } + } + }); + + // Update working buffer only + workingLayoutsRef.current = convertedLayouts; + logControllerState('WORKING_BUFFER_UPDATE', { + state, + operationId, + layoutItemCount: layout?.length, + version: lastVersionRef.current, // PHASE B: Include version in logs + hasLayouts: Object.values(convertedLayouts).some(l => l.length > 0) + }); + + // Don't persist during operations + return; + } + + // During grace period, ignore all layout changes + if (state === 'grace') { + logControllerState('GRACE_PERIOD_IGNORE', { + reason: 'Layout change during grace period', + operationId, + version: lastVersionRef.current // PHASE B: Include version in logs + }); + return; + } + + // During commit state, also ignore + if (state === 'commit') { + logControllerState('COMMIT_STATE_IGNORE', { + reason: 'Layout change during commit', + operationId, + version: lastVersionRef.current // PHASE B: Include version in logs + }); + return; + } + } + + // EARLY BOUNCE DETECTION: Block suspicious layout changes immediately + if (!operationId && !isResizing && !isDragging && layout && layout.length > 0) { + // Check if any item is trying to change to a position we've seen before (potential bounce) + for (const item of layout) { + const itemKey = `${item.i}`; + const intendedPos = intendedPositionsRef.current.get(itemKey); + + if (intendedPos) { + const timeSinceIntended = Date.now() - intendedPos.timestamp; + const currentPos = { x: item.x, y: item.y, w: item.w, h: item.h }; + + // If this change is happening soon after an intended position was set + // and it's different from the intended position, it's likely a bounce + if (timeSinceIntended < 1000) { + const isDifferentFromIntended = intendedPos.x !== currentPos.x || intendedPos.y !== currentPos.y || + intendedPos.w !== currentPos.w || intendedPos.h !== currentPos.h; + + if (isDifferentFromIntended) { + debugLog('🚫 EARLY BOUNCE BLOCK - Rejecting suspicious layout change', { + itemId: item.i, + currentPosition: currentPos, + intendedPosition: intendedPos, + timeSinceIntended, + reason: 'SUSPICIOUS_CHANGE_AFTER_OPERATION' + }); + + // Completely reject this layout change - CRITICAL: This prevents the bounce! + return; + } + } + } + } + } + + // BOUNCE DETECTION: Track position changes to detect visual bounces + if (layout && layout.length > 0) { + layout.forEach(item => { + const currentPos = { x: item.x, y: item.y, w: item.w, h: item.h }; + const itemKey = `${item.i}`; + + // Get previous position from ref + if (!previousPositionsRef.current) { + previousPositionsRef.current = new Map(); + } + + const prevPos = previousPositionsRef.current.get(itemKey); + + if (prevPos) { + // Check if position changed + const posChanged = prevPos.x !== currentPos.x || prevPos.y !== currentPos.y || + prevPos.w !== currentPos.w || prevPos.h !== currentPos.h; + + if (posChanged) { + // Check if this is a bounce back to an even earlier position + const prevPrevPos = previousPositionsRef.current.get(`${itemKey}_prev`); + if (prevPrevPos) { + const isBouncingBack = prevPrevPos.x === currentPos.x && prevPrevPos.y === currentPos.y && + prevPrevPos.w === currentPos.w && prevPrevPos.h === currentPos.h; + + if (isBouncingBack) { + debugLog('🔴 BOUNCE DETECTED! Item returned to previous position', { + itemId: item.i, + operationId, + isResizing, + isDragging, + previousPosition: prevPos, + currentPosition: currentPos, + bouncedBackTo: prevPrevPos, + timeSinceLastChange: Date.now() - (prevPos.timestamp || 0) + }); + + // AGGRESSIVE BOUNCE PREVENTION: Completely block bounce changes + const intendedPos = intendedPositionsRef.current.get(itemKey); + if (intendedPos && !operationId && !isResizing && !isDragging) { + // This is a bounce occurring after operation completion + const timeSinceIntended = Date.now() - intendedPos.timestamp; + + // Block bounces that occur within 1 second of the intended position being set + if (timeSinceIntended < 1000) { + debugLog('🚫 BLOCKING BOUNCE - Rejecting entire layout change', { + itemId: item.i, + bouncedTo: currentPos, + intendedPosition: intendedPos, + timeSinceIntended, + action: 'REJECTING_LAYOUT_CHANGE' + }); + + // COMPLETELY REJECT this layout change by returning early + // This prevents the bounce from being processed at all + return; + } + } + } + } + + debugLog('📍 POSITION CHANGE', { + itemId: item.i, + operationId, + isResizing, + isDragging, + from: prevPos, + to: currentPos, + deltaX: currentPos.x - prevPos.x, + deltaY: currentPos.y - prevPos.y, + deltaW: currentPos.w - prevPos.w, + deltaH: currentPos.h - prevPos.h + }); + + // Store previous position as prev_prev for bounce detection + previousPositionsRef.current.set(`${itemKey}_prev`, prevPos); + } + } + + // Update current position with timestamp + previousPositionsRef.current.set(itemKey, { ...currentPos, timestamp: Date.now() }); + }); + } // Check for duplicate processing if (operationId && processedOperations.current.has(operationId)) { @@ -231,6 +830,49 @@ export const LayoutEngine: React.FC = React.memo(({ tablet: [], desktop: [], wide: [], + ultrawide: [] + }; + + // Helper: normalize an RGL layout array to include moduleId/pluginId + const extractPluginId = (compositeId: string): string => { + if (!compositeId) return 'unknown'; + const tokens = compositeId.split('_'); + if (tokens.length === 1) return tokens[0]; + const idx = tokens.findIndex(t => /^(?:[0-9a-f]{24,}|\d{12,})$/i.test(t)); + const boundary = idx > 0 ? idx : 2; // heuristic: often 2 tokens like ServiceExample_Theme + return tokens.slice(0, boundary).join('_'); + }; + + const normalizeItems = (items: any[] = []): LayoutItem[] => { + return (items || []).map((it: any) => { + const id = it?.i ?? ''; + const pluginId = it?.pluginId || extractPluginId(typeof id === 'string' ? id : ''); + + // CRITICAL: Preserve moduleId from config if available (from args) + let moduleId = it?.moduleId; + if (!moduleId && it?.config?.moduleId) { + moduleId = it.config.moduleId; + } + if (!moduleId) { + moduleId = id; // Last resort fallback + } + + return { + i: id, + x: it?.x ?? 0, + y: it?.y ?? 0, + w: it?.w ?? 2, + h: it?.h ?? 2, + moduleId: moduleId, + pluginId: pluginId || 'unknown', + minW: it?.minW, + minH: it?.minH, + isDraggable: it?.isDraggable ?? true, + isResizable: it?.isResizable ?? true, + static: it?.static ?? false, + config: it?.config + } as LayoutItem; + }); }; // Map react-grid-layout breakpoints back to our breakpoint names @@ -239,21 +881,75 @@ export const LayoutEngine: React.FC = React.memo(({ sm: 'tablet', lg: 'desktop', xl: 'wide', - xxl: 'wide' + xxl: 'ultrawide' }; + // Phase 5: Preserve identity for ACTIVE breakpoint only to avoid legacy/blank flash + // The 'layout' parameter contains the active breakpoint's updated layout during drag/resize + if (layout && currentBreakpoint) { + const ourBreakpoint = breakpointMap[currentBreakpoint]; + if (ourBreakpoint) { + // Build a lookup of existing items so we can copy identity/config + const source = (workingLayoutsRef.current || canonicalLayoutsRef.current || currentLayouts) as ResponsiveLayouts; + const existing: LayoutItem[] = (source?.[ourBreakpoint] as LayoutItem[]) || []; + const existingMap = new Map(existing.map(it => [it.i, it])); + + convertedLayouts[ourBreakpoint] = (layout as any[]).map((it: any) => { + const id = it?.i ?? ''; + const pos = { x: it?.x ?? 0, y: it?.y ?? 0, w: it?.w ?? 2, h: it?.h ?? 2 }; + const base = existingMap.get(id); + // Keep identity/config from base; only update position/size + return base ? ({ ...base, ...pos } as LayoutItem) : normalizeItems([it])[0]; + }); + + // RECODE V2 BLOCK: Enhanced item-level dimension tracking + if (isResizing || operationId?.includes('resize')) { + console.log('[RECODE_V2_BLOCK] onLayoutChange during resize - item dimensions', { + operationId, + breakpoint: ourBreakpoint, + isResizing, + itemDimensions: layout.map((item: any) => ({ + id: item.i, + dimensions: { w: item.w, h: item.h, x: item.x, y: item.y } + })) + }); + } + + // Enhanced debugging to understand what dimensions we're getting + if (isDebugMode) { + console.log(`[LayoutEngine] Using current breakpoint layout for ${ourBreakpoint}`, { + operationId, + isResizing, + isDragging, + itemCount: layout.length, + layoutItems: layout.map((item: any) => ({ + i: item.i, + x: item.x, + y: item.y, + w: item.w, + h: item.h + })) + }); + } + } + } + + // Fill in other breakpoints from allLayouts (no merge to avoid display regressions) Object.entries(allLayouts).forEach(([gridBreakpoint, gridLayout]: [string, any]) => { - const ourBreakpoint = breakpointMap[gridBreakpoint]; - if (ourBreakpoint && Array.isArray(gridLayout)) { - convertedLayouts[ourBreakpoint] = gridLayout as LayoutItem[]; + const ourBp = breakpointMap[gridBreakpoint]; + if (ourBp && Array.isArray(gridLayout)) { + if (gridBreakpoint !== currentBreakpoint || !layout) { + convertedLayouts[ourBp] = normalizeItems(gridLayout as any[]); + } } }); - // Determine the origin based on current operation + // PHASE B: Determine the origin with version information const origin: LayoutChangeOrigin = { source: isDragging ? 'user-drag' : isResizing ? 'user-resize' : 'external-sync', timestamp: Date.now(), - operationId: currentOperationId.current || `late-${Date.now()}` + operationId: currentOperationId.current || `late-${Date.now()}`, + version: ENABLE_LAYOUT_CONTROLLER_V2 ? lastVersionRef.current : undefined }; debugLog(`Processing layout change from ${origin.source}`, { @@ -271,41 +967,231 @@ export const LayoutEngine: React.FC = React.memo(({ if (operationId) { processedOperations.current.add(operationId); debugLog('Marked operation as processed', { operationId }); + + // CAPTURE INTENDED POSITIONS: Store the final positions from user operations + if (origin.source === 'user-resize' || origin.source === 'user-drag') { + layout?.forEach(item => { + const itemKey = `${item.i}`; + const intendedPos = { x: item.x, y: item.y, w: item.w, h: item.h, timestamp: Date.now() }; + intendedPositionsRef.current.set(itemKey, intendedPos); + + debugLog('💾 CAPTURED INTENDED POSITION', { + itemId: item.i, + operationId, + operationType: origin.source, + intendedPosition: intendedPos + }); + }); + } } }, [isDragging, isResizing, unifiedLayoutState, debugLog]); - // Handle drag start + // ========== PHASE C3: Enhanced Drag Operation Support ========== + // Handle drag start - Enhanced with controller V2 and Phase C improvements const handleDragStart = useCallback(() => { const operationId = `drag-${Date.now()}`; currentOperationId.current = operationId; setIsDragging(true); + + // Controller V2: Use state transition function + if (ENABLE_LAYOUT_CONTROLLER_V2) { + transitionToState('dragging', { operationId }); + workingLayoutsRef.current = JSON.parse(JSON.stringify(canonicalLayoutsRef.current || currentLayouts)); + logControllerState('DRAG_OPERATION_STARTED', { + operationId, + copiedCanonicalToWorking: true, + version: lastVersionRef.current + }); + } + unifiedLayoutState.startOperation(operationId); - console.log('[LayoutEngine] Started drag operation:', operationId); - }, [unifiedLayoutState]); + }, [unifiedLayoutState, ENABLE_LAYOUT_CONTROLLER_V2, logControllerState, currentLayouts, transitionToState]); - // Handle drag stop + // Handle drag stop - Enhanced with controller V2 and Phase C improvements const handleDragStop = useCallback((layout: any[]) => { if (currentOperationId.current) { + const operationId = currentOperationId.current; + + // Controller V2: Use unified commit process + if (ENABLE_LAYOUT_CONTROLLER_V2) { + lastVersionRef.current += 1; + transitionToState('grace', { + operationId, + newVersion: lastVersionRef.current + }); + + // RECODE V2 BLOCK: Pass the final layout data to scheduleCommit for accurate positions + // Build allLayouts object with current breakpoint's layout (include alias) + const allLayouts: any = {}; + const toGridAlias = (name: string): string => { + const m: Record = { mobile: 'xs', tablet: 'sm', desktop: 'lg', wide: 'xl', ultrawide: 'xxl' }; + return m[name] || name; + }; + if (currentBreakpoint) { + allLayouts[currentBreakpoint] = layout; + const semanticToGrid: Record = { mobile: 'xs', tablet: 'sm', desktop: 'lg', wide: 'xl', ultrawide: 'xxl' }; + const gridKey = semanticToGrid[currentBreakpoint]; + if (gridKey) { + allLayouts[gridKey] = layout; + } + } + + // Update working buffer for the active breakpoint with final drag positions + if (layout && currentBreakpoint) { + const toOurBreakpoint = (bp: string): keyof ResponsiveLayouts | undefined => { + const map: Record = { + xs: 'mobile', sm: 'tablet', lg: 'desktop', xl: 'wide', xxl: 'ultrawide', + mobile: 'mobile', tablet: 'tablet', desktop: 'desktop', wide: 'wide', ultrawide: 'ultrawide' + }; + return map[bp]; + }; + const ourBreakpoint = toOurBreakpoint(currentBreakpoint); + if (ourBreakpoint && workingLayoutsRef.current) { + workingLayoutsRef.current[ourBreakpoint] = layout as LayoutItem[]; + } + } + + // Schedule debounced commit with final layout data and normalized breakpoint + const normalizedBreakpoint = currentBreakpoint ? toGridAlias(currentBreakpoint) : currentBreakpoint; + scheduleCommit(150, layout, allLayouts, normalizedBreakpoint); + } + unifiedLayoutState.stopOperation(currentOperationId.current); - console.log('[LayoutEngine] Stopped drag operation:', currentOperationId.current); - currentOperationId.current = null; + + // Delay clearing operation ID to catch late events + setTimeout(() => { + if (currentOperationId.current === operationId) { + currentOperationId.current = null; + } + }, 200); } setIsDragging(false); - }, [unifiedLayoutState]); + }, [unifiedLayoutState, ENABLE_LAYOUT_CONTROLLER_V2, transitionToState, scheduleCommit]); - // Handle resize start + // Handle resize start - Enhanced with controller V2 and Phase C improvements const handleResizeStart = useCallback(() => { const operationId = `resize-${Date.now()}`; currentOperationId.current = operationId; setIsResizing(true); + + // Controller V2: Use state transition function + if (ENABLE_LAYOUT_CONTROLLER_V2) { + transitionToState('resizing', { operationId }); + // RECODE V2 BLOCK: Ensure proper initialization with all breakpoints including ultrawide + const sourceLayouts = canonicalLayoutsRef.current || currentLayouts || { + mobile: [], + tablet: [], + desktop: [], + wide: [], + ultrawide: [] + }; + + // Ensure ultrawide field exists + if (!sourceLayouts.ultrawide) { + sourceLayouts.ultrawide = []; + } + + workingLayoutsRef.current = JSON.parse(JSON.stringify(sourceLayouts)); + + console.log('[RECODE_V2_BLOCK] RESIZE START - Working buffer initialized', { + operationId, + hasCanonical: !!canonicalLayoutsRef.current, + hasCurrent: !!currentLayouts, + workingBufferBreakpoints: Object.keys(workingLayoutsRef.current || {}), + itemCounts: Object.entries(workingLayoutsRef.current || {}).map(([bp, items]) => ({ + breakpoint: bp, + count: (items as any[])?.length || 0 + })) + }); + + logControllerState('RESIZE_OPERATION_STARTED', { + operationId, + copiedCanonicalToWorking: true, + version: lastVersionRef.current + }); + } + unifiedLayoutState.startOperation(operationId); debugLog('RESIZE START', { operationId, isResizing: true }); - }, [unifiedLayoutState, debugLog]); + }, [unifiedLayoutState, debugLog, ENABLE_LAYOUT_CONTROLLER_V2, logControllerState, currentLayouts, transitionToState]); - // Handle resize stop - const handleResizeStop = useCallback(() => { + // Handle resize stop - Enhanced with controller V2 and Phase C improvements + const handleResizeStop = useCallback((layout: any, oldItem: any, newItem: any, placeholder: any, e: any, element: any) => { if (currentOperationId.current) { const operationId = currentOperationId.current; + + // RECODE V2 BLOCK: Enhanced resize stop logging + console.log('[RECODE_V2_BLOCK] RESIZE STOP - Capturing final dimensions', { + operationId, + itemId: newItem?.i, + oldDimensions: oldItem ? { w: oldItem.w, h: oldItem.h, x: oldItem.x, y: oldItem.y } : null, + newDimensions: newItem ? { w: newItem.w, h: newItem.h, x: newItem.x, y: newItem.y } : null, + layoutItemCount: layout?.length, + currentBreakpoint, + timestamp: Date.now() + }); + + // Controller V2: Use unified commit process + if (ENABLE_LAYOUT_CONTROLLER_V2) { + lastVersionRef.current += 1; + transitionToState('grace', { + operationId, + newVersion: lastVersionRef.current + }); + + // RECODE V2 BLOCK: Pass the final layout data to scheduleCommit for accurate dimensions + // Build allLayouts object with current breakpoint's layout (include both semantic and grid aliases) + const allLayouts: any = {}; + const toGridAlias = (name: string): string => { + const m: Record = { mobile: 'xs', tablet: 'sm', desktop: 'lg', wide: 'xl', ultrawide: 'xxl' }; + return m[name] || name; + }; + + // Add the current breakpoint's final layout + if (currentBreakpoint) { + // currentBreakpoint might already be a grid key (xs/sm/lg/xl/xxl) or a semantic key + allLayouts[currentBreakpoint] = layout; + // Also add alias to ensure commit path can normalize either form + const semanticToGrid: Record = { mobile: 'xs', tablet: 'sm', desktop: 'lg', wide: 'xl', ultrawide: 'xxl' }; + const gridKey = semanticToGrid[currentBreakpoint]; + if (gridKey) { + allLayouts[gridKey] = layout; + } + } + + // RECODE V2 BLOCK: Immediately update working buffer with final layout + // This ensures the commit has the correct data + if (layout && currentBreakpoint) { + const toOurBreakpoint = (bp: string): keyof ResponsiveLayouts | undefined => { + const map: Record = { + xs: 'mobile', sm: 'tablet', lg: 'desktop', xl: 'wide', xxl: 'ultrawide', + mobile: 'mobile', tablet: 'tablet', desktop: 'desktop', wide: 'wide', ultrawide: 'ultrawide' + }; + return map[bp]; + }; + + const ourBreakpoint = toOurBreakpoint(currentBreakpoint); + if (ourBreakpoint && workingLayoutsRef.current) { + workingLayoutsRef.current[ourBreakpoint] = layout as LayoutItem[]; + console.log('[RECODE_V2_BLOCK] Updated working buffer with final resize dimensions', { + operationId, + breakpoint: ourBreakpoint, + itemCount: layout.length, + dimensions: layout.map((item: any) => ({ + id: item.i, + w: item.w, + h: item.h + })) + }); + } + } + + // Schedule debounced commit with final layout data + // Pass a normalized grid alias for breakpoint to maximize compatibility + const normalizedBreakpoint = currentBreakpoint ? toGridAlias(currentBreakpoint) : currentBreakpoint; + scheduleCommit(150, layout, allLayouts, normalizedBreakpoint); + } + unifiedLayoutState.stopOperation(operationId); debugLog('RESIZE STOP - Starting grace period', { operationId }); @@ -328,7 +1214,8 @@ export const LayoutEngine: React.FC = React.memo(({ } setIsResizing(false); debugLog('RESIZE STATE SET TO FALSE'); - }, [unifiedLayoutState, debugLog]); + }, [unifiedLayoutState, debugLog, ENABLE_LAYOUT_CONTROLLER_V2, transitionToState, scheduleCommit, currentBreakpoint]); + // ========== END PHASE C3 ========== // Handle drag over for drop zone functionality const handleDragOver = useCallback((e: React.DragEvent) => { @@ -350,7 +1237,7 @@ export const LayoutEngine: React.FC = React.memo(({ }, []); // Handle drop for adding new modules - const handleDrop = useCallback((e: React.DragEvent) => { + const handleDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); @@ -372,7 +1259,54 @@ export const LayoutEngine: React.FC = React.memo(({ // Parse the module data const moduleData = JSON.parse(moduleDataStr); - console.log('Parsed module data:', moduleData); + if (isDebugMode) { + console.log('[AddTrace] Parsed module data', moduleData); + } + + // DEBUG: Log controller + commit tracker state prior to add + if (isDebugMode) { + const tracker = getLayoutCommitTracker(); + const pending = tracker.getPendingCommits(); + const last = unifiedLayoutState.getLastCommitMeta?.(); + const committed = unifiedLayoutState.getCommittedLayouts?.(); + const committedCounts = committed ? Object.fromEntries(Object.entries(committed).map(([bp, arr]) => [bp, (arr as any[])?.length || 0])) : {}; + console.log('[AddTrace] Pre-Add State', { + controllerState: controllerStateRef.current, + hasCommitTimer: !!commitTimerRef.current, + pendingCommits: pending.length, + lastCommit: last, + committedCounts + }); + } + + // Phase 3: Commit barrier - await any pending commits before adding + if (ENABLE_LAYOUT_CONTROLLER_V2) { + const state = controllerStateRef.current; + + // If we're in grace or commit state, or have pending commits, wait for flush + if (state === 'grace' || state === 'commit' || commitTimerRef.current) { + if (isDebugMode) { + console.log('[LayoutEngine] Awaiting flush before drop-add', { + state, + hasPendingCommit: !!commitTimerRef.current + }); + } + + // Await the flush to ensure we're working with committed layouts + await unifiedLayoutState.flush(); + + if (isDebugMode) { + const committed = unifiedLayoutState.getCommittedLayouts?.(); + const committedCounts = committed ? Object.fromEntries(Object.entries(committed).map(([bp, arr]) => [bp, (arr as any[])?.length || 0])) : {}; + const last = unifiedLayoutState.getLastCommitMeta?.(); + console.log('[AddTrace] Post-Flush State', { lastCommit: last, committedCounts }); + } + + if (isDebugMode) { + console.log('[LayoutEngine] Flush complete, proceeding with drop-add'); + } + } + } // Calculate the drop position relative to the grid const rect = e.currentTarget.getBoundingClientRect(); @@ -398,17 +1332,35 @@ export const LayoutEngine: React.FC = React.memo(({ config: { moduleId: moduleData.moduleId, displayName: moduleData.displayName || moduleData.moduleName, - ...moduleData.config + ...moduleData.config, + // Preserve original item identity for adapter round-trips + _originalItem: { + i: uniqueId, + pluginId: moduleData.pluginId, + moduleId: moduleData.moduleId, + args: moduleData.config || {} + } }, isDraggable: true, isResizable: true, static: false }; - console.log('Adding new item to layout:', newItem); + if (isDebugMode) { + console.log('[AddTrace] Adding new item to layout', { + id: newItem.i, + pluginId: newItem.pluginId, + moduleId: newItem.moduleId, + x: newItem.x, + y: newItem.y, + w: newItem.w, + h: newItem.h + }); + } - // Add the item to the current layouts - const updatedLayouts = { ...currentLayouts }; + // Phase 3: Use committed layouts as the base for adding new items + const layoutsToUpdate = unifiedLayoutState.getCommittedLayouts() || currentLayouts; + const updatedLayouts = { ...layoutsToUpdate }; Object.keys(updatedLayouts).forEach(breakpoint => { const currentLayout = updatedLayouts[breakpoint as keyof ResponsiveLayouts]; if (currentLayout) { @@ -419,10 +1371,19 @@ export const LayoutEngine: React.FC = React.memo(({ } }); - // Update through unified state management + // Update through unified state management with version tracking + const version = lastVersionRef.current + 1; + lastVersionRef.current = version; + + if (isDebugMode) { + const counts = Object.fromEntries(Object.entries(updatedLayouts).map(([bp, arr]) => [bp, (arr as any[])?.length || 0])); + console.log('[AddTrace] Committing layouts after drop', { counts }); + } + unifiedLayoutState.updateLayouts(updatedLayouts, { source: 'drop-add', - timestamp: Date.now() + timestamp: Date.now(), + version }); onItemAdd?.(newItem); @@ -452,14 +1413,16 @@ export const LayoutEngine: React.FC = React.memo(({ // Handle item removal const handleItemRemove = useCallback((itemId: string) => { - console.log(`[LayoutEngine] Removing item: ${itemId}`); + + // Use displayedLayouts for consistency with controller V2 + const layoutsToUpdate = ENABLE_LAYOUT_CONTROLLER_V2 ? displayedLayouts : currentLayouts; // Create new layouts with the item removed from all breakpoints const updatedLayouts: ResponsiveLayouts = { - mobile: currentLayouts.mobile?.filter((item: LayoutItem) => item.i !== itemId) || [], - tablet: currentLayouts.tablet?.filter((item: LayoutItem) => item.i !== itemId) || [], - desktop: currentLayouts.desktop?.filter((item: LayoutItem) => item.i !== itemId) || [], - wide: currentLayouts.wide?.filter((item: LayoutItem) => item.i !== itemId) || [] + mobile: layoutsToUpdate.mobile?.filter((item: LayoutItem) => item.i !== itemId) || [], + tablet: layoutsToUpdate.tablet?.filter((item: LayoutItem) => item.i !== itemId) || [], + desktop: layoutsToUpdate.desktop?.filter((item: LayoutItem) => item.i !== itemId) || [], + wide: layoutsToUpdate.wide?.filter((item: LayoutItem) => item.i !== itemId) || [] }; // Update through unified state management @@ -468,6 +1431,13 @@ export const LayoutEngine: React.FC = React.memo(({ timestamp: Date.now(), operationId: `remove-${itemId}-${Date.now()}` }); + + // Controller V2: Update buffers when removing items + if (ENABLE_LAYOUT_CONTROLLER_V2) { + canonicalLayoutsRef.current = updatedLayouts; + workingLayoutsRef.current = updatedLayouts; + logControllerState('ITEM_REMOVED', { itemId }); + } // Clear selection if the removed item was selected if (selectedItem === itemId) { @@ -476,7 +1446,7 @@ export const LayoutEngine: React.FC = React.memo(({ // Call the external callback if provided onItemRemove?.(itemId); - }, [currentLayouts, unifiedLayoutState, selectedItem, onItemRemove]); + }, [currentLayouts, displayedLayouts, unifiedLayoutState, selectedItem, onItemRemove, ENABLE_LAYOUT_CONTROLLER_V2, logControllerState]); // Create ultra-stable module map with deep comparison const stableModuleMapRef = useRef>({}); @@ -498,89 +1468,95 @@ export const LayoutEngine: React.FC = React.memo(({ return stableModuleMapRef.current; }, [modules]); - // Render grid items + // Render grid items - Use displayedLayouts instead of currentLayouts const renderGridItems = useCallback(() => { - const currentLayout = currentLayouts[currentBreakpoint as keyof ResponsiveLayouts] || currentLayouts.desktop || []; - - if (process.env.NODE_ENV === 'development') { - console.log(`[LayoutEngine] RENDER TRIGGERED - Rendering ${currentLayout.length} items for breakpoint: ${currentBreakpoint}`, { - availableModules: Object.keys(moduleMap), - availableModuleDetails: Object.entries(moduleMap).map(([id, mod]) => ({ id, pluginId: mod.pluginId })), - layoutItems: currentLayout.map((item: LayoutItem) => ({ i: item.i, moduleId: item.moduleId, pluginId: item.pluginId })), - currentLayouts: Object.keys(currentLayouts), - currentBreakpoint, - stackTrace: new Error().stack?.split('\n').slice(0, 5).join('\n') - }); - } + const currentLayout = + displayedLayouts[currentBreakpoint as keyof ResponsiveLayouts] || + displayedLayouts.desktop || + displayedLayouts.wide || + []; + + + if (isDebugMode) { + const preview = currentLayout.map((i: any) => ({ i: i.i, pluginId: i.pluginId, moduleId: i.moduleId, x: i.x, y: i.y, w: i.w, h: i.h })); + console.log('[AddTrace] Render pass items', { breakpoint: currentBreakpoint, count: preview.length, items: preview }); + } + return currentLayout.map((item: LayoutItem) => { // Try to find the module by moduleId with multiple strategies let module = moduleMap[item.moduleId]; - // If direct lookup fails, try alternative matching strategies + // If direct lookup fails, try a conservative fallback only (avoid cross-binding by pluginId) + let resolvedVia: 'direct' | 'sanitized' | 'fallback' | 'none' = module ? 'direct' : 'none'; if (!module) { - // Strategy 1: Try without underscores (sanitized version) - const sanitizedModuleId = item.moduleId.replace(/_/g, ''); - module = moduleMap[sanitizedModuleId]; - - if (module) { - if (process.env.NODE_ENV === 'development') { - console.log(`[LayoutEngine] Found module using sanitized ID: ${sanitizedModuleId} for original: ${item.moduleId}`); - } - } else { - // Strategy 2: Try finding by pluginId match - for (const [moduleId, moduleConfig] of Object.entries(moduleMap)) { - if (moduleConfig.pluginId === item.pluginId) { - module = moduleConfig; - if (process.env.NODE_ENV === 'development') { - console.log(`[LayoutEngine] Found module by pluginId match: ${moduleId} for ${item.moduleId}`); - } - break; - } - } + // Try without underscores (sanitized id) once + if (item.moduleId && typeof item.moduleId === 'string') { + const sanitizedModuleId = item.moduleId.replace(/_/g, ''); + module = moduleMap[sanitizedModuleId]; + if (module) resolvedVia = 'sanitized'; } } if (!module) { - if (process.env.NODE_ENV === 'development') { - console.warn(`[LayoutEngine] Module not found for moduleId: ${item.moduleId}`, { - availableModules: Object.keys(moduleMap), - availableModuleDetails: Object.entries(moduleMap).map(([id, mod]) => ({ id, pluginId: mod.pluginId })), - layoutItem: item, - searchedModuleId: item.moduleId, - itemPluginId: item.pluginId - }); - } + // Instead of returning null, try to render with the layout item data directly // This allows the LegacyModuleAdapter to handle the module loading const isSelected = selectedItem === item.i; const isStudioMode = showControls; // Use control visibility instead of just mode check + // Helper function to extract plugin ID from composite ID + const extractPluginIdFromComposite = (compositeId: string): string => { + if (!compositeId) return 'unknown'; + const tokens = compositeId.split('_'); + if (tokens.length === 1) return tokens[0]; + const idx = tokens.findIndex(t => /^(?:[0-9a-f]{24,}|\d{12,})$/i.test(t)); + const boundary = idx > 0 ? idx : 2; + return tokens.slice(0, boundary).join('_'); + }; + // Try to extract pluginId from moduleId if item.pluginId is 'unknown' let fallbackPluginId = item.pluginId; if (!fallbackPluginId || fallbackPluginId === 'unknown') { // Try to extract plugin ID from the module ID pattern // e.g., "BrainDriveChat_1830586da8834501bea1ef1d39c3cbe8_BrainDriveChat_BrainDriveChat_1754404718788" - const moduleIdParts = item.moduleId.split('_'); - if (moduleIdParts.length > 0) { - const potentialPluginId = moduleIdParts[0]; - // Check if this matches any available plugin - const availablePluginIds = ['BrainDriveBasicAIChat', 'BrainDriveChat', 'BrainDriveSettings']; - if (availablePluginIds.includes(potentialPluginId)) { - fallbackPluginId = potentialPluginId; - if (process.env.NODE_ENV === 'development') { - console.log(`[LayoutEngine] Extracted pluginId '${fallbackPluginId}' from moduleId '${item.moduleId}'`); - } - } - } + const potentialPluginId = extractPluginIdFromComposite(item.moduleId || ''); + fallbackPluginId = potentialPluginId || fallbackPluginId; } - // Extract simple module ID from complex ID - calculate directly to avoid useMemo in render loop - // Pattern: BrainDriveBasicAIChat_59898811a4b34d9097615ed6698d25f6_1754507768265 - // We want: 59898811a4b34d9097615ed6698d25f6 - const parts = item.moduleId.split('_'); - const extractedModuleId = parts.length >= 6 ? parts[5] : item.moduleId; + // Extract moduleId more robustly + // First check if moduleId is in config (from args in database) + let extractedModuleId = (item.config as any)?.moduleId; + + // CRITICAL FIX: If item.moduleId is just the module name (not composite), use it directly + if (item.moduleId && !item.moduleId.includes('_')) { + extractedModuleId = item.moduleId; + console.log('[LayoutEngine] Using simple moduleId directly:', item.moduleId); + } + + console.log('[LayoutEngine] Module extraction for fallback path:', { + itemId: item.i, + itemModuleId: item.moduleId, + configModuleId: (item.config as any)?.moduleId, + config: item.config, + pluginId: fallbackPluginId, + extractedSoFar: extractedModuleId + }); + + // If not in config and item.moduleId looks like a composite ID, try to extract + if (!extractedModuleId && item.moduleId && item.moduleId.includes('_')) { + const parts = item.moduleId.split('_'); + const isTimestamp = (s: string) => /^\d{12,}$/.test(s); + extractedModuleId = parts.reverse().find(p => p && !isTimestamp(p) && p !== fallbackPluginId); + } + + // Otherwise use item.moduleId as-is + if (!extractedModuleId) { + extractedModuleId = item.moduleId; + } + + console.log('[LayoutEngine] Final extracted moduleId:', extractedModuleId); // Create stable breakpoint object const breakpointConfig = { @@ -593,13 +1569,33 @@ export const LayoutEngine: React.FC = React.memo(({ containerHeight: 800, }; + if (isDebugMode) { + console.log('[AddTrace] Resolve item (fallback path)', { + itemId: item.i, + pluginId: fallbackPluginId, + requestedModuleId: item.moduleId, + extractedModuleId + }); + } + return ( -
handleItemClick(item.i)} data-grid={item} - style={{ position: 'relative' }} + sx={{ + position: 'relative', + backgroundColor: theme.palette.background.paper, + border: `1px solid ${theme.palette.divider}`, + borderRadius: 1, + overflow: 'hidden', + transition: 'background-color 0.3s ease, border-color 0.3s ease', + ...(isSelected && { + borderColor: theme.palette.primary.main, + boxShadow: `0 0 0 2px ${theme.palette.primary.main}20` + }) + }} > {/* Use the legacy GridItemControls component for consistent behavior */} {showControls && ( @@ -609,34 +1605,47 @@ export const LayoutEngine: React.FC = React.memo(({ onRemove={() => handleItemRemove(item.i)} /> )} - -
- ); + Loading module...} + /> + + ); } const isSelected = selectedItem === item.i; const isStudioMode = showControls; // Use control visibility instead of just mode check + if (isDebugMode) { + console.log('[AddTrace] Resolve item', { + itemId: item.i, + requestedPluginId: item.pluginId, + requestedModuleId: item.moduleId, + resolvedModuleKey: module?.id || '(legacy)', + via: resolvedVia + }); + } + return ( -
handleItemClick(item.i)} data-grid={item} - style={{ position: 'relative' }} + sx={{ + position: 'relative', + backgroundColor: theme.palette.background.paper, + border: `1px solid ${theme.palette.divider}`, + borderRadius: 1, + overflow: 'hidden', + transition: 'background-color 0.3s ease, border-color 0.3s ease', + ...(isSelected && { + borderColor: theme.palette.primary.main, + boxShadow: `0 0 0 2px ${theme.palette.primary.main}20` + }) + }} > {/* Use the legacy GridItemControls component for consistent behavior */} {showControls && ( @@ -646,45 +1655,75 @@ export const LayoutEngine: React.FC = React.memo(({ onRemove={() => handleItemRemove(item.i)} /> )} - - { - // Extract simple module ID from complex ID - // Pattern: ServiceExample_Theme_userId_ServiceExample_Theme_actualModuleId_timestamp - // Example: ServiceExample_Theme_c34bfc30de004813ad5b5d3a4ab9df34_ServiceExample_Theme_ThemeDisplay_1756311722722 - // We want: ThemeDisplay (or ThemeController) - const parts = item.moduleId.split('_'); - if (parts.length >= 6) { - // The actual module ID is the sixth part (after ServiceExample_Theme_userId_ServiceExample_Theme) - return parts[5]; + {(() => { + // Helper function to extract plugin ID (local copy) + const extractPluginIdLocal = (compositeId: string): string => { + if (!compositeId) return 'unknown'; + const tokens = compositeId.split('_'); + if (tokens.length === 1) return tokens[0]; + const idx = tokens.findIndex(t => /^(?:[0-9a-f]{24,}|\d{12,})$/i.test(t)); + const boundary = idx > 0 ? idx : 2; + return tokens.slice(0, boundary).join('_'); + }; + + // Normalize current candidates + const candidatePluginId = + (item as any)?.config?._originalItem?.pluginId || + (item.pluginId && item.pluginId !== 'unknown' && item.pluginId.includes('_') + ? item.pluginId + : extractPluginIdLocal(item.moduleId || (item as any)?.config?._originalItem?.moduleId || (item.i || ''))); + const candidateModuleId = (item.config as any)?.moduleId || (item as any)?.config?._originalItem?.moduleId || item.moduleId; + + // Use last known-good identity if it is more specific/complete + const prev = stableIdentityRef.current.get(item.i); + let effectivePluginId = candidatePluginId; + let effectiveModuleId = candidateModuleId; + + if (prev) { + // Prefer composite plugin ids with an underscore + const prevIsComposite = prev.pluginId && prev.pluginId.includes('_'); + const candIsComposite = effectivePluginId && effectivePluginId.includes('_'); + if (prevIsComposite && !candIsComposite) { + effectivePluginId = prev.pluginId; } - return item.moduleId; // fallback to original if pattern doesn't match - })()} - moduleName={module._legacy?.moduleName} - moduleProps={module._legacy?.originalConfig || item.config} - useUnifiedRenderer={true} - mode={mode === RenderMode.STUDIO ? 'studio' : 'published'} - breakpoint={{ - name: currentBreakpoint, - width: 0, - height: 0, - orientation: 'landscape', - pixelRatio: 1, - containerWidth: 1200, - containerHeight: 800, - }} - lazyLoading={lazyLoading} - priority={preloadPlugins.includes(item.pluginId) ? 'high' : 'normal'} - enableMigrationWarnings={process.env.NODE_ENV === 'development'} - fallbackStrategy="on-error" - performanceMonitoring={process.env.NODE_ENV === 'development'} - /> -
+ // Prefer previously known moduleId if current is empty/falsy + if (!effectiveModuleId && prev.moduleId) { + effectiveModuleId = prev.moduleId; + } + } + + // Update cache only when both values look usable + if (effectivePluginId && effectiveModuleId) { + stableIdentityRef.current.set(item.i, { + pluginId: effectivePluginId, + moduleId: effectiveModuleId, + }); + } + + if (isDebugMode) { + console.log('[ModuleRenderTrace] Identity', { + id: item.i, + candidatePluginId, + candidateModuleId, + effectivePluginId, + effectiveModuleId, + }); + } + + return ( + Loading module...} + /> + ); + })()} + ); }); }, [ - unifiedLayoutState.layouts, + displayedLayouts, // Changed from unifiedLayoutState.layouts currentBreakpoint, moduleMap, selectedItem, @@ -696,11 +1735,11 @@ export const LayoutEngine: React.FC = React.memo(({ onItemConfig, ]); - // Grid layout props - convert ResponsiveLayouts to react-grid-layout Layouts format + // Grid layout props - A4: Use displayedLayouts instead of currentLayouts const gridProps = useMemo(() => { // Convert ResponsiveLayouts to the format expected by react-grid-layout const reactGridLayouts: any = {}; - Object.entries(currentLayouts).forEach(([breakpoint, layout]) => { + Object.entries(displayedLayouts).forEach(([breakpoint, layout]) => { if (layout && Array.isArray(layout) && layout.length > 0) { // Map breakpoint names to react-grid-layout breakpoint names const breakpointMap: Record = { @@ -735,26 +1774,15 @@ export const LayoutEngine: React.FC = React.memo(({ transformScale: 1, ...defaultGridConfig, }; - }, [currentLayouts, mode, showControls, handleLayoutChange, handleDragStart, handleDragStop, handleResizeStart, handleResizeStop]); + }, [displayedLayouts, mode, showControls, handleLayoutChange, handleDragStart, handleDragStop, handleResizeStart, handleResizeStop]); // Memoize the rendered grid items with minimal stable dependencies const gridItems = useMemo(() => { - if (process.env.NODE_ENV === 'development') { - console.log(`[LayoutEngine] MEMO RECALCULATION - gridItems being recalculated`, { - currentLayoutsKeys: Object.keys(currentLayouts), - currentBreakpoint, - moduleMapSize: Object.keys(moduleMap).length, - selectedItem, - mode, - lazyLoading, - preloadPluginsLength: preloadPlugins.length, - stackTrace: new Error().stack?.split('\n').slice(0, 3).join('\n') - }); - } + return renderGridItems(); }, [ // Only include the most essential dependencies that should trigger re-render - currentLayouts, + displayedLayouts, // Changed from currentLayouts currentBreakpoint, moduleMap, selectedItem, @@ -777,9 +1805,7 @@ export const LayoutEngine: React.FC = React.memo(({ ); }, (prevProps, nextProps) => { // Custom comparison function for React.memo - if (process.env.NODE_ENV === 'development') { - console.log('[LayoutEngine] MEMO COMPARISON - Checking if props are equal'); - } + // Compare primitive props if ( @@ -787,24 +1813,18 @@ export const LayoutEngine: React.FC = React.memo(({ prevProps.lazyLoading !== nextProps.lazyLoading || prevProps.pageId !== nextProps.pageId ) { - if (process.env.NODE_ENV === 'development') { - console.log('[LayoutEngine] MEMO COMPARISON - Primitive props changed, re-rendering'); - } + return false; } // Compare arrays by length and content if (prevProps.modules.length !== nextProps.modules.length) { - if (process.env.NODE_ENV === 'development') { - console.log('[LayoutEngine] MEMO COMPARISON - Modules length changed, re-rendering'); - } + return false; } if ((prevProps.preloadPlugins?.length || 0) !== (nextProps.preloadPlugins?.length || 0)) { - if (process.env.NODE_ENV === 'development') { - console.log('[LayoutEngine] MEMO COMPARISON - PreloadPlugins length changed, re-rendering'); - } + return false; } @@ -813,18 +1833,14 @@ export const LayoutEngine: React.FC = React.memo(({ const nextLayoutsStr = JSON.stringify(nextProps.layouts); if (prevLayoutsStr !== nextLayoutsStr) { - if (process.env.NODE_ENV === 'development') { - console.log('[LayoutEngine] MEMO COMPARISON - Layouts changed, re-rendering'); - } + return false; } // Compare modules by ID (assuming modules have stable IDs) for (let i = 0; i < prevProps.modules.length; i++) { if (prevProps.modules[i].id !== nextProps.modules[i].id) { - if (process.env.NODE_ENV === 'development') { - console.log('[LayoutEngine] MEMO COMPARISON - Module IDs changed, re-rendering'); - } + return false; } } @@ -832,11 +1848,9 @@ export const LayoutEngine: React.FC = React.memo(({ // Skip callback function comparison - they change frequently but don't affect rendering // This is the key optimization: ignore callback prop changes - if (process.env.NODE_ENV === 'development') { - console.log('[LayoutEngine] MEMO COMPARISON - Props are equal, preventing re-render'); - } + return true; // Props are equal, prevent re-render }); -export default LayoutEngine; \ No newline at end of file +export default LayoutEngine; diff --git a/frontend/src/features/unified-dynamic-page-renderer/components/ModuleRenderer.tsx b/frontend/src/features/unified-dynamic-page-renderer/components/ModuleRenderer.tsx index 94621b4..2a63b97 100644 --- a/frontend/src/features/unified-dynamic-page-renderer/components/ModuleRenderer.tsx +++ b/frontend/src/features/unified-dynamic-page-renderer/components/ModuleRenderer.tsx @@ -154,9 +154,51 @@ const UnifiedModuleRenderer: React.FC = ({ } // Load the plugin module using the same logic as PluginModuleRenderer - const remotePlugin = remotePluginService.getLoadedPlugin(pluginId); + // Attempt direct lookup; if missing, try to resolve a compatible loaded plugin id + let normalizedPluginId = pluginId; + let remotePlugin = remotePluginService.getLoadedPlugin(normalizedPluginId); if (!remotePlugin) { - throw new Error(`Plugin ${pluginId} not found or not loaded`); + const candidates = remotePluginService.listLoadedPluginIds(); + const variations = [ + normalizedPluginId, + normalizedPluginId.replace(/-/g, '_'), + normalizedPluginId.replace(/_/g, '-'), + ]; + const foundId = + candidates.find(id => variations.includes(id)) || + candidates.find(id => id.includes(normalizedPluginId)) || + candidates.find(id => normalizedPluginId.includes(id)); + if (foundId) { + normalizedPluginId = foundId; + if (process.env.NODE_ENV === 'development') { + console.debug(`[ModuleRenderer] Resolved pluginId '${pluginId}' -> '${normalizedPluginId}' (loaded)`); + } + remotePlugin = remotePluginService.getLoadedPlugin(normalizedPluginId)!; + } + } + // Lazy-load plugin by manifest if still missing + if (!remotePlugin) { + const manifest = await remotePluginService.getRemotePluginManifest(); + // Prefer manifest entries where id includes pluginId and module list includes moduleId + const byModules = manifest.filter(m => + (m.id && (m.id.includes(normalizedPluginId) || normalizedPluginId.includes(m.id))) && + Array.isArray(m.modules) && m.modules.some(mod => mod.id === moduleId || mod.name === moduleId) + ); + const byId = manifest.filter(m => m.id && (m.id.includes(normalizedPluginId) || normalizedPluginId.includes(m.id))); + const candidateManifest = byModules[0] || byId[0] || manifest.find(m => m.id === normalizedPluginId); + if (candidateManifest) { + const loaded = await remotePluginService.loadRemotePlugin(candidateManifest); + if (loaded) { + normalizedPluginId = loaded.id; + remotePlugin = loaded; + if (process.env.NODE_ENV === 'development') { + console.debug(`[ModuleRenderer] Lazy-loaded plugin '${normalizedPluginId}' for ${moduleId}`); + } + } + } + } + if (!remotePlugin) { + throw new Error(`Plugin ${normalizedPluginId} not found or not loaded`); } // Use loadedModules instead of modules - same as PluginModuleRenderer @@ -164,22 +206,68 @@ const UnifiedModuleRenderer: React.FC = ({ throw new Error(`Plugin ${pluginId} has no loaded modules`); } - // Extract the base moduleId from the custom moduleId (e.g., "component-display" from "component-display-2") + // Extract additional normalized forms of moduleId for robust matching + const normalize = (s?: string) => (s || '').toLowerCase().replace(/[_-]/g, ''); + const lastToken = (s?: string) => { + const t = (s || '').split('_'); + return t[t.length - 1] || s || ''; + }; const baseModuleId = moduleId ? moduleId.replace(/-\d+$/, '') : null; // Find the module by ID first, then by base ID, then by name - same logic as PluginModuleRenderer let foundModule: LoadedModule | undefined; if (moduleId) { + // Exact id match foundModule = remotePlugin.loadedModules.find(m => m.id === moduleId); - // If not found by exact moduleId, try with the base moduleId + // Base-id match (strip numeric suffix) if (!foundModule && baseModuleId) { foundModule = remotePlugin.loadedModules.find(m => m.id === baseModuleId); } - } else if (moduleName) { - foundModule = remotePlugin.loadedModules.find(m => m.name === moduleName); - } else { - // Default to first module + // Last token of composite id (e.g., ServiceExample_Theme_ThemeDisplay -> ThemeDisplay) + if (!foundModule) { + const lt = lastToken(moduleId); + foundModule = remotePlugin.loadedModules.find(m => m.id === lt || m.name === lt); + } + // Loose normalized comparison (ignore case and _-/) + if (!foundModule) { + const target = normalize(moduleId); + foundModule = remotePlugin.loadedModules.find(m => normalize(m.id) === target || normalize(m.name) === target); + } + } + + // Special handling for BrainDriveChat plugin + if (!foundModule && pluginId === 'BrainDriveChat') { + // BrainDriveChat has a single module that should be used regardless of moduleId + if (remotePlugin.loadedModules.length > 0) { + foundModule = remotePlugin.loadedModules[0]; + if (process.env.NODE_ENV === 'development') { + console.debug(`[ModuleRenderer] Using first module for BrainDriveChat plugin`); + } + } + } + + if (!foundModule && moduleName) { + // Try by name, including loose normalized comparison + foundModule = remotePlugin.loadedModules.find(m => m.name === moduleName) + || remotePlugin.loadedModules.find(m => normalize(m.name) === normalize(moduleName)); + } + // Cross-plugin fallback: search all loaded plugins for this module id/name if still not found + if (!foundModule && moduleId) { + // Cross-plugin fallback: allow loose matching by id/name and last token + const cross = remotePluginService.findLoadedPluginByModuleId(moduleId) + || remotePluginService.findLoadedPluginByModuleId(baseModuleId || '') + || remotePluginService.findLoadedPluginByModuleId(lastToken(moduleId)); + if (cross) { + if (process.env.NODE_ENV === 'development') { + console.debug(`[ModuleRenderer] Resolved module '${moduleId}' in plugin '${cross.plugin.id}'`); + } + remotePlugin = cross.plugin; + foundModule = cross.module; + } + } + if (!foundModule && !moduleId && !moduleName) { + // Default to first module if still nothing specified foundModule = remotePlugin.loadedModules[0]; } @@ -282,7 +370,13 @@ const UnifiedModuleRenderer: React.FC = ({ } } catch (err) { console.error(`[ModuleRenderer] Error loading module ${pluginId}:${moduleId}:`, err); - setError(err instanceof Error ? err.message : 'Unknown error loading module'); + // Sticky: If we had a previously loaded module, keep rendering it and suppress the error UI + if (prevModuleRef.current) { + setError(null); + setModule(prevModuleRef.current); + } else { + setError(err instanceof Error ? err.message : 'Unknown error loading module'); + } if (onError && err instanceof Error) { onError(err); } @@ -312,7 +406,7 @@ const UnifiedModuleRenderer: React.FC = ({ }, [pluginId, moduleId, moduleName, isLocal, createServiceBridgesWithMemo, additionalProps, serviceContext, onError, mountedRef]); // Loading state - if (loading) { + if (loading && !prevModuleRef.current && !module) { return ( @@ -323,8 +417,8 @@ const UnifiedModuleRenderer: React.FC = ({ ); } - // Error state - if (error) { + // Error state (only when no previous successful module rendered) + if (error && !prevModuleRef.current && !module) { return ( Module Load Error @@ -399,4 +493,4 @@ const UnifiedModuleRenderer: React.FC = ({ } }; -export default ModuleRenderer; \ No newline at end of file +export default ModuleRenderer; diff --git a/frontend/src/features/unified-dynamic-page-renderer/components/ResponsiveContainer.tsx b/frontend/src/features/unified-dynamic-page-renderer/components/ResponsiveContainer.tsx index 4d26434..c45c9d2 100644 --- a/frontend/src/features/unified-dynamic-page-renderer/components/ResponsiveContainer.tsx +++ b/frontend/src/features/unified-dynamic-page-renderer/components/ResponsiveContainer.tsx @@ -39,8 +39,10 @@ export const ResponsiveContainer: React.FC = ({ const responsiveState = useResponsive({ containerRef, breakpoints, + // Only enable container queries when both supported and requested containerQueries: containerQueries && supportsContainerQueries, - fallbackToViewport: !supportsContainerQueries, + // Fall back to viewport when container queries are unsupported OR disabled via prop + fallbackToViewport: !supportsContainerQueries || !containerQueries, }); // Handle breakpoint changes @@ -166,4 +168,4 @@ function getBreakpointScale(breakpoint: string): number { return scales[breakpoint] || 1; } -export default ResponsiveContainer; \ No newline at end of file +export default ResponsiveContainer; diff --git a/frontend/src/features/unified-dynamic-page-renderer/hooks/useUnifiedLayoutState.ts b/frontend/src/features/unified-dynamic-page-renderer/hooks/useUnifiedLayoutState.ts index d137fbc..80c68dd 100644 --- a/frontend/src/features/unified-dynamic-page-renderer/hooks/useUnifiedLayoutState.ts +++ b/frontend/src/features/unified-dynamic-page-renderer/hooks/useUnifiedLayoutState.ts @@ -1,12 +1,14 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { ResponsiveLayouts, LayoutItem } from '../types'; -import { - LayoutChangeManager, - LayoutChangeEvent, +import { + LayoutChangeManager, + LayoutChangeEvent, LayoutChangeOrigin, compareLayoutsSemanticaly, - generateLayoutHash + generateLayoutHash, + isStaleLayoutChange } from '../utils/layoutChangeManager'; +import { getLayoutCommitTracker, CommitMetadata } from '../utils/layoutCommitTracker'; export interface UnifiedLayoutStateOptions { initialLayouts?: ResponsiveLayouts | null; @@ -31,6 +33,11 @@ export interface UnifiedLayoutState { // Utility functions getLayoutHash: () => string; compareWithCurrent: (layouts: ResponsiveLayouts) => boolean; + + // Phase 1 & 3: Commit tracking and barrier + getLastCommitMeta: () => CommitMetadata | null; + flush: () => Promise<{ version: number; hash: string }>; + getCommittedLayouts: () => ResponsiveLayouts | null; } /** @@ -56,6 +63,14 @@ export function useUnifiedLayoutState(options: UnifiedLayoutStateOptions = {}): const lastPersistedHashRef = useRef(null); const initializationCompleteRef = useRef(false); const stableLayoutsRef = useRef(initialLayouts); + + // PHASE B: Add version tracking for stale update prevention + const lastCommittedVersionRef = useRef(0); + + // Phase 1: Add commit tracking + const committedLayoutsRef = useRef(initialLayouts); + const layoutCommitTracker = getLayoutCommitTracker(); + const isDebugMode = import.meta.env.VITE_LAYOUT_DEBUG === 'true'; // Stable refs for callbacks to prevent recreation const onLayoutPersistRef = useRef(onLayoutPersist); @@ -72,14 +87,33 @@ export function useUnifiedLayoutState(options: UnifiedLayoutStateOptions = {}): // Handle processed layout changes - now stable! const handleLayoutChangeEvent = useCallback((event: LayoutChangeEvent) => { - console.log(`[useUnifiedLayoutState] Processing layout change from ${event.origin.source}`); + // PHASE B: Check if this is a stale update based on version + if (event.origin.version !== undefined && event.origin.version < lastCommittedVersionRef.current) { + if (isDebugMode) { + console.log('[useUnifiedLayoutState] Ignoring stale layout change', { + eventVersion: event.origin.version, + currentVersion: lastCommittedVersionRef.current, + source: event.origin.source + }); + } + return; + } + + // Phase 1: Log the persist event + const version = event.origin.version || lastCommittedVersionRef.current + 1; + if (isDebugMode) { + console.log(`[UnifiedLayoutState] Persist v${version} hash:${event.hash}`, { + source: event.origin.source, + operationId: event.origin.operationId + }); + } setIsLayoutChanging(true); // Update the layouts state only if different setLayouts(prevLayouts => { if (compareLayoutsSemanticaly(prevLayouts, event.layouts)) { - console.log('[useUnifiedLayoutState] Layouts are identical, skipping state update'); + setIsLayoutChanging(false); // Reset immediately if no change return prevLayouts; // Return same reference to prevent re-render } @@ -89,15 +123,54 @@ export function useUnifiedLayoutState(options: UnifiedLayoutStateOptions = {}): return event.layouts; }); - // Persist the change if it's from a user action and different from last persisted - if ( - onLayoutPersistRef.current && - (event.origin.source === 'user-drag' || event.origin.source === 'user-resize' || event.origin.source === 'user-remove' || event.origin.source === 'drop-add') && - event.hash !== lastPersistedHashRef.current - ) { + // RECODE V2 BLOCK: Strengthen commit barrier - always persist resize operations + const isUserAction = event.origin.source === 'user-drag' || + event.origin.source === 'user-resize' || + event.origin.source === 'user-remove' || + event.origin.source === 'drop-add'; + + // For resize operations, force persist even if hash appears same (dimensions might differ) + const shouldPersist = onLayoutPersistRef.current && + isUserAction && + (event.hash !== lastPersistedHashRef.current || event.origin.source === 'user-resize'); + + if (shouldPersist) { try { - onLayoutPersistRef.current(event.layouts, event.origin); + // RECODE V2 BLOCK: Enhanced logging for resize operations + if (event.origin.source === 'user-resize') { + const desktopItems = event.layouts.desktop || []; + console.log('[RECODE_V2_BLOCK] useUnifiedLayoutState persist - resize dimensions', { + source: event.origin.source, + version: event.origin.version || lastCommittedVersionRef.current + 1, + hash: event.hash, + desktopItemDimensions: desktopItems.map((item: any) => ({ + id: item.i, + dimensions: { w: item.w, h: item.h, x: item.x, y: item.y } + })), + timestamp: Date.now() + }); + } + + // RECODE V2 BLOCK: Always update committed layouts for user actions + committedLayoutsRef.current = JSON.parse(JSON.stringify(event.layouts)); + + // Phase 1: Record commit in tracker + const commitMeta: CommitMetadata = { + version: event.origin.version || lastCommittedVersionRef.current + 1, + hash: event.hash, + timestamp: Date.now() + }; + layoutCommitTracker.recordCommit(commitMeta); + + onLayoutPersistRef.current!(event.layouts, event.origin); lastPersistedHashRef.current = event.hash; + + // PHASE B: Update committed version when persisting user changes + if (event.origin.version !== undefined) { + lastCommittedVersionRef.current = event.origin.version; + } else { + lastCommittedVersionRef.current++; + } } catch (error) { console.error('[useUnifiedLayoutState] Error persisting layout:', error); onErrorRef.current?.(error as Error); @@ -128,7 +201,7 @@ export function useUnifiedLayoutState(options: UnifiedLayoutStateOptions = {}): // Handle initial layouts useEffect(() => { if (initialLayouts && !initializationCompleteRef.current) { - console.log('[useUnifiedLayoutState] Setting initial layouts'); + setLayouts(initialLayouts); stableLayoutsRef.current = initialLayouts; lastPersistedHashRef.current = generateLayoutHash(initialLayouts); @@ -145,11 +218,23 @@ export function useUnifiedLayoutState(options: UnifiedLayoutStateOptions = {}): // Skip if layouts are semantically identical - use stable ref instead of state if (compareLayoutsSemanticaly(stableLayoutsRef.current, newLayouts)) { - console.log('[useUnifiedLayoutState] Skipping identical layout update'); + if (isDebugMode) { + console.log('[UnifiedLayoutState] Skipping identical layout update'); + } return; } - console.log(`[useUnifiedLayoutState] Queueing layout update from ${origin.source}`); + // Phase 1: Track pending commit + const hash = generateLayoutHash(newLayouts); + // If this matches the last persisted hash, it will be dropped by the pipeline; avoid tracking pending + if (lastPersistedHashRef.current === hash) { + if (isDebugMode) { + console.log('[UnifiedLayoutState] Skipping update equal to last persisted hash'); + } + return; + } + const version = origin.version || lastCommittedVersionRef.current + 1; + layoutCommitTracker.trackPending(version, hash); // Queue the layout change with appropriate debounce key const debounceKey = origin.operationId || origin.source; @@ -158,7 +243,7 @@ export function useUnifiedLayoutState(options: UnifiedLayoutStateOptions = {}): // Reset layouts function (for page changes, etc.) const resetLayouts = useCallback((newLayouts: ResponsiveLayouts | null) => { - console.log('[useUnifiedLayoutState] Resetting layouts'); + setLayouts(newLayouts); stableLayoutsRef.current = newLayouts; lastPersistedHashRef.current = newLayouts ? generateLayoutHash(newLayouts) : null; @@ -190,6 +275,39 @@ export function useUnifiedLayoutState(options: UnifiedLayoutStateOptions = {}): const compareWithCurrent = useCallback((otherLayouts: ResponsiveLayouts) => { return compareLayoutsSemanticaly(stableLayoutsRef.current, otherLayouts); }, []); + + // Phase 1: Get last commit metadata + const getLastCommitMeta = useCallback(() => { + return layoutCommitTracker.getLastCommit(); + }, []); + + // Phase 3: Enhanced flush that waits for pending layout changes to complete + const flush = useCallback(async (): Promise<{ version: number; hash: string }> => { + // First flush any pending changes in the layout change manager and wait for completion + if (layoutChangeManagerRef.current) { + await layoutChangeManagerRef.current.flush(); + } + + // Wait for the commit tracker to process all pending commits + await layoutCommitTracker.flush(); + + // Return the last committed metadata + const lastCommit = layoutCommitTracker.getLastCommit(); + if (lastCommit) { + return { version: lastCommit.version, hash: lastCommit.hash }; + } + + // If no commits yet, return current state + return { + version: lastCommittedVersionRef.current, + hash: generateLayoutHash(stableLayoutsRef.current) + }; + }, []); + + // Phase 1: Get committed layouts + const getCommittedLayouts = useCallback(() => { + return committedLayoutsRef.current; + }, []); // Create a stable layouts reference that only changes when layouts actually change const stableLayouts = useMemo(() => { @@ -204,6 +322,9 @@ export function useUnifiedLayoutState(options: UnifiedLayoutStateOptions = {}): startOperation, stopOperation, getLayoutHash, - compareWithCurrent + compareWithCurrent, + getLastCommitMeta, + flush, + getCommittedLayouts }; -} \ No newline at end of file +} diff --git a/frontend/src/features/unified-dynamic-page-renderer/styles/index.css b/frontend/src/features/unified-dynamic-page-renderer/styles/index.css index cd55ccd..15a9505 100644 --- a/frontend/src/features/unified-dynamic-page-renderer/styles/index.css +++ b/frontend/src/features/unified-dynamic-page-renderer/styles/index.css @@ -138,10 +138,26 @@ /* Layout Items */ .layout-item { position: relative; - background: white; - border: 1px solid #e5e7eb; + background: var(--mui-palette-background-paper, #ffffff); + border: 1px solid var(--mui-palette-divider, #e5e7eb); border-radius: 0.375rem; overflow: hidden; + transition: background-color 0.3s ease, border-color 0.3s ease; +} + +/* Dark theme support */ +@media (prefers-color-scheme: dark) { + .layout-item { + background: #1e1e1e; + border-color: rgba(255, 255, 255, 0.12); + } +} + +/* When inside a dark theme container */ +[data-mui-color-scheme="dark"] .layout-item, +.MuiPaper-root[data-mui-color-scheme="dark"] .layout-item { + background: #1e1e1e; + border-color: rgba(255, 255, 255, 0.12); } .layout-item--selected { @@ -279,10 +295,25 @@ /* Module Placeholder */ .module-placeholder { padding: 1rem; - background: #f9fafb; - border: 1px dashed #d1d5db; + background: var(--mui-palette-background-default, #f9fafb); + border: 1px dashed var(--mui-palette-divider, #d1d5db); border-radius: 0.375rem; text-align: center; + transition: background-color 0.3s ease, border-color 0.3s ease; +} + +/* Dark theme support for placeholder */ +@media (prefers-color-scheme: dark) { + .module-placeholder { + background: #121212; + border-color: rgba(255, 255, 255, 0.12); + } +} + +[data-mui-color-scheme="dark"] .module-placeholder, +.MuiPaper-root[data-mui-color-scheme="dark"] .module-placeholder { + background: #121212; + border-color: rgba(255, 255, 255, 0.12); } .module-placeholder--studio { diff --git a/frontend/src/features/unified-dynamic-page-renderer/utils/layoutChangeManager.ts b/frontend/src/features/unified-dynamic-page-renderer/utils/layoutChangeManager.ts index 25b8716..5dc5114 100644 --- a/frontend/src/features/unified-dynamic-page-renderer/utils/layoutChangeManager.ts +++ b/frontend/src/features/unified-dynamic-page-renderer/utils/layoutChangeManager.ts @@ -4,6 +4,7 @@ export interface LayoutChangeOrigin { source: 'user-drag' | 'user-resize' | 'user-remove' | 'external-sync' | 'initial-load' | 'drop-add'; timestamp: number; operationId?: string; + version?: number; // PHASE B: Add version field for stale update detection } export interface LayoutChangeEvent { @@ -79,6 +80,16 @@ export function generateLayoutHash(layouts: ResponsiveLayouts | null): string { return hashParts.join(';'); } +/** + * PHASE B: Check if a layout change is stale based on version comparison + * @param origin The origin of the layout change + * @param currentVersion The current committed version + * @returns true if the change is stale and should be ignored + */ +export const isStaleLayoutChange = (origin: LayoutChangeOrigin, currentVersion: number): boolean => { + return origin.version !== undefined && origin.version < currentVersion; +}; + /** * Layout Change Manager - handles debouncing, deduplication, and origin tracking */ @@ -87,6 +98,10 @@ export class LayoutChangeManager { private lastProcessedHash: string | null = null; private debounceTimeouts = new Map(); private activeOperations = new Set(); + // Phase 3: Add promise tracking for flush operations + private pendingPromises = new Map void; reject: (error: Error) => void }>(); + private flushPromise: Promise | null = null; + private flushResolve: (() => void) | null = null; constructor( private onLayoutChange: (event: LayoutChangeEvent) => void, @@ -105,13 +120,13 @@ export class LayoutChangeManager { // Skip if this is the exact same layout we just processed if (hash === this.lastProcessedHash) { - console.log('[LayoutChangeManager] Skipping duplicate layout change'); + return; } // Skip if there's an active operation that should block this change if (this.shouldBlockChange(origin)) { - console.log('[LayoutChangeManager] Blocking layout change due to active operation'); + return; } @@ -124,6 +139,13 @@ export class LayoutChangeManager { clearTimeout(existingTimeout); } + // Phase 3: Create flush promise if not exists + if (!this.flushPromise) { + this.flushPromise = new Promise((resolve) => { + this.flushResolve = resolve; + }); + } + // Set new debounced timeout const timeout = setTimeout(() => { this.processPendingChange(debounceKey); @@ -141,17 +163,31 @@ export class LayoutChangeManager { // Final deduplication check if (event.hash === this.lastProcessedHash) { - console.log('[LayoutChangeManager] Skipping duplicate in processPendingChange'); + return; } - console.log(`[LayoutChangeManager] Processing layout change from ${event.origin.source}`); + this.lastProcessedHash = event.hash; this.pendingChanges.delete(debounceKey); this.debounceTimeouts.delete(debounceKey); + // Phase 3: Resolve pending promise for this key + const pendingPromise = this.pendingPromises.get(debounceKey); + if (pendingPromise) { + pendingPromise.resolve(); + this.pendingPromises.delete(debounceKey); + } + this.onLayoutChange(event); + + // Phase 3: Check if all pending changes are processed + if (this.pendingChanges.size === 0 && this.flushResolve) { + this.flushResolve(); + this.flushPromise = null; + this.flushResolve = null; + } } /** @@ -159,7 +195,7 @@ export class LayoutChangeManager { */ startOperation(operationId: string): void { this.activeOperations.add(operationId); - console.log(`[LayoutChangeManager] Started operation: ${operationId}`); + } /** @@ -167,7 +203,7 @@ export class LayoutChangeManager { */ stopOperation(operationId: string): void { this.activeOperations.delete(operationId); - console.log(`[LayoutChangeManager] Stopped operation: ${operationId}`); + } /** @@ -188,16 +224,34 @@ export class LayoutChangeManager { } /** - * Force process all pending changes (useful for cleanup) + * Force process all pending changes and return a promise that resolves when done + * Phase 3: Enhanced flush with promise support */ - flush(): void { - for (const [key] of this.pendingChanges) { + flush(): Promise { + // If no pending changes, resolve immediately + if (this.pendingChanges.size === 0) { + return Promise.resolve(); + } + + // Process all pending changes immediately + const keys = Array.from(this.pendingChanges.keys()); + for (const key of keys) { const timeout = this.debounceTimeouts.get(key); if (timeout) { clearTimeout(timeout); } this.processPendingChange(key); } + + // Return the flush promise or resolve immediately if all processed + return this.flushPromise || Promise.resolve(); + } + + /** + * Get pending change keys (for debugging) + */ + getPending(): string[] { + return Array.from(this.pendingChanges.keys()); } /** diff --git a/frontend/src/features/unified-dynamic-page-renderer/utils/layoutCommitTracker.ts b/frontend/src/features/unified-dynamic-page-renderer/utils/layoutCommitTracker.ts new file mode 100644 index 0000000..f15b04c --- /dev/null +++ b/frontend/src/features/unified-dynamic-page-renderer/utils/layoutCommitTracker.ts @@ -0,0 +1,178 @@ +/** + * LayoutCommitTracker - Dev-only utility for tracking layout commits + * Part of Phase 1: Instrumentation & Verification + */ + +export interface CommitMetadata { + version: number; + hash: string; + timestamp: number; +} + +export interface PendingCommit { + version: number; + hash: string; + promise: Promise; + resolve: () => void; + reject: (error: Error) => void; +} + +class LayoutCommitTracker { + private static instance: LayoutCommitTracker | null = null; + private lastCommit: CommitMetadata | null = null; + private pendingCommits: Map = new Map(); + private isDebugMode: boolean; + + private constructor() { + this.isDebugMode = import.meta.env.VITE_LAYOUT_DEBUG === 'true'; + } + + static getInstance(): LayoutCommitTracker { + if (!LayoutCommitTracker.instance) { + LayoutCommitTracker.instance = new LayoutCommitTracker(); + } + return LayoutCommitTracker.instance; + } + + /** + * Record a new commit + */ + recordCommit(metadata: CommitMetadata): void { + if (!this.isDebugMode) return; + + this.lastCommit = metadata; + + // Resolve any pending flush promises for this hash or older versions + // In practice the debounced pipeline may change the final hash; consider + // the commit authoritative and resolve all <= version entries. + const toResolve: string[] = []; + for (const [hash, pending] of this.pendingCommits.entries()) { + if (pending.version <= metadata.version || hash === metadata.hash) { + pending.resolve(); + toResolve.push(hash); + } + } + toResolve.forEach(hash => this.pendingCommits.delete(hash)); + + this.log(`Commit recorded: v${metadata.version} hash:${metadata.hash}`); + } + + /** + * Start tracking a pending commit + */ + trackPending(version: number, hash: string): Promise { + if (!this.isDebugMode) { + return Promise.resolve(); + } + + // If already committed, resolve immediately + if (this.lastCommit?.hash === hash) { + return Promise.resolve(); + } + + // Check if already tracking this hash + const existing = this.pendingCommits.get(hash); + if (existing) { + return existing.promise; + } + + // Create new pending promise + let resolve: () => void; + let reject: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + const pending: PendingCommit = { + version, + hash, + promise, + resolve: resolve!, + reject: reject! + }; + + this.pendingCommits.set(hash, pending); + + // Add timeout to prevent hanging + setTimeout(() => { + if (this.pendingCommits.has(hash)) { + this.log(`Pending commit timeout: v${version} hash:${hash}`, 'warn'); + pending.resolve(); // Resolve anyway to prevent deadlock + this.pendingCommits.delete(hash); + } + }, 5000); // 5 second timeout + + this.log(`Tracking pending: v${version} hash:${hash}`); + return promise; + } + + /** + * Get the last committed metadata + */ + getLastCommit(): CommitMetadata | null { + return this.lastCommit; + } + + /** + * Get all pending commits + */ + getPendingCommits(): CommitMetadata[] { + return Array.from(this.pendingCommits.values()).map(p => ({ + version: p.version, + hash: p.hash, + timestamp: Date.now() // Approximate + })); + } + + /** + * Check if there are pending commits + */ + hasPendingCommits(): boolean { + return this.pendingCommits.size > 0; + } + + /** + * Flush all pending commits (wait for them to complete or timeout) + */ + async flush(): Promise { + if (!this.isDebugMode) return; + + const promises = Array.from(this.pendingCommits.values()).map(p => p.promise); + if (promises.length > 0) { + this.log(`Flushing ${promises.length} pending commits`); + await Promise.all(promises); + } + } + + /** + * Clear all tracking (for cleanup/reset) + */ + clear(): void { + this.lastCommit = null; + this.pendingCommits.clear(); + } + + private log(message: string, level: 'log' | 'warn' | 'error' = 'log'): void { + if (!this.isDebugMode) return; + console[level](`[LayoutCommitTracker] ${message}`); + } +} + +// Export singleton instance getter +export const getLayoutCommitTracker = (): LayoutCommitTracker => { + return LayoutCommitTracker.getInstance(); +}; + +// Export helper to generate hash from layout data +export const generateLayoutHash = (data: any): string => { + // Simple hash function for dev purposes + const str = JSON.stringify(data); + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash).toString(16).padStart(8, '0'); +}; diff --git a/frontend/src/index.css b/frontend/src/index.css index 310e908..9780b05 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -47,6 +47,7 @@ body { .react-resizable-handle { position: absolute; width: 20px; + height: 20px; bottom: 0; right: 0; diff --git a/frontend/src/services/remotePluginService.ts b/frontend/src/services/remotePluginService.ts index 8b8bd36..3a52140 100644 --- a/frontend/src/services/remotePluginService.ts +++ b/frontend/src/services/remotePluginService.ts @@ -481,6 +481,13 @@ class RemotePluginService { return this.loadedPlugins.get(pluginId); } + /** + * List IDs of all loaded plugins + */ + listLoadedPluginIds(): string[] { + return Array.from(this.loadedPlugins.keys()); + } + /** * Get a specific module from a loaded plugin * @param pluginId The ID of the plugin @@ -496,6 +503,19 @@ class RemotePluginService { ); } + /** + * Find a loaded plugin that contains a module with the given id or name + */ + findLoadedPluginByModuleId(moduleId: string): { plugin: LoadedRemotePlugin; module: LoadedModule } | undefined { + const norm = (s: string) => (s || '').toLowerCase(); + const target = norm(moduleId); + for (const plugin of this.loadedPlugins.values()) { + const mod = plugin.loadedModules.find(m => norm(m.id) === target || norm(m.name) === target); + if (mod) return { plugin, module: mod }; + } + return undefined; + } + /** * Get all loaded modules across all plugins * @returns Array of all loaded modules diff --git a/frontend/src/utils/clearPageCache.ts b/frontend/src/utils/clearPageCache.ts new file mode 100644 index 0000000..dc07943 --- /dev/null +++ b/frontend/src/utils/clearPageCache.ts @@ -0,0 +1,35 @@ +/** + * Clear page cache for a specific page or all pages + */ +export function clearPageCache(pageId?: string) { + if (typeof window === 'undefined') return; + + const keysToRemove: string[] = []; + + // Find all page cache keys + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.includes('page_cache_')) { + if (!pageId || key.includes(pageId)) { + keysToRemove.push(key); + } + } + } + + // Remove the keys + keysToRemove.forEach(key => { + localStorage.removeItem(key); + console.log(`[Cache] Cleared cache for key: ${key}`); + }); + + if (keysToRemove.length > 0) { + console.log(`[Cache] Cleared ${keysToRemove.length} cached page(s)`); + } +} + +// Clear AI Chat page cache on load (temporary fix) +if (typeof window !== 'undefined') { + // Clear cache for AI Chat page + clearPageCache('0c8f4dc670a4409c87030c3000779e14'); + clearPageCache('ai-chat'); +} \ No newline at end of file diff --git a/frontend/test_phase1.sh b/frontend/test_phase1.sh new file mode 100644 index 0000000..acd40fc --- /dev/null +++ b/frontend/test_phase1.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +# Phase 1 Testing Script +# This script helps test the Phase 1 instrumentation and verification features + +echo "=========================================" +echo "Phase 1 Testing - Instrumentation & Verification" +echo "=========================================" +echo "" +echo "This script will guide you through testing the Phase 1 implementation." +echo "" + +# Check if VITE_LAYOUT_DEBUG is set +if [ -z "$VITE_LAYOUT_DEBUG" ]; then + echo "⚠️ VITE_LAYOUT_DEBUG is not set. Setting it to 'true' for testing..." + export VITE_LAYOUT_DEBUG=true +fi + +# Check if VITE_USE_UNIFIED_RENDERER is set +if [ -z "$VITE_USE_UNIFIED_RENDERER" ]; then + echo "⚠️ VITE_USE_UNIFIED_RENDERER is not set. Setting it to 'true' for testing..." + export VITE_USE_UNIFIED_RENDERER=true +fi + +echo "" +echo "Environment Variables:" +echo " VITE_LAYOUT_DEBUG=$VITE_LAYOUT_DEBUG" +echo " VITE_USE_UNIFIED_RENDERER=$VITE_USE_UNIFIED_RENDERER" +echo "" + +echo "Test Checklist:" +echo "===============" +echo "" +echo "1. Layout Commit Badge:" +echo " [ ] Badge appears in bottom-right corner" +echo " [ ] Shows version number (v0, v1, v2...)" +echo " [ ] Shows hash (6 characters)" +echo " [ ] Shows 'Committed' or 'Pending' status" +echo " [ ] Shows time since last commit" +echo "" +echo "2. Console Logging Chain:" +echo " Open browser DevTools console and verify these logs appear:" +echo " [ ] [LayoutEngine] Commit v{version} hash:{hash}" +echo " [ ] [UnifiedLayoutState] Persist v{version} hash:{hash}" +echo " [ ] [PluginStudioAdapter] Convert v{version} hash:{hash}" +echo " [ ] [useLayout] Apply v{version} hash:{hash}" +echo " [ ] [savePage] Serialize v{version} hash:{hash}" +echo "" +echo "3. Version/Hash Consistency:" +echo " [ ] Same version/hash appears through entire pipeline" +echo " [ ] Version increments on each layout change" +echo " [ ] Hash changes when layout content changes" +echo " [ ] Hash stays same for identical layouts" +echo "" +echo "4. Test Scenarios:" +echo " a. Resize a module:" +echo " [ ] Version increments" +echo " [ ] New hash generated" +echo " [ ] Badge updates to show new version" +echo " [ ] Console shows commit chain" +echo "" +echo " b. Drag/move a module:" +echo " [ ] Version increments" +echo " [ ] New hash generated" +echo " [ ] Badge updates" +echo " [ ] Console shows commit chain" +echo "" +echo " c. Save the page:" +echo " [ ] [savePage] log appears with current version/hash" +echo " [ ] Save completes successfully" +echo "" +echo " d. Add a new module:" +echo " [ ] Version increments" +echo " [ ] New hash generated" +echo " [ ] Badge updates" +echo "" +echo "5. No Behavior Changes:" +echo " [ ] Layout operations work as before" +echo " [ ] No errors in console" +echo " [ ] No performance degradation" +echo "" + +echo "Starting frontend with debug mode enabled..." +echo "Press Ctrl+C to stop the server when testing is complete." +echo "" + +# Navigate to frontend directory and start dev server +cd /home/hacker/BrainDriveDev/BrainDrive/frontend +npm run dev \ No newline at end of file