Files
OpenNoodl/dev-docs/tasks/phase-2-react-migration/TASK-005-new-nodes/NODES-001-responsive-update/03-RUNTIME.md

18 KiB

Phase 3: Runtime - Viewport Detection

Overview

Implement the runtime system that detects viewport width changes and applies the correct breakpoint-specific values to nodes. This includes creating a BreakpointManager singleton, wiring up resize listeners, and ensuring nodes reactively update when the breakpoint changes.

Estimate: 2-3 days

Dependencies: Phase 1 (Foundation)

Goals

  1. Create BreakpointManager singleton for viewport detection
  2. Implement viewport resize listener with debouncing
  3. Wire nodes to respond to breakpoint changes
  4. Implement value resolution with cascade logic
  5. Support both desktop-first and mobile-first cascades
  6. Ensure smooth transitions when breakpoint changes

Technical Architecture

BreakpointManager

Central singleton that:

  • Monitors window.innerWidth
  • Determines current breakpoint based on project settings
  • Notifies subscribers when breakpoint changes
  • Handles both desktop-first and mobile-first cascade
┌─────────────────────────────────────────┐
│           BreakpointManager             │
├─────────────────────────────────────────┤
│ - currentBreakpoint: string             │
│ - settings: BreakpointSettings          │
│ - listeners: Set<Function>              │
├─────────────────────────────────────────┤
│ + initialize(settings)                  │
│ + getCurrentBreakpoint(): string        │
│ + getBreakpointForWidth(width): string  │
│ + subscribe(callback): unsubscribe      │
│ + getCascadeOrder(): string[]           │
└─────────────────────────────────────────┘
          │
          │ notifies
          ▼
┌─────────────────────────────────────────┐
│          Visual Nodes                   │
│  (subscribe to breakpoint changes)      │
└─────────────────────────────────────────┘

Value Resolution Flow

getResolvedValue(propertyName)
    │
    ▼
Is property breakpoint-aware?
    │
    ├─ No → return parameters[propertyName]
    │
    └─ Yes → Get current breakpoint
              │
              ▼
         Check breakpointParameters[currentBreakpoint]
              │
              ├─ Has value → return it
              │
              └─ No value → Cascade to next breakpoint
                            │
                            ▼
                       (repeat until found or reach default)
                            │
                            ▼
                       return parameters[propertyName]

Implementation Steps

Step 1: Create BreakpointManager

File: packages/noodl-runtime/src/breakpointmanager.js

'use strict';

const EventEmitter = require('events');

const DEFAULT_SETTINGS = {
  enabled: true,
  cascadeDirection: 'desktop-first',
  defaultBreakpoint: 'desktop',
  breakpoints: [
    { id: 'desktop', minWidth: 1024 },
    { id: 'tablet', minWidth: 768, maxWidth: 1023 },
    { id: 'phone', minWidth: 320, maxWidth: 767 },
    { id: 'smallPhone', minWidth: 0, maxWidth: 319 }
  ]
};

class BreakpointManager extends EventEmitter {
  constructor() {
    super();
    
    this.settings = DEFAULT_SETTINGS;
    this.currentBreakpoint = DEFAULT_SETTINGS.defaultBreakpoint;
    this._resizeTimeout = null;
    this._boundHandleResize = this._handleResize.bind(this);
    
    // Don't auto-initialize - wait for settings from project
  }
  
  initialize(settings) {
    this.settings = settings || DEFAULT_SETTINGS;
    this.currentBreakpoint = this.settings.defaultBreakpoint;
    
    // Set up resize listener
    if (typeof window !== 'undefined') {
      window.removeEventListener('resize', this._boundHandleResize);
      window.addEventListener('resize', this._boundHandleResize);
      
      // Initial detection
      this._updateBreakpoint(window.innerWidth);
    }
  }
  
  dispose() {
    if (typeof window !== 'undefined') {
      window.removeEventListener('resize', this._boundHandleResize);
    }
    this.removeAllListeners();
  }
  
  _handleResize() {
    // Debounce resize events
    if (this._resizeTimeout) {
      clearTimeout(this._resizeTimeout);
    }
    
    this._resizeTimeout = setTimeout(() => {
      this._updateBreakpoint(window.innerWidth);
    }, 100);  // 100ms debounce
  }
  
  _updateBreakpoint(width) {
    const newBreakpoint = this.getBreakpointForWidth(width);
    
    if (newBreakpoint !== this.currentBreakpoint) {
      const previousBreakpoint = this.currentBreakpoint;
      this.currentBreakpoint = newBreakpoint;
      
      this.emit('breakpointChanged', {
        breakpoint: newBreakpoint,
        previousBreakpoint,
        width
      });
    }
  }
  
  getBreakpointForWidth(width) {
    if (!this.settings.enabled) {
      return this.settings.defaultBreakpoint;
    }
    
    const breakpoints = this.settings.breakpoints;
    
    for (const bp of breakpoints) {
      const minMatch = bp.minWidth === undefined || width >= bp.minWidth;
      const maxMatch = bp.maxWidth === undefined || width <= bp.maxWidth;
      
      if (minMatch && maxMatch) {
        return bp.id;
      }
    }
    
    return this.settings.defaultBreakpoint;
  }
  
  getCurrentBreakpoint() {
    return this.currentBreakpoint;
  }
  
  /**
   * Get the cascade order for value inheritance.
   * Desktop-first: ['desktop', 'tablet', 'phone', 'smallPhone']
   * Mobile-first: ['smallPhone', 'phone', 'tablet', 'desktop']
   */
  getCascadeOrder() {
    const breakpointIds = this.settings.breakpoints.map(bp => bp.id);
    
    if (this.settings.cascadeDirection === 'mobile-first') {
      return breakpointIds.slice().reverse();
    }
    
    return breakpointIds;
  }
  
  /**
   * Get breakpoints that a given breakpoint inherits from.
   * For desktop-first with current='phone':
   *   returns ['tablet', 'desktop'] (phone inherits from tablet, which inherits from desktop)
   */
  getInheritanceChain(breakpointId) {
    const cascadeOrder = this.getCascadeOrder();
    const currentIndex = cascadeOrder.indexOf(breakpointId);
    
    if (currentIndex <= 0) return [];  // First in cascade inherits from nothing
    
    return cascadeOrder.slice(0, currentIndex);
  }
  
  /**
   * Subscribe to breakpoint changes.
   * Returns unsubscribe function.
   */
  subscribe(callback) {
    this.on('breakpointChanged', callback);
    return () => this.off('breakpointChanged', callback);
  }
  
  /**
   * Force a breakpoint (for testing/preview).
   * Pass null to return to auto-detection.
   */
  forceBreakpoint(breakpointId) {
    if (breakpointId === null) {
      // Return to auto-detection
      if (typeof window !== 'undefined') {
        this._updateBreakpoint(window.innerWidth);
      }
    } else {
      const previousBreakpoint = this.currentBreakpoint;
      this.currentBreakpoint = breakpointId;
      
      this.emit('breakpointChanged', {
        breakpoint: breakpointId,
        previousBreakpoint,
        forced: true
      });
    }
  }
}

// Singleton instance
const breakpointManager = new BreakpointManager();

module.exports = breakpointManager;

Step 2: Integrate with GraphModel

File: packages/noodl-runtime/src/models/graphmodel.js

const breakpointManager = require('../breakpointmanager');

// In setSettings method, initialize breakpoint manager
GraphModel.prototype.setSettings = function(settings) {
  this.settings = settings;
  
  // Initialize breakpoint manager with project settings
  if (settings.responsiveBreakpoints) {
    breakpointManager.initialize(settings.responsiveBreakpoints);
  }
  
  this.emit('projectSettingsChanged', settings);
};

Step 3: Add Value Resolution to Node Base

File: packages/noodl-runtime/src/nodes/nodebase.js (or equivalent base class)

const breakpointManager = require('../breakpointmanager');

// Add to node initialization
{
  _initializeBreakpointSupport() {
    // Subscribe to breakpoint changes
    this._breakpointUnsubscribe = breakpointManager.subscribe(
      this._onBreakpointChanged.bind(this)
    );
  },
  
  _disposeBreakpointSupport() {
    if (this._breakpointUnsubscribe) {
      this._breakpointUnsubscribe();
      this._breakpointUnsubscribe = null;
    }
  },
  
  _onBreakpointChanged({ breakpoint, previousBreakpoint }) {
    // Re-apply all breakpoint-aware properties
    this._applyBreakpointValues();
  },
  
  _applyBreakpointValues() {
    const ports = this.getPorts ? this.getPorts('input') : [];
    
    for (const port of ports) {
      if (port.allowBreakpoints) {
        const value = this.getResolvedParameterValue(port.name);
        this._applyParameterValue(port.name, value);
      }
    }
    
    // Force re-render if this is a React node
    if (this.forceUpdate) {
      this.forceUpdate();
    }
  },
  
  /**
   * Get the resolved value for a parameter, considering breakpoints and cascade.
   */
  getResolvedParameterValue(name) {
    const port = this.getPort ? this.getPort(name, 'input') : null;
    
    // If not breakpoint-aware, just return the base value
    if (!port || !port.allowBreakpoints) {
      return this.getParameterValue(name);
    }
    
    const currentBreakpoint = breakpointManager.getCurrentBreakpoint();
    const settings = breakpointManager.settings;
    
    // If at default breakpoint, use base parameters
    if (currentBreakpoint === settings.defaultBreakpoint) {
      return this.getParameterValue(name);
    }
    
    // Check for value at current breakpoint
    if (this._model.breakpointParameters?.[currentBreakpoint]?.[name] !== undefined) {
      return this._model.breakpointParameters[currentBreakpoint][name];
    }
    
    // Cascade: check inheritance chain
    const inheritanceChain = breakpointManager.getInheritanceChain(currentBreakpoint);
    
    for (const bp of inheritanceChain.reverse()) {  // Check from closest to furthest
      if (this._model.breakpointParameters?.[bp]?.[name] !== undefined) {
        return this._model.breakpointParameters[bp][name];
      }
    }
    
    // Fall back to base parameters
    return this.getParameterValue(name);
  },
  
  _applyParameterValue(name, value) {
    // Override in specific node types to apply the value
    // For visual nodes, this might update CSS properties
  }
}

Step 4: Integrate with Visual Nodes

File: packages/noodl-viewer-react/src/nodes/visual/visualbase.js (or equivalent)

const breakpointManager = require('@noodl/runtime/src/breakpointmanager');

// In visual node base

{
  initialize() {
    // ... existing initialization
    
    // Set up breakpoint support
    this._initializeBreakpointSupport();
  },
  
  _onNodeDeleted() {
    // ... existing cleanup
    
    this._disposeBreakpointSupport();
  },
  
  // Override to apply CSS property values
  _applyParameterValue(name, value) {
    // Map parameter name to CSS property
    const cssProperty = this._getCSSPropertyForParameter(name);
    
    if (cssProperty && this._internal.element) {
      this._internal.element.style[cssProperty] = value;
    }
    
    // Or if using React, set state/props
    if (this._internal.reactComponent) {
      // Trigger re-render with new value
      this.forceUpdate();
    }
  },
  
  _getCSSPropertyForParameter(name) {
    // Map Noodl parameter names to CSS properties
    const mapping = {
      marginTop: 'marginTop',
      marginRight: 'marginRight',
      marginBottom: 'marginBottom',
      marginLeft: 'marginLeft',
      paddingTop: 'paddingTop',
      paddingRight: 'paddingRight',
      paddingBottom: 'paddingBottom',
      paddingLeft: 'paddingLeft',
      width: 'width',
      height: 'height',
      minWidth: 'minWidth',
      maxWidth: 'maxWidth',
      minHeight: 'minHeight',
      maxHeight: 'maxHeight',
      fontSize: 'fontSize',
      lineHeight: 'lineHeight',
      letterSpacing: 'letterSpacing',
      flexDirection: 'flexDirection',
      alignItems: 'alignItems',
      justifyContent: 'justifyContent',
      flexWrap: 'flexWrap',
      gap: 'gap'
    };
    
    return mapping[name];
  },
  
  // Override getStyle to use resolved breakpoint values
  getStyle(name) {
    // Check if this is a breakpoint-aware property
    const port = this.getPort(name, 'input');
    
    if (port?.allowBreakpoints) {
      return this.getResolvedParameterValue(name);
    }
    
    // Fall back to existing behavior
    return this._existingGetStyle(name);
  }
}

Step 5: Update React Component Props

File: For React-based visual nodes, update how props are computed

// In the React component wrapper

getReactProps() {
  const props = {};
  const ports = this.getPorts('input');
  
  for (const port of ports) {
    // Use resolved value for breakpoint-aware properties
    if (port.allowBreakpoints) {
      props[port.name] = this.getResolvedParameterValue(port.name);
    } else {
      props[port.name] = this.getParameterValue(port.name);
    }
  }
  
  return props;
}

Step 6: Add Transition Support (Optional Enhancement)

File: packages/noodl-runtime/src/breakpointmanager.js

// Add transition support for smooth breakpoint changes

class BreakpointManager extends EventEmitter {
  // ... existing code
  
  _updateBreakpoint(width) {
    const newBreakpoint = this.getBreakpointForWidth(width);
    
    if (newBreakpoint !== this.currentBreakpoint) {
      const previousBreakpoint = this.currentBreakpoint;
      this.currentBreakpoint = newBreakpoint;
      
      // Add CSS class for transitions
      if (typeof document !== 'undefined') {
        document.body.classList.add('noodl-breakpoint-transitioning');
        
        // Remove after transition completes
        setTimeout(() => {
          document.body.classList.remove('noodl-breakpoint-transitioning');
        }, 300);
      }
      
      this.emit('breakpointChanged', {
        breakpoint: newBreakpoint,
        previousBreakpoint,
        width
      });
    }
  }
}

CSS: Add to runtime styles

/* Smooth transitions when breakpoint changes */
.noodl-breakpoint-transitioning * {
  transition: 
    margin 0.2s ease-out,
    padding 0.2s ease-out,
    width 0.2s ease-out,
    height 0.2s ease-out,
    font-size 0.2s ease-out,
    gap 0.2s ease-out !important;
}

Step 7: Editor-Runtime Communication

File: packages/noodl-editor/src/editor/src/views/VisualCanvas/CanvasView.ts

// When breakpoint settings change in editor, sync to runtime

onBreakpointSettingsChanged(settings: BreakpointSettings) {
  this.tryWebviewCall(() => {
    this.webview.executeJavaScript(`
      if (window.NoodlRuntime && window.NoodlRuntime.breakpointManager) {
        window.NoodlRuntime.breakpointManager.initialize(${JSON.stringify(settings)});
      }
    `);
  });
}

// Optionally: Force breakpoint for preview purposes
forceRuntimeBreakpoint(breakpointId: string | null) {
  this.tryWebviewCall(() => {
    this.webview.executeJavaScript(`
      if (window.NoodlRuntime && window.NoodlRuntime.breakpointManager) {
        window.NoodlRuntime.breakpointManager.forceBreakpoint(${JSON.stringify(breakpointId)});
      }
    `);
  });
}

Files to Modify

File Changes
packages/noodl-runtime/src/models/graphmodel.js Initialize breakpointManager with settings
packages/noodl-runtime/src/nodes/nodebase.js Add breakpoint value resolution
packages/noodl-viewer-react/src/nodes/visual/visualbase.js Wire up breakpoint subscriptions
packages/noodl-editor/src/editor/src/views/VisualCanvas/CanvasView.ts Add breakpoint sync to runtime

Files to Create

File Purpose
packages/noodl-runtime/src/breakpointmanager.js Central breakpoint detection and management
packages/noodl-runtime/src/styles/breakpoints.css Optional transition styles

Testing Checklist

  • BreakpointManager correctly detects breakpoint from window width
  • BreakpointManager fires 'breakpointChanged' event on resize
  • Debouncing prevents excessive events during resize drag
  • Nodes receive breakpoint change notifications
  • Nodes apply correct breakpoint-specific values
  • Cascade works correctly (tablet inherits desktop values)
  • Mobile-first cascade works when configured
  • Values update smoothly during breakpoint transitions
  • forceBreakpoint works for testing/preview
  • Memory cleanup works (no leaks on node deletion)
  • Works in both editor preview and deployed app

Success Criteria

  1. Resizing browser window changes applied breakpoint
  2. Visual nodes update their dimensions/spacing instantly
  3. Values cascade correctly when not overridden
  4. Both desktop-first and mobile-first work
  5. No performance issues with many nodes

Gotchas & Notes

  1. SSR Considerations: If Noodl supports SSR, window won't exist on server. Guard all window access with typeof window !== 'undefined'.

  2. Performance: With many nodes subscribed, breakpoint changes could cause many re-renders. Consider:

    • Batch updates using requestAnimationFrame
    • Only re-render nodes whose values actually changed
  3. Debounce Tuning: 100ms debounce is a starting point. May need adjustment based on feel.

  4. Transition Timing: The CSS transition duration (0.2s) should match user expectations. Could make configurable.

  5. Initial Load: On first load, breakpoint should be set BEFORE first render to avoid flash of wrong layout.

  6. Testing Breakpoints: Add breakpointManager.forceBreakpoint() to allow testing different breakpoints without resizing window.

  7. React Strict Mode: If using React Strict Mode, ensure subscriptions are properly cleaned up (may fire twice in dev).

Performance Optimization Ideas

  1. Selective Updates: Track which properties actually differ between breakpoints, only update those.

  2. CSS Variables: Consider using CSS custom properties for breakpoint values, letting browser handle changes:

    // Set CSS variable per breakpoint
    document.documentElement.style.setProperty('--node-123-margin-top', '24px');
    
  3. Batch Notifications: Collect all changed nodes and update in single batch:

    requestAnimationFrame(() => {
      changedNodes.forEach(node => node.forceUpdate());
    });