Skip to content

[Dev UI] Refactor: Make JsonEditorComponent Reactive with Safe Synchronization #5513

@MichaelDoyle

Description

@MichaelDoyle

Refactor: Make JsonEditorComponent Reactive with Standardized JSON Formatting & Safe Synchronization

Problem Statement

Currently, the JsonEditorComponent serves as a wrapper around Monaco Editor, but it leaks its internal Monaco ITextModel APIs to its parent consumers. Whenever a parent component needs to populate or format the editor content, it is forced to reach deep into the editor's internals to fetch the model and manually format the value:

// Pattern repeated in multiple runner components
const model = this.inputEditor()?.editor?.getModel();
if (model) {
  try {
    model.setValue(JSON.stringify(JSON.parse(content), null, 4));
  } catch {
    model.setValue(content);
  }
}

This leads to several architectural issues:

  1. Encapsulation Leaks: Parents must directly manipulate Monaco's private model, making future editor upgrades or replacements fragile.
  2. Boilerplate Duplication: There are at least 6 distinct places (including AppContextSidebar, ActionRunner, FlowRunner, PromptRunner, RetrieverRunner, and DatasetDetailsPage) doing identical try-catch / JSON.stringify manual formatting.
  3. Imperative Programming: Parent components must hook into child-loaded events ((editorLoaded)) to push data imperatively, rather than binding state declaratively.

Proposed Solution

We should refactor JsonEditorComponent to support a reactive, declarative API by exposing a value input signal. To do this safely—without resetting the user's active text cursor or discarding Monaco's undo/redo history during typing—we can implement a Safe Synchronization pattern with a structural equality guard.

1. Update JsonEditorComponent API

Expose a single, unified value input signal and a internal sync effect inside json-editor.component.ts:

export class JsonEditorComponent {
  // Declarative input for any raw string, object, or nullish value
  value = input<string | object | null | undefined>();

  constructor() {
    // Automatically synchronize state changes
    effect(() => {
      this.syncEditorValue(this.value());
    });
  }

  private syncEditorValue(newValue: string | object | null | undefined) {
    if (!this.editor) return;
    const model = this.editor.getModel();
    if (!model) return;

    const formattedNewValue = this.formatValue(newValue);
    const currentValue = model.getValue();

    // Prevent resetting Monaco cursor/undo stack if value hasn't structurally changed
    if (this.areJsonValuesEqual(currentValue, formattedNewValue)) {
      return;
    }

    model.setValue(formattedNewValue);
  }

  private formatValue(
    val: string | object | null | undefined,
    indent = 4
  ): string {
    if (val === null || val === undefined) return '';
    if (typeof val === 'object') return JSON.stringify(val, null, indent);

    try {
      const parsed = JSON.parse(val);
      return JSON.stringify(parsed, null, indent);
    } catch {
      return val; // Keep user's active keystrokes intact even if JSON is temporarily invalid
    }
  }

  private areJsonValuesEqual(a: string, b: string): boolean {
    if (a === b) return true;
    try {
      return JSON.stringify(JSON.parse(a)) === JSON.stringify(JSON.parse(b));
    } catch {
      return false;
    }
  }
}

Impact & Refactored Code Samples

Implementing this reduces the complexity of all parent components down to a single declarative property binding in their templates:

Example: AppContextSidebar

Before:

Component TS:

onEditorLoaded() {
  const editor = this.contextEditor();
  if (editor) {
    const model = editor.editor?.getModel();
    if (model) {
      model.setValue(this.contextService.contextString());
    }
  }
}

Component HTML:

<json-editor
  #contextEditor
  (editorLoaded)="onEditorLoaded()"
  (contentChangeEvent)="onValueChange($event)" />

After:

Component TS:
(All onEditorLoaded boilerplate is eliminated)
Component HTML:

<json-editor
  [value]="contextService.contextString()"
  (contentChangeEvent)="onValueChange($event)" />

Benefits

  • Strict Encapsulation: Monaco model APIs remain entirely private to JsonEditorComponent.
  • DRY Codebase: Eliminates manual formatting boilerplate and error-handling from 6+ parent components.
  • Improved UX: Automatic and consistent pretty-printing on load, with built-in cursor and history protection as the user edits.
  • Declarative Patterns: Aligns with modern Angular standards by replacing imperative hooks with clean signal-based data flows.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions