Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/architecture.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,35 @@ btuinは、状態・レイアウト・レンダリング・I/Oの4つの関心

- **`btuin` (ランタイム)**: 他のすべての部分を統合する最上位モジュール。`createApp`を提供し、アプリケーションのライフサイクルを管理し、コンポーネントAPIを公開します。

## コンポーネントコンテキスト(Provide/Inject)

btuin は、props のバケツリレーを避けて子孫コンポーネントへ値を渡すための、Vue 風のコンテキスト機構を提供します。

- `provide(key, value)`: 現在のコンポーネントインスタンスに値を登録します。
- `inject(key, defaultValue?)`: 親インスタンスを辿って値を解決します。見つからない場合は `defaultValue`(未指定なら `undefined`)を返します。

キーは `string` または型付き `symbol`(`InjectionKey<T>`)を利用できます。`provide()` / `inject()` はコンポーネント初期化(`setup` / `init`)中に呼ぶことを想定しており、それ以外で呼ぶと警告を出してデフォルト値にフォールバックします。

```ts
import { defineComponent, inject, provide, ui } from "btuin";

const Child = defineComponent({
setup() {
const theme = inject("theme", "dark");
return () => ui.Text(`theme=${theme}`);
},
});

const Parent = defineComponent({
setup() {
provide("theme", "light");
return () => ui.Block(/* ... */);
},
});
```

![Provide/Inject の解決経路](./assets/context-provide-inject.svg)

## ヘッドレス実行

I/Oが分離されているため、btuinはヘッドレス環境(例: CI)で実行できます。UIはTTYインターフェースに描画され、結果は`runtime.setExitOutput()`で`stdout`に送られます。`Bun.Terminal`はプログラムによるテストに使用できます。
Expand Down
29 changes: 29 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,35 @@ btuin separates concerns into four distinct modules: state, layout, rendering, a

- **`btuin` (Runtime)**: The top-level module that integrates all other parts. It provides `createApp`, manages the application lifecycle, and exposes the component API.

## Component Context (Provide/Inject)

btuin provides a lightweight, Vue-like context mechanism to share values down the component tree without prop drilling.

- `provide(key, value)`: Registers a value on the current component instance.
- `inject(key, defaultValue?)`: Resolves a value by walking up the parent instance chain. Returns `defaultValue` (or `undefined`) if not found.

Keys can be `string` or typed `symbol` (`InjectionKey<T>`). `provide()`/`inject()` are intended to be called during component initialization (`setup`/`init`); calling them outside of component init will emit a warning and fall back to the default value.

```ts
import { defineComponent, inject, provide, ui } from "btuin";

const Child = defineComponent({
setup() {
const theme = inject("theme", "dark");
return () => ui.Text(`theme=${theme}`);
},
});

const Parent = defineComponent({
setup() {
provide("theme", "light");
return () => ui.Block(/* ... */);
},
});
```

![Provide/Inject context resolution](./assets/context-provide-inject.svg)

## Headless Execution

The I/O separation allows btuin to run in headless environments (e.g., CI). The UI renders to a TTY interface, while results can be directed to `stdout` via `runtime.setExitOutput()`. `Bun.Terminal` can be used for programmatic testing.
Expand Down
2 changes: 1 addition & 1 deletion docs/assets/architecture.d2
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
direction: left
direction: down

classes: {
step: {
Expand Down
192 changes: 96 additions & 96 deletions docs/assets/architecture.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
88 changes: 88 additions & 0 deletions docs/assets/context-provide-inject.d2
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
direction: down

classes: {
step: {
style.fill: "#F8FAFC"
style.stroke: "#334155"
}
data: {
style.fill: "#ECFDF5"
style.stroke: "#10B981"
}
note: {
style.fill: "#FFF7ED"
style.stroke: "#F97316"
}
}

title: "Provide/Inject (Component Context)" {
class: step
}

instance_chain: "Instance chain (parent pointers)" {
class: step
direction: right

parent: "Parent instance\nprovides:\n- fromRoot: \"root\"" {
class: data
}

child: "Child instance\nprovides:\n- fromChild: \"root-child\"" {
class: data
}

grandchild: "Grandchild instance\nprovides:\n- (none)" {
class: data
}

grandchild -> child: "parent"
child -> parent: "parent"
}

resolution: "Resolution algorithm (inject)" {
class: step
direction: right

inject_call: "inject(key, default?)" {
class: step
}

lookup: "while (cursor)\n- if cursor.provides has key -> return\n- cursor = cursor.parent\nreturn default/undefined" {
class: note
}

inject_call -> lookup: "walk"
}

stack: "currentInstance stack (nested mount during setup)" {
class: step
direction: down

s1: "mountComponent(Parent)\nsetCurrentInstance(Parent)\nstack: [Parent]" {
class: step
}
s2: "Parent.setup():\nprovide(fromRoot)\nmountComponent(Child)" {
class: step
}
s3: "setCurrentInstance(Child)\nstack: [Parent, Child]" {
class: step
}
s4: "Child.setup():\ninject(fromRoot)\nprovide(fromChild)\nmountComponent(Grandchild)" {
class: step
}
s5: "setCurrentInstance(Grandchild)\nstack: [Parent, Child, Grandchild]" {
class: step
}
s6: "Grandchild.setup():\ninject(fromRoot/fromChild)" {
class: step
}
s7: "setup ends\nsetCurrentInstance(null)\nstack pops back to parent" {
class: note
}

s1 -> s2 -> s3 -> s4 -> s5 -> s6 -> s7
}

title -> instance_chain
instance_chain -> resolution
resolution -> stack
111 changes: 111 additions & 0 deletions docs/assets/context-provide-inject.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions docs/roadmap.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@
- [ ] スクロールリージョン(DECSTBM)を活用した高速スクロール
- [ ] **リアクティビティの高度化**
- [ ] Effect Scope の導入(コンポーネントに紐付いた Effect の自動追跡・破棄)
- [ ] **開発体験 (DX) / 大規模開発サポート**
- [ ] **状態共有パターン**
- [ ] `Provide/Inject` または `Context API` 相当の依存注入機能
- [x] **開発体験 (DX) / 大規模開発サポート**
- [x] **状態共有パターン**
- [x] `Provide/Inject` または `Context API` 相当の依存注入機能
- [ ] **安全性・堅牢性**
- [x] FFI 境界の同期テスト
- [ ] 致命的エラー時のセーフティネット(パニック時の Raw Mode 強制解除)
Expand Down
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export {
} from "./component";
export { createComponent } from "./core";
export { onBeforeUpdate, onKey, onMounted, onTick, onUnmounted, onUpdated } from "./lifecycle";
export { inject, provide, type InjectionKey } from "./provide-inject";
15 changes: 14 additions & 1 deletion src/components/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ export type TickHook = () => void;
* Set during component setup execution
*/
let currentInstance: ComponentInstance | null = null;
const instanceStack: ComponentInstance[] = [];

export interface ComponentInstance {
uid: number;
isMounted: boolean;
name?: string;
parent: ComponentInstance | null;
provides: Map<string | symbol, unknown>;

mountedHooks: LifecycleHook[];
unmountedHooks: LifecycleHook[];
Expand All @@ -47,6 +50,8 @@ export function createComponentInstance(): ComponentInstance {
return {
uid: uidGenerator.next(),
isMounted: false,
parent: null,
provides: new Map(),
mountedHooks: [],
unmountedHooks: [],
updatedHooks: [],
Expand All @@ -58,7 +63,15 @@ export function createComponentInstance(): ComponentInstance {
}

export function setCurrentInstance(instance: ComponentInstance | null) {
currentInstance = instance;
if (instance) {
instance.parent = currentInstance;
instanceStack.push(instance);
currentInstance = instance;
return;
}

instanceStack.pop();
currentInstance = instanceStack.length > 0 ? instanceStack[instanceStack.length - 1]! : null;
}

export function getCurrentInstance(): ComponentInstance | null {
Expand Down
33 changes: 33 additions & 0 deletions src/components/provide-inject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { getCurrentInstance } from "./lifecycle";

export type InjectionKey<T> = symbol & { __btuin_injectionKey?: T };

export function provide<T>(key: InjectionKey<T> | string, value: T): void {
const instance = getCurrentInstance();
if (!instance) {
console.warn("provide() called outside of component init()");
return;
}

instance.provides.set(key, value);
}

export function inject<T>(key: InjectionKey<T> | string): T | undefined;
export function inject<T>(key: InjectionKey<T> | string, defaultValue: T): T;
export function inject<T>(key: InjectionKey<T> | string, defaultValue?: T): T | undefined {
const instance = getCurrentInstance();
if (!instance) {
console.warn("inject() called outside of component init()");
return defaultValue;
}

let cursor: typeof instance | null = instance;
while (cursor) {
if (cursor.provides.has(key)) {
return cursor.provides.get(key) as T;
}
cursor = cursor.parent;
}

return defaultValue;
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ export * from "./view";
export * from "./hooks/";

export { onBeforeUpdate, onKey, onMounted, onTick, onUnmounted, onUpdated } from "./components";
export { inject, provide, type InjectionKey } from "./components";

export * from "./reactivity";
51 changes: 51 additions & 0 deletions tests/integration/provide-inject.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, it, expect } from "bun:test";
import { createApp, defineComponent, inject, provide } from "@/index";
import { mountComponent, renderComponent } from "@/components";
import { Text } from "@/view/primitives";
import { createMockPlatform, createMockTerminal } from "../e2e/helpers";

function stripAnsi(input: string): string {
return input.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
}

describe("Framework integration: provide/inject context passing", () => {
it("passes context across nested component mounts and supports shadowing", async () => {
const terminal = createMockTerminal();
const platform = createMockPlatform();

const Grandchild = defineComponent({
setup() {
const fromRoot = inject<string>("fromRoot", "missing");
const fromChild = inject<string>("fromChild", "missing");
return () => Text(`CTX=${fromRoot}:${fromChild}`);
},
});

const Child = defineComponent({
setup() {
const fromRoot = inject<string>("fromRoot", "missing");
provide("fromChild", `${fromRoot}-child`);
const mountedGrandchild = mountComponent(Grandchild);
return () => renderComponent(mountedGrandchild);
},
});

const app = createApp({
terminal,
platform,
init() {
provide("fromRoot", "root");
const mountedChild = mountComponent(Child);
return { mountedChild };
},
render: ({ mountedChild }) => renderComponent(mountedChild),
});

await app.mount();
await Bun.sleep(50);

expect(stripAnsi(terminal.output)).toContain("CTX=root:root-child");

app.unmount();
});
});
Loading