Files
OpenNoodl/dev-docs/reference/LEARNINGS.md
2025-12-15 11:58:55 +01:00

21 KiB

OpenNoodl Development Learnings

This document records discoveries, gotchas, and non-obvious patterns found while working on OpenNoodl. Search this file before tackling complex problems.


Project Migration & Versioning

[2025-07-12] - Legacy Projects Are Already at Version 4

Context: Investigating what migration work is needed for legacy Noodl v1.1.0 projects.

Discovery: Legacy projects from Noodl v1.1.0 are already at project format version "4", which is the current version expected by the editor. This significantly reduces migration scope.

Location:

  • packages/noodl-editor/src/editor/src/models/projectmodel.ts - Contains Upgraders object for format 0→1→2→3→4
  • packages/noodl-editor/src/editor/src/models/ProjectPatches/ - Node-level patches (e.g., RouterNavigate)

Key Points:

  • Project format version is stored in project.json as "version": "4"
  • The existing ProjectPatches/ system handles node-level migrations automatically on load
  • No major version migration infrastructure is needed for v1.1.0→v2.0.0
  • The Upgraders object has handlers for versions 0-4, upgrading sequentially

Keywords: project migration, version upgrade, legacy project, project.json, upgraders


[2025-07-12] - @noodl/platform FileInfo Interface

Context: Writing utility functions that use filesystem.listDirectory().

Discovery: The listDirectory() function returns FileInfo[], not strings. Each FileInfo has:

  • name: string - Just the filename
  • fullPath: string - Complete path
  • isDirectory: boolean

Location: packages/noodl-platform/src/filesystem/IFilesystem.ts

Keywords: filesystem, listDirectory, FileInfo, platform API


Webpack DevServer & Electron

[2025-08-12] - Webpack devServer onListening vs compiler.hooks.done Timing

Context: Debugging why npm run dev showed a black Electron window, took ages to load, and caused high CPU usage.

Discovery: The webpack dev configuration used devServer.onListening() to start Electron. This hook fires when the HTTP server port opens, NOT when webpack finishes compiling. This is a race condition:

  1. npm run dev starts webpack-dev-server
  2. Server starts listening on port 8080 → onListening fires
  3. Electron launches and loads http://localhost:8080/src/editor/index.bundle.js
  4. But webpack is still compiling! Bundle doesn't exist yet
  5. Black screen + high CPU until compilation finishes

Fix: Use devServer.compiler.hooks.done.tap() inside onListening to wait for the first successful compilation before spawning Electron:

onListening(devServer) {
  devServer.compiler.hooks.done.tap('StartElectron', (stats) => {
    if (!electronStarted && !stats.hasErrors()) {
      electronStarted = true;
      child_process.spawn('npm', ['run', 'start:_dev'], ...);
    }
  });
}

Why It Became Noticeable: This was a latent bug that existed from initial commit. It became visible after the Storybook 8 migration added ~91 files to process, increasing compilation time enough to consistently "lose" the race.

Location: packages/noodl-editor/webpackconfigs/webpack.renderer.dev.js

Keywords: webpack, devServer, onListening, electron, black screen, compilation, hooks.done, race condition, slow startup


[2025-08-12] - Webpack devtool Settings Impact on Compilation Speed

Context: Investigating slow development startup.

Discovery: The devtool: 'eval-source-map' setting provides the most accurate sourcemaps but is very slow for large codebases. Using 'eval-cheap-module-source-map' is significantly faster while still providing usable debugging:

devtool Rebuild Speed Quality
eval +++++ Poor
eval-cheap-source-map ++++ OK
eval-cheap-module-source-map +++ Good
eval-source-map + Best

For development where fast iteration matters more than perfect column accuracy in stack traces, eval-cheap-module-source-map is a good balance.

Location: packages/noodl-editor/webpackconfigs/webpack.renderer.dev.js

Keywords: webpack, devtool, sourcemap, performance, compilation speed, development


[2025-08-12] - TypeScript Path Resolution Requires baseUrl in Child tsconfig

Context: Build was failing with "Cannot find module '@noodl-hooks/...' or '@noodl-core-ui/...'" errors despite webpack aliases being correctly configured.

Discovery: When a child tsconfig.json extends a parent and overrides the paths property, the paths become relative to the child's directory. However, if baseUrl is not explicitly set in the child, path resolution fails.

The noodl-editor's tsconfig.json had:

{
  "extends": "../../tsconfig.json",
  "paths": {
    "@noodl-core-ui/*": ["../noodl-core-ui/src/*"],
    // ... other paths relative to packages/noodl-editor/
  }
}

Without baseUrl: "." in the child, TypeScript couldn't resolve the relative paths correctly.

Fix: Always set baseUrl explicitly when overriding paths in a child tsconfig:

{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": { ... }
  }
}

Location: packages/noodl-editor/tsconfig.json

Keywords: typescript, tsconfig, paths, baseUrl, module resolution, extends, cannot find module


[2025-08-12] - @ai-sdk Packages Require Zod v4 for zod/v4 Import

Context: After fixing webpack timing, Electron showed black screen. DevTools console showed: "Cannot find module 'zod/v4/index.cjs'"

Discovery: The @ai-sdk/provider-utils, @ai-sdk/gateway, and ai packages import from zod/v4. Zod version 3.25.x only has v4-mini folder (a transitional export), not the full v4 folder. Only Zod 4.x has the proper v4 subpath export.

The error chain was:

  1. ai package loads on startup
  2. It tries to require('zod/v4')
  3. Zod 3.25.76 doesn't have /v4 export → crash
  4. Black screen because editor fails to initialize

Fix: Upgrade to Zod 4.x by adding it as a direct dependency in root package.json:

"dependencies": {
  "zod": "^4.1.0"
}

Using overrides for this case can conflict with other version specifications. A direct dependency with a semver range works cleanly in npm workspaces.

Location: Root package.json, affects all packages using AI SDK

Keywords: zod, zod/v4, @ai-sdk, ai, black screen, cannot find module, module resolution


React 18/19 Migration Patterns

[2025-12-08] - React 18+ Removed ReactDOM.render() and unmountComponentAtNode()

Context: After React 19 migration, node graph editor was completely broken - right-click showed grab hand instead of node picker, couldn't click nodes or drag wires.

Discovery: React 18 removed the legacy ReactDOM.render() and ReactDOM.unmountComponentAtNode() APIs. Code using these APIs throws errors like:

  • ReactDOM.render is not a function
  • ReactDOM.unmountComponentAtNode is not a function

The migration pattern is:

// Before (React 17):
import ReactDOM from 'react-dom';
ReactDOM.render(<Component />, container);
ReactDOM.unmountComponentAtNode(container);

// After (React 18+):
import { createRoot } from 'react-dom/client';
const root = createRoot(container);
root.render(<Component />);
root.unmount();

Important: If rendering multiple times to the same container, you must:

  1. Create the root only ONCE
  2. Store the root reference
  3. Call root.render() for subsequent updates
  4. Call root.unmount() when disposing

Creating createRoot() on every render causes: "You are calling ReactDOMClient.createRoot() on a container that has already been passed to createRoot() before."

Location:

  • packages/noodl-editor/src/editor/src/views/nodegrapheditor.debuginspectors.js
  • packages/noodl-editor/src/editor/src/views/commentlayer.ts
  • packages/noodl-editor/src/editor/src/views/TextStylePicker/TextStylePicker.jsx

Keywords: ReactDOM.render, createRoot, unmountComponentAtNode, React 18, React 19, migration, root.unmount


[2025-12-08] - React 18+ createRoot() Renders Asynchronously

Context: After migrating to React 18+ createRoot, the NodePicker popup appeared offset to the bottom-right corner instead of centered.

Discovery: Unlike the old synchronous ReactDOM.render(), React 18's createRoot().render() is asynchronous. If code measures DOM dimensions immediately after calling render(), the React component hasn't painted yet.

In PopupLayer.showPopup():

this.$('.popup-layer-popup-content').append(content);
var contentWidth = content.outerWidth(true);   // Returns 0!
var contentHeight = content.outerHeight(true); // Returns 0!

When dimensions are zero, the centering calculation x = this.width / 2 - 0 / 2 places the popup at the far right.

Fix Options:

  1. Set explicit dimensions on the container div before React renders (recommended for fixed-size components)
  2. Use requestAnimationFrame or setTimeout before measuring
  3. Use a ResizeObserver to detect when content renders

For NodePicker (which has fixed 800x600 dimensions in CSS), the simplest fix was setting dimensions on the container div before React renders:

render() {
  const div = document.createElement('div');
  div.style.width = '800px';
  div.style.height = '600px';
  this.renderReact(div);  // createRoot is async
  return this.el;
}

Location: packages/noodl-editor/src/editor/src/views/createnewnodepanel.ts

Keywords: createRoot, async render, dimensions, outerWidth, outerHeight, popup positioning, React 18, React 19


Electron & Node.js Patterns

[2025-12-14] - EPIPE Errors When Writing to stdout

Context: Editor was crashing with Error: write EPIPE when trying to open projects.

Discovery: EPIPE errors occur when a process tries to write to stdout/stderr but the receiving pipe has been closed (e.g., the terminal or parent process that spawned the subprocess is gone). In Electron apps, this happens when:

  • The terminal that started npm run dev is closed before the app
  • The parent process that spawned a child dies unexpectedly
  • stdout is redirected to a file that gets closed

Cloud-function-server.js was calling console.log() during project operations. When the stdout pipe was broken, the error bubbled up and crashed the editor.

Fix: Wrap console.log calls in a try-catch:

function safeLog(...args) {
  try {
    console.log(...args);
  } catch (e) {
    // Ignore EPIPE errors - stdout pipe may be broken
  }
}

Location: packages/noodl-editor/src/main/src/cloud-function-server.js

Keywords: EPIPE, console.log, stdout, broken pipe, electron, subprocess, crash


Webpack & Build Patterns

[2025-12-14] - Webpack SCSS Cache Can Persist Old Files

Context: MigrationWizard.module.scss was fixed on disk but webpack kept showing errors for a removed import line.

Discovery: Webpack's sass-loader caches compiled SCSS files aggressively. Even after fixing a file on disk, if an old error is cached, webpack may continue to report the stale error. This is especially confusing because:

  • cat and grep show the correct file contents
  • But webpack reports errors for lines that no longer exist
  • The webpack process may be from a previous session that cached the old content

Fix Steps:

  1. Kill ALL webpack processes: pkill -9 -f webpack
  2. Clear webpack cache: rm -rf node_modules/.cache/ in the affected package
  3. Touch the file to force rebuild: touch path/to/file.scss
  4. Restart dev server fresh

Location: Any SCSS file processed by sass-loader

Keywords: webpack, sass-loader, cache, SCSS, stale error, module build failed


Event-Driven UI Patterns

[2025-12-14] - Async Detection Requires Re-render Listener

Context: Migration UI badges weren't showing on legacy projects even though runtime detection was working.

Discovery: In OpenNoodl's jQuery-based View system, the template is rendered once when render() is called. If data is populated asynchronously (e.g., runtime detection), the UI won't update unless you explicitly listen for a completion event and re-render.

The pattern:

  1. renderProjectItems() is called - projects show without runtime info
  2. detectAllProjectRuntimes() runs async in background
  3. Detection completes, runtimeDetectionComplete event fires
  4. BUT... no one was listening → UI stays stale

Fix: Subscribe to the async completion event in the View:

this.projectsModel.on('runtimeDetectionComplete', () => this.renderProjectItemsPane(), this);

This pattern applies to any async data in the jQuery View system:

  • Runtime detection
  • Cloud service status
  • Git remote checks
  • etc.

Location: packages/noodl-editor/src/editor/src/views/projectsview.ts

Keywords: async, re-render, event listener, runtimeDetectionComplete, jQuery View, stale UI


CSS & Styling Patterns

[2025-12-14] - BaseDialog ::after Pseudo-Element Blocks Clicks

Context: Migration wizard popup buttons weren't clickable at all - no response to any interaction.

Discovery: The BaseDialog component uses a ::after pseudo-element on .VisibleDialog to render the background color. This pseudo covers the entire dialog area:

.VisibleDialog {
  &::after {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background-color: var(--background);
    // Without pointer-events: none, this blocks all clicks!
  }
}

The .ChildContainer has z-index: 1 which should put it above the ::after, but due to stacking context behavior with filter: drop-shadow() on the parent, clicks were being intercepted by the pseudo-element.

Fix: Add pointer-events: none to the ::after pseudo-element:

&::after {
  // ...existing styles...
  pointer-events: none; // Allow clicks to pass through
}

Location: packages/noodl-core-ui/src/components/layout/BaseDialog/BaseDialog.module.scss

Keywords: BaseDialog, ::after, pointer-events, click not working, buttons broken, Modal, dialog


[2025-12-14] - Theme Color Variables Are --theme-color-* Not --color-*

Context: Migration wizard UI appeared gray-on-gray with unreadable text.

Discovery: OpenNoodl's theme system uses CSS variables prefixed with --theme-color-*, NOT --color-*. Using undefined variables like --color-grey-800 results in invalid/empty values causing display issues.

Correct Variables:

Wrong Correct
--color-grey-800 --theme-color-bg-3
--color-grey-700 --theme-color-bg-2
--color-grey-400, --color-grey-300 --theme-color-secondary-as-fg (for text!)
--color-grey-200, --color-grey-100 --theme-color-fg-highlight
--color-primary --theme-color-primary
--color-success-500 --theme-color-success
--color-warning --theme-color-warning
--color-danger --theme-color-danger

Location: Any SCSS module files in @noodl-core-ui or noodl-editor

Keywords: CSS variables, theme-color, --color, --theme-color, gray text, contrast, undefined variable, SCSS


[2025-12-14] - --theme-color-secondary Is NOT For Text - Use --theme-color-secondary-as-fg

Context: Migration wizard text was impossible to read even after using --theme-color-* prefix.

Discovery: Two commonly misused theme variables cause text to be unreadable:

  1. --theme-color-fg-1 doesn't exist! The correct variable is:

    • --theme-color-fg-highlight = #f5f5f5 (white/light text)
    • --theme-color-fg-default = #b8b8b8 (normal text)
    • --theme-color-fg-default-shy = #9a9999 (subtle text)
    • --theme-color-fg-muted = #7e7d7d (muted text)
  2. --theme-color-secondary is a BACKGROUND color!

    • --theme-color-secondary = #005769 (dark teal - use for backgrounds only!)
    • --theme-color-secondary-as-fg = #7ec2cf (light teal - use for text!)

When text appears invisible/gray, check for these common mistakes:

// WRONG - produces invisible text
color: var(--theme-color-fg-1);           // Variable doesn't exist!
color: var(--theme-color-secondary);       // Dark teal background color!

// CORRECT - visible text
color: var(--theme-color-fg-highlight);    // White text
color: var(--theme-color-secondary-as-fg); // Light teal text

Color Reference from colors.css:

--theme-color-bg-1: #151414;  /* Darkest background */
--theme-color-bg-2: #292828;
--theme-color-bg-3: #3c3c3c;
--theme-color-bg-4: #504f4f;  /* Lightest background */

--theme-color-fg-highlight: #f5f5f5;       /* Bright white text */
--theme-color-fg-default-contrast: #d4d4d4; /* High contrast text */
--theme-color-fg-default: #b8b8b8;          /* Normal text */
--theme-color-fg-default-shy: #9a9999;      /* Subtle text */
--theme-color-fg-muted: #7e7d7d;            /* Muted text */

--theme-color-secondary: #005769;           /* BACKGROUND only! */
--theme-color-secondary-as-fg: #7ec2cf;     /* For text */

Location: packages/noodl-core-ui/src/styles/custom-properties/colors.css

Keywords: --theme-color-fg-1, --theme-color-secondary, invisible text, gray on gray, secondary-as-fg, text color, theme variables


[2025-12-14] - Flex Container Scrolling Requires min-height: 0

Context: Migration wizard content wasn't scrollable on shorter screens.

Discovery: When using flexbox with overflow: auto on a child, the child needs min-height: 0 (or min-width: 0 for horizontal) to allow it to shrink below its content size. Without this, the default min-height: auto prevents shrinking and breaks scrolling.

Pattern:

.Parent {
  display: flex;
  flex-direction: column;
  max-height: 80vh;
  overflow: hidden;
}

.ScrollableChild {
  flex: 1;
  min-height: 0;  // Critical! Allows shrinking
  overflow-y: auto;
}

The min-height: 0 overrides the default min-height: auto which would prevent the element from being smaller than its content.

Location: Any scrollable flex container, e.g., MigrationWizard.module.scss

Keywords: flex, overflow, scroll, min-height, flex-shrink, not scrolling, content cut off


[2025-12-14] - useReducer State Must Be Initialized Before Actions Work

Context: Migration wizard "Start Migration" button did nothing - no errors, no state change, no visual feedback.

Discovery: When using useReducer to manage component state, all action handlers typically guard against null state:

case 'START_SCAN':
  if (!state.session) return state;  // Does nothing if session is null!
  return { ...state, session: { ...state.session, step: 'scanning' } };

The bug pattern:

  1. Component initializes with session: null in reducer state
  2. External manager (migrationSessionManager) creates and stores the session
  3. UI renders using manager.getSession() - works fine
  4. Button click dispatches action to reducer
  5. Reducer checks if (!state.session) → returns unchanged state
  6. Nothing happens - no errors, no visual change

The fix is to dispatch a SET_SESSION action to initialize the reducer state:

// In useEffect after creating session:
const session = await manager.createSession(...);
dispatch({ type: 'SET_SESSION', session });  // Initialize reducer!

// In reducer:
case 'SET_SESSION':
  return { ...state, session: action.session };

Key Insight: If using both an external manager AND useReducer, the reducer state must be explicitly synchronized with the manager's state for actions to work.

Location: packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.tsx

Keywords: useReducer, dispatch, null state, button does nothing, state not updating, SET_SESSION, state synchronization


[2025-12-14] - CoreBaseDialog vs Modal Component Patterns

Context: Migration wizard popup wasn't working - clicks blocked, layout broken.

Discovery: OpenNoodl has two dialog patterns:

  1. CoreBaseDialog (Working, Recommended):

    • Direct component from @noodl-core-ui/components/layout/BaseDialog
    • Used by ConfirmDialog and other working dialogs
    • Props: isVisible, hasBackdrop, onClose
    • Content is passed as children
  2. Modal (Problematic):

    • Wrapper component with additional complexity
    • Was causing issues with click handling and layout

When creating new dialogs, use the CoreBaseDialog pattern:

import { CoreBaseDialog } from '@noodl-core-ui/components/layout/BaseDialog';

<CoreBaseDialog isVisible hasBackdrop onClose={onCancel}>
  <div className={css['YourContainer']}>
    {/* Your content */}
  </div>
</CoreBaseDialog>

Location:

  • Working example: packages/noodl-editor/src/editor/src/views/ConfirmDialog/
  • packages/noodl-core-ui/src/components/layout/BaseDialog/

Keywords: CoreBaseDialog, Modal, dialog, popup, BaseDialog, modal not working, clicks blocked


Template for Future Entries

### [YYYY-MM-DD] - Brief Title

**Context**: What were you trying to do?

**Discovery**: What did you learn?

**Location**: What files/areas does this apply to?

**Keywords**: [searchable terms]