Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part 21 — The Custom Editor host: composing projections client-side (React/Vite)

The LSP host (article 20) was the server-side composition surface. The Custom Editor host is the client-side composition surface — the runtime that VSCode hands a .req.ts file to when the user opens it as a Custom Editor, the runtime that mounts the right Projection micro-DSL renderers, the runtime that bridges PatchBus mutations across the WebView boundary so projections in the WebView can mutate the canonical AST in the extension host. Like the LSP host, the Custom Editor host depends on no concrete projection — it discovers them through the same kernel contribution registry and lets the user switch projections at runtime.

The article also names a deliberately bounded technology choice. The host's WebView bootstrap is built with Vite and React. This is the default for the example consumer, not an architectural constraint. A WebView host built with Lit, Svelte, vanilla DOM, or Solid would compose the same Projection contributions; the kernel-shaped contracts are framework-agnostic. We pick React + Vite because they are widely adopted in the TypeScript developer audience the suite targets and because the bundling story is mature; the choice is documented and defended but not imposed.

What the host does

The host's responsibilities, in order:

  1. Register as a Custom Editor. From the Language micro-DSL declarations, derive the contributes.customEditors block in package.json — one entry per language whose DSL declares at least one Projection. Bind the editor to the language's file extensions.
  2. Discover Projection contributions. At activation, walk the kernel contribution registry for ProjectionContribution<T> entries. Build a per-Concept projection map.
  3. Mount the WebView. When VSCode opens a Custom Editor for a .req.ts file, instantiate a WebView, load the Vite-bundled React bundle, post the initial AST + projection registry to the WebView.
  4. Bridge PatchBus. Every WebView event that mutates state translates to a PatchBus operation in the extension host; every PatchBus mutation in the extension host that affects this document translates to a WebView re-render. The bridge is a typed message channel; the kernel's PatchBus contract carries through unchanged.
  5. Manage lifecycle. Save events, focus changes, close events, dispose events — all standard VSCode Custom Editor API.

The bridge is the only non-trivial part. The other four are mechanical.

The bridge

The PatchBus bridge crosses two process boundaries: the React WebView (a sandboxed iframe with restricted postMessage access) and the extension host (the Node process where the kernel and the LSP host run). The kernel's PatchBus is in-process to the extension host; the projection renderers run in the WebView. Bridging is necessary because direct mutation from the WebView to the kernel would require either pulling the kernel into the WebView (large bundle, sandboxing complications) or letting the WebView mutate via a non-typed channel (loses the article 05 invariants).

The bridge shape:

// In the WebView (React component):
function onCellEdit(value: string) {
  postMessage({
    op: 'setProperty',
    nodeId: cell.nodeId,
    propertyName: cell.property,
    value,
  });
  // No local state mutation; wait for the kernel to confirm via re-render.
}

// In the extension host:
webview.onMessage(async (msg) => {
  switch (msg.op) {
    case 'setProperty':
      await kernel.patchBus.setProperty(msg.nodeId, msg.propertyName, msg.value);
      break;
    // ... insertChild, removeChild, setReference ...
  }
  // The kernel's onPatchBatch event will trigger the re-render path below.
});

kernel.onEditLogAppend((batch) => {
  if (!batch.affects(currentDocument)) return;
  const newProjection = renderProjection(currentDocument);
  webview.postMessage({ kind: 'render', tree: newProjection });
});

Three properties:

  • The WebView never owns state. It renders what the kernel says; it proposes mutations through PatchBus messages; it re-renders when the kernel publishes an EditLog event affecting the current document. There is no optimistic local mutation, no eventual consistency story.
  • The PatchBus contract is preserved across the bridge. A WebView-originated mutation has the same validation, the same EditLog entry, the same atomicity as a refactoring-originated or LSP-originated mutation.
  • The re-render is rendering, not editing. The kernel produces the projection tree (a serialisable description of what to render); the WebView translates the tree to React virtual DOM. The translation is per-projection; each Projection contribution declares its own React component.

Vite + React: the example bootstrap

The host ships a Vite configuration that compiles the WebView bundle. The bundle's entry point:

// host/webview-src/main.tsx
import { createRoot } from 'react-dom/client';
import { App } from './App';

const root = createRoot(document.getElementById('root')!);
root.render(<App />);

// App receives the initial AST + projection registry from the extension host
// via a postMessage; subsequent renders come through the same channel.
// host/webview-src/App.tsx (sketch)
function App() {
  const [state, setState] = useExtensionHostState();
  const projection = state.projections.find(p => p.id === state.activeProjectionId);
  const ProjectionComponent = projectionComponents[projection.kind];
  return (
    <ProjectionToolbar
      projections={state.projections}
      active={state.activeProjectionId}
      onSwitch={id => postMessage({ op: 'switchProjection', id })}
    >
      <ProjectionComponent ast={state.ast} projection={projection} />
    </ProjectionToolbar>
  );
}

The projectionComponents registry is populated at build time from the discovered Projection contributions; each contribution exports a React component for its kind ('text', 'form', 'table', 'diagram'). The host does not own these components; it imports them from the discovered contributions. Replacing React with another framework means changing the host's bundling and the projection components' implementations; the bridge protocol stays the same.

The text projection is a special case: its "renderer" is the Formatter (article 15) producing canonical text, which the WebView displays in a Monaco editor instance. The WebView component for text projections embeds Monaco; edits to the Monaco buffer translate to text edits, which re-parse to AST changes, which propagate to the kernel through the standard load path. The bidirectional sync from article 18 happens through this Monaco-hosted text projection.

Composition with peers

  • Projection (article 18) — declares the bindings the host consumes.
  • Formatter (article 15) — produces the text projection's canonical output.
  • Commands (article 13) — projection-switch commands and projection-specific commands (zoom, layout, etc.) are owned by Commands.
  • Kernel PatchBus — the mutation channel the bridge respects.
  • Extension host — the Custom Editor host is part of the extension package; it is registered through the host's activation events.

Lifecycle, briefly

  • Open. VSCode invokes the Custom Editor's resolveCustomTextEditor. The host creates a WebView, loads the bundle, posts initial state, registers EditLog subscriptions.
  • Edit. User edits in the WebView; messages flow to the extension host; PatchBus mutations apply; EditLog events propagate; WebView re-renders.
  • Save. VSCode invokes save. The host serialises the AST through the text projection (canonical text), writes to disk via VSCode's save API. Banner-stamped if the file is also a generation target.
  • Focus loss. WebView remains mounted; the host stops re-rendering on EditLog events for off-screen documents to save CPU.
  • Close. WebView disposed; EditLog subscriptions cleaned up; document released from the kernel's per-document state.

Why React + Vite, briefly

  • React for the rendering: large ecosystem, well-understood lifecycle, hooks-based state, mature WebView integration. Alternatives (Svelte, Solid, Lit) are equally viable; the contributor pool of TypeScript developers comfortable with React is the operational reason.
  • Vite for the bundling: fast dev server with HMR over the WebView channel (the host can hot-reload projection components during development without rebuilding the extension), small production bundles, ESM-native. Alternatives (esbuild standalone, webpack) are equally viable.
  • Monaco for the text projection: VSCode's own editor engine, embeddable in WebViews, supports the same TextMate grammars and semantic tokens the main editor uses, supports LSP via custom integration. Reusing Monaco in the WebView gives the text projection the same highlighting and completion the main editor would provide.

What this article verifies

This article verifies the four acceptance criteria of FEAT-MICRODSL-21 declared in assets/features.ts:

  • webViewBootstrapWithViteExplained — the Vite + React section shows the bundle entry point, the React root, the Vite role.
  • reactProjectionRendererArchitectureShown — the App.tsx sketch shows the registry-driven projection rendering.
  • patchBusBridgeAcrossWebViewBoundaryStated — the bridge section walks the message protocol both directions, names the three properties (WebView owns no state, PatchBus contract preserved, re-render is rendering).
  • noProjectionHardCodedInHostJustified — the What the host does section names the discovery via contribution registry; the Composition with peers section names that projections are imported from contributions, not the host.

What article 22 picks up

Article 22 closes the series by composing the suite end to end. The Requirements IDE — the smallest useful instance of the architecture — is assembled from kernel + Language + Syntax + Snippets + Completion + Hover + CodeLens + Generator (six micro-DSLs plus the kernel plus the LSP host plus the Extension host), and the resulting .vsix-ready folder is walked. The dog-food loop with the Requirements DSL is made explicit; the bridge backward to meta-ide-dsl article 07 ("here is what was a single class, decomposed") and forward to ide-dsl article 01 ("here is the build, starting from the decomposition this series proved") is named.

⬇ Download