Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Angular support for LiveCodes — research + PoC

Goal: run Angular in the browser with no server and no build step, so it can become a LiveCodes language.

Status

  • poc/index.html — working proof of concept. Open it directly (file://) or over any static server. TypeScript + Angular run entirely client-side. Verified in Chrome from file:// with no server.
  • LiveCodes currently has no Angular language (src/livecodes/languages/ has react, vue, svelte, solid, stencil, … but no angular).

The Angular compiler that works in a browser runs at runtime

  • @angular/compiler — the runtime JIT template compiler. Pure JS/ESM, runs in browsers and workers. It compiles @Component metadata into component defs lazily, when a class's ɵcmp is first read (i.e. during bootstrap). It is not a .ts → .js file transform.
  • @angular/compiler-cli (ngtsc) — the build-time file→file compiler. Node-only (needs typescript, fs, a program). Not usable in a browser.
  • oxc-angular-compiler — build-time file→file and fast, but native NAPI only (see below).

So there is no browser-capable library that turns an Angular .ts file into runnable JS. What you use directly is @angular/compiler, as a runtime dependency, plus a small runtime shim.

Finding: oxc-angular-compiler cannot run in the browser today

From napi/angular-compiler/package.json:

"napi": { "targets": [
  "aarch64-apple-darwin", "aarch64-pc-windows-msvc",
  "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl",
  "x86_64-apple-darwin",  "x86_64-pc-windows-msvc",
  "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"
]}
  • No wasm32 target, no WASM artifact, no browser bundle. Published as @oxc-angular/vite (a Vite plugin peer-depending on vite >=8) plus Node NAPI bindings.
  • Its napi/playground is a Vite/Node app that calls the native binding — still not a browser build.
  • Making it work in the browser would mean adding a wasm-bindgen target, building the Rust crate to wasm32-unknown-unknown, and shipping the .wasm — a separate project, not a PoC.

How the PoC works

Everything is client-side; a CDN is used for distribution only.

  1. An import map maps @angular/* and rxjs to ESM builds on esm.sh (esm.sh rewrites internal @angular/* imports to absolute URLs, so all packages share one @angular/core instance — required for JIT).
  2. TypeScript (UMD from jsDelivr) transpiles the user's .ts with experimentalDecorators: true and useDefineForClassFields: false. No type-checking, so it is fast.
  3. The emitted JS becomes a Blob module and is loaded with dynamic import(). Bare @angular/core imports resolve through the page's import map.
  4. @angular/compiler is imported for its side effect: it publishes the JIT compiler facade on globalThis.ng.ɵcompilerFacade, which @angular/core reads when a component def is first needed.
  5. The root component is found (default export) and bootstrapped with bootstrapApplication + provideZonelessChangeDetection() (no zone.js needed). The host element is created from the component's selector.
  6. Re-running destroys the previous ApplicationRef and clears the outlet.

ngJitMode is not required — Angular's JIT is enabled by default (ngJitMode = false is what disables it). Verified: JIT works with typeof window.ngJitMode === 'undefined'.

Verified: signals, @if/@for, property/event bindings, computed signals, style encapsulation, zoneless change detection, re-run with changed code, and JIT template errors surfacing in the log.

Fitting this into LiveCodes (no compiler wrapper needed)

LiveCodes has the hooks. The split is just different from Vue/Svelte, which compile in the worker and only execute the result:

  • compiler.factory — strip types with ts.transpileModule. Set experimentalDecorators: true and useDefineForClassFields: false: LiveCodes' typescriptOptions sets neither, and TypeScript 5's default emit (standard decorators) is incompatible with Angular's legacy decorators.
  • compiler.imports (or CompileInfo.imports) — @angular/core, @angular/compiler, @angular/platform-browser, @angular/common, rxjs → esm.sh URLs. LiveCodes already turns this into the result document's import map.
  • compiler.inlineModule — the ~30-line shim that runs in the result document: import @angular/compiler for its side effect, import the user's module, create the host element from the component selector, bootstrapApplication(comp, { providers: [provideZonelessChangeDetection()] }), then destroy the ApplicationRef on re-run.

Because the real compilation happens in the result document, template errors surface at runtime rather than in the worker. Getting the Vue/Svelte model (compile in the worker → AOT JS) would require a WASM build of ngtsc/oxc emitting partial declarations (ɵɵngDeclareComponent) — that is the only genuine "wrapper" project, and oxc-angular-compiler has no WASM target today.

Known limitations

  • NG0912 on repeat runs. Re-running the same code creates a new class with the same name and selector, and Angular caches component IDs globally, so it warns about an ID collision. Rendering is still correct. The real fix is a fresh realm per run — which is what LiveCodes already does with its result iframe.
  • JIT, not AOT. No template type-checking, no build-time optimizations.
  • Single file. No multi-file / templateUrl / npm dependency resolution.
  • TypeScript is ~10 MB from CDN on first load.
  • Pinned to Angular 20.3.31 (LTS); provideZonelessChangeDetection is stable there.

Multi-file (components, templates, styles)

The PoC is strictly single-file, and this is not just missing UI — there are two independent limitations, both verified against the running PoC:

  1. Module graph. The transpiled code is loaded as a single Blob module. Relative specifiers cannot resolve against a blob: base, so import { Child } from './child.component' fails with Failed to resolve module specifier "./child.component". Invalid relative url or base scheme isn't hierarchical. Only bare specifiers (via the import map) work.
  2. Component resources. Angular's JIT does not support templateUrl/styleUrl(s) as-is; resources are queued and must be resolved before bootstrap, otherwise: Component 'AppComponent' is not resolved: - templateUrl: ./app.component.html / Did you run and wait for 'resolveComponentResources()'?

LiveCodes already ships the machinery to fix both:

  • Bare imports — createImportMap() maps them to esm.sh. Pin every @angular/* package to the same version so exactly one @angular/core instance exists.
  • Secondary modules — LiveCodes' replaceSFCImports() pattern already does what is needed: fetch the dependency, compile it, convert it with toDataUrl(), and add an import-map entry for the specifier. Bare specifiers inside a data: module still resolve through the import map, so @angular/core stays shared.
  • External templates/styles — either (a) inline at compile time (rewrite templateUrl/styleUrls/styleUrl → template/styles), which is simplest and surfaces errors earlier, or (b) keep real URLs and call resolveComponentResources(resourceResolver) before bootstrapApplication.
  • Best fit for LiveCodes' multi-pane model — map the markup pane to the component's template and the style pane to its styles. Users get separate HTML/CSS "files" with no virtual file system and no resolveComponentResources at all.

Angular-specific caveats:

  • Instance identity is critical. Every @angular/core, @angular/compiler, @angular/common and @angular/platform-browser import must resolve to the same URL everywhere. If replaceImports inlines a full esm.sh URL into one file while another resolves the bare specifier through the import map, you can get two core instances and JIT breaks.
  • Same class object graph. imports: [ChildComponent] needs that identical class; importing one source through two different specifiers yields two classes and Angular will complain.
  • The worker still cannot compile templates, so cross-file template errors only appear at runtime.

Can multiple components reference each other under JIT?

Yes — verified in poc/multifile.html with four files: counter.service.ts (@Injectable({providedIn: 'root'})), format.pipe.ts (standalone @Pipe), child.component.ts (imports the service and pipe, plus @Input/@Output) and app.component.ts (imports ChildComponent, binds [label] and (bumped)).

Each file is transpiled to its own module and loaded with an absolute module URL; relative specifiers are rewritten to the dependency's URL, while bare @angular/core stays bare and comes from the import map. That mirrors LiveCodes' ~/… + data-URL mechanism.

Verified working:

  • Cross-file component import with imports: [ChildComponent].
  • Cross-file standalone pipe ({{ label | shout }}).
  • Cross-file @Injectable — a single shared instance: incrementing from either component updates both.
  • @Input() / @Output() (decorator form) in both directions.
  • useDefineForClassFields true and false — both work identically.

Signal input() / output() don't work under JIT — but this is fixable in the compiler, not a reason to change compilers.

Root cause: JIT builds a component's inputs from decorator metadata (propMetadata plus any explicit inputs), and a component def is created before any instance exists — so nothing can know that a class field holds an InputSignal (and input() cannot know its own property name). AOT doesn't have this problem because it emits the per-input signal flag into ɵɵdefineComponent; the decorator/JIT path has no equivalent.

Measured in poc/multifile.html:

child declares label as result
input('child') NG0303: Can't bind to 'label' since it isn't a known property of 'app-child'; output() bindings silently ignored
@Input() label = input('child'), plus inputs: ['label'], outputs: ['bumped'], signals: true input registers, but Angular assigns the plain value over the InputSignal → child template renders partially and label() throws
backing signal + get + @Input() set (below) works

The working shim keeps the template unchanged ({{ label() }}) and keeps this.label.set(...) working:

// from:  label = input('child');
private readonly _label = signal('child');
get label() {
  return this._label;
}
@Input() set label(value: string) {
  this._label.set(value);
}

output() maps directly onto @Output() x = new EventEmitter(). So the Angular language compiler can rewrite signal I/O declarations into decorator form and the limitation goes away with the JIT compiler we already have. Remaining variants to handle: input.required(), input(..., {alias, transform}), and model() (→ an @Input plus an @Output pair).

The only way to get true signal inputs and compile-time template diagnostics is to emit the component definition (with signal flags) ourselves — real AOT in the browser. That requires a browser-capable compiler frontend: ngtsc is Node-only and oxc-angular-compiler has no WASM target, so it is a project, not a patch.

src/angular-compiler/ — the reusable package

Everything Angular-specific lives behind one entry point, so the host does not see the implementation details — and so the JIT-shim approach can later be swapped for a real AOT compiler without the host changing:

import {
  transformSignalIo,      // signal I/O → decorator I/O
  inlineResources,        // templateUrl / styleUrl(s) → inline template / styles
  resolvePath,            // relative specifier → project path
  compileAngularFile,     // the steps above, plus type stripping and sourcemaps
  angularImports,         // pinned @angular/* + rxjs import-map entries
  angularBootstrapModule, // the module that runs in the result document
  ANGULAR_COMPILER_OPTIONS,
} from './angular-compiler/index.js';

Constraints it keeps:

  • ts is injected, never imported — usable in a worker, in Node tests and in the browser, without loading TypeScript twice.
  • Pure functions over source text. Only angularBootstrapModule returns code meant to run elsewhere, as a string, because it must execute against the host's import map.
  • resolve(path) => { content } | null is the only project-layout assumption. Nothing here knows about config.files, data URLs, iframes or ~/ keys.
  • Zero dependencies, no build step.

src/language.angular.js is the thin glue for the multi-file-support branch: it maps config.files onto resolve, forwards options.filename, applies compileAngularFile, and sets compiler.imports / inlineModule / multiFileSupport. It also passes the entry specifier (~/main.ts) in explicitly rather than baking it into the package.

Signal I/O transform

Deliberately conservative: a class field is only rewritten when its initializer is literally a call to input, output, model, viewChild or contentChild, resolved through the file's @angular/core import bindings (so import { input as inp } works). Everything else is left untouched, and the result reports unsupported / conflicts for anything it declined.

Emitted shapes — the template and the signal API are unchanged in every case:

source emitted
label = input('child') backing signal + get label() + @Input() set label()
greeting = input.required<string>() same, plus a throw until a value is set
bumped = output<number>() @Output() bumped = new EventEmitter()
value = model(0) backing signal + @Output() valueChange + callable with emitting .set/.update
probe = viewChild('t') @ViewChild('t') + state signal + one ngAfterViewChecked sync hook per class

alias and transform are passed through; read is passed through on queries. viewChildren() / contentChildren() are reported, not rewritten — the reactive-array semantics cannot be reproduced by a decorator shim. queries: false turns the query rewriting off entirely, for comparison.

Refuses to rewrite (and says why) when a needed name is already bound by something else, rather than silently binding to the wrong thing.

Covered by assertions in Node across the emitters, aliasing, options, import handling and the decline cases.

Resource inlining

templateUrl → template, and styleUrl / styleUrls → styles, resolved against the importing file and emitted as JSON string literals. Reported rather than rewritten when the file cannot be resolved, when the value is not statically readable, or when the object already sets the inline property (Angular rejects template together with templateUrl).

Component resources are the one thing LiveCodes' import rewriting cannot see: they are decorator string literals, not import statements, and Angular's JIT refuses to bootstrap a component whose resources are unresolved. Inlining is therefore a compiler concern — the alternative would be a resolveComponentResources() loader at runtime, which needs a handle on resources the compiler step has already discarded.

Non-CSS style sources are the caller's problem: run them through their own compiler before handing the content over.

Verified end to end in the browser

poc/multifile.html is a six-file app (counter.service.ts, format.pipe.ts, child.component.ts, child.component.html, child.component.css, app.component.ts) that consumes the package exactly as LiveCodes would — the host supplies resolve, the package does the rest. In Chrome:

  • input() / input.required() receive parent values; output() events reach the parent.
  • model() two-way: "Bump local model" updates the child and the parent binding.
  • The cross-file @Injectable stays one shared instance.
  • viewChild() resolves with the shim; with ?queries=off the raw viewChild() never resolves (still undefined after a change-detection pass), confirming the query is never registered.
  • templateUrl: './child.component.html' and styleUrl: './child.component.css' are both inlined and applied, with view encapsulation intact (color: rgb(0, 128, 0), host carrying an _ng… attribute).
  • Console is clean — no NG0303, no NG0100.

Gotcha found on the way: a template reference variable shadows a component property of the same name, so {{ box() }} next to <p #box> calls the ElementRef, not the query. That confounded an earlier reading of this test and is worth remembering when writing starter templates.

Running the tests

npm install --include=dev
npm test        # test/signal-io.mjs, then test/collections.mjs

test/signal-io.mjs covers the emitters, aliasing, options, import handling, resource inlining and compileAngularFile. It runs the transformed code against a stub @angular/core (signals, decorator metadata, an EventEmitter) instead of needing a browser, which is how two real bugs got caught.

test/collections.mjs is the spec for viewChildren() / contentChildren(), which are not implemented yet. It ships two deliberately-naive fixtures — a hook sync with a !== guard, and a subscription that never unsubscribes — and asserts that both pass the happy path (seed, add, remove) and fail the scenario each exists to catch (writing on every check; writing after destroy). That is what gives the five pending assertions teeth: they cannot be satisfied by an implementation that merely looks plausible.

Note: this environment has NODE_ENV=production, which makes npm skip devDependencies, so a plain npm install installs nothing. --include=dev is required.

Next steps

  1. Wire src/language.angular.js + src/angular-compiler/ into multi-file-support as src/livecodes/languages/angular/, bundling the package into lang-angular-compiler.js (the Vue language's importScripts pattern).
  2. Ship the starter template (index.html + main.ts + a component) and the Angular docs page, and note the input/output/model support and the viewChildren gap there.
  3. Decide whether extra markup files become allowed: inlineResources already handles templateUrl, but multi-file currently permits only the main markup file, so a conventional app.component.html cannot exist yet.
  4. Revisit oxc-angular-compiler only if it gains a WASM build — that is the only route to true signal inputs and compile-time template diagnostics.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages