Asynchronous Initialization

Modern editors often require asynchronous initialization for loading resources, parsing schemas, or setting up plugins. This is similar to how WebGPU requires async device initialization.

Overview

Unlike traditional synchronous APIs, modern editors need to handle asynchronous operations during initialization. This includes loading schemas from network, initializing plugins that may need to fetch data, and setting up view layers that require DOM manipulation.

The challenge is providing a clean API that doesn't block the main thread while ensuring the editor is fully ready before use. This pattern is similar to how WebGPU requires async device initialization, or how modern frameworks handle component mounting.

Promise-Based API Design

Instead of synchronous initialization, use a Promise-based API that resolves when the editor is ready:

Initialization Pattern

The core pattern is to return a Promise from the constructor and expose it via a getter:

class Editor {
  constructor(config) {
    this.#config = config;
    this.#initialized = false;
    
    // Create initialization promise
    this.#initPromise = this.#initialize();
  }
  
  get initialized() {
    return this.#initPromise;
  }
  
  async #initialize() {
    // Load schema
    const schema = await this.#loadSchema(this.#config.schema);
    
    // Initialize plugins
    await this.#initializePlugins();
    
    // Set up view layer
    await this.#setupView();
    
    this.#initialized = true;
    return this;
  }
  
  async #loadSchema(schemaConfig) {
    if (typeof schemaConfig === 'string') {
      // Load from URL
      const response = await fetch(schemaConfig);
      return await response.json();
    }
    return schemaConfig;
  }
  
  async #initializePlugins() {
    const pluginPromises = this.#config.plugins.map(plugin => {
      return plugin.initialize?.(this) || Promise.resolve();
    });
    await Promise.all(pluginPromises);
  }
  
  async #setupView() {
    // Set up DOM event listeners
    // Initialize rendering
    // Set up selection handling
  }
}

// Usage
const editor = await new Editor({
  schema: './schema.json',
  plugins: [new HistoryPlugin(), new LinkPlugin()]
}).initialized;

// Editor is now ready to use
editor.insertText('Hello');

Key points:

  • The promise is created immediately in the constructor
  • Multiple async operations can run in parallel using Promise.all
  • The editor instance is returned from the promise for chaining
  • All initialization happens before the promise resolves

Error Handling

Proper error handling is crucial for async initialization:

class Editor {
  async #initialize() {
    try {
      // Load schema
      const schema = await this.#loadSchema(this.#config.schema);
      if (!schema) {
        throw new Error('Failed to load schema');
      }
      
      // Initialize plugins
      await this.#initializePlugins();
      
      // Set up view layer
      await this.#setupView();
      
      this.#initialized = true;
      return this;
    } catch (error) {
      // Clean up on error
      this.#cleanup();
      throw error;
    }
  }
  
  #cleanup() {
    // Remove event listeners
    // Clear timers
    // Release resources
  }
}

// Usage with error handling
try {
  const editor = await new Editor({
    schema: './schema.json',
    plugins: [new HistoryPlugin()]
  }).initialized;
  
  // Use editor
  editor.insertText('Hello');
} catch (error) {
  console.error('Failed to initialize editor:', error);
  // Show error to user
}

Initialization Lifecycle

Understanding the initialization lifecycle helps manage state transitions and provides hooks for monitoring progress:

Lifecycle Phases

Break initialization into distinct phases with clear state transitions:

class Editor {
  #state = 'idle'; // idle -> loading -> ready -> error
  
  async #initialize() {
    this.#transition('loading');
    
    try {
      // Phase 1: Pre-initialization
      await this.#phasePreInit();
      
      // Phase 2: Resource loading
      await this.#phaseLoadResources();
      
      // Phase 3: Plugin initialization
      await this.#phaseInitPlugins();
      
      // Phase 4: View setup
      await this.#phaseSetupView();
      
      // Phase 5: Post-initialization
      await this.#phasePostInit();
      
      this.#transition('ready');
    } catch (error) {
      this.#transition('error', error);
      throw error;
    }
  }
  
  #transition(newState, data = null) {
    const oldState = this.#state;
    this.#state = newState;
    this.emit('stateChange', { from: oldState, to: newState, data });
  }
  
  async #phasePreInit() {
    // Validate configuration
    // Set up internal structures
  }
  
  async #phaseLoadResources() {
    // Load schemas, assets, etc.
  }
  
  async #phaseInitPlugins() {
    // Initialize all plugins
  }
  
  async #phaseSetupView() {
    // Set up DOM, event listeners
  }
  
  async #phasePostInit() {
    // Final validation, emit ready event
  }
}

Phase breakdown:

  • Pre-initialization: Configuration validation, internal structure setup
  • Resource loading: Schemas, assets, external dependencies
  • Plugin initialization: Initialize plugins in dependency order
  • View setup: DOM manipulation, event listeners, rendering
  • Post-initialization: Final validation, ready event emission

State Transitions

State machine pattern ensures predictable transitions and prevents invalid states:

  • idle → loading: Initialization starts
  • loading → ready: All phases complete successfully
  • loading → error: Any phase fails
  • error → loading: Retry initialization

State transitions emit events that plugins can listen to for lifecycle management.

Dependency Resolution

Plugins often depend on other plugins. Resolving dependencies correctly ensures proper initialization order:

Dependency Graph

Build a dependency graph from plugin declarations:

class DependencyResolver {
  resolve(plugins) {
    // Build dependency graph
    const graph = this.#buildGraph(plugins);
    
    // Detect cycles
    if (this.#hasCycle(graph)) {
      throw new Error('Circular dependency detected');
    }
    
    // Topological sort
    return this.#topologicalSort(graph);
  }
  
  #buildGraph(plugins) {
    const graph = new Map();
    
    for (const plugin of plugins) {
      const deps = plugin.dependencies || [];
      graph.set(plugin, deps);
    }
    
    return graph;
  }
  
  #hasCycle(graph) {
    const visited = new Set();
    const recStack = new Set();
    
    const hasCycleDFS = (node) => {
      if (recStack.has(node)) return true;
      if (visited.has(node)) return false;
      
      visited.add(node);
      recStack.add(node);
      
      const deps = graph.get(node) || [];
      for (const dep of deps) {
        if (hasCycleDFS(dep)) return true;
      }
      
      recStack.delete(node);
      return false;
    };
    
    for (const node of graph.keys()) {
      if (hasCycleDFS(node)) return true;
    }
    
    return false;
  }
  
  #topologicalSort(graph) {
    const visited = new Set();
    const result = [];
    
    const visit = (node) => {
      if (visited.has(node)) return;
      
      const deps = graph.get(node) || [];
      for (const dep of deps) {
        visit(dep);
      }
      
      visited.add(node);
      result.push(node);
    };
    
    for (const node of graph.keys()) {
      visit(node);
    }
    
    return result;
  }
}

// Usage
const resolver = new DependencyResolver();
const orderedPlugins = resolver.resolve([
  { name: 'link', dependencies: ['history'] },
  { name: 'history', dependencies: [] },
  { name: 'image', dependencies: ['link'] }
]);
// Result: ['history', 'link', 'image']

Key concepts:

  • Dependency graph: Directed graph where edges represent dependencies
  • Cycle detection: Prevent circular dependencies that would cause infinite loops
  • Topological sort: Order plugins so dependencies are initialized first

Topological Sort

Topological sorting ensures plugins are initialized in the correct order:

  • Plugins with no dependencies are initialized first
  • Dependent plugins are initialized after their dependencies
  • Multiple independent plugins can be initialized in parallel
  • Circular dependencies are detected and reported as errors

Initialization Hooks

Use hooks to allow plugins to participate in initialization:

Hook Integration

Integrate hooks into the initialization process:

class Editor {
  constructor(config) {
    this.#hooks = {
      init: new SyncHook(),
      initAsync: new AsyncParallelHook(),
      ready: new SyncHook()
    };
    
    this.#initPromise = this.#initialize();
  }
  
  async #initialize() {
    // Synchronous initialization hooks
    this.#hooks.init.call();
    
    // Asynchronous initialization hooks
    await this.#hooks.initAsync.promise();
    
    // Ready hook
    this.#hooks.ready.call();
    
    return this;
  }
  
  get hooks() {
    return this.#hooks;
  }
}

// Plugin can hook into initialization
class DatabasePlugin {
  apply(editor) {
    editor.hooks.initAsync.tapPromise(async () => {
      // Load data from database
      const data = await this.loadFromDatabase();
      editor.setInitialContent(data);
    });
  }
  
  async loadFromDatabase() {
    const response = await fetch('/api/document');
    return await response.json();
  }
}

// Usage
const editor = await new Editor({
  plugins: [new DatabasePlugin()]
}).initialized;

Plugin Initialization

Plugins can perform async initialization:

class Plugin {
  constructor(config) {
    this.#config = config;
  }
  
  async initialize(editor) {
    // Load plugin resources
    await this.#loadResources();
    
    // Set up plugin state
    this.#setupState(editor);
    
    // Register hooks
    this.#registerHooks(editor);
  }
  
  async #loadResources() {
    if (this.#config.resources) {
      const promises = this.#config.resources.map(url => fetch(url));
      await Promise.all(promises);
    }
  }
  
  #setupState(editor) {
    this.#editor = editor;
    this.#state = new Map();
  }
  
  #registerHooks(editor) {
    editor.hooks.beforeOperation.tap((operation) => {
      this.#handleOperation(operation);
    });
  }
}

// Plugin with async initialization
class ImageUploadPlugin extends Plugin {
  async initialize(editor) {
    await super.initialize(editor);
    
    // Initialize upload service
    this.#uploadService = await this.#createUploadService();
  }
  
  async #createUploadService() {
    // Set up cloud storage connection
    return new UploadService({
      apiKey: this.#config.apiKey,
      endpoint: this.#config.endpoint
    });
  }
}

Resource Loading

Loading resources efficiently during initialization:

Schema Loading

Load schemas from various sources:

class SchemaLoader {
  async load(schemaConfig) {
    if (typeof schemaConfig === 'string') {
      if (this.isURL(schemaConfig)) {
        return await this.loadFromURL(schemaConfig);
      }
      if (this.isLocalPath(schemaConfig)) {
        return await this.loadFromFile(schemaConfig);
      }
      return await this.loadFromRegistry(schemaConfig);
    }
    return schemaConfig;
  }
  
  isURL(str) {
    return str.startsWith('http://') || str.startsWith('https://');
  }
  
  isLocalPath(str) {
    return str.startsWith('./') || str.startsWith('../');
  }
  
  async loadFromURL(url) {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error('Failed to load schema from URL');
    }
    return await response.json();
  }
  
  async loadFromFile(path) {
    const response = await fetch(path);
    return await response.json();
  }
  
  async loadFromRegistry(name) {
    const registry = await this.getRegistry();
    return registry.get(name);
  }
}

Plugin Loading

Load plugins dynamically:

class PluginLoader {
  async loadPlugins(pluginConfigs) {
    const plugins = [];
    
    for (const config of pluginConfigs) {
      if (typeof config === 'string') {
        // Load plugin module
        const plugin = await this.#loadPluginModule(config);
        plugins.push(plugin);
      } else if (config.module) {
        // Load from module
        const plugin = await this.#loadPluginModule(config.module);
        plugins.push(new plugin(config.options));
      } else {
        // Already a plugin instance
        plugins.push(config);
      }
    }
    
    return plugins;
  }
  
  async #loadPluginModule(name) {
    // Dynamic import
    const module = await import(`./plugins/${name}.js`);
    return module.default || module[name];
  }
}

// Usage
const loader = new PluginLoader();
const plugins = await loader.loadPlugins([
  'history',
  'link',
  { module: 'image', options: { uploadUrl: '/api/upload' } }
]);

Timeout and Retry Strategies

Network requests and external resources can fail or timeout. Implement robust retry and timeout mechanisms:

Timeout Handling

Prevent initialization from hanging indefinitely:

  • Set reasonable timeouts for each initialization phase
  • Use Promise.race to enforce timeouts
  • Provide different timeout values for different operations
  • Emit timeout events for monitoring and debugging

Retry Mechanisms

Implement exponential backoff retry strategy:

class InitializationManager {
  async initializeWithTimeout(initFn, timeout = 5000) {
    return Promise.race([
      initFn(),
      new Promise((_, reject) => 
        setTimeout(() => reject(new Error('Initialization timeout')), timeout)
      )
    ]);
  }
  
  async initializeWithRetry(initFn, options = {}) {
    const { maxRetries = 3, delay = 1000, backoff = 2 } = options;
    let lastError;
    
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        return await initFn();
      } catch (error) {
        lastError = error;
        
        if (attempt < maxRetries) {
          const waitTime = delay * Math.pow(backoff, attempt);
          await this.#sleep(waitTime);
        }
      }
    }
    
    throw lastError;
  }
  
  async initializeWithTimeoutAndRetry(initFn, options = {}) {
    const { timeout = 5000, maxRetries = 3 } = options;
    
    return this.initializeWithRetry(
      () => this.initializeWithTimeout(initFn, timeout),
      { maxRetries }
    );
  }
  
  #sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

// Usage
const manager = new InitializationManager();

try {
  const editor = await manager.initializeWithTimeoutAndRetry(
    () => new Editor(config).initialized,
    { timeout: 10000, maxRetries: 3 }
  );
} catch (error) {
  console.error('Failed after retries:', error);
}

Retry strategies:

  • Exponential backoff: Increase delay between retries exponentially
  • Max retries: Limit number of attempts to prevent infinite loops
  • Selective retry: Only retry on transient errors (network, timeout)
  • Jitter: Add randomness to prevent thundering herd problem

Best Practices

Best practices for async initialization:

  • Always await initialization: Never use the editor before initialization completes
  • Handle errors gracefully: Provide meaningful error messages and cleanup on failure
  • Load resources in parallel: Use Promise.all when possible
  • Provide loading states: Show progress to users during initialization
  • Cache initialized state: Don't re-initialize if already initialized
  • Support lazy initialization: Allow deferring initialization until needed
class Editor {
  async #initialize() {
    // Check if already initialized
    if (this.#initialized) {
      return this;
    }
    
    // Show loading state
    this.#setLoadingState(true);
    
    try {
      // Load resources in parallel
      const [schema, plugins] = await Promise.all([
        this.#loadSchema(this.#config.schema),
        this.#loadPlugins(this.#config.plugins)
      ]);
      
      // Initialize sequentially (plugins may depend on schema)
      await this.#setupSchema(schema);
      await this.#setupPlugins(plugins);
      await this.#setupView();
      
      this.#initialized = true;
      this.#setLoadingState(false);
      
      return this;
    } catch (error) {
      this.#setLoadingState(false);
      this.#setErrorState(error);
      throw error;
    }
  }
  
  #setLoadingState(loading) {
    // Emit loading event
    this.emit('loading', { loading });
  }
  
  #setErrorState(error) {
    // Emit error event
    this.emit('error', { error });
  }
}