From 27d0648924892e751396b37310e7acb916127167 Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Thu, 25 Dec 2025 21:56:53 +0900 Subject: [PATCH] Add provide/inject API for component context Track component instance stack and parent/provides in lifecycle to support provide/inject. Export provide, inject and InjectionKey from components and root index. Add unit and integration tests for context passing and shadowing. --- docs/architecture.ja.md | 29 +++ docs/architecture.md | 29 +++ docs/assets/architecture.d2 | 2 +- docs/assets/architecture.svg | 192 +++++++++--------- docs/assets/context-provide-inject.d2 | 88 ++++++++ docs/assets/context-provide-inject.svg | 111 ++++++++++ docs/roadmap.ja.md | 6 +- src/components/index.ts | 1 + src/components/lifecycle.ts | 15 +- src/components/provide-inject.ts | 33 +++ src/index.ts | 1 + .../provide-inject.integration.test.ts | 51 +++++ tests/units/view/components/component.test.ts | 82 +++++++- 13 files changed, 538 insertions(+), 102 deletions(-) create mode 100644 docs/assets/context-provide-inject.d2 create mode 100644 docs/assets/context-provide-inject.svg create mode 100644 src/components/provide-inject.ts create mode 100644 tests/integration/provide-inject.integration.test.ts diff --git a/docs/architecture.ja.md b/docs/architecture.ja.md index 2a7feb5..633fe56 100644 --- a/docs/architecture.ja.md +++ b/docs/architecture.ja.md @@ -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`)を利用できます。`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`はプログラムによるテストに使用できます。 diff --git a/docs/architecture.md b/docs/architecture.md index 2a601a3..02f83f6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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`). `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. diff --git a/docs/assets/architecture.d2 b/docs/assets/architecture.d2 index 0a61a4b..5a60e34 100644 --- a/docs/assets/architecture.d2 +++ b/docs/assets/architecture.d2 @@ -1,4 +1,4 @@ -direction: left +direction: down classes: { step: { diff --git a/docs/assets/architecture.svg b/docs/assets/architecture.svg index 4743d68..a67eef8 100644 --- a/docs/assets/architecture.svg +++ b/docs/assets/architecture.svg @@ -1,16 +1,16 @@ -Userawait app.mount()src/runtime/app.tsmount()Terminal setup(1) patchConsole()(2) startCapture()(3) setupRawMode()(4) setBracketedPaste(true)(5) clearScreen() [fullscreen]mountComponent(root)-> init(ctx)-> returns stateLoopManager.start()- terminal.onKey(...)- createRenderer(...)- renderOnce(true)- render() effectPlatform hooksonExit / SIGINT / SIGTERM-> app.exit(...)Steady state- key events -> handleComponentKey- resize -> rerenderReactivity(init/setup creates refs)render() reads -> deps trackedstate change -> rerenderLayoutlayout-engine (Rust FFI)View tree -> computed layout(x/y/w/h)Diff + bufferrenderDiff(prev, next)-> minimal ANSIUI write pathterminal.write(str)-> src/terminal/io.ts-> UI TTY (stdout/stderr/devtty)(bypass capture)Exit pathLifecycleManager.exit()- run onExit handlers- unmount()- if normal: compute exitOutput- UI cleanup to TTY- exitOutput -> stdout only- platform.exit(code)stdout(pipe/redirect)TTY(stdout/stderr/devtty) callin ordermount rootstart loopregister handlersruns until exitinit/setupeffect(render)layout(view)render to bufferANSIUItrigger exitruntime.exit()cleanup sequencesexitOutput only - - - - - - - - - - - - - - - - - + .d2-3809262720 .fill-N1{fill:#0A0F25;} + .d2-3809262720 .fill-N2{fill:#676C7E;} + .d2-3809262720 .fill-N3{fill:#9499AB;} + .d2-3809262720 .fill-N4{fill:#CFD2DD;} + .d2-3809262720 .fill-N5{fill:#DEE1EB;} + .d2-3809262720 .fill-N6{fill:#EEF1F8;} + .d2-3809262720 .fill-N7{fill:#FFFFFF;} + .d2-3809262720 .fill-B1{fill:#0D32B2;} + .d2-3809262720 .fill-B2{fill:#0D32B2;} + .d2-3809262720 .fill-B3{fill:#E3E9FD;} + .d2-3809262720 .fill-B4{fill:#E3E9FD;} + .d2-3809262720 .fill-B5{fill:#EDF0FD;} + .d2-3809262720 .fill-B6{fill:#F7F8FE;} + .d2-3809262720 .fill-AA2{fill:#4A6FF3;} + .d2-3809262720 .fill-AA4{fill:#EDF0FD;} + .d2-3809262720 .fill-AA5{fill:#F7F8FE;} + .d2-3809262720 .fill-AB4{fill:#EDF0FD;} + .d2-3809262720 .fill-AB5{fill:#F7F8FE;} + .d2-3809262720 .stroke-N1{stroke:#0A0F25;} + .d2-3809262720 .stroke-N2{stroke:#676C7E;} + .d2-3809262720 .stroke-N3{stroke:#9499AB;} + .d2-3809262720 .stroke-N4{stroke:#CFD2DD;} + .d2-3809262720 .stroke-N5{stroke:#DEE1EB;} + .d2-3809262720 .stroke-N6{stroke:#EEF1F8;} + .d2-3809262720 .stroke-N7{stroke:#FFFFFF;} + .d2-3809262720 .stroke-B1{stroke:#0D32B2;} + .d2-3809262720 .stroke-B2{stroke:#0D32B2;} + .d2-3809262720 .stroke-B3{stroke:#E3E9FD;} + .d2-3809262720 .stroke-B4{stroke:#E3E9FD;} + .d2-3809262720 .stroke-B5{stroke:#EDF0FD;} + .d2-3809262720 .stroke-B6{stroke:#F7F8FE;} + .d2-3809262720 .stroke-AA2{stroke:#4A6FF3;} + .d2-3809262720 .stroke-AA4{stroke:#EDF0FD;} + .d2-3809262720 .stroke-AA5{stroke:#F7F8FE;} + .d2-3809262720 .stroke-AB4{stroke:#EDF0FD;} + .d2-3809262720 .stroke-AB5{stroke:#F7F8FE;} + .d2-3809262720 .background-color-N1{background-color:#0A0F25;} + .d2-3809262720 .background-color-N2{background-color:#676C7E;} + .d2-3809262720 .background-color-N3{background-color:#9499AB;} + .d2-3809262720 .background-color-N4{background-color:#CFD2DD;} + .d2-3809262720 .background-color-N5{background-color:#DEE1EB;} + .d2-3809262720 .background-color-N6{background-color:#EEF1F8;} + .d2-3809262720 .background-color-N7{background-color:#FFFFFF;} + .d2-3809262720 .background-color-B1{background-color:#0D32B2;} + .d2-3809262720 .background-color-B2{background-color:#0D32B2;} + .d2-3809262720 .background-color-B3{background-color:#E3E9FD;} + .d2-3809262720 .background-color-B4{background-color:#E3E9FD;} + .d2-3809262720 .background-color-B5{background-color:#EDF0FD;} + .d2-3809262720 .background-color-B6{background-color:#F7F8FE;} + .d2-3809262720 .background-color-AA2{background-color:#4A6FF3;} + .d2-3809262720 .background-color-AA4{background-color:#EDF0FD;} + .d2-3809262720 .background-color-AA5{background-color:#F7F8FE;} + .d2-3809262720 .background-color-AB4{background-color:#EDF0FD;} + .d2-3809262720 .background-color-AB5{background-color:#F7F8FE;} + .d2-3809262720 .color-N1{color:#0A0F25;} + .d2-3809262720 .color-N2{color:#676C7E;} + .d2-3809262720 .color-N3{color:#9499AB;} + .d2-3809262720 .color-N4{color:#CFD2DD;} + .d2-3809262720 .color-N5{color:#DEE1EB;} + .d2-3809262720 .color-N6{color:#EEF1F8;} + .d2-3809262720 .color-N7{color:#FFFFFF;} + .d2-3809262720 .color-B1{color:#0D32B2;} + .d2-3809262720 .color-B2{color:#0D32B2;} + .d2-3809262720 .color-B3{color:#E3E9FD;} + .d2-3809262720 .color-B4{color:#E3E9FD;} + .d2-3809262720 .color-B5{color:#EDF0FD;} + .d2-3809262720 .color-B6{color:#F7F8FE;} + .d2-3809262720 .color-AA2{color:#4A6FF3;} + .d2-3809262720 .color-AA4{color:#EDF0FD;} + .d2-3809262720 .color-AA5{color:#F7F8FE;} + .d2-3809262720 .color-AB4{color:#EDF0FD;} + .d2-3809262720 .color-AB5{color:#F7F8FE;}.appendix text.text{fill:#0A0F25}.md{--color-fg-default:#0A0F25;--color-fg-muted:#676C7E;--color-fg-subtle:#9499AB;--color-canvas-default:#FFFFFF;--color-canvas-subtle:#EEF1F8;--color-border-default:#0D32B2;--color-border-muted:#0D32B2;--color-neutral-muted:#EEF1F8;--color-accent-fg:#0D32B2;--color-accent-emphasis:#0D32B2;--color-attention-subtle:#676C7E;--color-danger-fg:red;}.sketch-overlay-B1{fill:url(#streaks-darker-d2-3809262720);mix-blend-mode:lighten}.sketch-overlay-B2{fill:url(#streaks-darker-d2-3809262720);mix-blend-mode:lighten}.sketch-overlay-B3{fill:url(#streaks-bright-d2-3809262720);mix-blend-mode:darken}.sketch-overlay-B4{fill:url(#streaks-bright-d2-3809262720);mix-blend-mode:darken}.sketch-overlay-B5{fill:url(#streaks-bright-d2-3809262720);mix-blend-mode:darken}.sketch-overlay-B6{fill:url(#streaks-bright-d2-3809262720);mix-blend-mode:darken}.sketch-overlay-AA2{fill:url(#streaks-dark-d2-3809262720);mix-blend-mode:overlay}.sketch-overlay-AA4{fill:url(#streaks-bright-d2-3809262720);mix-blend-mode:darken}.sketch-overlay-AA5{fill:url(#streaks-bright-d2-3809262720);mix-blend-mode:darken}.sketch-overlay-AB4{fill:url(#streaks-bright-d2-3809262720);mix-blend-mode:darken}.sketch-overlay-AB5{fill:url(#streaks-bright-d2-3809262720);mix-blend-mode:darken}.sketch-overlay-N1{fill:url(#streaks-darker-d2-3809262720);mix-blend-mode:lighten}.sketch-overlay-N2{fill:url(#streaks-dark-d2-3809262720);mix-blend-mode:overlay}.sketch-overlay-N3{fill:url(#streaks-normal-d2-3809262720);mix-blend-mode:color-burn}.sketch-overlay-N4{fill:url(#streaks-normal-d2-3809262720);mix-blend-mode:color-burn}.sketch-overlay-N5{fill:url(#streaks-bright-d2-3809262720);mix-blend-mode:darken}.sketch-overlay-N6{fill:url(#streaks-bright-d2-3809262720);mix-blend-mode:darken}.sketch-overlay-N7{fill:url(#streaks-bright-d2-3809262720);mix-blend-mode:darken}.light-code{display: block}.dark-code{display: none}]]>Userawait app.mount()src/runtime/app.tsmount()Terminal setup(1) patchConsole()(2) startCapture()(3) setupRawMode()(4) setBracketedPaste(true)(5) clearScreen() [fullscreen]mountComponent(root)-> init(ctx)-> returns stateLoopManager.start()- terminal.onKey(...)- createRenderer(...)- renderOnce(true)- render() effectPlatform hooksonExit / SIGINT / SIGTERM-> app.exit(...)Steady state- key events -> handleComponentKey- resize -> rerenderReactivity(init/setup creates refs)render() reads -> deps trackedstate change -> rerenderLayoutlayout-engine (Rust FFI)View tree -> computed layout(x/y/w/h)Diff + bufferrenderDiff(prev, next)-> minimal ANSIUI write pathterminal.write(str)-> src/terminal/io.ts-> UI TTY (stdout/stderr/devtty)(bypass capture)Exit pathLifecycleManager.exit()- run onExit handlers- unmount()- if normal: compute exitOutput- UI cleanup to TTY- exitOutput -> stdout only- platform.exit(code)stdout(pipe/redirect)TTY(stdout/stderr/devtty) callin ordermount rootstart loopregister handlersruns until exitinit/setupeffect(render)layout(view)render to bufferANSIUItrigger exitruntime.exit()cleanup sequencesexitOutput only + + + + + + + + + + + + + + + + + diff --git a/docs/assets/context-provide-inject.d2 b/docs/assets/context-provide-inject.d2 new file mode 100644 index 0000000..298c6e6 --- /dev/null +++ b/docs/assets/context-provide-inject.d2 @@ -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 diff --git a/docs/assets/context-provide-inject.svg b/docs/assets/context-provide-inject.svg new file mode 100644 index 0000000..e6cfe14 --- /dev/null +++ b/docs/assets/context-provide-inject.svg @@ -0,0 +1,111 @@ +Provide/Inject (Component Context)Instance chain (parent pointers)Resolution algorithm (inject)currentInstance stack (nested mount during setup)Parent instanceprovides:- fromRoot: "root"Child instanceprovides:- fromChild: "root-child"Grandchild instanceprovides:- (none)inject(key, default?)while (cursor)- if cursor.provides has key -> return- cursor = cursor.parentreturn default/undefinedmountComponent(Parent)setCurrentInstance(Parent)stack: [Parent]Parent.setup():provide(fromRoot)mountComponent(Child)setCurrentInstance(Child)stack: [Parent, Child]Child.setup():inject(fromRoot)provide(fromChild)mountComponent(Grandchild)setCurrentInstance(Grandchild)stack: [Parent, Child, Grandchild]Grandchild.setup():inject(fromRoot/fromChild)setup endssetCurrentInstance(null)stack pops back to parent parentparentwalk + + + + + diff --git a/docs/roadmap.ja.md b/docs/roadmap.ja.md index 98a2e73..ae0a88d 100644 --- a/docs/roadmap.ja.md +++ b/docs/roadmap.ja.md @@ -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 強制解除) diff --git a/src/components/index.ts b/src/components/index.ts index 44b6f6f..dfebf8e 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -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"; diff --git a/src/components/lifecycle.ts b/src/components/lifecycle.ts index 56c37ee..d8334c9 100644 --- a/src/components/lifecycle.ts +++ b/src/components/lifecycle.ts @@ -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; mountedHooks: LifecycleHook[]; unmountedHooks: LifecycleHook[]; @@ -47,6 +50,8 @@ export function createComponentInstance(): ComponentInstance { return { uid: uidGenerator.next(), isMounted: false, + parent: null, + provides: new Map(), mountedHooks: [], unmountedHooks: [], updatedHooks: [], @@ -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 { diff --git a/src/components/provide-inject.ts b/src/components/provide-inject.ts new file mode 100644 index 0000000..d7bf046 --- /dev/null +++ b/src/components/provide-inject.ts @@ -0,0 +1,33 @@ +import { getCurrentInstance } from "./lifecycle"; + +export type InjectionKey = symbol & { __btuin_injectionKey?: T }; + +export function provide(key: InjectionKey | 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(key: InjectionKey | string): T | undefined; +export function inject(key: InjectionKey | string, defaultValue: T): T; +export function inject(key: InjectionKey | 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; +} diff --git a/src/index.ts b/src/index.ts index 64365e5..a24c8b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/tests/integration/provide-inject.integration.test.ts b/tests/integration/provide-inject.integration.test.ts new file mode 100644 index 0000000..da2ff14 --- /dev/null +++ b/tests/integration/provide-inject.integration.test.ts @@ -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("fromRoot", "missing"); + const fromChild = inject("fromChild", "missing"); + return () => Text(`CTX=${fromRoot}:${fromChild}`); + }, + }); + + const Child = defineComponent({ + setup() { + const fromRoot = inject("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(); + }); +}); diff --git a/tests/units/view/components/component.test.ts b/tests/units/view/components/component.test.ts index 9b3dff0..7746570 100644 --- a/tests/units/view/components/component.test.ts +++ b/tests/units/view/components/component.test.ts @@ -7,7 +7,8 @@ import { renderComponent, handleComponentKey, } from "@/components/component"; -import { onKey } from "@/components/lifecycle"; +import { onKey, onMounted } from "@/components/lifecycle"; +import { inject, provide, type InjectionKey } from "@/components/provide-inject"; import { Block, Text } from "@/view/primitives"; describe("defineComponent", () => { @@ -40,6 +41,85 @@ describe("defineComponent", () => { }); }); +describe("provide/inject", () => { + it("should provide values to nested components (string key)", () => { + const Child = defineComponent({ + setup() { + const value = inject("foo"); + return () => Text(String(value)); + }, + }); + + let didMount = false; + const Parent = defineComponent({ + setup() { + provide("foo", 42); + const mountedChild = mountComponent(Child); + + // This should still work even though we mounted a child during setup. + // (Requires currentInstance stack behavior.) + onMounted(() => { + didMount = true; + }); + + return () => renderComponent(mountedChild); + }, + }); + + const mountedParent = mountComponent(Parent); + const element = renderComponent(mountedParent); + expect(didMount).toBe(true); + + expect(element.type).toBe("text"); + if (element.type === "text") { + expect(element.content).toBe("42"); + } + }); + + it("should provide values to nested components (symbol key)", () => { + const key = Symbol("answer") as InjectionKey; + + const Child = defineComponent({ + setup() { + const value = inject(key); + return () => Text(String(value)); + }, + }); + + const Parent = defineComponent({ + setup() { + provide(key, 123); + const mountedChild = mountComponent(Child); + return () => renderComponent(mountedChild); + }, + }); + + const mountedParent = mountComponent(Parent); + const element = renderComponent(mountedParent); + + expect(element.type).toBe("text"); + if (element.type === "text") { + expect(element.content).toBe("123"); + } + }); + + it("should return default values when injection is missing", () => { + const Comp = defineComponent({ + setup() { + const value = inject("missing", "default"); + return () => Text(value); + }, + }); + + const mounted = mountComponent(Comp); + const element = renderComponent(mounted); + expect(element.type).toBe("text"); + if (element.type === "text") { + expect(element.content).toBe("default"); + } + }); +}); + describe("mountComponent", () => { it("should mount and unmount a component", () => { const component = defineComponent({