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.raceto 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.allwhen 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 });
}
}