Repository: alloy-framework/alloy
Pull request #166 (2025-05-28 20:04:12)

Refactor JSX Runtime
Circular references abounded and duplicate JSX runtime errors kept happening. The only real reason the jsx-runtime sub export exists is as an implementation detail so VSCode gets proper tooling. This PR moves many things directly into core. Core does not import JSXRuntime. JSXRuntime imports and re-exports some bits from core. The world is happier.

github-actions[bot]: <!--chronus-github-change-commenter-->
:x: There is undocummented changes. Run `chronus add` to add a changeset or [click here](https://github.com/bterlson/alloy/new/refactor-jsx-runtime?filename=.chronus/changes/refactor-jsx-runtime-2025-4-28-20-4-38.md&value=---%0A%23%20Change%20versionKind%20to%20one%20of%3A%20breaking%2C%20feature%2C%20fix%2C%20internal%0AchangeKind%3A%20fix%0Apackages%3A%0A%20%20-%20%22%40alloy-js%2Fcore%22%0A%20%20-%20%22%40alloy-js%2Ftypescript%22%0A---%0A%0ARefactor%20JSX%20Runtime).

**The following packages have changes but are not documented.**
 - :x:`@alloy-js/core`
 - :x:`@alloy-js/typescript`

<details>
<summary>Show changes</summary>


</details>

pkg-pr-new[bot]: 
[Open in StackBlitz](https://pkg.pr.new/template/c8f0a71f-96f8-4d25-92a9-ec1b88d00f27)


<details><summary><b>@alloy-js/babel-plugin</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/babel-plugin@166
```

</p></details>
      
    

<details><summary><b>@alloy-js/babel-plugin-jsx-dom-expressions</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/babel-plugin-jsx-dom-expressions@166
```

</p></details>
      
    

<details><summary><b>@alloy-js/babel-preset</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/babel-preset@166
```

</p></details>
      
    

<details><summary><b>@alloy-js/cli</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/cli@166
```

</p></details>
      
    

<details><summary><b>@alloy-js/core</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/core@166
```

</p></details>
      
    

<details><summary><b>@alloy-js/create</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/create@166
```

</p></details>
      
    

<details><summary><b>@alloy-js/csharp</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/csharp@166
```

</p></details>
      
    

<details><summary><b>@alloy-js/java</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/java@166
```

</p></details>
      
    

<details><summary><b>@alloy-js/json</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/json@166
```

</p></details>
      
    

<details><summary><b>@alloy-js/markdown</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/markdown@166
```

</p></details>
      
    

<details><summary><b>@alloy-js/prettier-plugin-alloy</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/prettier-plugin-alloy@166
```

</p></details>
      
    

<details><summary><b>@alloy-js/rollup-plugin</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/rollup-plugin@166
```

</p></details>
      
    

<details><summary><b>@alloy-js/typescript</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/typescript@166
```

</p></details>
      
    

_commit: <a href="https://github.com/alloy-framework/alloy/runs/43079919530"><code>794b9a7</code></a>_


Patch:
diff --git a/.chronus/changes/refactor-jsx-runtime-2025-4-28-14-40-45.md b/.chronus/changes/refactor-jsx-runtime-2025-4-28-14-40-45.md
new file mode 100644
index 0000000..d11c8b5
--- /dev/null
+++ b/.chronus/changes/refactor-jsx-runtime-2025-4-28-14-40-45.md
@@ -0,0 +1,9 @@
+---
+changeKind: internal
+packages:
+  - "@alloy-js/core"
+  - "@alloy-js/java"
+  - "@alloy-js/typescript"
+---
+
+Refactor JSX runtime
\ No newline at end of file
diff --git a/packages/core/src/binder.ts b/packages/core/src/binder.ts
index ef6c458..633934b 100644
--- a/packages/core/src/binder.ts
+++ b/packages/core/src/binder.ts
@@ -1,13 +1,11 @@
 import { computed, ref, Ref, ShallowRef, shallowRef } from "@vue/reactivity";
 import { useMemberScope } from "./context/member-scope.js";
 import { useScope } from "./context/scope.js";
-import { effect, untrack } from "./jsx-runtime.js";
+import { effect, untrack } from "./reactivity.js";
 import { refkey, Refkey } from "./refkey.js";
+import { OutputSymbolFlags } from "./symbols/flags.js";
 import { OutputScope } from "./symbols/output-scope.js";
-import {
-  OutputSymbolFlags,
-  type OutputSymbol,
-} from "./symbols/output-symbol.js";
+import { type OutputSymbol } from "./symbols/output-symbol.js";
 import {
   formatRefkeys,
   formatSymbol,
diff --git a/packages/core/src/code.ts b/packages/core/src/code.ts
index 8f45805..b8ea63f 100644
--- a/packages/core/src/code.ts
+++ b/packages/core/src/code.ts
@@ -2,7 +2,7 @@
 // it should be possible to share logic between this and the babel transform, but
 // this is an exercise for the future.
 import { hbr, indent } from "./components/stc/sti.js";
-import { Child, Children } from "./jsx-runtime.js";
+import type { Child, Children } from "./runtime/component.js";
 
 export function text(
   template: TemplateStringsArray,
diff --git a/packages/core/src/components/Block.tsx b/packages/core/src/components/Block.tsx
index 1b70125..d5bfd5f 100644
--- a/packages/core/src/components/Block.tsx
+++ b/packages/core/src/components/Block.tsx
@@ -1,5 +1,5 @@
 import { computed } from "@vue/reactivity";
-import { Children } from "../jsx-runtime.js";
+import type { Children } from "../runtime/component.js";
 import { childrenArray } from "../utils.jsx";
 import { Indent } from "./Indent.jsx";
 
diff --git a/packages/core/src/components/Declaration.tsx b/packages/core/src/components/Declaration.tsx
index 19552c1..12f4b24 100644
--- a/packages/core/src/components/Declaration.tsx
+++ b/packages/core/src/components/Declaration.tsx
@@ -1,8 +1,9 @@
 import { useContext } from "../context.js";
 import { BinderContext } from "../context/binder.js";
 import { DeclarationContext } from "../context/declaration.js";
-import { Children, onCleanup } from "../jsx-runtime.js";
+import { onCleanup } from "../reactivity.js";
 import { Refkey } from "../refkey.js";
+import type { Children } from "../runtime/component.js";
 import { OutputSymbol } from "../symbols/output-symbol.js";
 
 /**
diff --git a/packages/core/src/components/For.tsx b/packages/core/src/components/For.tsx
index 4cc861b..920f4f9 100644
--- a/packages/core/src/components/For.tsx
+++ b/packages/core/src/components/For.tsx
@@ -1,5 +1,6 @@
 import { isRef, Ref } from "@vue/reactivity";
-import { type Children, memo } from "../jsx-runtime.js";
+import { memo } from "../reactivity.js";
+import type { Children } from "../runtime/component.js";
 import { baseListPropsToMapJoinArgs, mapJoin } from "../utils.js";
 import { BaseListProps } from "./List.jsx";
 
diff --git a/packages/core/src/components/Indent.tsx b/packages/core/src/components/Indent.tsx
index 85c8af2..7103402 100644
--- a/packages/core/src/components/Indent.tsx
+++ b/packages/core/src/components/Indent.tsx
@@ -1,4 +1,4 @@
-import type { Children } from "../jsx-runtime.js";
+import type { Children } from "../runtime/component.js";
 
 export interface IndentProps {
   children: Children;
diff --git a/packages/core/src/components/List.tsx b/packages/core/src/components/List.tsx
index d0cd0ef..727a2ab 100644
--- a/packages/core/src/components/List.tsx
+++ b/packages/core/src/components/List.tsx
@@ -1,4 +1,6 @@
-import { Children, memo, splitProps } from "../jsx-runtime.js";
+import { splitProps } from "../props-combinators.js";
+import { memo } from "../reactivity.js";
+import type { Children } from "../runtime/component.js";
 import { childrenArray } from "../utils.js";
 import { For } from "./For.jsx";
 
diff --git a/packages/core/src/components/MemberDeclaration.tsx b/packages/core/src/components/MemberDeclaration.tsx
index 2a2e1dd..31ac72f 100644
--- a/packages/core/src/components/MemberDeclaration.tsx
+++ b/packages/core/src/components/MemberDeclaration.tsx
@@ -1,9 +1,10 @@
 import { useContext } from "../context.js";
 import { BinderContext } from "../context/binder.js";
 import { MemberDeclarationContext } from "../context/member-declaration.js";
-import { Children } from "../jsx-runtime.js";
-import { Refkey } from "../refkey.js";
-import { OutputSymbol, OutputSymbolFlags } from "../symbols/output-symbol.js";
+import type { Refkey } from "../refkey.js";
+import type { Children } from "../runtime/component.js";
+import { OutputSymbolFlags } from "../symbols/flags.js";
+import { OutputSymbol } from "../symbols/output-symbol.js";
 
 /**
  * Create a member declaration by providing a symbol name and optional symbol
diff --git a/packages/core/src/components/MemberScope.tsx b/packages/core/src/components/MemberScope.tsx
index 2f988ae..2fe1fe1 100644
--- a/packages/core/src/components/MemberScope.tsx
+++ b/packages/core/src/components/MemberScope.tsx
@@ -1,6 +1,6 @@
 import { MemberScopeContext } from "../context/member-scope.js";
-import { Children } from "../jsx-runtime.js";
-import { OutputSymbol } from "../symbols/output-symbol.js";
+import type { Children } from "../runtime/component.js";
+import type { OutputSymbol } from "../symbols/output-symbol.js";
 export interface MemberScopeProps {
   /**
    * The name of the member scope.
diff --git a/packages/core/src/components/Output.tsx b/packages/core/src/components/Output.tsx
index 36f5927..7c4c90f 100644
--- a/packages/core/src/components/Output.tsx
+++ b/packages/core/src/components/Output.tsx
@@ -5,9 +5,10 @@ import {
 } from "../binder.js";
 import { BinderContext } from "../context/binder.js";
 import { NamePolicyContext } from "../context/name-policy.js";
-import { Children, getContext } from "../jsx-runtime.js";
 import { NamePolicy } from "../name-policy.js";
+import { getContext } from "../reactivity.js";
 import { PrintTreeOptions } from "../render.js";
+import type { Children } from "../runtime/component.js";
 import { extensionEffects } from "../slot.js";
 import { SourceDirectory } from "./SourceDirectory.js";
 
diff --git a/packages/core/src/components/Prose.tsx b/packages/core/src/components/Prose.tsx
index 7b2578c..d0a5498 100644
--- a/packages/core/src/components/Prose.tsx
+++ b/packages/core/src/components/Prose.tsx
@@ -1,5 +1,5 @@
 import { childrenArray, computed } from "@alloy-js/core";
-import type { Children } from "../jsx-runtime.js";
+import type { Children } from "../runtime/component.js";
 
 export interface Prose {
   children: Children;
diff --git a/packages/core/src/components/ReferenceOrContent.tsx b/packages/core/src/components/ReferenceOrContent.tsx
index 4ab2266..e39ea6d 100644
--- a/packages/core/src/components/ReferenceOrContent.tsx
+++ b/packages/core/src/components/ReferenceOrContent.tsx
@@ -1,8 +1,8 @@
 import { computed } from "@vue/reactivity";
 import { useContext } from "../context.js";
 import { BinderContext } from "../context/binder.js";
-import type { Children } from "../jsx-runtime.js";
 import type { Refkey } from "../refkey.js";
+import type { Children } from "../runtime/component.js";
 
 export interface ReferenceOrContentProps {
   readonly refkey: Refkey;
diff --git a/packages/core/src/components/Scope.tsx b/packages/core/src/components/Scope.tsx
index 0332c04..3c26c77 100644
--- a/packages/core/src/components/Scope.tsx
+++ b/packages/core/src/components/Scope.tsx
@@ -1,5 +1,5 @@
 import { ScopeContext } from "../context/scope.js";
-import { Children } from "../jsx-runtime.js";
+import type { Children } from "../runtime/component.js";
 import { OutputScope } from "../symbols/output-scope.js";
 
 /**
diff --git a/packages/core/src/components/Show.tsx b/packages/core/src/components/Show.tsx
index 32fda63..9b645cf 100644
--- a/packages/core/src/components/Show.tsx
+++ b/packages/core/src/components/Show.tsx
@@ -1,4 +1,4 @@
-import type { Children } from "../jsx-runtime.js";
+import type { Children } from "../runtime/component.js";
 
 export interface ShowProps {
   children: Children;
diff --git a/packages/core/src/components/SourceDirectory.tsx b/packages/core/src/components/SourceDirectory.tsx
index bcaf7a7..1209f34 100644
--- a/packages/core/src/components/SourceDirectory.tsx
+++ b/packages/core/src/components/SourceDirectory.tsx
@@ -2,7 +2,8 @@ import { shallowReactive } from "@vue/reactivity";
 import { join } from "pathe";
 import { useContext } from "../context.js";
 import { SourceDirectoryContext } from "../context/source-directory.js";
-import { Children, getContext } from "../jsx-runtime.js";
+import { getContext } from "../reactivity.js";
+import type { Children } from "../runtime/component.js";
 
 export interface SourceDirectoryProps {
   path: string;
diff --git a/packages/core/src/components/SourceFile.tsx b/packages/core/src/components/SourceFile.tsx
index 76724d0..137463e 100644
--- a/packages/core/src/components/SourceFile.tsx
+++ b/packages/core/src/components/SourceFile.tsx
@@ -2,9 +2,10 @@ import { join } from "pathe";
 import { useContext } from "../context.js";
 import { SourceDirectoryContext } from "../context/source-directory.js";
 import { SourceFileContext } from "../context/source-file.js";
-import { Children, ComponentDefinition, getContext } from "../jsx-runtime.js";
+import { getContext } from "../reactivity.js";
 import { Refkey } from "../refkey.js";
 import { PrintTreeOptions } from "../render.js";
+import type { Children, ComponentDefinition } from "../runtime/component.js";
 import { Show } from "./Show.jsx";
 
 export interface SourceFileProps extends PrintTreeOptions {
diff --git a/packages/core/src/components/StatementList.tsx b/packages/core/src/components/StatementList.tsx
index 240138d..62620ff 100644
--- a/packages/core/src/components/StatementList.tsx
+++ b/packages/core/src/components/StatementList.tsx
@@ -1,4 +1,4 @@
-import type { Children } from "../jsx-runtime.js";
+import type { Children } from "../runtime/component.js";
 import { List } from "./List.jsx";
 
 export interface StatementListProps {
diff --git a/packages/core/src/components/Switch.tsx b/packages/core/src/components/Switch.tsx
index 9e90b31..eed85a1 100644
--- a/packages/core/src/components/Switch.tsx
+++ b/packages/core/src/components/Switch.tsx
@@ -1,4 +1,5 @@
-import { type Children, memo, taggedComponent } from "../jsx-runtime.js";
+import { memo } from "../reactivity.js";
+import { type Children, taggedComponent } from "../runtime/component.js";
 import { childrenArray, findKeyedChildren } from "../utils.js";
 
 export interface SwitchProps {
diff --git a/packages/core/src/components/Wrap.tsx b/packages/core/src/components/Wrap.tsx
index 1d5c4d8..099fb7e 100644
--- a/packages/core/src/components/Wrap.tsx
+++ b/packages/core/src/components/Wrap.tsx
@@ -1,4 +1,4 @@
-import { Children, ComponentDefinition } from "../jsx-runtime.js";
+import type { Children, ComponentDefinition } from "../runtime/component.js";
 
 export interface WrapProps<TProps> {
   /**
diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts
index 4fe71ca..06a4ccf 100644
--- a/packages/core/src/context.ts
+++ b/packages/core/src/context.ts
@@ -1,11 +1,7 @@
 import { shallowRef } from "@vue/reactivity";
-import {
-  Children,
-  ComponentDefinition,
-  effect,
-  getContext,
-} from "./jsx-runtime.js";
-import { stc, StcSignature } from "./stc.js";
+import { effect, getContext } from "./reactivity.js";
+import type { Children, ComponentDefinition } from "./runtime/component.js";
+import { stc, type StcSignature } from "./stc.js";
 
 export interface ComponentContext<T> {
   id: symbol;
diff --git a/packages/core/src/context/binder.ts b/packages/core/src/context/binder.ts
index f43ed46..a94bdaf 100644
--- a/packages/core/src/context/binder.ts
+++ b/packages/core/src/context/binder.ts
@@ -1,6 +1,6 @@
 import type { Binder } from "../binder.js";
 import {
-  ComponentContext,
+  type ComponentContext,
   createNamedContext,
   useContext,
 } from "../context.js";
diff --git a/packages/core/src/context/source-file.ts b/packages/core/src/context/source-file.ts
index 4269824..4537318 100644
--- a/packages/core/src/context/source-file.ts
+++ b/packages/core/src/context/source-file.ts
@@ -1,6 +1,6 @@
 import { ComponentContext, createNamedContext } from "../context.js";
-import type { ComponentDefinition } from "../jsx-runtime.js";
 import type { Refkey } from "../refkey.js";
+import { ComponentDefinition } from "../runtime/component.js";
 
 export interface SourceFileContext {
   path: string;
diff --git a/packages/core/src/debug.ts b/packages/core/src/debug.ts
index 8602a85..8741f3a 100644
--- a/packages/core/src/debug.ts
+++ b/packages/core/src/debug.ts
@@ -2,7 +2,7 @@ import { isReactive } from "@vue/reactivity";
 import Table from "cli-table3";
 import pc from "picocolors";
 import { contextsByKey } from "./context.js";
-import { Context, getContext } from "./jsx-runtime.js";
+import { Context, getContext } from "./reactivity.js";
 
 interface DebugInterface {
   component: {
@@ -206,3 +206,7 @@ function kvTable(sep = "  ") {
     style: { "padding-left": 0, "padding-right": 0 },
   });
 }
+
+export function shouldDebug() {
+  return typeof process !== "undefined" && !!process.env?.ALLOY_DEBUG;
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 220212b..c801e87 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -27,11 +27,14 @@ export * from "./code.js";
 export * from "./components/index.js";
 export * from "./context.js";
 export * from "./context/index.js";
-export * from "./jsx-runtime.js";
 export * from "./name-policy.js";
+export * from "./props-combinators.js";
 export * from "./reactive-union-set.js";
+export * from "./reactivity.js";
 export * from "./refkey.js";
 export * from "./render.js";
+export * from "./runtime/component.js";
+export * from "./runtime/intrinsic.js";
 export * from "./stc.js";
 export * from "./sti.js";
 export * from "./symbols/index.js";
diff --git a/packages/core/src/jsx-runtime.ts b/packages/core/src/jsx-runtime.ts
index cd73dcb..f718b5b 100644
--- a/packages/core/src/jsx-runtime.ts
+++ b/packages/core/src/jsx-runtime.ts
@@ -1,325 +1,19 @@
 // Much of the implementations in this file are inspired by vuerx-js
 // See: https://github.com/ryansolid/vuerx-jsx.
-import {
-  computed,
-  isReactive,
-  pauseTracking,
-  proxyRefs,
-  ReactiveEffectRunner,
-  Ref,
-  resetTracking,
-  ShallowReactive,
-  shallowRef,
-  stop,
-  toRefs,
-  effect as vueEffect,
-} from "@vue/reactivity";
-import type { Refkey } from "./refkey.js";
-import type { RenderedTextTree } from "./render.js";
-import { scheduler } from "./scheduler.js";
-import type { OutputSymbol } from "./symbols/output-symbol.js";
-
-if ((globalThis as any).ALLOY) {
-  throw new Error(
-    "Multiple versions of the JSX Runtime have been loaded. This will likely cause undesirable behavior.",
-  );
-}
-(globalThis as any).ALLOY = true;
-
-export interface Disposable {
-  (): void;
-}
-
-export interface Context {
-  disposables: Disposable[];
-  owner: Context | null;
-
-  // context providers
-  context?: Record<symbol, unknown>;
-
-  // store random info about the node
-  meta?: Record<string, any>;
-
-  /**
-   * A cache of RenderTextTree nodes created within this context,
-   * indexed by the component or function which created them.
-   */
-  elementCache: ElementCache;
-  /**
-   * When this context was created by a component, this will
-   * be the component that created it.
-   */
-  componentOwner?: ComponentCreator<unknown>;
-
-  /**
-   * Whether this context will take an emitted symbol.
-   */
-  takesSymbols: boolean;
-
-  /**
-   * The symbol that this component has taken.
-   */
-  takenSymbols?: ShallowReactive<Set<OutputSymbol>>;
-}
-
-let globalContext: Context | null = null;
-export function getContext() {
-  return globalContext;
-}
-
-export function getElementCache() {
-  return getContext()!.elementCache;
-}
-
-export type ElementCacheKey =
-  | ComponentCreator
-  | (() => unknown)
-  | CustomContext;
-export type ElementCache = Map<ElementCacheKey, RenderedTextTree>;
-
-export interface RootOptions {
-  componentOwner?: ComponentCreator<any>;
-}
-
-export function root<T>(fn: (d: Disposable) => T, options?: RootOptions): T {
-  const context: Context = {
-    componentOwner: options?.componentOwner,
-    disposables: [],
-    owner: globalContext,
-    context: {},
-    elementCache: new Map(),
-    takesSymbols: false,
-    takenSymbols: undefined,
-  };
-
-  globalContext = context;
-  let ret;
-  try {
-    ret = untrack(() =>
-      fn(() => {
-        for (const d of context!.disposables) {
-          d();
-        }
-      }),
-    );
-  } finally {
-    globalContext = globalContext!.owner;
-  }
-
-  return ret;
-}
-
-export function untrack<T>(fn: () => T): T {
-  pauseTracking();
-  const v = fn();
-  resetTracking();
-  return v;
-}
-
-export function memo<T>(fn: () => T, equal?: boolean): () => T {
-  const o = shallowRef();
-  effect((prev) => {
-    const res = fn();
-    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
-    (!equal || prev !== res) && (o.value = res);
-    return res;
-  }, undefined as T);
-  return () => o.value;
-}
-
-export function effect<T>(fn: (prev?: T) => T, current?: T) {
-  const context: Context = {
-    context: {},
-    disposables: [] as (() => void)[],
-    owner: globalContext,
-    elementCache: new Map(),
-    takesSymbols: false,
-    takenSymbols: undefined,
-  };
-
-  const cleanupFn = (final: boolean) => {
-    const d = context.disposables;
-    context.disposables = [];
-    for (let k = 0, len = d.length; k < len; k++) d[k]();
-
-    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
-    final && stop(runner);
-  };
-
-  onCleanup(() => cleanupFn(true));
-  const runner: ReactiveEffectRunner<void> = vueEffect(
-    () => {
-      cleanupFn(false);
-
-      const oldContext = globalContext;
-      globalContext = context;
-      try {
-        current = fn(current);
-      } finally {
-        globalContext = oldContext;
-      }
-    },
-    {
-      scheduler: scheduler(() => runner),
-    },
-  );
-
-  // allow recursive effects (recursive option does nothing, possible bug)
-  (runner as any).effect.flags |= 1 << 5;
-}
-
-/**
- * Register a cleanup function which is called when the current reactive scope
- * is recalculated or disposed. This is useful to clean up any side effects
- * created in the reactive scope.
- *
- * @remarks
- *
- * When onCleanup is called inside a component definition, the provided function
- * is called when the component is removed from the tree. This can be useful to
- * clean up any side effects created as a result of rendering the component. For
- * example, if rendering a component creates a symbol, `onCleanup` can be used
- * to remove the symbol when the component is removed from the tree.
- *
- * When onCleanup is called inside a memo or effect, the function is called when
- * the effect is refreshed (e.g. when a memo or computed recalculates) or
- * disposed (e.g. it is no longer needed because it is attached to a component
- * which was removed).
- */
-export function onCleanup(fn: Disposable) {
-  if (globalContext != null) {
-    globalContext.disposables.push(fn);
-  }
-}
-
-/**
- * Create a custom reactive context for the children returned by
- * the provided context.
- */
-export interface CustomContext {
-  [CUSTOM_CONTEXT_SYM]: true;
-  useCustomContext: (useCb: CustomContextChildrenCallback) => void;
-}
-
-export type CustomContextChildrenCallback = (child: Children) => void;
-const CUSTOM_CONTEXT_SYM = Symbol();
-
-export function createCustomContext(
-  useCallback: (useChildren: CustomContextChildrenCallback) => void,
-): CustomContext {
-  return {
-    [CUSTOM_CONTEXT_SYM]: true,
-    useCustomContext(useCb: (children: Children) => void): void {
-      useCallback(useCb);
-    },
-  };
-}
-
-export function isCustomContext(child: Children): child is CustomContext {
-  return (
-    typeof child === "object" &&
-    child !== null &&
-    Object.hasOwn(child, CUSTOM_CONTEXT_SYM)
-  );
-}
-
-export type Child =
-  | string
-  | boolean
-  | number
-  | undefined
-  | null
-  | void
-  | (() => Children)
-  | Ref
-  | Refkey
-  | CustomContext
-  | IntrinsicElement;
-
-export type Children = Child | Children[];
-export type Props = Record<string, any>;
-
-export interface ComponentDefinition<TProps = Props> {
-  (props: TProps): Children;
-}
-export interface Component<TProps = Props> {
-  (props: TProps): Children;
-  tag?: symbol;
-}
-
-export interface ComponentCreator<TProps = Props> {
-  component: Component<TProps>;
-  (): Children;
-  props: TProps;
-  tag?: symbol;
-}
-
-// debugging utilities
-const renderStack: {
-  component: Component<any>;
-  props: Props;
-}[] = [];
-
-export function pushStack(component: Component<any>, props: Props) {
-  if (!shouldDebug()) return;
-  renderStack.push({ component, props });
-}
-
-export function popStack() {
-  if (!shouldDebug()) return;
-  renderStack.pop();
-}
-
-export function printRenderStack() {
-  if (!shouldDebug()) return;
-
-  // eslint-disable-next-line no-console
-  console.error("Error rendering:");
-  for (let i = renderStack.length - 1; i >= 0; i--) {
-    const { component, props } = renderStack[i];
-    // eslint-disable-next-line no-console
-    console.error(`    at ${component.name}(${inspectProps(props)})`);
-  }
-}
-
-function inspectProps(props: Props) {
-  return JSON.stringify(
-    Object.fromEntries(
-      Object.entries(props).map(([key, value]) => {
-        let safeValue;
-        switch (typeof value) {
-          case "string":
-          case "number":
-          case "boolean":
-            safeValue = value;
-            break;
-          case "undefined":
-            safeValue = "undefined";
-            break;
-          case "object":
-            safeValue = value ? "{...}" : null;
-            break;
-          case "function":
-            safeValue = "function";
-            break;
-        }
-        return [key, safeValue];
-      }),
-    ),
-  );
-}
-
-// These can be removed with a smarter transform that encodes the information we
-// need in the compiled JSX output.
-export function isComponentCreator<TProps = any>(
-  item: unknown,
-  component?: Component<TProps>,
-): item is ComponentCreator<TProps> {
-  if (!component) {
-    return typeof item === "function" && (item as any).component;
-  }
-  return typeof item === "function" && (item as any).component === component;
-}
+import type {
+  Children,
+  Component,
+  ComponentDefinition,
+  IntrinsicElements as CoreIntrinsicElements,
+} from "@alloy-js/core";
+import { createComponent } from "@alloy-js/core";
+export {
+  createComponent,
+  createIntrinsic,
+  memo,
+  mergeProps,
+  type Children,
+} from "@alloy-js/core";
 
 /**
  * This namespace is predominantly for interop with React tooling in VSCode
@@ -327,134 +21,7 @@ export function isComponentCreator<TProps = any>(
  */
 // eslint-disable-next-line @typescript-eslint/no-namespace
 export namespace JSX {
-  export interface IntrinsicElements {
-    /**
-     * Attempt to render the children on a single line if possible. If a group
-     * contains `<breakParent />` or a hard line, or if the group exceeds the
-     * print width, all linebreaks in the group will be broken.
-     */
-    group: { shouldBreak?: boolean; id?: symbol; children: Children };
-
-    /**
-     * A regular line break. This will break if the line exceeds the print
-     * width, otherwise it will be a space.
-     */
-    line: {};
-
-    /**
-     * A regular line break. This will break if the line exceeds the print
-     * width, otherwise it will be a space.
-     */
-    br: {};
-
-    /**
-     * A hard line break. This is a line that will always break, even if the
-     * group does not exceed print width.
-     */
-    hardline: {};
-
-    /**
-     * A hard line break. This is a line that will always break, even if the
-     * group does not exceed print width.
-     */
-    hbr: {};
-
-    /**
-     * A soft line break. This will break if the line exceeds the print width,
-     * otherwise it will be be nothing.
-     */
-    softline: {};
-
-    /**
-     * A soft line break. This will break if the line exceeds the print width,
-     * otherwise it will be be nothing.
-     */
-    sbr: {};
-
-    /**
-     * A literal line break. This will always break, even if the group does not
-     * exceed print width. The new line will ignore indentation.
-     */
-    literalline: {};
-
-    /**
-     * A literal line break. This will always break, even if the group does not
-     * exceed print width. The new line will ignore indentation.
-     */
-    lbr: {};
-
-    /**
-     * Increase the indentation level of the children of this component.
-     * Indentation is determined by the print options provided to the Output
-     * component or source file.
-     */
-    indent: { children: Children };
-
-    /**
-     * Indent the children of this component if the group specified by `groupId`
-     * is broken (or not broken if `negate` is passed). The specified group must
-     * already be printed.
-     */
-    indentIfBreak: { children: Children; groupId: symbol; negate?: boolean };
-
-    /**
-     * Similar to `group`, but will only place a line break before the last
-     * segment to exceed the print width. This is useful for formatting
-     * paragraphs of text where breaks are inserted prior to words which would
-     * otherwise exceed the print width.
-     */
-    fill: { children: Children };
-
-    /**
-     * Force the parent group to break.
-     */
-    breakParent: {};
-
-    /**
-     * Print children if the current group or already printed group specified by
-     * `groupId` is broken. Otherwise, `flatContents` is printed instead.
-     */
-    ifBreak: { children: Children; flatContents?: Children; groupId?: symbol };
-
-    /**
-     * Print this content at the end of the line. Useful for things like line
-     * comments.
-     */
-    lineSuffix: { children: Children };
-
-    /**
-     * Force any line suffixes to print at this point.
-     */
-    lineSuffixBoundary: {};
-
-    /**
-     * Decrease the indentation level of the children of this component.
-     * Indentation is determined by the print options provided to the Output
-     * component or source file.
-     */
-    dedent: { children: Children };
-
-    /**
-     * Indent the children of this component by either the number of characters
-     * indicated by the `width` prop, or by the provided string indicated by the
-     * `string` prop.
-     */
-    align:
-      | { children: Children; width: number }
-      | { children: Children; string: string };
-
-    /**
-     * Mark the current indentation level as "root" for the purposes of literal
-     * line breaks and `dedentToRoot`.
-     */
-    markAsRoot: { children: Children };
-
-    /**
-     * Decrease the indentation level to the root level specified by
-     * `<markAsRoot />`, or else to no indentation.
-     */
-    dedentToRoot: { children: Children };
-  }
+  export type IntrinsicElements = CoreIntrinsicElements;
   export type ElementType = string | ComponentDefinition<any>;
   export type Element = Children;
   export interface ElementChildrenAttribute {
@@ -470,245 +37,3 @@ export function jsx(type: Component<any>, props: Record<string, unknown>) {
 }
 
 export const jsxs = jsx;
-
-export function createComponent<TProps extends Props = Props>(
-  C: Component<TProps>,
-  props: TProps,
-): ComponentCreator<TProps> {
-  const creator: ComponentCreator<TProps> = () => /* */ C(props);
-  creator.props = props;
-  creator.component = C;
-  if (C.tag) {
-    creator.tag = C.tag;
-  }
-
-  return creator;
-}
-
-export function taggedComponent<TProps = Props>(
-  tag: symbol,
-  component: Component<TProps>,
-): Component<TProps> & Required<Pick<Component<TProps>, "tag">> {
-  component.tag = tag;
-  return component as any;
-}
-
-export const intrinsicElementKey = Symbol();
-
-export type IndentIntrinsicElement = IntrinsicElementBase<"indent">;
-export type IndentIfBreakIntrinsicElement =
-  IntrinsicElementBase<"indentIfBreak">;
-export type BrIntrinsicElement = IntrinsicElementBase<"br">;
-export type LineIntrinsicElement = IntrinsicElementBase<"line">;
-export type HbrIntrinsicElement = IntrinsicElementBase<"hbr">;
-export type HardlineIntrinsicElement = IntrinsicElementBase<"hardline">;
-export type SbrIntrinsicElement = IntrinsicElementBase<"sbr">;
-export type SoftlineIntrinsicElement = IntrinsicElementBase<"softline">;
-export type GroupIntrinsicElement = IntrinsicElementBase<"group">;
-export type AlignIntrinsicElement = IntrinsicElementBase<"align">;
-export type FillIntrinsicElement = IntrinsicElementBase<"fill">;
-export type BreakParentIntrinsicElement = IntrinsicElementBase<"breakParent">;
-export type LineSuffixIntrinsicElement = IntrinsicElementBase<"lineSuffix">;
-export type LineSuffixBoundaryIntrinsicElement =
-  IntrinsicElementBase<"lineSuffixBoundary">;
-export type DedentIntrinsicElement = IntrinsicElementBase<"dedent">;
-export type DedentToRootIntrinsicElement = IntrinsicElementBase<"dedentToRoot">;
-export type MarkAsRootIntrinsicElement = IntrinsicElementBase<"markAsRoot">;
-export type LiterallineIntrinsicElement = IntrinsicElementBase<"literalline">;
-export type LbrIntrinsicElement = IntrinsicElementBase<"lbr">;
-export type IfBreakIntrinsicElement = IntrinsicElementBase<"ifBreak">;
-
-export type IntrinsicElement =
-  | IndentIntrinsicElement
-  | IndentIfBreakIntrinsicElement
-  | BrIntrinsicElement
-  | LineIntrinsicElement
-  | HbrIntrinsicElement
-  | HardlineIntrinsicElement
-  | SbrIntrinsicElement
-  | SoftlineIntrinsicElement
-  | GroupIntrinsicElement
-  | AlignIntrinsicElement
-  | FillIntrinsicElement
-  | BreakParentIntrinsicElement
-  | LineSuffixIntrinsicElement
-  | LineSuffixBoundaryIntrinsicElement
-  | DedentIntrinsicElement
-  | LiterallineIntrinsicElement
-  | LbrIntrinsicElement
-  | DedentToRootIntrinsicElement
-  | MarkAsRootIntrinsicElement
-  | IfBreakIntrinsicElement;
-
-export interface IntrinsicElementBase<
-  TKey extends keyof JSX.IntrinsicElements = keyof JSX.IntrinsicElements,
-> {
-  [intrinsicElementKey]: true;
-  name: TKey;
-  props: JSX.IntrinsicElements[TKey];
-}
-export function createIntrinsic<TKey extends keyof JSX.IntrinsicElements>(
-  name: TKey,
-  props: JSX.IntrinsicElements[TKey],
-): IntrinsicElementBase<TKey> {
-  return {
-    [intrinsicElementKey]: true,
-    name,
-    props,
-  };
-}
-
-export function isIntrinsicElement(type: unknown): type is IntrinsicElement {
-  return (
-    typeof type === "object" && type !== null && intrinsicElementKey in type
-  );
-}
-export function mergeProps<T, U>(source: T, source1: U): T & U;
-export function mergeProps<T, U, V>(
-  source: T,
-  source1: U,
-  source2: V,
-): T & U & V;
-export function mergeProps<T, U, V, W>(
-  source: T,
-  source1: U,
-  source2: V,
-  source3: W,
-): T & U & V & W;
-export function mergeProps(...sources: any): any {
-  const target = {};
-  for (let i = 0; i < sources.length; i++) {
-    let source = sources[i];
-    if (typeof source === "function") source = source();
-    if (source) {
-      const descriptors = Object.getOwnPropertyDescriptors(source);
-      for (const key in descriptors) {
-        if (key in target) continue;
-        Object.defineProperty(target, key, {
-          enumerable: true,
-          get() {
-            for (let i = sources.length - 1; i >= 0; i--) {
-              let s = sources[i];
-              if (typeof s === "function") s = s();
-              const v = (s || {})[key];
-              if (v !== undefined) return v;
-            }
-          },
-        });
-      }
-    }
-  }
-  return target;
-}
-
-export type SplitProps<T, K extends (readonly (keyof T)[])[]> = [
-  ...{
-    [P in keyof K]: P extends `${number}` ?
-      Pick<T, Extract<K[P], readonly (keyof T)[]>[number]>
-    : never;
-  },
-  { [P in keyof T as Exclude<P, K[number][number]>]: T[P] },
-];
-
-export function splitProps<
-  T extends Record<any, any>,
-  K extends [readonly (keyof T)[], ...(readonly (keyof T)[])[]],
->(props: T, ...keys: K): SplitProps<T, K> {
-  if (isReactive(props)) {
-    const refs = untrack(() => toRefs(props));
-    const remainingKeys = new Set(Object.keys(refs));
-
-    const result: any = keys.map((keySet) => {
-      const resultSet: any = {};
-      for (const key of keySet) {
-        resultSet[key] = refs[key];
-        remainingKeys.delete(key as string);
-      }
-      return proxyRefs(resultSet);
-    });
-
-    const remaining: any = {};
-    for (const key of remainingKeys) {
-      remaining[key] = refs[key];
-    }
-
-    return [...result, proxyRefs(remaining)] as any;
-  }
-
-  const descriptors = Object.getOwnPropertyDescriptors(props);
-  const remainingKeys = new Set(Object.keys(descriptors));
-  const result: any = keys.map((keySet) => {
-    const resultSet: any = {};
-    for (const key of keySet) {
-      if (key in descriptors) {
-        Object.defineProperty(resultSet, key, descriptors[key]);
-        remainingKeys.delete(key as string);
-      }
-    }
-    return resultSet;
-  });
-
-  const remaining: any = {};
-  for (const key of remainingKeys) {
-    Object.defineProperty(remaining, key, descriptors[key]);
-  }
-
-  return [...result, remaining] as any;
-}
-
-/**
- * Applies default values to a props object. Reactive props are handled properly
- * by ensuring that their value is not accessed by `defaultProps`, avoiding any
- * unintended side effects.
- */
-export function defaultProps<T extends {}>(props: T, defaults: Partial<T>): T {
-  if (isReactive(props)) {
-    const refs = untrack(() => toRefs(props));
-    for (const key in defaults) {
-      const originalRef = refs[key];
-      refs[key] = computed(() =>
-        originalRef.value === undefined ? defaults[key] : originalRef.value,
-      ) as any;
-    }
-
-    return proxyRefs(refs);
-  }
-  const withDefaults = {} as T;
-  const copied = new Set<string>();
-  for (const key in defaults) {
-    copied.add(key);
-    const desc = Object.getOwnPropertyDescriptor(props, key);
-    if (!desc) {
-      withDefaults[key] = defaults[key]!;
-      continue;
-    }
-
-    if (desc.get) {
-      const originalGet = desc.get;
-      desc.get = function () {
-        const value = originalGet.call(this);
-        return value === undefined ? defaults[key as keyof T] : value;
-      };
-      Object.defineProperty(withDefaults, key, desc);
-    } else {
-      desc.value =
-        desc.value === undefined ? defaults[key as keyof T] : desc.value;
-      Object.defineProperty(withDefaults, key, desc);
-    }
-  }
-
-  const descriptors = Object.getOwnPropertyDescriptors(props);
-  for (const key in descriptors) {
-    if (copied.has(key)) {
-      continue;
-    }
-
-    Object.defineProperty(withDefaults, key, descriptors[key]);
-  }
-
-  return withDefaults;
-}
-
-function shouldDebug() {
-  return typeof process !== "undefined" && !!process.env?.ALLOY_DEBUG;
-}
diff --git a/packages/core/src/props-combinators.ts b/packages/core/src/props-combinators.ts
new file mode 100644
index 0000000..a2fd00f
--- /dev/null
+++ b/packages/core/src/props-combinators.ts
@@ -0,0 +1,148 @@
+import { computed, isReactive, proxyRefs, toRefs } from "@vue/reactivity";
+import { untrack } from "./reactivity.js";
+
+export function mergeProps<T, U>(source: T, source1: U): T & U;
+export function mergeProps<T, U, V>(
+  source: T,
+  source1: U,
+  source2: V,
+): T & U & V;
+export function mergeProps<T, U, V, W>(
+  source: T,
+  source1: U,
+  source2: V,
+  source3: W,
+): T & U & V & W;
+export function mergeProps(...sources: any): any {
+  const target = {};
+  for (let i = 0; i < sources.length; i++) {
+    let source = sources[i];
+    if (typeof source === "function") source = source();
+    if (source) {
+      const descriptors = Object.getOwnPropertyDescriptors(source);
+      for (const key in descriptors) {
+        if (key in target) continue;
+        Object.defineProperty(target, key, {
+          enumerable: true,
+          get() {
+            for (let i = sources.length - 1; i >= 0; i--) {
+              let s = sources[i];
+              if (typeof s === "function") s = s();
+              const v = (s || {})[key];
+              if (v !== undefined) return v;
+            }
+          },
+        });
+      }
+    }
+  }
+  return target;
+}
+
+export type SplitProps<T, K extends (readonly (keyof T)[])[]> = [
+  ...{
+    [P in keyof K]: P extends `${number}` ?
+      Pick<T, Extract<K[P], readonly (keyof T)[]>[number]>
+    : never;
+  },
+  { [P in keyof T as Exclude<P, K[number][number]>]: T[P] },
+];
+
+export function splitProps<
+  T extends Record<any, any>,
+  K extends [readonly (keyof T)[], ...(readonly (keyof T)[])[]],
+>(props: T, ...keys: K): SplitProps<T, K> {
+  if (isReactive(props)) {
+    const refs = untrack(() => toRefs(props));
+    const remainingKeys = new Set(Object.keys(refs));
+
+    const result: any = keys.map((keySet) => {
+      const resultSet: any = {};
+      for (const key of keySet) {
+        resultSet[key] = refs[key];
+        remainingKeys.delete(key as string);
+      }
+      return proxyRefs(resultSet);
+    });
+
+    const remaining: any = {};
+    for (const key of remainingKeys) {
+      remaining[key] = refs[key];
+    }
+
+    return [...result, proxyRefs(remaining)] as any;
+  }
+
+  const descriptors = Object.getOwnPropertyDescriptors(props);
+  const remainingKeys = new Set(Object.keys(descriptors));
+  const result: any = keys.map((keySet) => {
+    const resultSet: any = {};
+    for (const key of keySet) {
+      if (key in descriptors) {
+        Object.defineProperty(resultSet, key, descriptors[key]);
+        remainingKeys.delete(key as string);
+      }
+    }
+    return resultSet;
+  });
+
+  const remaining: any = {};
+  for (const key of remainingKeys) {
+    Object.defineProperty(remaining, key, descriptors[key]);
+  }
+
+  return [...result, remaining] as any;
+}
+
+/**
+ * Applies default values to a props object. Reactive props are handled properly
+ * by ensuring that their value is not accessed by `defaultProps`, avoiding any
+ * unintended side effects.
+ */
+export function defaultProps<T extends {}>(props: T, defaults: Partial<T>): T {
+  if (isReactive(props)) {
+    const refs = untrack(() => toRefs(props));
+    for (const key in defaults) {
+      const originalRef = refs[key];
+      refs[key] = computed(() =>
+        originalRef.value === undefined ? defaults[key] : originalRef.value,
+      ) as any;
+    }
+
+    return proxyRefs(refs);
+  }
+  const withDefaults = {} as T;
+  const copied = new Set<string>();
+  for (const key in defaults) {
+    copied.add(key);
+    const desc = Object.getOwnPropertyDescriptor(props, key);
+    if (!desc) {
+      withDefaults[key] = defaults[key]!;
+      continue;
+    }
+
+    if (desc.get) {
+      const originalGet = desc.get;
+      desc.get = function () {
+        const value = originalGet.call(this);
+        return value === undefined ? defaults[key as keyof T] : value;
+      };
+      Object.defineProperty(withDefaults, key, desc);
+    } else {
+      desc.value =
+        desc.value === undefined ? defaults[key as keyof T] : desc.value;
+      Object.defineProperty(withDefaults, key, desc);
+    }
+  }
+
+  const descriptors = Object.getOwnPropertyDescriptors(props);
+  for (const key in descriptors) {
+    if (copied.has(key)) {
+      continue;
+    }
+
+    Object.defineProperty(withDefaults, key, descriptors[key]);
+  }
+
+  return withDefaults;
+}
diff --git a/packages/core/src/reactive-union-set.ts b/packages/core/src/reactive-union-set.ts
index 87364e7..bbd0613 100644
--- a/packages/core/src/reactive-union-set.ts
+++ b/packages/core/src/reactive-union-set.ts
@@ -1,4 +1,3 @@
-import { effect } from "@alloy-js/core/jsx-runtime";
 import {
   ITERATE_KEY,
   ReactiveFlags,
@@ -9,6 +8,7 @@ import {
   trigger,
   TriggerOpTypes,
 } from "@vue/reactivity";
+import { effect } from "./reactivity.js";
 
 export interface ReactiveUnionSetOptions<T> {
   onAdd?: OnReactiveSetAddCallback<T>;
diff --git a/packages/core/src/reactivity.ts b/packages/core/src/reactivity.ts
new file mode 100644
index 0000000..0b1f012
--- /dev/null
+++ b/packages/core/src/reactivity.ts
@@ -0,0 +1,230 @@
+import {
+  pauseTracking,
+  ReactiveEffectRunner,
+  resetTracking,
+  ShallowReactive,
+  shallowRef,
+  stop,
+  effect as vueEffect,
+} from "@vue/reactivity";
+import type { RenderedTextTree } from "./render.js";
+import type { Children, ComponentCreator } from "./runtime/component.js";
+import { scheduler } from "./scheduler.js";
+import type { OutputSymbol } from "./symbols/output-symbol.js";
+import { trace, TracePhase } from "./tracer.js";
+
+// check for multiple versions of alloy here.
+if ((globalThis as any).__ALLOY__) {
+  throw new Error(
+    "Multiple versions of Alloy are loaded for this project. This will likely cause undesirable behavior.",
+  );
+}
+(globalThis as any).__ALLOY__ = true;
+
+export function getElementCache() {
+  return getContext()!.elementCache;
+}
+
+export type ElementCacheKey =
+  | ComponentCreator
+  | (() => unknown)
+  | CustomContext;
+
+export type ElementCache = Map<ElementCacheKey, RenderedTextTree>;
+
+export interface Disposable {
+  (): void;
+}
+
+export interface Context {
+  disposables: Disposable[];
+  owner: Context | null;
+
+  // context providers
+  context?: Record<symbol, unknown>;
+
+  // store random info about the node
+  meta?: Record<string, any>;
+
+  /**
+   * A cache of RenderTextTree nodes created within this context,
+   * indexed by the component or function which created them.
+   */
+  elementCache: ElementCache;
+  /**
+   * When this context was created by a component, this will
+   * be the component that created it.
+   */
+  componentOwner?: ComponentCreator<unknown>;
+
+  /**
+   * Whether this context will take an emitted symbol.
+   */
+  takesSymbols: boolean;
+
+  /**
+   * The symbol that this component has taken.
+   */
+  takenSymbols?: ShallowReactive<Set<OutputSymbol>>;
+}
+
+let globalContext: Context | null = null;
+export function getContext() {
+  return globalContext;
+}
+
+export interface RootOptions {
+  componentOwner?: ComponentCreator<any>;
+}
+
+export function root<T>(fn: (d: Disposable) => T, options?: RootOptions): T {
+  const context: Context = {
+    componentOwner: options?.componentOwner,
+    disposables: [],
+    owner: globalContext,
+    context: {},
+    elementCache: new Map(),
+    takesSymbols: false,
+    takenSymbols: undefined,
+  };
+
+  globalContext = context;
+  let ret;
+  try {
+    ret = untrack(() =>
+      fn(() => {
+        for (const d of context!.disposables) {
+          d();
+        }
+      }),
+    );
+  } finally {
+    globalContext = globalContext!.owner;
+  }
+
+  return ret;
+}
+
+export function untrack<T>(fn: () => T): T {
+  pauseTracking();
+  const v = fn();
+  resetTracking();
+  return v;
+}
+
+export function memo<T>(fn: () => T, equal?: boolean): () => T {
+  const o = shallowRef();
+  effect((prev) => {
+    const res = fn();
+    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+    (!equal || prev !== res) && (o.value = res);
+    return res;
+  }, undefined as T);
+  return () => o.value;
+}
+
+export function effect<T>(fn: (prev?: T) => T, current?: T) {
+  const context: Context = {
+    context: {},
+    disposables: [] as (() => void)[],
+    owner: globalContext,
+    elementCache: new Map(),
+    takesSymbols: false,
+    takenSymbols: undefined,
+  };
+
+  const cleanupFn = (final: boolean) => {
+    const d = context.disposables;
+    context.disposables = [];
+    for (let k = 0, len = d.length; k < len; k++) d[k]();
+
+    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+    final && stop(runner);
+  };
+
+  onCleanup(() => cleanupFn(true));
+  const runner: ReactiveEffectRunner<void> = vueEffect(
+    () => {
+      cleanupFn(false);
+
+      const oldContext = globalContext;
+      globalContext = context;
+      try {
+        current = fn(current);
+      } finally {
+        globalContext = oldContext;
+      }
+    },
+    {
+      scheduler: scheduler(() => runner),
+      onTrack(event) {
+        trace(TracePhase.effect.track, () => {
+          return `tracking ${event.target}, ${event.key}`;
+        });
+      },
+      onTrigger(event) {
+        trace(TracePhase.effect.trigger, () => {
+          return `triggering ${event.target}, ${event.key}`;
+        });
+      },
+    },
+  );
+
+  // allow recursive effects (recursive option does nothing, possible bug)
+  (runner as any).effect.flags |= 1 << 5;
+}
+
+/**
+ * Register a cleanup function which is called when the current reactive scope
+ * is recalculated or disposed. This is useful to clean up any side effects
+ * created in the reactive scope.
+ *
+ * @remarks
+ *
+ * When onCleanup is called inside a component definition, the provided function
+ * is called when the component is removed from the tree. This can be useful to
+ * clean up any side effects created as a result of rendering the component. For
+ * example, if rendering a component creates a symbol, `onCleanup` can be used
+ * to remove the symbol when the component is removed from the tree.
+ *
+ * When onCleanup is called inside a memo or effect, the function is called when
+ * the effect is refreshed (e.g. when a memo or computed recalculates) or
+ * disposed (e.g. it is no longer needed because it is attached to a component
+ * which was removed).
+ */
+export function onCleanup(fn: Disposable) {
+  if (globalContext != null) {
+    globalContext.disposables.push(fn);
+  }
+}
+
+/**
+ * Create a custom reactive context for the children returned by
+ * the provided context.
+ */
+export interface CustomContext {
+  [CUSTOM_CONTEXT_SYM]: true;
+  useCustomContext: (useCb: CustomContextChildrenCallback) => void;
+}
+
+export type CustomContextChildrenCallback = (child: Children) => void;
+const CUSTOM_CONTEXT_SYM = Symbol();
+
+export function createCustomContext(
+  useCallback: (useChildren: CustomContextChildrenCallback) => void,
+): CustomContext {
+  return {
+    [CUSTOM_CONTEXT_SYM]: true,
+    useCustomContext(useCb: (children: Children) => void): void {
+      useCallback(useCb);
+    },
+  };
+}
+
+export function isCustomContext(child: Children): child is CustomContext {
+  return (
+    typeof child === "object" &&
+    child !== null &&
+    Object.hasOwn(child, CUSTOM_CONTEXT_SYM)
+  );
+}
diff --git a/packages/core/src/render.ts b/packages/core/src/render.ts
index 5b3c355..88441d8 100644
--- a/packages/core/src/render.ts
+++ b/packages/core/src/render.ts
@@ -3,26 +3,29 @@ import { Doc, doc } from "prettier";
 import prettier from "prettier/doc.js";
 import { useContext } from "./context.js";
 import { SourceFileContext } from "./context/source-file.js";
+import { shouldDebug } from "./debug.js";
 import {
-  Child,
-  Children,
   Context,
   CustomContext,
   effect,
   getContext,
   getElementCache,
-  IntrinsicElement,
-  isComponentCreator,
   isCustomContext,
-  isIntrinsicElement,
-  popStack,
-  printRenderStack,
-  pushStack,
   root,
   untrack,
-} from "./jsx-runtime.js";
+} from "./reactivity.js";
 import { isRefkey } from "./refkey.js";
+import {
+  Child,
+  Children,
+  Component,
+  isComponentCreator,
+  Props,
+} from "./runtime/component.js";
+import { IntrinsicElement, isIntrinsicElement } from "./runtime/intrinsic.js";
 import { flushJobs } from "./scheduler.js";
+import { trace, TracePhase } from "./tracer.js";
+
 const {
   builders: {
     align,
@@ -172,11 +175,6 @@ export function isPrintHook(type: unknown): type is PrintHook {
 
 export type RenderedTextTree = (string | RenderedTextTree | PrintHook)[];
 
-function traceRender(phase: string, message: () => string) {
-  return false;
-  //console.log(`[\x1b[34m${phase}\x1b[0m]: ${message()}`);
-}
-
 export function render(
   children: Children,
   options?: PrintTreeOptions,
@@ -284,7 +282,12 @@ export function renderTree(children: Children) {
 }
 
 function renderWorker(node: RenderedTextTree, children: Children) {
-  traceRender("render", () => dumpChildren(children));
+  if (!getContext()) {
+    throw new Error(
+      "Cannot render without a context. Make sure you are using the Output component.",
+    );
+  }
+  trace(TracePhase.render.worker, () => dumpChildren(children));
 
   if (Array.isArray(node)) {
     nodesToContext.set(node, getContext()!);
@@ -300,7 +303,7 @@ function renderWorker(node: RenderedTextTree, children: Children) {
 }
 
 function appendChild(node: RenderedTextTree, rawChild: Child) {
-  traceRender("appendChild", () => debugPrintChild(rawChild));
+  trace(TracePhase.render.appendChild, () => debugPrintChild(rawChild));
   const child = normalizeChild(rawChild);
 
   if (typeof child === "string") {
@@ -308,12 +311,18 @@ function appendChild(node: RenderedTextTree, rawChild: Child) {
   } else {
     const cache = getElementCache();
     if (cache.has(child as any)) {
-      traceRender("appendChild:cached", () => debugPrintChild(child));
+      trace(
+        TracePhase.render.appendChild,
+        () => "Cached: " + debugPrintChild(child),
+      );
       node.push(cache.get(child as any)!);
       return;
     }
     if (isCustomContext(child)) {
-      traceRender("appendChild:custom-context", () => debugPrintChild(child));
+      trace(
+        TracePhase.render.appendChild,
+        () => "CustomContext: " + debugPrintChild(child),
+      );
       child.useCustomContext((children) => {
         const newNode: RenderedTextTree = [];
         renderWorker(newNode, children);
@@ -321,10 +330,11 @@ function appendChild(node: RenderedTextTree, rawChild: Child) {
         cache.set(child, newNode);
       });
     } else if (isIntrinsicElement(child)) {
-      // don't need a new context here because intrinsics are never reactive
-      traceRender("appendChild:intrinsic-element", () =>
-        debugPrintChild(child),
+      trace(
+        TracePhase.render.appendChild,
+        () => "IntrinsicElement: " + debugPrintChild(child),
       );
+      // don't need a new context here because intrinsics are never reactive
       const newNode: RenderedTextTree = [];
 
       function formatHookWithChildren(command: (doc: Doc) => Doc) {
@@ -442,20 +452,26 @@ function appendChild(node: RenderedTextTree, rawChild: Child) {
       }
     } else if (isComponentCreator(child)) {
       effect(() => {
-        traceRender("appendChild:component", () => debugPrintChild(child));
+        trace(
+          TracePhase.render.appendChild,
+          () => "Component: " + debugPrintChild(child),
+        );
         const componentRoot: RenderedTextTree = [];
         pushStack(child.component, child.props);
         renderWorker(componentRoot, untrack(child));
         popStack();
         node.push(componentRoot);
         cache.set(child, componentRoot);
-        traceRender("appendChild:component-done", () => debugPrintChild(child));
+        trace(
+          TracePhase.render.appendChild,
+          () => "Component done: " + debugPrintChild(child),
+        );
       });
     } else if (typeof child === "function") {
-      traceRender("appendChild:memo", () => child.toString());
+      trace(TracePhase.render.appendChild, () => "Memo: " + child.toString());
       const index = node.length;
       effect(() => {
-        traceRender("memoEffect:run", () => "");
+        trace(TracePhase.render.renderEffect, () => "");
         let res = child();
         while (typeof res === "function" && !isComponentCreator(res)) {
           res = res();
@@ -466,7 +482,6 @@ function appendChild(node: RenderedTextTree, rawChild: Child) {
         cache.set(child, newNodes);
         return newNodes;
       });
-      traceRender("appendChild:memo-done", () => "");
     } else {
       throw new Error("Unexpected child type");
     }
@@ -525,6 +540,8 @@ function debugPrintChild(child: Children): string {
     return "$memo";
   } else if (isRef(child)) {
     return "$ref";
+  } else if (isIntrinsicElement(child)) {
+    return `<${child.name}>`;
   } else {
     return JSON.stringify(child);
   }
@@ -588,3 +605,57 @@ function printTreeWorker(tree: RenderedTextTree): Doc {
 
   return doc;
 }
+// debugging utilities
+const renderStack: {
+  component: Component<any>;
+  props: Props;
+}[] = [];
+
+export function pushStack(component: Component<any>, props: Props) {
+  if (!shouldDebug()) return;
+  renderStack.push({ component, props });
+}
+
+export function popStack() {
+  if (!shouldDebug()) return;
+  renderStack.pop();
+}
+
+export function printRenderStack() {
+  if (!shouldDebug()) return;
+
+  // eslint-disable-next-line no-console
+  console.error("Error rendering:");
+  for (let i = renderStack.length - 1; i >= 0; i--) {
+    const { component, props } = renderStack[i];
+    // eslint-disable-next-line no-console
+    console.error(`    at ${component.name}(${inspectProps(props)})`);
+  }
+}
+
+function inspectProps(props: Props) {
+  return JSON.stringify(
+    Object.fromEntries(
+      Object.entries(props).map(([key, value]) => {
+        let safeValue;
+        switch (typeof value) {
+          case "string":
+          case "number":
+          case "boolean":
+            safeValue = value;
+            break;
+          case "undefined":
+            safeValue = "undefined";
+            break;
+          case "object":
+            safeValue = value ? "{...}" : null;
+            break;
+          case "function":
+            safeValue = "function";
+            break;
+        }
+        return [key, safeValue];
+      }),
+    ),
+  );
+}
diff --git a/packages/core/src/runtime/component.ts b/packages/core/src/runtime/component.ts
new file mode 100644
index 0000000..c4727d7
--- /dev/null
+++ b/packages/core/src/runtime/component.ts
@@ -0,0 +1,67 @@
+import { Ref } from "@vue/reactivity";
+import { CustomContext } from "../reactivity.js";
+import { Refkey } from "../refkey.js";
+import { IntrinsicElement } from "./intrinsic.js";
+
+export type Child =
+  | string
+  | boolean
+  | number
+  | undefined
+  | null
+  | void
+  | (() => Children)
+  | Ref
+  | Refkey
+  | CustomContext
+  | IntrinsicElement;
+
+export type Children = Child | Children[];
+export type Props = Record<string, any>;
+
+export interface ComponentDefinition<TProps = Props> {
+  (props: TProps): Children;
+}
+export interface Component<TProps = Props> {
+  (props: TProps): Children;
+  tag?: symbol;
+}
+
+export interface ComponentCreator<TProps = Props> {
+  component: Component<TProps>;
+  (): Children;
+  props: TProps;
+  tag?: symbol;
+}
+
+export function isComponentCreator<TProps = any>(
+  item: unknown,
+  component?: Component<TProps>,
+): item is ComponentCreator<TProps> {
+  if (!component) {
+    return typeof item === "function" && (item as any).component;
+  }
+  return typeof item === "function" && (item as any).component === component;
+}
+
+export function createComponent<TProps extends Props = Props>(
+  C: Component<TProps>,
+  props: TProps,
+): ComponentCreator<TProps> {
+  const creator: ComponentCreator<TProps> = () => /* */ C(props);
+  creator.props = props;
+  creator.component = C;
+  if (C.tag) {
+    creator.tag = C.tag;
+  }
+
+  return creator;
+}
+
+export function taggedComponent<TProps = Props>(
+  tag: symbol,
+  component: Component<TProps>,
+): Component<TProps> & Required<Pick<Component<TProps>, "tag">> {
+  component.tag = tag;
+  return component as any;
+}
diff --git a/packages/core/src/runtime/intrinsic.ts b/packages/core/src/runtime/intrinsic.ts
new file mode 100644
index 0000000..8e1d203
--- /dev/null
+++ b/packages/core/src/runtime/intrinsic.ts
@@ -0,0 +1,199 @@
+import type { Children } from "./component.js";
+export interface IntrinsicElements {
+  /**
+   * Attempt to render the children on a single line if possible. If a group
+   * contains `<breakParent />` or a hard line, or if the group exceeds the
+   * print width, all linebreaks in the group will be broken.
+   */
+  group: { shouldBreak?: boolean; id?: symbol; children: Children };
+
+  /**
+   * A regular line break. This will break if the line exceeds the print
+   * width, otherwise it will be a space.
+   */
+  line: {};
+
+  /**
+   * A regular line break. This will break if the line exceeds the print
+   * width, otherwise it will be a space.
+   */
+  br: {};
+
+  /**
+   * A hard line break. This is a line that will always break, even if the
+   * group does not exceed print width.
+   */
+  hardline: {};
+
+  /**
+   * A hard line break. This is a line that will always break, even if the
+   * group does not exceed print width.
+   */
+  hbr: {};
+
+  /**
+   * A soft line break. This will break if the line exceeds the print width,
+   * otherwise it will be be nothing.
+   */
+  softline: {};
+
+  /**
+   * A soft line break. This will break if the line exceeds the print width,
+   * otherwise it will be be nothing.
+   */
+  sbr: {};
+
+  /**
+   * A literal line break. This will always break, even if the group does not
+   * exceed print width. The new line will ignore indentation.
+   */
+  literalline: {};
+
+  /**
+   * A literal line break. This will always break, even if the group does not
+   * exceed print width. The new line will ignore indentation.
+   */
+  lbr: {};
+
+  /**
+   * Increase the indentation level of the children of this component.
+   * Indentation is determined by the print options provided to the Output
+   * component or source file.
+   */
+  indent: { children: Children };
+
+  /**
+   * Indent the children of this component if the group specified by `groupId`
+   * is broken (or not broken if `negate` is passed). The specified group must
+   * already be printed.
+   */
+  indentIfBreak: { children: Children; groupId: symbol; negate?: boolean };
+
+  /**
+   * Similar to `group`, but will only place a line break before the last
+   * segment to exceed the print width. This is useful for formatting
+   * paragraphs of text where breaks are inserted prior to words which would
+   * otherwise exceed the print width.
+   */
+  fill: { children: Children };
+
+  /**
+   * Force the parent group to break.
+   */
+  breakParent: {};
+
+  /**
+   * Print children if the current group or already printed group specified by
+   * `groupId` is broken. Otherwise, `flatContents` is printed instead.
+   */
+  ifBreak: { children: Children; flatContents?: Children; groupId?: symbol };
+
+  /**
+   * Print this content at the end of the line. Useful for things like line
+   * comments.
+   */
+  lineSuffix: { children: Children };
+
+  /**
+   * Force any line suffixes to print at this point.
+   */
+  lineSuffixBoundary: {};
+
+  /**
+   * Decrease the indentation level of the children of this component.
+   * Indentation is determined by the print options provided to the Output
+   * component or source file.
+   */
+  dedent: { children: Children };
+
+  /**
+   * Indent the children of this component by either the number of characters
+   * indicated by the `width` prop, or by the provided string indicated by the
+   * `string` prop.
+   */
+  align:
+    | { children: Children; width: number }
+    | { children: Children; string: string };
+
+  /**
+   * Mark the current indentation level as "root" for the purposes of literal
+   * line breaks and `dedentToRoot`.
+   */
+  markAsRoot: { children: Children };
+
+  /**
+   * Decrease the indentation level to the root level specified by
+   * `<markAsRoot />`, or else to no indentation.
+   */
+  dedentToRoot: { children: Children };
+}
+export interface IntrinsicElementBase<
+  TKey extends keyof IntrinsicElements = keyof IntrinsicElements,
+> {
+  [intrinsicElementKey]: true;
+  name: TKey;
+  props: IntrinsicElements[TKey];
+}
+
+export function createIntrinsic<TKey extends keyof IntrinsicElements>(
+  name: TKey,
+  props: IntrinsicElements[TKey],
+): IntrinsicElementBase<TKey> {
+  return {
+    [intrinsicElementKey]: true,
+    name,
+    props,
+  };
+}
+
+export function isIntrinsicElement(type: unknown): type is IntrinsicElement {
+  return (
+    typeof type === "object" && type !== null && intrinsicElementKey in type
+  );
+}
+
+export const intrinsicElementKey = Symbol();
+export type IndentIntrinsicElement = IntrinsicElementBase<"indent">;
+export type IndentIfBreakIntrinsicElement =
+  IntrinsicElementBase<"indentIfBreak">;
+export type BrIntrinsicElement = IntrinsicElementBase<"br">;
+export type LineIntrinsicElement = IntrinsicElementBase<"line">;
+export type HbrIntrinsicElement = IntrinsicElementBase<"hbr">;
+export type HardlineIntrinsicElement = IntrinsicElementBase<"hardline">;
+export type SbrIntrinsicElement = IntrinsicElementBase<"sbr">;
+export type SoftlineIntrinsicElement = IntrinsicElementBase<"softline">;
+export type GroupIntrinsicElement = IntrinsicElementBase<"group">;
+export type AlignIntrinsicElement = IntrinsicElementBase<"align">;
+export type FillIntrinsicElement = IntrinsicElementBase<"fill">;
+export type BreakParentIntrinsicElement = IntrinsicElementBase<"breakParent">;
+export type LineSuffixIntrinsicElement = IntrinsicElementBase<"lineSuffix">;
+export type LineSuffixBoundaryIntrinsicElement =
+  IntrinsicElementBase<"lineSuffixBoundary">;
+export type DedentIntrinsicElement = IntrinsicElementBase<"dedent">;
+export type DedentToRootIntrinsicElement = IntrinsicElementBase<"dedentToRoot">;
+export type MarkAsRootIntrinsicElement = IntrinsicElementBase<"markAsRoot">;
+export type LiterallineIntrinsicElement = IntrinsicElementBase<"literalline">;
+export type LbrIntrinsicElement = IntrinsicElementBase<"lbr">;
+export type IfBreakIntrinsicElement = IntrinsicElementBase<"ifBreak">;
+
+export type IntrinsicElement =
+  | IndentIntrinsicElement
+  | IndentIfBreakIntrinsicElement
+  | BrIntrinsicElement
+  | LineIntrinsicElement
+  | HbrIntrinsicElement
+  | HardlineIntrinsicElement
+  | SbrIntrinsicElement
+  | SoftlineIntrinsicElement
+  | GroupIntrinsicElement
+  | AlignIntrinsicElement
+  | FillIntrinsicElement
+  | BreakParentIntrinsicElement
+  | LineSuffixIntrinsicElement
+  | LineSuffixBoundaryIntrinsicElement
+  | DedentIntrinsicElement
+  | LiterallineIntrinsicElement
+  | LbrIntrinsicElement
+  | DedentToRootIntrinsicElement
+  | MarkAsRootIntrinsicElement
+  | IfBreakIntrinsicElement;
diff --git a/packages/core/src/slot.ts b/packages/core/src/slot.ts
index da93e9c..301fc21 100644
--- a/packages/core/src/slot.ts
+++ b/packages/core/src/slot.ts
@@ -1,12 +1,11 @@
 import { ref, Ref } from "@vue/reactivity";
+import { effect, memo } from "./reactivity.js";
 import {
   Children,
   Component,
   ComponentDefinition,
-  effect,
-  memo,
-} from "./jsx-runtime.js";
-import { OutputSymbol } from "./symbols/output-symbol.js";
+} from "./runtime/component.js";
+import type { OutputSymbol } from "./symbols/output-symbol.js";
 
 export interface SlotInstance extends ComponentDefinition {}
 
diff --git a/packages/core/src/stc.ts b/packages/core/src/stc.ts
index 7e85ad0..52a1bee 100644
--- a/packages/core/src/stc.ts
+++ b/packages/core/src/stc.ts
@@ -1,9 +1,9 @@
 import { code, text } from "./code.js";
-import type {
+import {
   Children,
   ComponentCreator,
   ComponentDefinition,
-} from "./jsx-runtime.js";
+} from "./runtime/component.js";
 
 export type MakeChildrenOptional<T extends object> =
   T extends { children?: any } ?
diff --git a/packages/core/src/sti.ts b/packages/core/src/sti.ts
index 0e1154f..f416d5b 100644
--- a/packages/core/src/sti.ts
+++ b/packages/core/src/sti.ts
@@ -1,19 +1,19 @@
 import { code, text } from "./code.js";
+import { Children } from "./runtime/component.js";
 import {
-  type Children,
   createIntrinsic,
-  type IntrinsicElementBase,
-  type JSX,
-} from "./jsx-runtime.js";
+  IntrinsicElementBase,
+  IntrinsicElements,
+} from "./runtime/intrinsic.js";
 
-export type StiSignature<T extends keyof JSX.IntrinsicElements> = (
+export type StiSignature<T extends keyof IntrinsicElements> = (
   ...args: unknown extends T ? []
-  : {} extends Omit<JSX.IntrinsicElements[T], "children"> ?
-    [props?: JSX.IntrinsicElements[T]]
-  : [props: JSX.IntrinsicElements[T]]
+  : {} extends Omit<IntrinsicElements[T], "children"> ?
+    [props?: IntrinsicElements[T]]
+  : [props: IntrinsicElements[T]]
 ) => StiComponentCreator<T>;
 
-export type StiComponentCreator<T extends keyof JSX.IntrinsicElements> =
+export type StiComponentCreator<T extends keyof IntrinsicElements> =
   (() => IntrinsicElementBase<T>) & {
     code(
       template: TemplateStringsArray,
@@ -26,11 +26,11 @@ export type StiComponentCreator<T extends keyof JSX.IntrinsicElements> =
     children(...children: Children[]): () => IntrinsicElementBase<T>;
   };
 
-export function sti<T extends keyof JSX.IntrinsicElements>(
+export function sti<T extends keyof IntrinsicElements>(
   name: T,
 ): StiSignature<T> {
   return (...args) => {
-    const props: JSX.IntrinsicElements[T] | undefined = args[0];
+    const props: IntrinsicElements[T] | undefined = args[0];
     const fn: StiComponentCreator<T> = () => createIntrinsic(name, props!);
     fn.children = (...children: Children[]) => {
       const propsWithChildren = {
diff --git a/packages/core/src/symbols/flags.ts b/packages/core/src/symbols/flags.ts
new file mode 100644
index 0000000..e9811fb
--- /dev/null
+++ b/packages/core/src/symbols/flags.ts
@@ -0,0 +1,82 @@
+/**
+ * Flags that describe an output scope.
+ */
+export enum OutputScopeFlags {
+  None = 0,
+
+  /**
+   * This scope is a static member scope.
+   */
+  StaticMemberScope = 1 << 0,
+
+  /**
+   * This scope is an instance member scope.
+   */
+  InstanceMemberScope = 1 << 1,
+
+  /**
+   * This scope is transient, and only exists to be merged with other scopes.
+   */
+  Transient = 1 << 2,
+
+  /**
+   * This scope is a member scope. Scopes with this flag will have an `owner`
+   * property that points to the symbol whose member this scope holds.
+   */
+  MemberScope = StaticMemberScope | InstanceMemberScope,
+}
+
+/**
+ * Flags that describe an output symbol.
+ */
+export enum OutputSymbolFlags {
+  None = 0,
+
+  /**
+   * The symbol is an instance member container. Symbols with this flag will have a
+   * instanceMemberScope property that contains symbols for instance members.
+   */
+  InstanceMemberContainer = 1 << 0,
+
+  /**
+   * The symbol is a static member container. Symbols with this flag will have a
+   * staticMemberScope property that contains symbols for static members.
+   */
+  StaticMemberContainer = 1 << 1,
+
+  /**
+   * Whether this symbol contains members of any kind.
+   */
+  MemberContainer = InstanceMemberContainer | StaticMemberContainer,
+
+  /**
+   * Whether this symbol is an instance member of another symbol (i.e that it is
+   * stored in an instance member scope).
+   */
+  InstanceMember = 1 << 2,
+
+  /**
+   * Whether this symbol is a static member of another symbol (i.e that it is
+   * stored in a static member scope).
+   */
+  StaticMember = 1 << 3,
+
+  /**
+   * Transient symbols are not added to symbol tables and do not create
+   * referencable refkeys. They are used for temporary symbols that are intended
+   * to be used to calculate other symbols.
+   */
+  Transient = 1 << 4,
+
+  /**
+   * This symbol aliases another symbol. Only its name, flags, and scope are
+   * stored in this symbol. Otherwise, everything else is located on the alias
+   * target.
+   */
+  Alias = 1 << 5,
+
+  /**
+   * Whether this is an instance member or static member of another symbol.
+   */
+  Member = InstanceMember | StaticMember,
+}
diff --git a/packages/core/src/symbols/index.ts b/packages/core/src/symbols/index.ts
index c10f57b..7e168c3 100644
--- a/packages/core/src/symbols/index.ts
+++ b/packages/core/src/symbols/index.ts
@@ -1,3 +1,4 @@
+export * from "./flags.js";
 export * from "./output-scope.js";
 export * from "./output-symbol.js";
 export * from "./symbol-flow.js";
diff --git a/packages/core/src/symbols/output-scope.ts b/packages/core/src/symbols/output-scope.ts
index 6933fc0..439f73a 100644
--- a/packages/core/src/symbols/output-scope.ts
+++ b/packages/core/src/symbols/output-scope.ts
@@ -20,37 +20,10 @@ import {
   traceEffect,
   TracePhase,
 } from "../tracer.js";
-import { OutputSymbol } from "./output-symbol.js";
+import { OutputScopeFlags } from "./flags.js";
+import type { OutputSymbol } from "./output-symbol.js";
 import { SymbolTable } from "./symbol-table.js";
 
-/**
- * Flags that describe an output scope.
- */
-export enum OutputScopeFlags {
-  None = 0,
-
-  /**
-   * This scope is a static member scope.
-   */
-  StaticMemberScope = 1 << 0,
-
-  /**
-   * This scope is an instance member scope.
-   */
-  InstanceMemberScope = 1 << 1,
-
-  /**
-   * This scope is transient, and only exists to be merged with other scopes.
-   */
-  Transient = 1 << 2,
-
-  /**
-   * This scope is a member scope. Scopes with this flag will have an `owner`
-   * property that points to the symbol whose member this scope holds.
-   */
-  MemberScope = StaticMemberScope | InstanceMemberScope,
-}
-
 let scopeCount = 0;
 
 export interface OutputScopeOptions {
diff --git a/packages/core/src/symbols/output-symbol.ts b/packages/core/src/symbols/output-symbol.ts
index 56eb706..c4cb78c 100644
--- a/packages/core/src/symbols/output-symbol.ts
+++ b/packages/core/src/symbols/output-symbol.ts
@@ -21,61 +21,8 @@ import {
   traceEffect,
   TracePhase,
 } from "../tracer.js";
-import { OutputScope, OutputScopeFlags } from "./output-scope.js";
-/**
- * Flags that describe an output symbol.
- */
-export enum OutputSymbolFlags {
-  None = 0,
-
-  /**
-   * The symbol is an instance member container. Symbols with this flag will have a
-   * instanceMemberScope property that contains symbols for instance members.
-   */
-  InstanceMemberContainer = 1 << 0,
-
-  /**
-   * The symbol is a static member container. Symbols with this flag will have a
-   * staticMemberScope property that contains symbols for static members.
-   */
-  StaticMemberContainer = 1 << 1,
-
-  /**
-   * Whether this symbol contains members of any kind.
-   */
-  MemberContainer = InstanceMemberContainer | StaticMemberContainer,
-
-  /**
-   * Whether this symbol is an instance member of another symbol (i.e that it is
-   * stored in an instance member scope).
-   */
-  InstanceMember = 1 << 2,
-
-  /**
-   * Whether this symbol is a static member of another symbol (i.e that it is
-   * stored in a static member scope).
-   */
-  StaticMember = 1 << 3,
-
-  /**
-   * Transient symbols are not added to symbol tables and do not create
-   * referencable refkeys. They are used for temporary symbols that are intended
-   * to be used to calculate other symbols.
-   */
-  Transient = 1 << 4,
-
-  /**
-   * This symbol aliases another symbol. Only its name, flags, and scope are
-   * stored in this symbol. Otherwise, everything else is located on the alias
-   * target.
-   */
-  Alias = 1 << 5,
-
-  /**
-   * Whether this is an instance member or static member of another symbol.
-   */
-  Member = InstanceMember | StaticMember,
-}
+import { OutputScopeFlags, OutputSymbolFlags } from "./flags.js";
+import { OutputScope } from "./output-scope.js";
 
 export interface OutputSymbolOptions {
   binder?: Binder;
diff --git a/packages/core/src/symbols/symbol-flow.ts b/packages/core/src/symbols/symbol-flow.ts
index 8b7592c..7817e4a 100644
--- a/packages/core/src/symbols/symbol-flow.ts
+++ b/packages/core/src/symbols/symbol-flow.ts
@@ -1,14 +1,10 @@
-import {
-  Context,
-  effect,
-  getContext,
-  onCleanup,
-} from "@alloy-js/core/jsx-runtime";
 import { isRef, Ref, shallowReactive } from "@vue/reactivity";
+import { Context, effect, getContext, onCleanup } from "../reactivity.js";
 
 import { MemberScopeContext } from "../context/member-scope.js";
 import { ScopeContext } from "../context/scope.js";
-import { OutputSymbol, OutputSymbolFlags } from "./output-symbol.js";
+import { OutputSymbolFlags } from "./flags.js";
+import { OutputSymbol } from "./output-symbol.js";
 
 export interface TakeSymbolCallback {
   (symbol: OutputSymbol): void;
diff --git a/packages/core/src/symbols/symbol-slot.tsx b/packages/core/src/symbols/symbol-slot.tsx
index e1a536b..c22ef1d 100644
--- a/packages/core/src/symbols/symbol-slot.tsx
+++ b/packages/core/src/symbols/symbol-slot.tsx
@@ -1,5 +1,6 @@
 import { Ref, ShallowReactive, shallowRef } from "@vue/reactivity";
-import { Children, effect, onCleanup } from "../jsx-runtime.js";
+import { effect, onCleanup } from "../reactivity.js";
+import type { Children } from "../runtime/component.js";
 import { OutputSymbol } from "./output-symbol.js";
 import { takeSymbols } from "./symbol-flow.js";
 
diff --git a/packages/core/src/symbols/symbol-table.ts b/packages/core/src/symbols/symbol-table.ts
index 64d5d7f..1ad356e 100644
--- a/packages/core/src/symbols/symbol-table.ts
+++ b/packages/core/src/symbols/symbol-table.ts
@@ -1,4 +1,4 @@
-import { NameConflictResolver } from "../binder.js";
+import type { NameConflictResolver } from "../binder.js";
 import { ReactiveUnionSet } from "../reactive-union-set.js";
 import { queueJob } from "../scheduler.js";
 import {
@@ -7,8 +7,8 @@ import {
   trace,
   TracePhase,
 } from "../tracer.js";
-import { OutputScope } from "./output-scope.js";
-import { OutputSymbol } from "./output-symbol.js";
+import type { OutputScope } from "./output-scope.js";
+import type { OutputSymbol } from "./output-symbol.js";
 
 export class SymbolTable extends ReactiveUnionSet<OutputSymbol> {
   private _namesToDeconflict: Set<string> = new Set();
diff --git a/packages/core/src/tap.ts b/packages/core/src/tap.ts
index 6183c2c..aa19491 100644
--- a/packages/core/src/tap.ts
+++ b/packages/core/src/tap.ts
@@ -4,7 +4,7 @@ import { DeclarationContext } from "./context/declaration.js";
 import { MemberDeclarationContext } from "./context/member-declaration.js";
 import { useScope } from "./context/scope.js";
 import { SourceFileContext } from "./context/source-file.js";
-import { ComponentDefinition } from "./jsx-runtime.js";
+import type { ComponentDefinition } from "./runtime/component.js";
 import { OutputScope } from "./symbols/output-scope.js";
 import { OutputSymbol } from "./symbols/output-symbol.js";
 
diff --git a/packages/core/src/tracer.ts b/packages/core/src/tracer.ts
index 7373129..d0b95f8 100644
--- a/packages/core/src/tracer.ts
+++ b/packages/core/src/tracer.ts
@@ -1,9 +1,10 @@
-import { untrack } from "@alloy-js/core/jsx-runtime";
 import { effect, ReactiveEffectRunner } from "@vue/reactivity";
+import { untrack } from "./reactivity.js";
 import type { Refkey } from "./refkey.js";
 import { scheduler } from "./scheduler.js";
-import { OutputScope, OutputScopeFlags } from "./symbols/output-scope.js";
-import { OutputSymbol, OutputSymbolFlags } from "./symbols/output-symbol.js";
+import { OutputScopeFlags, OutputSymbolFlags } from "./symbols/flags.js";
+import { type OutputScope } from "./symbols/output-scope.js";
+import { type OutputSymbol } from "./symbols/output-symbol.js";
 
 // enable tracing for specific phases using a comma separated list of
 // dotted identifiers, e.g. `scope.update,symbol.create`.
@@ -30,7 +31,6 @@ debuggerIdsEnv.split(",").forEach((id) => {
   }
 });
 export const TracePhase = {
-  render: {},
   scope: {
     update: {
       area: "scope",
@@ -112,6 +112,40 @@ export const TracePhase = {
       bg: { r: 100, g: 50, b: 50 },
     },
   },
+  effect: {
+    schedule: {
+      area: "effect",
+      subarea: "schedule",
+      bg: { r: 100, g: 100, b: 0 },
+    },
+    track: {
+      area: "effect",
+      subarea: "track",
+      bg: { r: 75, g: 75, b: 0 },
+    },
+    trigger: {
+      area: "effect",
+      subarea: "trigger",
+      bg: { r: 50, g: 50, b: 0 },
+    },
+  },
+  render: {
+    worker: {
+      area: "render",
+      subarea: "worker",
+      bg: { r: 100, g: 50, b: 0 },
+    },
+    appendChild: {
+      area: "render",
+      subarea: "appendChild",
+      bg: { r: 100, g: 50, b: 0 },
+    },
+    renderEffect: {
+      area: "render",
+      subarea: "render effect",
+      bg: { r: 100, g: 50, b: 0 },
+    },
+  },
 } as const;
 
 interface TracePhase extends TextFormat {
diff --git a/packages/core/src/utils.tsx b/packages/core/src/utils.tsx
index dcfed3c..39d8c90 100644
--- a/packages/core/src/utils.tsx
+++ b/packages/core/src/utils.tsx
@@ -1,19 +1,21 @@
 import { toRaw } from "@vue/reactivity";
+import { BaseListProps } from "./components/List.jsx";
 import {
-  Children,
-  ComponentCreator,
   createCustomContext,
   CustomContext,
   Disposable,
-  isComponentCreator,
   memo,
   onCleanup,
   root,
   untrack,
-} from "./jsx-runtime.js";
+} from "./reactivity.js";
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
-import { BaseListProps } from "./components/List.jsx";
 import { OutputDirectory, OutputFile, render } from "./render.js";
+import {
+  Children,
+  ComponentCreator,
+  isComponentCreator,
+} from "./runtime/component.js";
 
 export interface JoinOptions {
   /**
diff --git a/packages/java/src/builtins/util.ts b/packages/java/src/builtins/util.ts
index 632adc7..62419d3 100644
--- a/packages/java/src/builtins/util.ts
+++ b/packages/java/src/builtins/util.ts
@@ -1,4 +1,10 @@
+import type { SymbolCreator } from "@alloy-js/core";
 import { createLibrary } from "../create-library.js";
+// work around very strange issue where TypeScript complains that the inferred
+// type of the below cannot be named without a reference to ../../alloy-js/core
+// and demands a type annotation.
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+type dummy = SymbolCreator;
 
 export const javaUtil = createLibrary({
   groupId: "java.util",
diff --git a/packages/typescript/src/builtins/node.ts b/packages/typescript/src/builtins/node.ts
index f051596..44c2cec 100644
--- a/packages/typescript/src/builtins/node.ts
+++ b/packages/typescript/src/builtins/node.ts
@@ -1,5 +1,9 @@
+import { SymbolCreator } from "@alloy-js/core";
 import { createPackage } from "../create-package.js";
 
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+type dummy = SymbolCreator;
+
 export const path = createPackage({
   name: "node:path",
   version: "22.5.1",
diff --git a/packages/typescript/src/components/ArrowFunction.tsx b/packages/typescript/src/components/ArrowFunction.tsx
index 474a6a2..68f7340 100644
--- a/packages/typescript/src/components/ArrowFunction.tsx
+++ b/packages/typescript/src/components/ArrowFunction.tsx
@@ -3,8 +3,8 @@ import {
   findKeyedChild,
   findUnkeyedChildren,
   Scope,
+  type Children,
 } from "@alloy-js/core";
-import { Children } from "@alloy-js/core/jsx-runtime";
 import { getCallSignatureProps } from "../utils.js";
 import { CallSignature, CallSignatureProps } from "./CallSignature.jsx";
 import { Declaration } from "./Declaration.jsx";
diff --git a/packages/typescript/src/components/CallSignature.tsx b/packages/typescript/src/components/CallSignature.tsx
index bac652e..eb66a2a 100644
--- a/packages/typescript/src/components/CallSignature.tsx
+++ b/packages/typescript/src/components/CallSignature.tsx
@@ -1,4 +1,4 @@
-import { Children } from "@alloy-js/core/jsx-runtime";
+import { type Children } from "@alloy-js/core";
 import {
   ParameterDescriptor,
   TypeParameterDescriptor,
diff --git a/packages/typescript/src/components/FunctionBase.tsx b/packages/typescript/src/components/FunctionBase.tsx
index f26ea73..7f3a3dd 100644
--- a/packages/typescript/src/components/FunctionBase.tsx
+++ b/packages/typescript/src/components/FunctionBase.tsx
@@ -7,8 +7,8 @@ import {
   onCleanup,
   Show,
   taggedComponent,
+  type Children,
 } from "@alloy-js/core";
-import type { Children } from "@alloy-js/core/jsx-runtime";
 import { useTSNamePolicy } from "../name-policy.js";
 import type {
   FunctionTypeParameterDescriptor,
diff --git a/packages/typescript/src/components/FunctionDeclaration.tsx b/packages/typescript/src/components/FunctionDeclaration.tsx
index ebe351c..277fcc8 100644
--- a/packages/typescript/src/components/FunctionDeclaration.tsx
+++ b/packages/typescript/src/components/FunctionDeclaration.tsx
@@ -6,8 +6,8 @@ import {
   Prose,
   Scope,
   Show,
+  type Children,
 } from "@alloy-js/core";
-import { Children } from "@alloy-js/core/jsx-runtime";
 import { getCallSignatureProps } from "../utils.js";
 import { CallSignature, CallSignatureProps } from "./CallSignature.jsx";
 import { BaseDeclarationProps, Declaration } from "./Declaration.js";
diff --git a/packages/typescript/src/components/FunctionExpression.tsx b/packages/typescript/src/components/FunctionExpression.tsx
index ed4c49f..6887dcf 100644
--- a/packages/typescript/src/components/FunctionExpression.tsx
+++ b/packages/typescript/src/components/FunctionExpression.tsx
@@ -3,8 +3,8 @@ import {
   findKeyedChild,
   findUnkeyedChildren,
   Scope,
+  type Children,
 } from "@alloy-js/core";
-import { Children } from "@alloy-js/core/jsx-runtime";
 import { getCallSignatureProps } from "../utils.js";
 import { CallSignature, CallSignatureProps } from "./CallSignature.jsx";
 import { Declaration } from "./Declaration.jsx";
diff --git a/packages/typescript/src/components/FunctionType.tsx b/packages/typescript/src/components/FunctionType.tsx
index deaf09a..5204ea7 100644
--- a/packages/typescript/src/components/FunctionType.tsx
+++ b/packages/typescript/src/components/FunctionType.tsx
@@ -4,8 +4,8 @@ import {
   Prose,
   Scope,
   Show,
+  type Children,
 } from "@alloy-js/core";
-import { Children } from "@alloy-js/core/jsx-runtime";
 import { getCallSignatureProps } from "../utils.js";
 import { CallSignature, CallSignatureProps } from "./CallSignature.jsx";
 import {
diff --git a/packages/typescript/src/components/IfStatement.tsx b/packages/typescript/src/components/IfStatement.tsx
index 84e0154..b6f86cd 100644
--- a/packages/typescript/src/components/IfStatement.tsx
+++ b/packages/typescript/src/components/IfStatement.tsx
@@ -1,5 +1,4 @@
-import { Block } from "@alloy-js/core";
-import { Children } from "@alloy-js/core/jsx-runtime";
+import { Block, type Children } from "@alloy-js/core";
 
 export interface IfStatementProps {
   condition: Children;
diff --git a/packages/typescript/src/components/InterfaceMethod.tsx b/packages/typescript/src/components/InterfaceMethod.tsx
index d75d5f6..37aea2e 100644
--- a/packages/typescript/src/components/InterfaceMethod.tsx
+++ b/packages/typescript/src/components/InterfaceMethod.tsx
@@ -6,8 +6,8 @@ import {
   Refkey,
   Scope,
   Show,
+  type Children,
 } from "@alloy-js/core";
-import { Children } from "@alloy-js/core/jsx-runtime";
 import { useTSNamePolicy } from "../name-policy.js";
 import { getCallSignatureProps } from "../utils.js";
 import { CallSignature, CallSignatureProps } from "./CallSignature.jsx";
diff --git a/packages/typescript/src/components/JSDoc.tsx b/packages/typescript/src/components/JSDoc.tsx
index 96e43bb..1a41b90 100644
--- a/packages/typescript/src/components/JSDoc.tsx
+++ b/packages/typescript/src/components/JSDoc.tsx
@@ -1,5 +1,4 @@
-import { List, childrenArray } from "@alloy-js/core";
-import { Children } from "@alloy-js/core/jsx-runtime";
+import { List, childrenArray, type Children } from "@alloy-js/core";
 import { JSDocComment } from "./JSDocComment.jsx";
 
 export interface JSDocProps {
diff --git a/packages/typescript/src/components/JSDocComment.tsx b/packages/typescript/src/components/JSDocComment.tsx
index db97692..eeaa8f9 100644
--- a/packages/typescript/src/components/JSDocComment.tsx
+++ b/packages/typescript/src/components/JSDocComment.tsx
@@ -1,4 +1,4 @@
-import { Children } from "@alloy-js/core/jsx-runtime";
+import { type Children } from "@alloy-js/core";
 
 export interface JSDocCommentProps {
   children: Children;
diff --git a/packages/typescript/src/components/JSDocExample.tsx b/packages/typescript/src/components/JSDocExample.tsx
index 8c3fc3f..0a3811e 100644
--- a/packages/typescript/src/components/JSDocExample.tsx
+++ b/packages/typescript/src/components/JSDocExample.tsx
@@ -1,5 +1,4 @@
-import { Show } from "@alloy-js/core";
-import { Children } from "@alloy-js/core/jsx-runtime";
+import { Show, type Children } from "@alloy-js/core";
 
 export interface JSDocExampleProps {
   fenced?: boolean;
diff --git a/packages/typescript/src/components/JSDocParam.tsx b/packages/typescript/src/components/JSDocParam.tsx
index ff5cc7a..df2f409 100644
--- a/packages/typescript/src/components/JSDocParam.tsx
+++ b/packages/typescript/src/components/JSDocParam.tsx
@@ -1,5 +1,4 @@
-import { For, Prose, Show } from "@alloy-js/core";
-import { Children } from "@alloy-js/core/jsx-runtime";
+import { For, Prose, Show, type Children } from "@alloy-js/core";
 import { ParameterDescriptor } from "../parameter-descriptor.js";
 
 export interface JSDocParamsProps {
diff --git a/packages/typescript/src/components/SingleLineCommentBlock.tsx b/packages/typescript/src/components/SingleLineCommentBlock.tsx
index 236987c..af19c41 100644
--- a/packages/typescript/src/components/SingleLineCommentBlock.tsx
+++ b/packages/typescript/src/components/SingleLineCommentBlock.tsx
@@ -1,5 +1,4 @@
-import { Prose } from "@alloy-js/core";
-import { Children } from "@alloy-js/core/jsx-runtime";
+import { Prose, type Children } from "@alloy-js/core";
 
 export interface SingleLineCommentBlockProps {
   children: Children;
diff --git a/packages/typescript/src/components/SourceFile.tsx b/packages/typescript/src/components/SourceFile.tsx
index 716c108..c8ccf3a 100644
--- a/packages/typescript/src/components/SourceFile.tsx
+++ b/packages/typescript/src/components/SourceFile.tsx
@@ -6,9 +6,9 @@ import {
   Show,
   SourceDirectoryContext,
   useContext,
+  type Children,
 } from "@alloy-js/core";
 
-import { Children } from "@alloy-js/core/jsx-runtime";
 import { join } from "pathe";
 import { getSourceDirectoryData } from "../source-directory-data.js";
 import { TSModuleScope } from "../symbols/index.js";
diff --git a/packages/typescript/src/components/SwitchStatement.tsx b/packages/typescript/src/components/SwitchStatement.tsx
index 3a58fcb..0603cda 100644
--- a/packages/typescript/src/components/SwitchStatement.tsx
+++ b/packages/typescript/src/components/SwitchStatement.tsx
@@ -1,5 +1,4 @@
-import { Block, Indent, Match, Switch } from "@alloy-js/core";
-import { Children } from "@alloy-js/core/jsx-runtime";
+import { Block, Indent, Match, Switch, type Children } from "@alloy-js/core";
 import { BlockScope } from "./BlockScope.jsx";
 import { ValueExpression } from "./ValueExpression.jsx";
 
diff --git a/packages/typescript/src/components/TypeRefContext.tsx b/packages/typescript/src/components/TypeRefContext.tsx
index c32c9c2..b075536 100644
--- a/packages/typescript/src/components/TypeRefContext.tsx
+++ b/packages/typescript/src/components/TypeRefContext.tsx
@@ -1,4 +1,4 @@
-import type { Children } from "@alloy-js/core/jsx-runtime";
+import type { Children } from "@alloy-js/core";
 import {
   isTypeRefContext,
   TypeRefContext as TypeRefContextDef,
diff --git a/packages/typescript/src/create-package.ts b/packages/typescript/src/create-package.ts
index e1d8d2a..af54e8d 100644
--- a/packages/typescript/src/create-package.ts
+++ b/packages/typescript/src/create-package.ts
@@ -1,8 +1,8 @@
 import {
-  Binder,
+  type Binder,
   getSymbolCreatorSymbol,
   OutputSymbolFlags,
-  Refkey,
+  type Refkey,
   refkey,
   SymbolCreator,
 } from "@alloy-js/core";
diff --git a/packages/typescript/src/index.ts b/packages/typescript/src/index.ts
index f4d34b6..ad08943 100644
--- a/packages/typescript/src/index.ts
+++ b/packages/typescript/src/index.ts
@@ -4,4 +4,6 @@ export * from "./context/index.js";
 export * from "./create-package.js";
 export * from "./name-conflict-resolver.js";
 export * from "./name-policy.js";
+export * from "./parameter-descriptor.js";
+export * from "./source-directory-data.js";
 export * from "./symbols/index.js";
diff --git a/packages/typescript/src/parameter-descriptor.ts b/packages/typescript/src/parameter-descriptor.ts
index d4ef6d0..3968664 100644
--- a/packages/typescript/src/parameter-descriptor.ts
+++ b/packages/typescript/src/parameter-descriptor.ts
@@ -1,4 +1,4 @@
-import { Children, Refkey } from "@alloy-js/core";
+import type { Children, Refkey } from "@alloy-js/core";
 
 /**
  * Common properties for ParameterDescriptor and FunctionTypeParameterDescriptor.
diff --git a/packages/typescript/src/utils.ts b/packages/typescript/src/utils.ts
index f6b8cc9..17e2148 100644
--- a/packages/typescript/src/utils.ts
+++ b/packages/typescript/src/utils.ts
@@ -1,4 +1,4 @@
-import { defaultProps, splitProps } from "@alloy-js/core/jsx-runtime";
+import { defaultProps, splitProps } from "@alloy-js/core";
 import { CallSignatureProps } from "./components/CallSignature.jsx";
 
 export function modulePath(path: string) {


Test patch:
diff --git a/packages/core/test/props-with-defaults.test.ts b/packages/core/test/props-with-defaults.test.ts
index a1ae150..222135a 100644
--- a/packages/core/test/props-with-defaults.test.ts
+++ b/packages/core/test/props-with-defaults.test.ts
@@ -1,6 +1,6 @@
 import { effect, reactive } from "@vue/reactivity";
 import { expect, it, vi } from "vitest";
-import { defaultProps } from "../src/jsx-runtime.js";
+import { defaultProps } from "../src/props-combinators.js";
 
 it("applies defaults to regular object props", () => {
   const props = {
diff --git a/packages/core/test/reactive-union-set.test.tsx b/packages/core/test/reactive-union-set.test.tsx
index 463eb86..1d12ee2 100644
--- a/packages/core/test/reactive-union-set.test.tsx
+++ b/packages/core/test/reactive-union-set.test.tsx
@@ -1,7 +1,7 @@
-import { effect } from "@alloy-js/core/jsx-runtime";
 import { reactive } from "@vue/reactivity";
 import { describe, expect, it } from "vitest";
 import { ReactiveUnionSet } from "../src/reactive-union-set.js";
+import { effect } from "../src/reactivity.js";
 import { flushJobs } from "../src/scheduler.js";
 
 describe("is reactive", () => {
diff --git a/packages/core/test/reactivity/cleanup.test.tsx b/packages/core/test/reactivity/cleanup.test.tsx
index 15f8b84..8733c43 100644
--- a/packages/core/test/reactivity/cleanup.test.tsx
+++ b/packages/core/test/reactivity/cleanup.test.tsx
@@ -1,7 +1,8 @@
-import { Children, effect, memo, onCleanup } from "@alloy-js/core/jsx-runtime";
 import { ref } from "@vue/reactivity";
 import { describe, expect, it } from "vitest";
+import { effect, memo, onCleanup } from "../../src/reactivity.js";
 import { renderTree } from "../../src/render.js";
+import type { Children } from "../../src/runtime/component.js";
 import { flushJobs } from "../../src/scheduler.js";
 
 describe("memo cleanup", () => {
diff --git a/packages/core/test/reactivity/memo.test.tsx b/packages/core/test/reactivity/memo.test.tsx
index d387268..59a0de8 100644
--- a/packages/core/test/reactivity/memo.test.tsx
+++ b/packages/core/test/reactivity/memo.test.tsx
@@ -1,6 +1,6 @@
-import { memo } from "@alloy-js/core/jsx-runtime";
 import { ref } from "@vue/reactivity";
 import { expect, it } from "vitest";
+import { memo } from "../../src/reactivity.js";
 
 it("doesn't recalculate when dependencies don't change", () => {
   const signal = ref(0);
diff --git a/packages/core/test/reactivity/untrack.test.ts b/packages/core/test/reactivity/untrack.test.ts
index 36bd91c..88f0bc6 100644
--- a/packages/core/test/reactivity/untrack.test.ts
+++ b/packages/core/test/reactivity/untrack.test.ts
@@ -1,6 +1,6 @@
 import { ref } from "@vue/reactivity";
 import { expect, it } from "vitest";
-import { memo, untrack } from "../../src/jsx-runtime.js";
+import { memo, untrack } from "../../src/reactivity.js";
 import { flushJobs } from "../../src/scheduler.js";
 
 it("ignores signals for dependency tracking", () => {
diff --git a/packages/core/test/rendering/basic.test.tsx b/packages/core/test/rendering/basic.test.tsx
index 6661c41..6e0ab62 100644
--- a/packages/core/test/rendering/basic.test.tsx
+++ b/packages/core/test/rendering/basic.test.tsx
@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest";
-import { Children } from "../../src/jsx-runtime.js";
+import { Children } from "../../src/runtime/component.js";
 import "../../testing/extend-expect.js";
 describe("string nodes", () => {
   it("renders string nodes with substitutions", () => {
diff --git a/packages/core/test/rendering/memoization.test.tsx b/packages/core/test/rendering/memoization.test.tsx
index aee0b0d..f13db1d 100644
--- a/packages/core/test/rendering/memoization.test.tsx
+++ b/packages/core/test/rendering/memoization.test.tsx
@@ -1,6 +1,6 @@
-import { memo } from "@alloy-js/core/jsx-runtime";
 import { ref } from "@vue/reactivity";
 import { expect, it } from "vitest";
+import { memo } from "../../src/reactivity.js";
 import { renderTree } from "../../src/render.js";
 import { flushJobs } from "../../src/scheduler.js";
 
diff --git a/packages/core/test/split-props.test.ts b/packages/core/test/split-props.test.ts
index 3a91b1d..f01ee5a 100644
--- a/packages/core/test/split-props.test.ts
+++ b/packages/core/test/split-props.test.ts
@@ -1,6 +1,6 @@
 import { effect, reactive } from "@vue/reactivity";
 import { expect, it, vi } from "vitest";
-import { splitProps } from "../src/jsx-runtime.js";
+import { splitProps } from "../src/props-combinators.js";
 
 it("splits regular object props", () => {
   const props = {
diff --git a/packages/core/test/symbols/output-scope.test.ts b/packages/core/test/symbols/output-scope.test.ts
index 02b2b69..d63572c 100644
--- a/packages/core/test/symbols/output-scope.test.ts
+++ b/packages/core/test/symbols/output-scope.test.ts
@@ -3,10 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
 import { Binder, createOutputBinder } from "../../src/binder.js";
 import { Refkey } from "../../src/refkey.js";
 import { flushJobs } from "../../src/scheduler.js";
-import {
-  OutputScope,
-  OutputScopeFlags,
-} from "../../src/symbols/output-scope.js";
+import { OutputScopeFlags } from "../../src/symbols/flags.js";
+import { OutputScope } from "../../src/symbols/output-scope.js";
 import { OutputSymbol } from "../../src/symbols/output-symbol.js";
 import { SymbolTable } from "../../src/symbols/symbol-table.js";
 
diff --git a/packages/core/test/symbols/output-symbol.test.ts b/packages/core/test/symbols/output-symbol.test.ts
index 99335de..622cdf7 100644
--- a/packages/core/test/symbols/output-symbol.test.ts
+++ b/packages/core/test/symbols/output-symbol.test.ts
@@ -2,19 +2,16 @@ import { reactive, watch } from "@vue/reactivity";
 import { beforeEach, describe, expect, it, vi } from "vitest";
 import { Binder, createOutputBinder } from "../../src/binder.js";
 import { ComponentContext } from "../../src/context.js";
+import { MemberScopeContext } from "../../src/context/member-scope.js";
 import { ScopeContext } from "../../src/index.browser.js";
 import { renderTree } from "../../src/render.js";
 import { flushJobs } from "../../src/scheduler.js";
 import {
-  OutputScope,
   OutputScopeFlags,
-} from "../../src/symbols/output-scope.js";
-import {
-  OutputSymbol,
   OutputSymbolFlags,
-} from "../../src/symbols/output-symbol.js";
-
-import { MemberScopeContext } from "../../src/context/member-scope.js";
+} from "../../src/symbols/flags.js";
+import { OutputScope } from "../../src/symbols/output-scope.js";
+import { OutputSymbol } from "../../src/symbols/output-symbol.js";
 
 let binder: Binder;
 beforeEach(() => {
diff --git a/packages/core/test/symbols/resolution.test.ts b/packages/core/test/symbols/resolution.test.ts
index cd51ace..f5c2f41 100644
--- a/packages/core/test/symbols/resolution.test.ts
+++ b/packages/core/test/symbols/resolution.test.ts
@@ -1,10 +1,8 @@
 import { describe, expect, it } from "vitest";
 import { createOutputBinder, refkey } from "../../src/index.browser.js";
 import { flushJobs } from "../../src/scheduler.js";
-import {
-  OutputSymbol,
-  OutputSymbolFlags,
-} from "../../src/symbols/output-symbol.js";
+import { OutputSymbolFlags } from "../../src/symbols/flags.js";
+import { OutputSymbol } from "../../src/symbols/output-symbol.js";
 import { createScopeTree } from "./utils.js";
 
 describe("Symbol name resolution", () => {
diff --git a/packages/core/test/symbols/utils.ts b/packages/core/test/symbols/utils.ts
index 551b4fd..ec6262b 100644
--- a/packages/core/test/symbols/utils.ts
+++ b/packages/core/test/symbols/utils.ts
@@ -1,13 +1,11 @@
 import { Binder } from "../../src/index.browser.js";
 import { Refkey, refkey } from "../../src/refkey.js";
 import {
-  OutputScope,
   OutputScopeFlags,
-} from "../../src/symbols/output-scope.js";
-import {
-  OutputSymbol,
   OutputSymbolFlags,
-} from "../../src/symbols/output-symbol.js";
+} from "../../src/symbols/flags.js";
+import { OutputScope } from "../../src/symbols/output-scope.js";
+import { OutputSymbol } from "../../src/symbols/output-symbol.js";
 
 type ScopeRecords = Record<string, ScopeDescriptor>;
 type SymbolRecords = Record<string, SymbolDescriptor>;
diff --git a/packages/core/test/utils.test.tsx b/packages/core/test/utils.test.tsx
index 9b1fb50..6592563 100644
--- a/packages/core/test/utils.test.tsx
+++ b/packages/core/test/utils.test.tsx
@@ -1,6 +1,6 @@
-import { Children } from "@alloy-js/core/jsx-runtime";
 import { computed, ref, triggerRef } from "@vue/reactivity";
 import { describe, expect, it } from "vitest";
+import { Children } from "../src/index.js";
 import { renderTree } from "../src/render.js";
 import { flushJobs } from "../src/scheduler.js";
 import { children, join, mapJoin } from "../src/utils.js";
diff --git a/packages/core/testing/render.ts b/packages/core/testing/render.ts
index c7760ef..38d7716 100644
--- a/packages/core/testing/render.ts
+++ b/packages/core/testing/render.ts
@@ -1,5 +1,4 @@
-import { printTree, renderTree } from "@alloy-js/core";
-import { Children } from "../src/jsx-runtime.js";
+import { Children, printTree, renderTree } from "@alloy-js/core";
 
 export { printTree } from "@alloy-js/core";